Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HMTP

HTTP Mail Transfer Protocol: a minimal self-hosted mail node over HTTP. No SMTP.

Protocol specification: 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, including a test vector for writing interoperable implementations in other languages. Read the article 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.

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
Loading

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.

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
Loading

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.

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 ...:

python3 -m venv .venv
source .venv/bin/activate
pip install -e .

Option C: Docker. No Python on the host at all; see Docker below.

1. Create your identity and start your node

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):

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

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:

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:

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):

# 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:

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:

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:

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:

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:

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:

uv run hmtp init you@yourdomain.com https://yourdomain.com

With plain Python (create the venv as in the quickstart first):

.venv/bin/hmtp init you@yourdomain.com https://yourdomain.com

With Docker:

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 section), so this step and the next are one command:

docker compose up -d

With uv. Create a systemd unit, /etc/systemd/system/hmtp.service:

[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:

ExecStart=/opt/hmtp/.venv/bin/hmtp serve

Then enable it:

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:

*/5 * * * * cd /opt/hmtp && /usr/local/bin/uv run hmtp flush

With plain Python:

*/5 * * * * cd /opt/hmtp && .venv/bin/hmtp flush

7. Verify

From anywhere, your identity document must be public:

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:

uv run hmtp send you@yourdomain.com "production ping"
uv run hmtp list

With plain Python:

.venv/bin/hmtp send you@yourdomain.com "production ping"
.venv/bin/hmtp list

With Docker:

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

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.

License

GPLv3

About

A minimal self-hosted mail node over HTTP. One file, no SMTP.

Topics

Resources

Stars

37 stars

Watchers

0 watching

Forks

Sponsor this project

Contributors

Languages