Files

392 lines
16 KiB
Markdown

# HMTP
**HTTP Mail Transfer Protocol**: a minimal self-hosted mail node over HTTP. No SMTP.
Protocol specification: [SPEC.md](SPEC.md)
HMTP is a thought experiment turned into working code: what would email look like if it were designed today, on top of the tools we already have? Nothing in this node is invented; every piece is a standard already deployed at scale:
| Problem | Existing technology | Who uses it today |
|---|---|---|
| Transport and status codes | HTTP | The whole web |
| Transport encryption | TLS + Let's Encrypt | The whole web |
| User discovery | A `.well-known` document | WebFinger, Mastodon |
| Message delivery | POST to an inbox | ActivityPub |
| Sender verification at the source | The Webmention / DKIM pattern | IndieWeb, all email |
| Signatures | Ed25519 | SSH, Signal |
| Content encryption | X25519 sealed boxes | age, Signal |
| Deduplication | Content-addressed ids | Git, IPFS |
| First-contact consent | Message requests | Signal, Instagram |
| Mailbox storage | SQLite | Every phone on Earth |
It never talks to SMTP: it only federates with other HMTP nodes.
Your identity is `user@domain`. The domain serves `GET /.well-known/hmtp/<user>` with your inbox URL and your public keys (this doubles as an MX record: the inbox can live on any host). A message is a visible envelope `{from, to, date}` plus a sealed payload carrying the subject and the body, encrypted to the recipient's X25519 key (ChaCha20-Poly1305): the receiving server stores ciphertext it cannot read, and the subject travels as protected as the body (PGP left it in the clear for decades; we don't). The `id` is the SHA-256 of the canonical plaintext, computed before sealing, so every copy of a message shares the same id, thread references match across nodes and retries are idempotent. The Ed25519 `signature` covers the envelope, the id and the ciphertext: the receiver fetches the sender's key from the sender's domain and verifies before accepting, and the recipient re-checks the id against the plaintext after unsealing. Delivery is a `POST` to the recipient's inbox. `201` delivered, `200` duplicate, `401` bad signature, `503` sender keys unreachable (retry later), `413` too large.
Version 1 also covers encrypted attachments by reference (blobs mirrored by the recipient's server at delivery, deferred for strangers), optional postage stamps for strangers (`402`), a signing-key rotation chain with per-sender continuity pins, per-device sealed copies, and a token-authenticated read endpoint for your devices.
The exact wire format (canonical JSON, ids, signatures, sealing, attachments, postage, rotation, status codes, verification duties) is specified in [SPEC.md](SPEC.md), including a test vector for writing interoperable implementations in other languages. Read the article [Modern email can be built from borrowed parts](https://en.andros.dev/blog/d7ed8b07/modern-email-can-be-built-from-borrowed-parts/) for the design rationale.
## Architecture
The codebase follows clean architecture: business logic in `core`, the outside world in `infra`, dependencies always pointing inwards. Use cases receive their gateways (storage, network, blob store, config) as injected interfaces and return plain dictionaries (`{type, errors, data}`); exceptions never cross a layer boundary.
```
hmtp/
core/
entities/ # constants, canonical JSON, ids, crypto: pure logic
gateways/ # Protocols the use cases depend on
use_cases/ # one file per operation: send, receive, rotate, accept...
infra/
database/ # SQLite repository
filesystem/ # config.json and blob storage
gateways/ # httpx network client (SSRF guard lives here)
api/flask/ # the HTTP node: discovery, inbox, mailbox, blobs
cli/click/ # the command line
```
Swapping Flask, SQLite or httpx touches only `infra`; the protocol logic and its tests never change.
```mermaid
flowchart LR
subgraph infra["infra (external interfaces)"]
C["CLI with Click"]
F["HTTP node with Flask"]
S[("SQLite / files / httpx")]
end
subgraph core["core (business logic)"]
U["use cases"]
P["gateway Protocols"]
E["entities: canonical JSON, ids, crypto"]
end
C --> U
F --> U
U --> E
U --> P
P -. implemented by .-> S
```
## How a delivery travels
The whole protocol fits in one exchange: two GETs to `.well-known` (discovery and verification), one POST (the delivery), and a queue on the sender's side when the destination is down.
```mermaid
sequenceDiagram
autonumber
participant Ana as Ana's client
participant SA as Ana's node
participant SB as Bob's node
Ana->>SA: hmtp send (sign + seal)
SA->>SB: GET /.well-known/hmtp/bob
SB-->>SA: inbox, keys, rotations
SA->>SB: POST /hmtp/inbox/bob (envelope + sealed content)
Note over SB: down: no response
Note over SA: queued: exponential backoff
SA->>SB: POST /hmtp/inbox/bob (retry, same id)
SB->>SA: GET /.well-known/hmtp/ana
SA-->>SB: Ana's signing key + rotation chain
Note over SB: signature verified, key continuity checked,<br/>deduplicated by id
SB-->>SA: 201 delivered
```
## Quickstart
### 0. Prerequisites
Clone the repo and pick one of the three ways to run the node. Nothing else is needed for a local try-out; to federate with other nodes on the internet you will also need a domain with HTTPS in front, see [Production](#production).
```bash
git clone https://github.com/tanrax/hmtp.git
cd hmtp
```
**Option A: uv (recommended).** There is nothing to install: `uv run hmtp ...` resolves the project and its dependencies on first use. The examples below use this form.
**Option B: plain Python (3.11+).** Create a virtualenv, install the project, and use `hmtp ...` (or `.venv/bin/hmtp ...` without activating) wherever the examples say `uv run hmtp ...`:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```
**Option C: Docker.** No Python on the host at all; see [Docker](#docker) below.
### 1. Create your identity and start your node
```bash
export HMTP_INSECURE=1 # local test only: plain HTTP, SSRF guard off
uv run hmtp init me@localhost:8025 http://localhost:8025
uv run hmtp serve
```
Leave `serve` running. Your node now publishes your address and signing key at `http://localhost:8025/.well-known/hmtp/me` and accepts deliveries on `/hmtp/inbox/me`.
### 2. Send a message
In another terminal (also with `HMTP_INSECURE=1` exported):
```bash
uv run hmtp send me@localhost:8025 "Hello, world. Signed and delivered."
# delivered sha256:a39442a5ad64f1351892200b41da1b21f332de9fb234d54727c2e0ddefef5f6e
```
Yes, you just mailed yourself, and that exercised the whole protocol: the sender discovered the inbox through the `.well-known` document, sealed the subject and the body to your published encryption key, signed the message with your Ed25519 key, delivered it with a POST, and the receiving side fetched the key back from the sender's address and verified the signature before accepting. On disk, subject and body are ciphertext; only `list` can read them.
### 3. Read your mail
```bash
uv run hmtp list
# == inbox ==
# [2026-07-27T05:49:46+00:00] me@localhost:8025 (sha256:a39442a5ad64)
# Hello, world. Signed and delivered.
# == requests ==
```
Mail from senders you never wrote to lands in `requests` instead of `inbox`; promote a sender with `uv run hmtp accept <address>`. Reply to any message with `uv run hmtp reply <message-id> <text>` (the id is the `sha256:` shown by `list`): the reply carries the thread reference and a `Re:` subject. Subjects go on new mail with `-s`: `uv run hmtp send <address> -s "Subject" <text>`. If a delivery fails because the destination node is down, it is queued; `uv run hmtp flush` retries with exponential backoff.
For a real conversation between two different mailboxes, see the demo below.
## Demo: two nodes exchanging mail on one machine
Start two nodes, Ana and Bob, and make them exchange signed mail, including the contact-request flow.
Create both identities:
```bash
export HMTP_INSECURE=1
HMTP_HOME=/tmp/hmtp-a uv run hmtp init ana@localhost:8025 http://localhost:8025
HMTP_HOME=/tmp/hmtp-b uv run hmtp init bob@localhost:8026 http://localhost:8026
```
Run each node in its own terminal:
```bash
HMTP_HOME=/tmp/hmtp-a uv run hmtp serve 8025
HMTP_HOME=/tmp/hmtp-b uv run hmtp serve 8026
```
And in a third terminal (also with `HMTP_INSECURE=1`):
```bash
# Ana writes to Bob. Bob doesn't know her, so it lands in requests
HMTP_HOME=/tmp/hmtp-a uv run hmtp send bob@localhost:8026 "Hi Bob, testing hmtp"
HMTP_HOME=/tmp/hmtp-b uv run hmtp list
# Bob accepts Ana and replies. Ana already welcomed his replies
# (writing to someone accepts their answers), so it goes straight to her inbox
HMTP_HOME=/tmp/hmtp-b uv run hmtp accept ana@localhost:8025
HMTP_HOME=/tmp/hmtp-b uv run hmtp send ana@localhost:8025 "Hi Ana, received and signed"
HMTP_HOME=/tmp/hmtp-a uv run hmtp list
```
To see store and forward in action: kill Bob's node, send from Ana (you will see `queued (recipient node unreachable)`), start Bob's node again and run `flush` on Ana's side. The message gets delivered. In production that `flush` lives in a cron entry, with exponential backoff between attempts.
## Docker
If you prefer containers, the repo ships a `Dockerfile` and a `compose.yaml`. Create your identity once, then bring the node up:
```bash
docker compose run --rm hmtp init you@yourdomain.com https://yourdomain.com
docker compose up -d
```
This starts two containers: the node itself, published on `127.0.0.1:8025` (put your Nginx in front of it, see below), and a `flush` sidecar that retries queued deliveries every 5 minutes, so you don't need cron. Keys and mail live in `./data` on the host; back up that folder.
The CLI works through the same image:
```bash
docker compose run --rm hmtp send bob@example.org "hello from a container"
docker compose run --rm hmtp list
docker compose run --rm hmtp accept ana@example.com
```
## Production
Goal: a real node answering at `you@yourdomain.com`. In production there is no `HMTP_INSECURE`: TLS comes from Nginx + Let's Encrypt, and the node itself listens on localhost only, behind the proxy.
### 1. Point your domain at the server
Create an `A` (and `AAAA` if you have IPv6) DNS record for `yourdomain.com` pointing to your server's IP. Your address lives on this domain: other nodes will fetch `https://yourdomain.com/.well-known/hmtp/you` to verify your signatures.
### 2. Install Nginx and get a certificate
On a Debian/Ubuntu server:
```bash
sudo apt install nginx certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com
```
Certbot creates the HTTPS server block and keeps the certificate renewed. (Its own challenge uses `/.well-known/acme-challenge/`, which does not clash with `/.well-known/hmtp/`.)
### 3. Proxy the two HMTP routes
Inside the `server { listen 443 ssl; ... }` block that certbot configured, add:
```nginx
location /.well-known/hmtp/ { proxy_pass http://127.0.0.1:8025; }
location /hmtp/ { proxy_pass http://127.0.0.1:8025; }
```
Then check and reload:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
### 4. Create your identity
Clone the repo on the server (e.g. into `/opt/hmtp`) and initialize with your real address and public URL.
With uv:
```bash
uv run hmtp init you@yourdomain.com https://yourdomain.com
```
With plain Python (create the venv as in the quickstart first):
```bash
.venv/bin/hmtp init you@yourdomain.com https://yourdomain.com
```
With Docker:
```bash
docker compose run --rm hmtp init you@yourdomain.com https://yourdomain.com
```
Keys and mail land in `~/.hmtp` (or `./data` with Docker). Back that up.
### 5. Run the node as a service
**With Docker (simplest).** Everything is already wired in `compose.yaml` (see the [Docker](#docker) section), so this step and the next are one command:
```bash
docker compose up -d
```
**With uv.** Create a systemd unit, `/etc/systemd/system/hmtp.service`:
```ini
[Unit]
Description=hmtp node
After=network.target
[Service]
User=you
WorkingDirectory=/opt/hmtp
ExecStart=/usr/local/bin/uv run hmtp serve
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
**With plain Python.** The same unit, with this `ExecStart` instead:
```ini
ExecStart=/opt/hmtp/.venv/bin/hmtp serve
```
Then enable it:
```bash
sudo systemctl enable --now hmtp
```
The node serves through waitress, a production-grade WSGI server, so there is nothing to swap for real traffic.
### 6. Schedule the retry queue
Docker users already have the `flush` sidecar. Otherwise, one cron line (`crontab -e`).
With uv:
```bash
*/5 * * * * cd /opt/hmtp && /usr/local/bin/uv run hmtp flush
```
With plain Python:
```bash
*/5 * * * * cd /opt/hmtp && .venv/bin/hmtp flush
```
### 7. Verify
From anywhere, your identity document must be public:
```bash
curl https://yourdomain.com/.well-known/hmtp/you
# {"address": "you@yourdomain.com", "inbox": "https://yourdomain.com/hmtp/inbox/you", "signing_key": "...", "encryption_key": "..."}
```
And from the server, mail yourself through the full public loop (discovery, signature, delivery, verification).
With uv:
```bash
uv run hmtp send you@yourdomain.com "production ping"
uv run hmtp list
```
With plain Python:
```bash
.venv/bin/hmtp send you@yourdomain.com "production ping"
.venv/bin/hmtp list
```
With Docker:
```bash
docker compose run --rm hmtp send you@yourdomain.com "production ping"
docker compose run --rm hmtp list
```
### 8. Rotate your keys when you need to
```bash
uv run hmtp rotate
```
One command, no coordination with anyone. Because receivers fetch your current key from your domain on every delivery (nothing is pinned), the new signing key is trusted by the whole network the moment the command returns, and the old one becomes useless to a thief just as instantly. The rotation also keeps your old encryption keys in `config.json` so `list` can still decrypt mail that was sealed to them, and re-signs any queued outgoing mail so it will verify against the new published key. Rotate on a schedule, after restoring a backup onto a new machine, or whenever you suspect a key leaked.
You are now a mail server. Total moving parts: Nginx, one script, SQLite.
## Commands
```
hmtp init <address> <public-base-url> create identity and database
hmtp serve [port] run the node (default 8025)
hmtp send <address> [-s <subject>] [-a <file>]... [--stamp <token>] <text>
hmtp reply <message-id> <text> reply to a message (threaded)
hmtp attachments <message-id> [dir] save and decrypt attachments
hmtp flush retry queued deliveries
hmtp list show inbox and contact requests
hmtp accept <address> accept a contact request
hmtp rotate replace signing and encryption keys
hmtp postage on|off require stamps from strangers
hmtp stamp issue a single-use postage stamp
hmtp token print the mailbox read token
hmtp device keygen generate a key pair for a device
hmtp device add <name> <public-key> publish a device encryption key
```
State lives in `$HMTP_HOME` (default `~/.hmtp`): `config.json` holds your keys, `hmtp.db` holds your mail. Back up both.
## What is deliberately left out
The remaining edges, listed in SPEC.md section 16: full JMAP synchronization (the read endpoint is deliberately minimal), payment rails for stamps (issuance is out of band), chunked encryption for large attachments, automated device enrollment, and timed re-anchor announcements. Everything else is in: end-to-end encryption of subject, body and attachments, threading, first-contact consent, postage for strangers, deduplication, exponential backoff, a signing-key rotation chain with continuity pins, multi-device copies, a production WSGI server. Each use case still fits on one screen, and the whole core reads in one sitting. A protocol you cannot extend on a Sunday afternoon would not deserve the experiment.
## Contributing
Contributions are welcome! Please see the [contribution guidelines](https://git.andros.dev/andros/contribute) for instructions on how to submit issues or pull requests.
## License
[GPLv3](LICENSE)