# Mail-To-Nostr — Agent & Integration Reference

Send emails to `npub1...@mail2nostr.com` and they arrive as encrypted Nostr Direct Messages (NIP-04). Free Tier works out of the box. Optional paid features activate for 30 or 365 days via Lightning.

All endpoints are on the same host. Base URL: `https://mail2nostr.com`

> ⚠️ **By using this API you accept our [Terms of Service](/terms.md) and [Privacy Policy](/privacy.md).** Integrators are responsible for ensuring their use of the service — including data sent, jurisdictions reached, and downstream relay propagation — complies with these terms. EU residents are restricted to the Free Tier.

## Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/features` | GET | Feature catalog with metadata + pricing (always fetch at runtime) |
| `/prices` | GET | Price list (per-feature, per-duration) |
| `/scrypt-params` | GET | Scrypt parameters + salts for client-side pubkey hashing |
| `/pubkey` | GET | Gateway Nostr public key (for encryption key derivation) |
| `/checkout` | POST | Buy + bind features in one step — returns BOLT11 invoice |
| `/checkout/{payment_hash}/status` | GET | Poll payment status — **returns nonce on success, then deletes the record** |
| `/purchase-status` | GET | Check active features for a pubkey hash |
| `/renew` | POST | Renew active features — returns BOLT11 invoice |

---

## GET /features

Returns the full feature catalog. **Always fetch at runtime** — features and prices may change.

```bash
curl https://mail2nostr.com/features
```

Response fields per feature:

| Field | Type | Description |
|-------|------|-------------|
| `key` | string | Feature identifier (e.g. `"relays"`, `"email"`) |
| `name` | string | Human-readable display name |
| `description` | string | What the feature does |
| `max_value` | int | Maximum purchasable value (1 = flat/toggle, >1 = quantity or tier) |
| `bound_type` | string | How the bound value is encoded: `"int"`, `"string"`, `"list"` |
| `bound_format_hint` | string | Example format for the bound value |
| `price_per_unit` | int | Price in sats per unit or tier |
| `unit_label` | string\|null | Unit label (`"relay"`, `"tier"`, null = flat pricing) |

If `unit_label` is `"tier"`, `max_value` is the highest tier (e.g. 2 = two tiers). If `null` and `max_value` > 1, it's a quantity. If `null` and `max_value` = 1, it's a flat one-time purchase.

Top-level response also includes `currency` (`"sats"`), `ttl_days`, `rate_limits` (`free_tier_mails_per_day`, `bonus_mails_per_feature`), and `eu_blocked` (boolean).

## GET /prices

```bash
curl https://mail2nostr.com/prices
```

Response:

| Field | Type | Description |
|-------|------|-------------|
| `currency` | string | `"sats"` |
| `prices` | object | `{feature_key: price}` — 30-day prices |
| `annual_prices` | object | `{feature_key: price}` — 365-day prices |
| `allowed_durations` | list | Allowed `duration_days` values (typically `[30, 365]`) |
| `rate_limits` | object | Free tier daily limit + bonus per feature |
| `eu_blocked` | bool | Whether the requesting IP is EU-blocked |

## GET /scrypt-params

```bash
curl https://mail2nostr.com/scrypt-params
```

Response:

| Field | Type | Description |
|-------|------|-------------|
| `pubkey_salt` | string | 64 hex chars — salt for pubkey hashing |
| `username_salt` | string | 64 hex chars — salt for username hashing |
| `N` | int | scrypt cost factor |
| `r` | int | scrypt block size |
| `p` | int | scrypt parallelism |
| `dklen` | int | output length in bytes |

## GET /pubkey

```bash
curl https://mail2nostr.com/pubkey
```

Returns the Gateway's Nostr public key (hex). Used to derive the checkout encryption key (see [Encryption](#encryption) below).

Response: `{"pubkey": "<64 hex chars>"}`

Gateway public key (hex): `ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8`

---

## POST /checkout

Buy and bind features in a single call. The Payment Processor validates the request, calculates the price, creates a BOLT11 hold invoice, and returns it. Once paid, features are activated automatically.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `pf` | object | yes | Purchased feature quantities: `{"relays": 2, "anonymous_sender": 1}` |
| `hp` | string | yes | Hash of recipient pubkey: `"scrypt:N=..:r=..:p=..:<hash>"` |
| `ef` | string | yes | NIP-44 encrypted bound features (concrete values) |
| `epf` | string | yes | NIP-44 encrypted purchased features (same as `pf`, encrypted) |
| `duration_days` | int | no | `30` (default) or `365` |
| `username_pub` | string | no | scrypt-derived pubkey when buying the `email` feature |
| `username_target_enc` | string | no | NIP-44 encrypted customer pubkey for username binding |
| `ownership_nonce` | string | no | Nonce from original purchase — proof of ownership for renew or re-register |

**Which pubkey?** The `hp` field is the hash of the **customer's** Nostr public key — the person who will receive emails as Nostr DMs. If you are buying features for yourself, hash your own npub.

### Response

| Field | Type | Description |
|-------|------|-------------|
| `invoice` | string | BOLT11 Lightning invoice |
| `payment_hash` | string | Payment hash for status tracking |
| `amount_sat` | int | Total amount in sats |
| `expires_at` | string | ISO 8601 timestamp (invoice expiry) |

**The nonce is NOT in this response.** After paying, poll `GET /checkout/{payment_hash}/status` — the nonce appears in the response once payment succeeds.

### Errors

| Status | Error | Cause |
|--------|-------|-------|
| 400 | various | Validation errors (invalid features, missing fields, etc.) |
| 403 | `not_available_in_eu` | EU IP detected |
| 503 | `Payment processor not ready` | Breez SDK not initialized |

---

## GET /checkout/{payment_hash}/status

Poll the status of a checkout request. This is how you detect payment completion and retrieve the nonce.

```bash
curl https://mail2nostr.com/checkout/abc123def456.../status
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `"pending"`, `"paid"`, `"success"`, `"failed"`, `"claim_failed"` |
| `amount_sat` | int | Invoice amount |
| `created_at` | string | ISO 8601 timestamp |
| `expires_at` | string | ISO 8601 timestamp |
| `paid_at` | string\|null | When payment was received |
| `nonce` | string\|null | **Purchase nonce** — appears here after successful payment |
| `error` | string\|null | Error details if failed |

**Payment lifecycle:**
1. `pending` — Invoice created, awaiting payment
2. `paid` — Payment received, processing (bind + claim)
3. `success` — Features activated, `nonce` is now available
4. `failed` — Bind failed, HTLC expires, funds return to payer

**Poll every 2-5 seconds after payment.** Once `status` is `"success"`, save the `nonce` — you need it for `/purchase-status` and `/renew`.

**Checkout records are retained for 7 days, but deleted on first successful poll.** When `status` reaches `"success"`, the record (including the nonce) is returned once and then immediately deleted — the next poll returns 404. If you never poll, the record survives 7 days (sensitive fields are sanitized after 1h, but the nonce is kept). Save the nonce as soon as you see it — `/purchase-status` and `/renew` work indefinitely with the nonce (it's stored permanently in the Gateway database).

---

## GET /purchase-status

Check which features are active for a pubkey hash.

### Query parameters

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `h` | string | yes | Hashed pubkey — repeat for multiple: `?h=hash1&h=hash2` |
| `nonce` | string | yes | Purchase nonce (from `/checkout/{hash}/status` after payment) |

```bash
curl "https://mail2nostr.com/purchase-status?h=scrypt:N=16384:r=6:p=1:<hash>&nonce=<nonce>"
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `found` | bool | Whether an active purchase exists |
| `is_active` | bool | Whether features are currently active |
| `expires_at` | string | ISO 8601 expiry timestamp |

The raw pubkey never leaves the client — only the scrypt hash is sent.

---

## POST /renew

Renew currently active features. Reads the existing feature set, calculates the price for the chosen duration, and returns a new BOLT11 invoice. On payment, the expiry is extended.

**Verification:** The renew endpoint uses a blind-signer (Vorgang 1) to verify that the client's `pf` matches the originally purchased features — without the Payment Processor knowing the `purchase_salt`. The client sends its `pf` (purchased feature quantities); the PP normalizes it, computes a feature hash, asks the Gateway's blind signer to derive the nonce from it, and compares in constant time.

### Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `hash_pubkey` | string | yes | Hashed pubkey: `"scrypt:N=..:r=..:p=..:<hash>"` |
| `nonce` | string | yes | Purchase nonce (from original checkout) |
| `pf` | object | yes | Purchased feature quantities: `{"relays": 2, "anonymous_sender": 1}` — same as the original checkout `pf` |
| `duration_days` | int | no | `30` (default) or `365` |

```bash
curl -X POST https://mail2nostr.com/renew \
  -H "Content-Type: application/json" \
  -d '{
    "hash_pubkey": "scrypt:N=16384:r=6:p=1:<hash>",
    "nonce": "<your-nonce>",
    "pf": {"email": 1, "relays": 2},
    "duration_days": 365
  }'
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `invoice` | string | BOLT11 Lightning invoice |
| `payment_hash` | string | Payment hash for status tracking |
| `amount_sat` | int | Total amount in sats |

On payment, the expiry is extended by `duration_days` from the current expiry (not from payment time — remaining days are preserved).

**The nonce stays the same.** Renewal does not generate a new nonce — it extends the expiry of the features bound to the original nonce. Continue using the same nonce for all future `/purchase-status` and `/renew` calls.

### Errors

| Status | Error | Cause |
|--------|-------|-------|
| 400 | `No active purchase found` | No active features for this hash+nonce |
| 400 | `feature_mismatch` | `pf` does not match the originally purchased features |
| 400 | `Nothing to renew` | Active features but price = 0 |
| 400 | `Invalid duration_days` | Duration not in `allowed_durations` |
| 400 | `Missing purchased_at from Gateway` | Gateway returned incomplete data |
| 400 | `pf must be a non-empty dict` | Empty or missing `pf` field |
| 403 | `not_available_in_eu` | EU IP detected |
| 502 | `Gateway unreachable` | Cannot verify purchase with Gateway |
| 502 | `Verification service unavailable` | Blind signer (Vorgang 1) unreachable |
| 503 | `Payment processor not ready` | Breez SDK not initialized |

---

## GET /renew/{payment_hash}/status

Poll the status of a renewal payment. Same pattern as checkout status polling.

```bash
curl https://mail2nostr.com/renew/abc123def456.../status
```

### Response

| Field | Type | Description |
|-------|------|-------------|
| `status` | string | `"pending"`, `"success"`, `"failed"` |
| `amount_sat` | int | Invoice amount |
| `new_expires_at` | string\|null | New expiry timestamp after successful renewal |

---

## Encryption

Feature configurations are encrypted before sending to the server. The server cannot read your feature values until an email arrives with your public key.

### NIP-44

This service uses [NIP-44](https://github.com/nostr-protocol/nips/blob/master/44.md) ("Encrypted Direct Messages") for encryption. NIP-44 is a Nostr encryption standard based on HKDF + ChaCha-Poly1305.

**Reference implementations:**
- **JavaScript/Browser:** [`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers) (used by our frontend)
- **Python:** [`pynostr`](https://github.com/honzonM/python-nostr) — `from pynostr.nip44 import NIP44Encryption`
- **Rust:** [`nostr-sdk`](https://github.com/rust-nostr/nostr) — `nip44::encrypt()`
- **Go:** [`nfield/go-nostr`](https://github.com/nfield/go-nostr)

NIP-44 takes a **sender private key** and a **recipient public key**, derives a conversation key via ECDH (secp256k1), then encrypts with HKDF + ChaCha-Poly1305. The output is a base64-encoded payload string.

### Checkout Encryption: Derived Key

The checkout flow uses a **deterministically derived key** — not your actual Nostr private key. This means you don't need to expose your nsec during checkout.

**Derivation:**

```
derived_privkey = sha256(gateway_pubkey_hex + customer_pubkey_hex)
```

Then ensure it's a valid secp256k1 private key (re-hash if 0 or ≥ curve order — practically never happens).

**You use this derived private key as the NIP-44 sender key**, encrypting to the Gateway's public key. The Gateway can derive the same key (it knows both pubkeys) and decrypt.

### What to Encrypt

Two JSON blobs, each base64-encoded before NIP-44 encryption:

**1. Bound features (`ef`)** — concrete feature values:

```json
{"relays": ["wss://relay.example.com", "wss://relay2.example.com"]}
```

**2. Purchased features (`epf`)** — same content as the `pf` field, encrypted:

```json
{"relays": 2}
```

**Format inside NIP-44:** `base64(JSON_string)` — the plaintext fed to NIP-44 encrypt is the base64 encoding of the normalized JSON string, not the raw JSON.

### JSON Normalization

Before base64-encoding, normalize the JSON:

1. **Lowercase** all keys
2. **Sort** keys alphabetically
3. **Remove** entries where value is `0` (but keep `false` — booleans are preserved)
4. **Compact JSON** — no whitespace, no newlines (`separators=(',', ':')`)

**Example:**

```python
import json, base64

# Input (unnormalized)
config = {"Relays": ["wss://a.com", "wss://b.com"], "rate_limit": 0}

# Step 1+2: lowercase + sort
normalized = {"relays": ["wss://a.com", "wss://b.com"]}  # rate_limit removed (value=0)

# Step 4: compact JSON
json_str = json.dumps(normalized, separators=(',', ':'))
# → '{"relays":["wss://a.com","wss://b.com"]}'

# Base64 encode (this is the plaintext for NIP-44)
plaintext = base64.b64encode(json_str.encode()).decode()
# → 'eyJyZWxheXMiOlsid3NzOi8vYS5jb20iLCJ3c3M6Ly9iLmNvbSJdfQ=='
```

**Nested values** (arrays, strings) are left as-is. Only top-level keys are lowercased and sorted.

---

## Checkout Flow (Step by Step)

### Step 1: Get scrypt parameters

```bash
curl https://mail2nostr.com/scrypt-params
```

```json
{
  "pubkey_salt": "<64 hex chars>",
  "username_salt": "<64 hex chars>",
  "N": 16384, "r": 6, "p": 1, "dklen": 32
}
```

### Step 2: Compute hash_pubkey

Hash the **customer's** pubkey (hex, not npub) with scrypt. The customer pubkey is the Nostr public key of the person who will receive emails as encrypted DMs.

```python
hash_pubkey = "scrypt:N=16384:r=6:p=1:" + scrypt(
    bytes.fromhex(customer_pubkey_hex),
    salt=bytes.fromhex(pubkey_salt),
    N=16384, r=6, p=1, dklen=32
).hex()
```

### Step 3: Get Gateway pubkey

```bash
curl https://mail2nostr.com/pubkey
# {"pubkey": "ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8"}
```

### Step 4: Derive checkout encryption key

```python
import hashlib

raw = hashlib.sha256(
    (gateway_pubkey_hex + customer_pubkey_hex).encode()
).hexdigest()
# → This is your derived private key (for NIP-44 sender)

# Guard: re-hash if not a valid secp256k1 private key (extremely rare — ~2^-128)
SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
while int(raw, 16) == 0 or int(raw, 16) >= SECP256K1_N:
    raw = hashlib.sha256(raw.encode()).hexdigest()
derived_priv = raw
```

### Step 5: Normalize + encrypt bound + purchased features

Encrypt two JSON blobs (see [Encryption](#encryption) for details):

- **Bound features** (concrete values): `{"relays": ["wss://relay.example.com"]}` → normalize → base64 → NIP-44 encrypt → `ef`
- **Purchased features** (quantities): `{"relays": 1}` → normalize → base64 → NIP-44 encrypt → `epf`

### Step 6: POST /checkout

```bash
curl -X POST https://mail2nostr.com/checkout \
  -H "Content-Type: application/json" \
  -d '{
    "pf": {"relays": 1},
    "hp": "scrypt:N=16384:r=6:p=1:<hash>",
    "ef": "<NIP-44 encrypted bound features>",
    "epf": "<NIP-44 encrypted purchased features>",
    "duration_days": 30
  }'
```

Response:

```json
{
  "invoice": "lnbc1000n1p3qqq...",
  "payment_hash": "abcdef0123...",
  "amount_sat": 20,
  "expires_at": "2026-07-10T18:00:00Z"
}
```

### Step 7: Pay + poll for nonce

Open any Lightning wallet, paste the invoice, pay. Then poll:

```bash
curl https://mail2nostr.com/checkout/abcdef0123.../status
```

```json
{
  "status": "success",
  "nonce": "9f3a7b2c...",
  "paid_at": "2026-07-11T12:00:00Z"
}
```

**The server does NOT send the nonce automatically.** There is no DM, webhook, or callback. You must poll `/checkout/{payment_hash}/status` until `status` is `"success"`, then extract the nonce from the response.

**Save the nonce.** You need it for `/purchase-status` and `/renew`.

### Step 8: Verify (optional)

```bash
curl "https://mail2nostr.com/purchase-status?h=scrypt:N=16384:r=6:p=1:<hash>&nonce=9f3a7b2c..."
```

---

## Complete Python Example

```python
import hashlib, json, base64
from hashlib import scrypt
# You need a NIP-44 library, e.g. pynostr:
# pip install pynostr
from pynostr.nip44 import NIP44Encryption
from pynostr.key_utils import PrivateKey

BASE = "https://mail2nostr.com"
GATEWAY_PUBKEY = "ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8"

# ── Convert npub → hex ──
# npub uses bech32 encoding. Decode the data part to get the 32-byte hex pubkey.
# Python one-liner using pynostr:
from pynostr.key_utils import pubkey_nip19
from pynostr.nip19 import nip19_decode  # or use bech32 library directly

# Alternative minimal bech32 decoder (no dependency):
def npub_to_hex(npub: str) -> str:
    """Decode npub (bech32) to 64-char hex pubkey."""
    import hashlib
    CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
    if not npub.startswith("npub1"):
        raise ValueError("Not an npub")
    data = npub[5:]  # strip "npub1"
    # Decode bech32 → bytes
    values = [CHARSET.index(c) for c in data[:-6]]  # strip checksum
    # Convert 5-bit groups to 8-bit bytes
    bits = 0
    nbits = 0
    result = bytearray()
    for v in values:
        bits = (bits << 5) | v
        nbits += 5
        while nbits >= 8:
            nbits -= 8
            result.append((bits >> nbits) & 0xFF)
    return result[:32].hex()  # first 32 bytes = pubkey

customer_npub = "npub1..."
pubkey_hex = npub_to_hex(customer_npub)
print(f"Pubkey hex: {pubkey_hex}")

# Step 1: Get scrypt params (use requests or httpx)
import requests
params = requests.get(f"{BASE}/scrypt-params").json()

# Step 2: Hash pubkey
hp = "scrypt:N=16384:r=6:p=1:" + scrypt(
    bytes.fromhex(pubkey_hex),
    salt=bytes.fromhex(params["pubkey_salt"]),
    N=16384, r=6, p=1, dklen=32,
).hex()

# Step 3: Derive encryption key
# sha256(gw_pub + cust_pub) — may need re-hash if ≥ curve order (extremely rare)
SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
raw = hashlib.sha256((GATEWAY_PUBKEY + pubkey_hex).encode()).hexdigest()
while int(raw, 16) == 0 or int(raw, 16) >= SECP256K1_N:
    raw = hashlib.sha256(raw.encode()).hexdigest()
derived_priv = raw

# Step 4: Normalize + encrypt
def normalize(obj):
    norm = {}
    for k, v in sorted(obj.items()):
        if v == 0 and not isinstance(v, bool):
            continue
        norm[k.lower()] = v
    return json.dumps(norm, separators=(',', ':'))

bound_config = {"relays": ["wss://relay.example.com"]}
purchased = {"relays": 1}

ef_plaintext = base64.b64encode(normalize(bound_config).encode()).decode()
epf_plaintext = base64.b64encode(normalize(purchased).encode()).decode()

# NIP-44 encrypt (using derived key as sender, gateway pubkey as recipient)
nip44 = NIP44Encryption(PrivateKey(derived_priv))
ef = nip44.encrypt(ef_plaintext, GATEWAY_PUBKEY)
epf = nip44.encrypt(epf_plaintext, GATEWAY_PUBKEY)

# Step 5: Checkout
resp = requests.post(f"{BASE}/checkout", json={
    "pf": purchased,
    "hp": hp,
    "ef": ef,
    "epf": epf,
    "duration_days": 30,
})
data = resp.json()
print(f"Invoice: {data['invoice']}")
print(f"Amount: {data['amount_sat']} sats")

# Step 6: Pay the invoice in your Lightning wallet, then poll:
# NOTE: The server does NOT send the nonce via DM or webhook.
# You must poll /checkout/{hash}/status yourself.
import time
while True:
    status = requests.get(
        f"{BASE}/checkout/{data['payment_hash']}/status"
    ).json()
    if status["status"] == "success":
        print(f"Nonce: {status['nonce']}")
        nonce = status["nonce"]
        break
    elif status["status"] == "failed":
        print(f"Failed: {status.get('error')}")
        break
    time.sleep(3)
```

---

## SMTP Usage

Send emails to Nostr public keys via standard SMTP (port 25, STARTTLS):

```
To: npub1...@mail2nostr.com
From: you@example.com
Subject: Hello

Your message here.
```

### Supported Domains

The Gateway accepts email on any of the following domains. All resolve to the same service — pick whichever you prefer:

| Domain | Notes |
|--------|-------|
| `mail2nostr.com` | Primary domain (used in examples throughout this doc) |
| `mail-to-nostr.com` | Original full name |
| `mail-2-nostr.com` | Hyphenated variant |
| `m2n.email` | Short alias |

All four behave identically — recipient lookup, feature binding, and delivery work the same way on each. Use whichever fits your use case (e.g. `m2n.email` for shorter addresses, `mail-to-nostr.com` for clarity).

Recipients who purchased the `email` feature can also use readable usernames:

```
To: user@mail2nostr.com
```

### Plus-Addressing (Subaddressing)

Recipients with the `email` feature automatically support **plus-addressing**: append `+tag` to the username to tag or categorize messages. The tag appears as a prefix in the delivered DM body, so the recipient can see which subaddress was used.

```
To: user+newsletter@mail2nostr.com     → DM body starts with [‹newsletter›]
To: user+bank@mail2nostr.com           → DM body starts with [‹bank›]
```

The base username (`user@`) and the plus-address variant (`user+anything@`) resolve to the same Nostr pubkey. No extra purchase is needed — plus-addressing is included with the `email` feature.

### Default Relay Pool

Without the `relays` feature, messages are published to a pool of 5 default Nostr relays:

```
wss://relay.damus.io
wss://relay.primal.net
wss://relay.nostr.net
wss://nostr.oxtr.dev
wss://no.str.cr
```

For each message, **2 relays are picked at random** from this pool. As soon as **one** accepts the event, delivery is considered successful (quorum = 1). The second relay provides redundancy, not a hard requirement — this avoids duplicate delivery on retries while still protecting against a single relay failing.

### Custom Relays (Paid Feature)

The `relays` feature provides **exclusive delivery** to a list of relays you specify. Without it, messages go to 2 random relays from the default pool (free). With it:

- We send your messages to each relay in your list (up to **10**) — not a random subset.
- You can add your own relays, or include defaults from the pool, or a mix.
- Each relay you add costs the per-relay price (see `GET /prices`).
- **The default-pool random delivery is replaced entirely** — your messages go only to the relays you specified, not to the random 2-relay subset.
- We attempt delivery to all configured relays, but **cannot guarantee that any specific relay will accept or store an event**. Relay operators may change their policies at any time.

This is useful when you need certainty that your message reaches a specific relay (e.g. your own relay, or a recipient's preferred relay), rather than relying on the 2-relay random subset.

**Note on Gossip Propagation:** Nostr is a gossip protocol. Once an event is published to any relay, the relay network may propagate it to other relays you did not configure. This is inherent Nostr behavior and outside our control — events published only to a custom relay may still appear on default relays (and vice versa) depending on which clients and relays subscribe to the recipient's pubkey. See [Terms §10](/terms.md) for details.

HTML emails are converted to Markdown (tracking pixels stripped). Attachments are discarded. Plain text preferred when available; HTML used when text/plain is missing or a stub (e.g. "view in browser"). Max 5 MB.

### NIP-05 Opt-in (Optional)

NIP-05 is **off by default**. To activate it, the pubkey owner sends a NIP-04 encrypted DM to the gateway:

- **Gateway pubkey:** `npub1la5d522amhsdczpk9tuw2jf5rhqk30ner8d7u573su8xnkwr3l5q5jg28t`
- **DM content:** `user@mail2nostr.com` (or just `user`)

The gateway decrypts the DM, verifies the sender is the registered owner of that username, and enables NIP-05. After that:

```
GET https://mail2nostr.com/.well-known/nostr.json?name=user
→ {"names": {"user": "<pubkey_hex>"}}
```

NIP-05 stays active as long as:
1. The email feature is active (not expired)
2. The registered owner has not changed (ownership transfer invalidates NIP-05 until the new owner sends a DM)

Free tier: 5 emails/day. Each purchased feature adds a bonus daily allowance.

---

## Email Username Feature

The `email` feature lets users receive emails at `<username>@mail2nostr.com` instead of `<npub>@mail2nostr.com`. The username is never stored — instead, a deterministic Nostr keypair is derived from it via scrypt.

### How It Works

The username → pubkey mapping is stored as `scrypt(username) → NIP-44-encrypted-customer-pubkey`. The server can look up the scrypt-derived pubkey but cannot decrypt the target (your real npub) until an email arrives.

### Step-by-Step (Agent)

**1. Derive username keypair** (scrypt, using `username_salt` from `/scrypt-params`):

The scrypt output is interpreted directly as a secp256k1 private key. Since the output is random 32 bytes, it may (with probability ~2^-128) be invalid (0 or ≥ curve order). In that case, repeatedly SHA-256 the hex string until valid. This is deterministic — both sides (browser JS and Python) always derive the same keypair.

```python
import hashlib

SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def ensure_valid_privkey(priv_hex: str) -> str:
    """Ensure a derived private key is valid for secp256k1 (1 ≤ k < n).
    If invalid (0 or ≥ curve order), repeatedly SHA-256 until valid.
    In practice this loop never executes — but both client and server
    must implement it identically to guarantee the same keypair."""
    val = int(priv_hex, 16)
    while val == 0 or val >= SECP256K1_N:
        priv_hex = hashlib.sha256(priv_hex.encode()).hexdigest()
        val = int(priv_hex, 16)
    return priv_hex

username_bytes = username.encode("utf-8")
derived = scrypt(
    username_bytes,
    salt=bytes.fromhex(params["username_salt"]),
    N=16384, r=6, p=1, dklen=32,
)
username_priv_hex = ensure_valid_privkey(derived.hex())

# Derive pubkey from private key via secp256k1 (x-only, 32 bytes)
# Using coincurve (pip install coincurve):
from coincurve import PrivateKey
sk = PrivateKey(bytes.fromhex(username_priv_hex))
pub_compressed = sk.public_key.format(compressed=True)  # 33 bytes
username_pub_hex = pub_compressed[1:].hex()  # drop prefix → x-only 32 bytes

# Or using ecdsa (pip install ecdsa):
# from ecdsa import SigningKey, SECP256k1
# sk = SigningKey.from_string(bytes.fromhex(username_priv_hex), curve=SECP256k1)
# username_pub_hex = sk.get_verifying_key().to_string("uncompressed").hex()[2:]  # drop 04 prefix
```

**2. Encrypt customer pubkey** — NIP-44 encrypt your real pubkey hex, using `username_priv` as sender and `gateway_pubkey` as recipient:

```python
username_target_enc = nip44_encrypt(
    sender_priv=username_priv_hex,
    recipient_pub=GATEWAY_PUBKEY,
    plaintext=customer_pubkey_hex,
)
```

**3. Add to checkout request:**

```json
{
  "pf": {"email": 1, "relays": 1},
  "hp": "scrypt:N=16384:r=6:p=1:<hash>",
  "ef": "<NIP-44 encrypted bound features>",
  "epf": "<NIP-44 encrypted purchased features>",
  "username_pub": "<username_pub_hex from step 1>",
  "username_target_enc": "<username_target_enc from step 2>",
  "duration_days": 30
}
```

**4. Renewing or Re-registering a Username** — The `ownership_nonce` is the nonce from the **original purchase** (not the most recent renewal). It serves as proof of ownership. There are three scenarios:

| Scenario | Feature state | `check-username` | What to do |
|----------|--------------|-------------------|------------|
| **New registration** | — | 200 | `POST /checkout` without `ownership_nonce` |
| **Renew** (feature still active) | active | 409 | `POST /renew` with the current nonce (no `ownership_nonce` needed — `/renew` uses the active nonce directly) |
| **Re-register** (feature expired, username still yours) | expired | 409 | `POST /checkout` with `ownership_nonce` set to the nonce from the original purchase |

**Key points:**

- `/check-username` returns 409 for **any** username that was ever registered — active or expired. The binding is permanent.
- `ownership_nonce` is checked against `beleg_nonce` — the nonce stored at first registration. This nonce never changes, even after renewals.
- **Without the original nonce, an expired username cannot be re-registered** — it is permanently locked. This is by design (security).
- `/renew` does **not** generate a new nonce. It extends the expiry of the existing bound feature. The nonce stays the same.

```json
{
  "...": "...",
  "ownership_nonce": "<nonce-from-original-purchase>"
}
```

### Check Username Availability

```bash
curl "https://mail2nostr.com/check-username?pub=<username_pub_hex>"
```

| HTTP Status | Meaning |
|-------------|---------|
| 200 | Username available (new registration — no `ownership_nonce` needed) |
| 409 | Username taken (either active or expired — pass `ownership_nonce` to re-register, or use `/renew` if still active) |

---

## Test Vectors

Verify your implementation against these known-good outputs. All use fixed test inputs — not real keys.

### Test Vector 1: scrypt hash_pubkey

```
pubkey_hex:    3bf0c63fcb93463407af97a5e5ee64d883f107299b96e8c1f3a3a4f4a3c3e3c3
salt (hex):    a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
scrypt params: N=16384, r=6, p=1, dklen=32
→ hash_pubkey: scrypt:N=16384:r=6:p=1:b6b7ce16fad82471c8754a32f124d997086abd1c74714af9c878b3729541f995
```

### Test Vector 2: Derived checkout key

```
gateway_pub:    ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8
customer_pub:   3bf0c63fcb93463407af97a5e5ee64d883f107299b96e8c1f3a3a4f4a3c3e3c3
sha256(gw+cust): 30d4c12079a325d3a36eaf74c8826e0b89f3aeb4ba3afd87fe8264f7c6b7ef29
valid secp256k1: yes (0 < val < N)
→ derived_priv:  30d4c12079a325d3a36eaf74c8826e0b89f3aeb4ba3afd87fe8264f7c6b7ef29
```

### Test Vector 3: JSON normalization + base64

```
input:  {"Relays": ["wss://a.com", "wss://b.com"], "rate_limit": 0, "shadow_pubkey": false}
        ↓ lowercase keys, sort, remove value=0 (keep false), compact
norm:   {"relays":["wss://a.com","wss://b.com"],"shadow_pubkey":false}
→ base64: eyJyZWxheXMiOlsid3NzOi8vYS5jb20iLCJ3c3M6Ly9iLmNvbSJdLCJzaGFkb3dfcHVia2V5IjpmYWxzZX0=
```

### Test Vector 4: Username key derivation

```
username:     alice
salt (hex):    c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3
scrypt params: N=16384, r=6, p=1, dklen=32
→ priv_hex:   0f8818dffe81ffd0c2a24737628ff1dee85c323a4af223db3d002da0a566b838  (valid, no re-hash needed)
→ pub_hex:    b42d65b2bcc7860b4817d069698c3b246d890d24367a898879cd185e006083a7
```

---

## Privacy

See [privacy documentation](/privacy.md) for a complete overview of what is stored, how, and for how long.

Key points:
- No plaintext pubkeys stored (only scrypt hashes)
- Feature configs stored NIP-44 encrypted
- No email content persisted
- No IP addresses logged (Postfix → `/dev/null`, nginx → off)

---

## EU Restriction

Paid features are not offered to EU residents. EU IPs get Free Tier only. `/checkout` and `/renew` return `403 not_available_in_eu`.

---

## Feedback Channel

There is a public feedback channel for bug reports, feature ideas, complaints, or praise.
Feedback is delivered as a **NIP-04 encrypted DM** to the service feedback key.

### How it works

You publish the feedback DM yourself — directly to Nostr relays. There is no
gateway endpoint for publishing; the browser (or your agent) publishes the
event to the default relay pool.

1. Generate an **ephemeral keypair** (use a fresh key for each message)
2. Encrypt a JSON payload to the feedback pubkey via NIP-04
3. Construct a **kind 4 event** tagged to the feedback pubkey
4. Sign the event with the ephemeral secret key
5. Publish to the default relays (see below)

### Feedback pubkey

```
130f5542467ccb58f1b393d064c3820276d936eb39c2c1e0aeb60b81ac8dae28
```

### Default relays (publish to all for redundancy)

```
wss://relay.damus.io
wss://relay.primal.net
wss://relay.nostr.net
wss://nostr.oxtr.dev
wss://no.str.cr
```

### JSON payload schema

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `category` | string | ✅ | One of: `bug`, `idea`, `complaint`, `praise`, `other` |
| `message` | string | ✅ | Free-text feedback body |
| `timestamp` | string | ✅ | ISO-8601 (e.g. `2026-07-19T16:50:04.123Z`) |
| `url` | string | ❌ | Page URL the feedback was sent from |
| `user_agent` | string | ❌ | Browser user agent |
| `npub` | string | ❌ | Optional contact npub for follow-up |

### Status reporting

Publishing counts as successful when **at least 1 relay** returns `OK`. For
redundancy, publish to all 5 default relays and report `N/5 succeeded` to the
user.

**Privacy:** ephemeral keys provide forward secrecy — the sender identity is not linked
to the feedback content. Including an optional `npub` is the only way to receive a follow-up.
