No description
Find a file
2026-10-05 18:05:04 +00:00
docker/postfix feat: add Postfix/pneumatube container rig with live recipient rejection 2026-10-03 17:58:29 +03:00
scripts feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
src feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
.gitignore feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
Cargo.lock feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
Cargo.toml feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
devbox.json feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
docker-compose.yml feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
Dockerfile feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
flake.lock feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
flake.nix feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
README.md feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00
THIRD_PARTY_NOTICES.md feat: port pneumatube to Rust on fumi-core 2026-10-05 12:20:26 +03:00

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 reference smolmaild.py (ThreadingTCPServer backlog of 5) does not reproduce against bunshin: 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-To fields reached the outbound sink with PNEUMATUBE_REQUIRE_ACCEPTED_SENDER at 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_RECIPIENT at 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 no Email-From field at all, since MAIL FROM is 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_access to work, but only safe because it's loopback-only. This requirement is load-bearing, not cosmetic: never expose PNEUMATUBE_LOOKUP_HOST/PNEUMATUBE_LOOKUP_PORT beyond Postfix.
  • Open relay, smolmail → email, opt-in: setting PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0 deliberately 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=0 deliberately 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.