|
|
||
|---|---|---|
| docker/postfix | ||
| scripts | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| devbox.json | ||
| docker-compose.yml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| THIRD_PARTY_NOTICES.md | ||
pneumatube
A gateway bridging a smolmail account and an SMTP mailbox: smolmail messages carrying an Email-To frontmatter field are relayed to that address over SMTP, and mail sent to <username>@<email domain> is relayed as a smolmail message to <username> if that user is registered on the configured smolmail server. See src/main.rs and src/config.rs for the full environment variable list, docker-compose.yml/docker/postfix/ for a Postfix-fronted container deployment, and flake.nix for a NixOS deployment with no containers.
The implementation is Rust on fumi-core — the smolmail client library that also powers the fumi CLI — reached as a Cargo path dependency on a sibling checkout (../fumi/core, the same convention the nix flake and container build spell out). The email side is self-contained: a hand-rolled LMTP listener, a minimal outbound SMTP client (STARTTLS and AUTH LOGIN included, rustls underneath), and a mail-parser based body extractor. The gateway's own smolmail state (gateway.db) is a fumi store, so every fumi CLI command works against it for bootstrap and manual review.
Using the gateway as a registered smolmail user
This assumes a smolmail account already exists, and that the gateway's smolmail address (e.g. gateway@smol.example.org) and the email domain it answers to (e.g. example.com) are known — the operator can provide both. Everything below uses fumi, the same way it's used for anything else.
Sending email through the gateway
Send the gateway a message with an Email-To frontmatter field naming where it should go:
echo "Hi there, this is coming in from smolmail." | \
fumi send gateway@smol.example.org --subject "Hello" --header "Email-To: someone@example.com"
A few things that aren't obvious from the outside:
- No
Email-To, no relay. A message without it is never forwarded — it just sits there for the operator to notice, never bounced back to the sender. - The recipient won't see the sender's smolmail identity. The email arrives
From:the gateway's own fixed address, the same for every user of that gateway, not something tied to whoever sent it. A sender who wants to be identified needs to say so in the body. - By default, a sender has to be accepted first. Most gateways only relay mail from senders they've explicitly accepted (SPEC.md §5.8) — the sender needs to ask the operator to accept their address. One wrinkle worth knowing about: being accepted isn't quite enough on its own. The accept token that makes the sender's next message count only arrives once the gateway has sent something back, so if a message still doesn't go through right after being accepted, the operator needs to send a quick reply first. (Some gateways are run deliberately open with this requirement turned off — in that case none of this applies and anyone can use it.)
Receiving email through the gateway
Mail sent to <username>@example.com reaches that registered user as a smolmail message, but by default only once the user has accepted the gateway:
fumi accept gateway@smol.example.org
echo "hi" | fumi send gateway@smol.example.org
Both steps matter, not just the first. Accepting the gateway only changes the user's own server's tiering for mail from it; the gateway has no way to know that happened until it sees it reflected in a message from the user (same §5.8 mechanism as above), which means sending it at least one message. Until both are done, inbound email addressed to that user is rejected outright — not queued, not quietly demoted to some low-priority folder, just refused before it's even accepted.
Once that's done, the smolmail messages received this way won't say who originally sent the email — that's deliberate, since an email's claimed sender is never something the gateway can verify, so it doesn't forward a claim it can't back up. If the original sender identified themselves, that'll only show up in whatever text they wrote, not in a field that can be trusted on its own.
(Again: some gateways run with this requirement turned off, relaying to anyone registered regardless of acceptance — it depends on how that gateway is configured.)
Test Results
Unit tests
cargo test (or devbox run test) — 32 passing, offline (stubs plus fumi's in-memory store). Covers address validation including the CR/LF/NUL/unicode-whitespace injection battery and the CR/LF header-value backstop (smtp_client), frontmatter sanitization, MIME body extraction including the malformed-MIME battery (empty, binary, 50-deep nesting, declared-huge-missing-body) (email_to_smol), the accepted-recipient token gate including default-port normalization against a real fumi store (email_to_smol), per-recipient LMTP reply assembly, the accepted-recipient RCPT reject, command parsing and dot-unstuffing (smtp_server), the Postfix tcp_table(5) lookup protocol including the same gate and %XX quoting round-trips (lookup_server), and failure isolation plus the accepted-sender tier selection in the smolmail → email poll loop (smol_to_email).
Stress test (scripts/stress_test.py, devbox run stress)
Concurrent load against pneumatube directly, and through the real Postfix container rig (devbox run stress-postfix). The throwaway server is bunshin; the load-driving sender and recipient accounts use the reference smolmail.py client in-process, which doubles as an interop check of the Python reference client against the Rust gateway and Rust server. Needs bunshin and fumi binaries on PATH and a smolmail checkout next to this repo.
- Direct target, default concurrency 20 (200 messages per phase): both phases correct (133 ok / 67 rejected exactly as constructed; 200/200 relayed to the outbound sink, no loss or duplication). Email → smolmail sustains ~88 msg/s — each accepted recipient paying a live connect + Noise handshake + RESOLVE at RCPT time — and smolmail → email SENDs land at ~335 msg/s.
- No serialization point left: the Python implementation serialized every smolmail operation onto one worker thread (sqlite thread-affinity of the vendored client). fumi's store is
Send + Sync(mutex + WAL) and every dial is its own Noise session, so the Rust port's concurrency is capped only by the smolmail server on the other end. The old concurrency ≈30 collapse against the referencesmolmaild.py(ThreadingTCPServer backlog of 5) does not reproduce againstbunshin: concurrency 30 completes both phases with zero errors and full drain. - The poll loop relays one message per SMTP conversation, sequentially by design; the harness's drain deadline is sized for that (~150 ms per relay against a local sink).
Security test (scripts/security_test.py, devbox run security)
Live checks against a throwaway rig (same fixtures as the stress test): 3 PASS (defense verified), 3 RISK (confirmed, accepted design tradeoffs — not bugs, but worth knowing they're real), 0 BUG. The unit-style checks the Python version ran in this script live in cargo test now (see Unit tests above).
PASS:
- The smolmail → email direction relays no first-contact sender by default: 0/15 never-before-seen senders with
Email-Tofields reached the outbound sink withPNEUMATUBE_REQUIRE_ACCEPTED_SENDERat its default. - The email → smolmail direction rejects a registered-but-never-accepted recipient live at RCPT TO (outcome=rejected, not queued-then-bounced) with
PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENTat its default. - A forged
MAIL FROM(security-team@trusted-bank.example) leaves no trace in the relayed message's frontmatter — the email → smolmail direction carries noEmail-Fromfield at all, sinceMAIL FROMis never authenticated.
RISK (confirmed, accepted tradeoffs):
- The Postfix lookup service unambiguously reveals which usernames exist, with no auth or rate limiting of its own — correct and necessary for
check_recipient_accessto work, but only safe because it's loopback-only. This requirement is load-bearing, not cosmetic: never exposePNEUMATUBE_LOOKUP_HOST/PNEUMATUBE_LOOKUP_PORTbeyond Postfix. - Open relay, smolmail → email, opt-in: setting
PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0deliberately restores the open-bridge behavior (confirmed live: 15/15 first-contact sends relayed) — an accepted tradeoff for an operator who wants a public bridge, not a default. - Open relay, email → smolmail, opt-in: setting
PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0deliberately restores the "relay to anyone" behavior (confirmed live: 15/15 distinct-sender messages relayed to an unaccepted recipient) — same tradeoff, operator's explicit choice. Note there is still no per-sender throttling in either mode: the gate is per-recipient (have they accepted the gateway at all), not per-sender, so an accepted recipient remains reachable by any external sender once opted in.