# Mail-To-Nostr — Complete Reference for Agents and Client Tools

## Overview

Mail-To-Nostr is an email-to-Nostr gateway. Send emails to `npub1...@<host>:10025` and they are delivered as encrypted Nostr Direct Messages. The service has a **Free Tier** (works out of the box) and **Paid Features** (activated via Lightning payments for 30 days).

---

## Free Tier

No purchase required. Every sender gets:

| Property | Free Tier |
|---|---|
| Forwarding | Email → NIP-04 encrypted DM |
| Encryption | NIP-04 (default) |
| Relays | Server default relays |
| Rate limit | 3 emails per day (IP-based) |
| Max email size | 5 MB |
| First-contact disclaimer | Attached automatically |

---

## Paid Features

Paid features are purchased by paying Lightning sats to a **BOLT12 Offer**. Each purchase activates features for **30 days**, after which they automatically revert to Free Tier. No subscription, no renewal — just pay again when needed.

### Available Features

You buy **quantities** (integers). At bind time, you configure **concrete values**.

| Feature | Key | Max qty | Bound value format | Description |
|---|---|---|---|---|
| Custom relays | `relays` | 10 | `["wss://relay1", "wss://relay2"]` | Additional Nostr relays to publish to |
| Encryption upgrade | `encryption` | 2 | `"nip44"` or `"nip04","nip44"` | Stronger encryption method(s) |
| Multiple recipients | `multiple_recipients` | 10 | `3` (int, count) | CC/BCC → multiple Nostr pubkeys |
| Rate limit removal | `rate_limit` | 1 | `1` (packet ID) | Relaxed or removed rate limits |
| Greylisting | `greylisting` | 1 | `true` (implicit) | Spam protection via SMTP greylisting. First delivery from unknown senders is deferred (450), retried after 60s delay. Reduces spam and phishing. |
| Anonymous sender | `anonymous_sender` | 2 | `true` (implicit) | Rotating sender keys. **Basic** (qty 1): deterministic key per recipient+purchase, rotates on repurchase. **Premium** (qty 2): fresh random key per message. Prevents correlation of sent events with the Gateway pubkey. |

### Validation rule

For each feature: `bound_count ≤ purchased_quantity`. For list-type features (relays, webhooks), `bound_count = len(list)`. For scalar types, `bound_count = 1`. Example: buy `relays: 3` → bind up to 3 relay URLs.

---

## How to Buy — BOLT12 Offer Flow

### Step 1: Obtain the BOLT12 Offer

The service operator publishes a static BOLT12 Offer string (starts with `lno1...`). This Offer is **reusable** — every customer pays to the same Offer. It may be displayed on a website, distributed via a client tool, or shared directly.

### Step 2: Prepare Your Payload

You need to build two things:

**A) Purchased features** (quantities, plaintext):
```json
{"relays": 3, "encryption": 1}
```

**B) Bound features** (concrete values, will be encrypted):
```json
{"relays": ["wss://damus.com", "wss://nos.lol", "wss://relay.damus.io"], "encryption": "nip44"}
```

Then compute:
- `hash_pubkey = sha256(recipient_pubkey_hex)`
- `encrypted_feature = nip44_encrypt(base64(normiert(bound_features)), gateway_pubkey, your_privkey)`
- `encrypted_purchased_features = nip44_encrypt(base64(normiert(purchased_features)), gateway_pubkey, your_privkey)`

### Step 3: Build the `payer_note`

The `payer_note` is a JSON string attached to the Lightning payment:

```json
{
  "v": 2,
  "pf": {"relays": 3, "encryption": 1},
  "hp": "a3f5b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
  "ef": "<NIP-44 encrypted bound features>",
  "epf": "<NIP-44 encrypted purchased features>"
}
```

| Field | Description |
|---|---|
| `v` | Payload version. Must be `2`. |
| `pf` | Purchased quantities. Keys must match the feature table above. |
| `hp` | `sha256(pubkey_hex)` of the recipient — hex string, 64 chars. |
| `ef` | NIP-44 encrypted `base64(normiert(bound_features))` — encrypted to Gateway's pubkey. |
| `epf` | NIP-44 encrypted `base64(normiert(purchased_features))` — encrypted to Gateway's pubkey. |

### Step 4: Pay

Use any Lightning wallet that supports **BOLT12 Offers** and allows setting a **payer note** (also called "payment note" or "message"). Pay the correct sat amount with the `payer_note` JSON as the note.

**Wallets known to support BOLT12 with payer note:**
- [AQUA Wallet](https://aquawallet.io/) (Liquid + Lightning, mobile)
- [Breez SDK](https://sdk.breez.technology/) based wallets (e.g. Breez app)
- Any wallet implementing [BOLT12 spec](https://github.com/lightning/bolts/blob/master/12-offer-encoding.md) with payer-amount-and-note support

> **Note:** BOLT12 is newer than BOLT11. Not all wallets support it yet. If your wallet doesn't support BOLT12 Offers or payer notes, ask the operator for alternatives.

### Step 5: Payment Processor → Gateway (Vorgang 1 — Blind Signer)

After payment confirmation, the Payment Processor (PP):

1. Validates the `payer_note` and checks the amount.
2. Computes `feature_hash = sha256(base64(normiert(purchased_features, remove_empty_keys=True)))` locally.
3. Calls the Gateway's Admin API to get a nonce — the Gateway acts as a **blind signer**:

```
POST http://gateway:8001/api/v1/admin/feature
Authorization: Bearer <PP_TOKEN>
Content-Type: application/json

{
  "feature_hash": "<sha256(base64(normiert(purchased_features, remove_empty_keys=True)))>",
  "purchased_at": "2026-06-24T12:00:00Z"
}

→ 200 OK
{
  "nonce": "<sha256(feature_hash + purchase_salt + purchased_at)>"
}
```

The Gateway uses a scoped **PP Token** (`MAIL_TO_NOSTR_PP_TOKEN`) that only allows nonce-signing — no access to bound features, stats, or admin operations. See [Interfaces — Rollen-Modell](doc/wiki/interfaces.md) for details.

5. Calls Vorgang 2 (`POST /api/v1/feature/bind`) to bind features to the pubkey.

Features activate immediately — no callback needed. You'll see the wallet's standard "Payment sent" notification.

---

## Normalization (`normiert()`)

All feature JSON is normalized before hashing and encryption. This ensures deterministic hashes regardless of key order or formatting.

**Rules:**
1. Keys lowercased (`"Relays"` → `"relays"`). Values unchanged.
2. Keys sorted alphabetically.
3. No whitespace: `json.dumps(obj, separators=(',', ':'))`.
4. `remove_empty_keys=True`: entries with value `0` are removed.

**Example:**
```python
Input:  {"Encryption": 1, "Relays": 3, "Domain": 0}
Output: {"encryption":1,"relays":3}  # domain removed (0), keys lower+sorted, no spaces
```

**Critical:** `remove_empty_keys=True` is always used for hash computation. If your normalization doesn't match exactly, the nonce verification will fail and features won't activate.

---

## Hashing

### hash_pubkey
```
hash_pubkey = sha256(pubkey_hex).hexdigest()
```
The pubkey is the **hex-encoded** Nostr public key (64 hex chars, not `npub1...` format). SHA-256 of that hex string. Result is a 64-char lowercase hex string.

### feature_hash (computed by the Payment Processor)
```
feature_hash = sha256(base64(normiert(purchased_features, remove_empty_keys=True)))
```
The Payment Processor computes this before calling the Gateway. The Gateway receives only this hash — it never sees the concrete features being purchased.

### nonce (computed by Gateway, blind to features)
```
nonce = sha256(feature_hash + purchase_salt + purchased_at)
```
The Gateway signs the `feature_hash` with its secret `purchase_salt`. The `purchase_salt` never leaves the Gateway. The Gateway acts as a **blind signer** — it stamps the hash without knowing what it represents.

---

## Ready-to-Use Python Code

```python
"""
mail-to-nostr-purchase.py — Build a payer_note for Mail-To-Nostr feature purchase.

Usage:
    python mail-to-nostr-purchase.py

Prerequisites:
    pip install nostr-sdk pynacl

Before running: set YOUR private key, the recipient pubkey, the Gateway pubkey,
and your feature selection below.
"""

import json
import hashlib
import base64
from nostr_sdk import Keys, PublicKey, SecretKey

# ═══════════════════════════════════════════════════════════════
# CONFIGURATION — fill these in
# ═══════════════════════════════════════════════════════════════

# Your Nostr private key (hex or nsec)
YOUR_PRIVKEY = "nsec1..."  # ← replace

# Recipient's Nostr public key (hex or npub)
RECIPIENT_PUBKEY = "npub1..."  # ← replace

# Gateway's Nostr public key (hex or npub)
GATEWAY_PUBKEY = "ff68da295ddde0dc08362af8e549341dc168be7919dbee53d1870e69d9c38fe8"

# Features to buy (quantities)
PURCHASED_FEATURES = {
    "relays": 3,
    "encryption": 1,
}

# Concrete feature values to bind
BOUND_FEATURES = {
    "relays": [
        "wss://damus.com",
        "wss://nos.lol",
        "wss://relay.damus.io",
    ],
    "encryption": "nip44",
}

# Price table (sats per unit — fetch live from PP `GET /prices`)
PRICE_TABLE = {
    "relays": 1000,
    "encryption": 500,
    "multiple_recipients": 1000,
    "rate_limit": 1000,
    "anonymous_sender": 2000,
}

# ═══════════════════════════════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════════════════════════════

def normiert(features: dict, remove_empty_keys: bool = True) -> str:
    """
    Normalize feature JSON for deterministic hashing.
    1. Lowercase keys (values unchanged)
    2. Sort keys alphabetically
    3. Remove entries with value 0 (if remove_empty_keys)
    4. Compact JSON (no whitespace)
    """
    normalized = {}
    for key, value in features.items():
        if remove_empty_keys and not isinstance(value, bool) and value == 0:
            continue
        normalized[key.lower()] = value
    sorted_features = dict(sorted(normalized.items()))
    return json.dumps(sorted_features, separators=(',', ':'))


def calculate_price(purchased: dict, prices: dict) -> int:
    """Calculate total sats from purchased features."""
    total = 0
    for feature, quantity in purchased.items():
        if quantity == 0:
            continue
        unit_price = prices.get(feature, 0)
        total += unit_price * quantity
    return total


def build_payer_note(
    your_privkey: str,
    recipient_pubkey: str,
    gateway_pubkey: str,
    purchased_features: dict,
    bound_features: dict,
) -> tuple[str, int]:
    """
    Build the payer_note JSON string and calculate the total price.

    Returns: (payer_note_json_string, total_sats)
    """
    keys = Keys.parse(your_privkey)
    recipient = PublicKey.parse(recipient_pubkey)
    gateway = PublicKey.parse(gateway_pubkey)

    # hash_pubkey = sha256(recipient pubkey hex)
    recipient_hex = recipient.to_hex()
    hash_pubkey = hashlib.sha256(recipient_hex.encode()).hexdigest()

    # Normalize + base64
    norm_purchased = normiert(purchased_features, remove_empty_keys=True)
    norm_bound = normiert(bound_features, remove_empty_keys=True)
    b64_purchased = base64.b64encode(norm_purchased.encode()).decode()
    b64_bound = base64.b64encode(norm_bound.encode()).decode()

    # NIP-44 encrypt to Gateway
    encrypted_feature = keys.encrypt_nip44(b64_bound, gateway)
    encrypted_purchased = keys.encrypt_nip44(b64_purchased, gateway)

    # Build payer_note
    payer_note = json.dumps({
        "v": 2,
        "pf": purchased_features,
        "hp": hash_pubkey,
        "ef": encrypted_feature,
        "epf": encrypted_purchased,
    }, separators=(',', ':'))

    # Calculate price
    total_sats = calculate_price(purchased_features, PRICE_TABLE)

    return payer_note, total_sats


# ═══════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════

if __name__ == "__main__":
    payer_note, total_sats = build_payer_note(
        your_privkey=YOUR_PRIVKEY,
        recipient_pubkey=RECIPIENT_PUBKEY,
        gateway_pubkey=GATEWAY_PUBKEY,
        purchased_features=PURCHASED_FEATURES,
        bound_features=BOUND_FEATURES,
    )

    print("═══════════════════════════════════════════════════")
    print("  Mail-To-Nostr — Feature Purchase Ready")
    print("═══════════════════════════════════════════════════")
    print()
    print(f"Amount to pay:   {total_sats} sats")
    print()
    print("payer_note (copy as payment note in your wallet):")
    print()
    print(payer_note)
    print()
    print("═══════════════════════════════════════════════════")
    print("Next steps:")
    print("  1. Open your BOLT12-compatible Lightning wallet")
    print("  2. Pay the BOLT12 Offer (get it from the operator)")
    print(f"  3. Set amount to {total_sats} sats")
    print("  4. Paste the payer_note above as the payment note")
    print("  5. Pay — features activate within seconds")
    print("═══════════════════════════════════════════════════")
```

---

## Pricing

Prices are configured by the service operator in `pp.yaml` and served live via the Payment Processor's public API:

```bash
curl https://payment-processor-host:9090/prices
```

**Response:**
```json
{
  "currency": "sats",
  "ttl_days": 30,
  "prices": {
    "relays": 1000,
    "encryption": 500,
    "domain": 5000,
    ...
  }
}
```

Clients should fetch prices at runtime rather than hardcoding them — the operator may adjust prices without notice. The PP validates the paid amount against this same table before activating features.

---

## 30-Day Validity & Renewal

Purchased features are active for **30 days** from the moment they are bound. After expiry, the feature record is deleted and the account reverts to Free Tier.

### Renewal (50% off, +30 days)

Active features can be renewed **before they expire** at **50% of the original price**. Renewal extends the expiry date by 30 days from the **current expiry** (not from today) — no overlap is lost.

**Example:** Feature expires Jul 26 → renew on Jul 20 → new expiry is Aug 25 (Jul 26 + 30 days).

#### Step 1: Request a Renewal Invoice

```bash
curl -X POST https://payment-processor-host:9090/renew \
  -H "Content-Type: application/json" \
  -d '{
    "pubkey": "npub1...",
    "nonce": "<your-purchase-nonce>"
  }'
```

**Response:**
```json
{
  "invoice": "lnbc500n1p3qqq...",
  "payment_hash": "abcdef0123...",
  "total_sats": 50,
  "features": {"rate_limit": 1}
}
```

#### Step 2: Pay

Open any Lightning wallet, paste the invoice, and pay. The total is 50% of the original feature prices combined.

#### Step 3: Check Renewal Status (optional)

```bash
curl https://payment-processor-host:9090/renew/<payment_hash>/status
```

```json
{"status": "success", "new_expires_at": "2026-08-25T23:59:59Z"}
```

**Constraints:**
- Only **active** (non-expired) features can be renewed. Expired features must be purchased anew.
- Renewal extends **all** bound features as a bundle — you cannot renew individual features separately.
- EU users: Renewal is not available (paid features are blocked for EU IPs).

---

## Checkout API (BOLT11 — Universal Wallet Support)

For users **without BOLT12-compatible wallets**, the Checkout API creates a standard BOLT11 Lightning invoice. This works with **any** Lightning wallet (AQUA, Phoenix, Breez, Zeus, Muun, etc.).

### Step 1: Prepare Your Payload

Same as the BOLT12 flow — compute `hash_pubkey`, `encrypted_feature`, `encrypted_purchased_features` locally.

### Step 2: Request an Invoice

```bash
curl -X POST https://payment-processor-host:9090/checkout \
  -H "Content-Type: application/json" \
  -d '{
    "pf": {"relays": 1},
    "hp": "<sha256(recipient_pubkey_hex)>",
    "ef": "<NIP-44 encrypted bound features>",
    "epf": "<NIP-44 encrypted purchased features>"
  }'
```

**Response:**

```json
{
  "invoice": "lnbc1000n1p3qqq...",
  "payment_hash": "abcdef0123...",
  "amount_sat": 1000,
  "expires_at": "2026-06-25T16:00:00Z"
}
```

### Step 3: Pay

Open **any** Lightning wallet, paste the `invoice` string, and pay. The invoice description shows "Mail-To-Nostr Feature Purchase".

### Step 4: Check Status (optional)

```bash
curl https://payment-processor-host:9090/checkout/<payment_hash>/status
```

```json
{"status": "success", "amount_sat": 1000}
```

Status values: `pending` → `paid` → `success` (or `failed`, `expired`).

Features activate immediately after payment confirmation. No callback needed.

### BOLT12 vs Checkout — which to use?

| Feature | BOLT12 Offer | Checkout API |
|---------|-------------|-------------|
| Wallet support | Breez SDK, CLN, agents | **Any** Lightning wallet |
| Payment note | Required (`payer_note` JSON) | Not needed |
| Invoice type | BOLT12 offer (reusable) | BOLT11 (single-use, expires) |
| Privacy | PP doesn't store purchase data | PP stores encrypted blobs temporarily |

If your wallet supports BOLT12 + payer notes, use the BOLT12 flow (more private). If not, use the Checkout API.

---

## Purchase Status Check

Check which features are active for a given pubkey + nonce combination. This is useful for verifying that a purchase was successful, checking remaining time, or determining whether renewal is available.

```bash
curl "https://payment-processor-host:9090/purchase-status?pubkey=npub1...&nonce=<your-nonce>"
```

**Response (found):**
```json
{
  "found": true,
  "features": {"rate_limit": 1, "encryption": 1},
  "is_active": true,
  "expires_at": "2026-07-26T23:59:59"
}
```

**Response (not found / wrong nonce):**
```json
{"found": false}
```

**Fields:**
- `pubkey` — your Nostr public key (`npub1...` or hex)
- `nonce` — the purchase nonce received at checkout completion
- `features` — map of feature keys to purchased quantities
- `is_active` — `true` if within the 30-day validity window
- `expires_at` — ISO timestamp when features expire

**Privacy:** The nonce serves as proof-of-purchase. Without the correct nonce, the endpoint returns `{"found": false}` — no information is leaked. Raw pubkeys are never persisted (only SHA-256 hashes).

---

## SMTP Usage (Port 25, STARTTLS)

Send emails to Nostr public keys:

```
To: npub1...@<host>
From: sender@example.com
Subject: Your subject

Message body here.
```

The SMTP server (Postfix on port 25) supports **STARTTLS** with a valid Let's Encrypt certificate (`mail.mail-to-nostr.com`). TLS-capable clients negotiate encryption automatically.

The Gateway validates the recipient, checks rate limits and active features, strips HTML/attachments (text only), then forwards as an encrypted NIP-04 DM.

For custom domains or custom email users (requires purchase):
```
To: npub1...@yourdomain.tld        (with domain feature)
To: alice@mail-to-nostr.com         (with email feature)
```

---

## Privacy & Data Storage

**What the Gateway stores:**
- `sha256(pubkey_hex)` — a one-way hash. Cannot be reversed to find the original pubkey.
- Feature configurations — **NIP-44 encrypted**. Only decryptable at processing time with the Gateway's private key.
- Purchase nonce — a hash derived from features + a secret salt. No plaintext data. The Gateway signs the hash without knowing which features it represents (blind signer).

**What the Gateway does NOT store:**
- Your Nostr public key (only the hash)
- Your email address
- Your IP address (rate limiting is in-memory, not persisted)
- Email content (processed and forwarded, never stored)
- Any plaintext feature values (only encrypted blobs)

**What the Payment Processor stores:**
- Payment hash and amount (for audit)
- The `payer_note` raw string (for debugging)
- `sha256(pubkey_hex)` (not the pubkey itself)

**What the Payment Processor does NOT store:**
- Your Nostr public key
- Feature values (they're encrypted in the `payer_note`)
- Any personal data

The Payment Processor and Gateway are operated by the same entity but use different databases. Neither stores your raw pubkey or plaintext feature values.

---

## EU Restriction

**This service is not offered to EU residents for paid features.**

Incoming connections from EU IP addresses are restricted to **Free Tier only**. Paid features will not activate for EU IPs, even if a valid purchase exists. This is a deliberate restriction based on regulatory considerations.

- The geo-check uses IP-based country lookup (good-faith effort, not foolproof)
- EU users can still use the Free Tier (3 emails/day, NIP-04 encryption)
- Lightning payments from EU wallets are not blocked at the payment layer, but features will not activate
- No refund obligation exists for payments made from EU IPs after this restriction takes effect

**EU member states:** AT, BE, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IE, IT, LV, LT, LU, MT, NL, PL, PT, RO, SK, SI, ES, SE.

This restriction does not constitute legal advice. The operator may modify or remove this restriction at any time.

---

## Disclaimer

This service forwards emails as Nostr messages. The operator is not responsible for:
- Content of forwarded emails (users are solely responsible)
- Delivery guarantees (Nostr is a best-effort relay network)
- Lightning payment failures (handled by the Lightning network, not this service)
- Feature activation delays due to network latency

First-contact emails include an automated disclaimer explaining that the message was forwarded via this service.

By using this service, you acknowledge that emails are processed, encrypted, and forwarded over public networks (SMTP, Nostr relays, Lightning). While encryption (NIP-04/NIP-44) protects message content, metadata (timing, approximate volume) may be observable.

---

## Error Handling

| Situation | What happens |
|---|---|
| Missing `payer_note` | Payment logged, no features activated. Contact operator. |
| Wrong `payer_note` version | Payment logged, rejected. Contact operator. |
| Amount too low | Payment logged, features not activated. Contact operator for refund. |
| Invalid NIP-44 encryption | Payment logged, bind fails. Verify your keys and retry. |
| Gateway unavailable | Payment Processor retries automatically. If persistent, contact operator. |

Automatic refunds are not yet implemented. If a payment fails validation, contact the service operator with your payment hash for manual resolution.
