No description
Find a file
2026-10-03 22:24:26 +03:00
docker/postfix feat: add Postfix/pneumatube container rig with live recipient rejection 2026-10-03 17:58:29 +03:00
scripts feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
src/pneumatube feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
tests feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
.dockerignore feat: add Postfix/pneumatube container rig with live recipient rejection 2026-10-03 17:58:29 +03:00
.gitignore feat: add pneumatube gateway bridging smolmail and SMTP via LMTP 2026-10-03 17:22:14 +03:00
devbox.json feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
docker-compose.yml feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
Dockerfile feat: add Postfix/pneumatube container rig with live recipient rejection 2026-10-03 17:58:29 +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: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
pyproject.toml feat: add pneumatube gateway bridging smolmail and SMTP via LMTP 2026-10-03 17:22:14 +03:00
README.md feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs 2026-10-03 22:24:26 +03:00
uv.lock feat: add pneumatube gateway bridging smolmail and SMTP via LMTP 2026-10-03 17:22:14 +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/pneumatube/__main__.py 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.

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 the reference client, smolmail.py, 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." | \
  smolmail.py 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:

smolmail.py accept gateway@smol.example.org
echo "hi" | smolmail.py 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

uv run pytest (or devbox run test) — 27/27 passing. Covers address validation and header-injection rejection (smtp_client), frontmatter sanitization (email_to_smol), MIME body extraction, the accepted-recipient gate (email_to_smol), per-recipient LMTP reply assembly and the accepted-recipient RCPT reject (smtp_server), the Postfix tcp_table(5) lookup protocol including the same gate (lookup_server), and failure isolation plus the accepted-sender tier gate 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.

  • Healthy at low-to-moderate concurrency: ~5 concurrent senders sustains ~140 msg/s with zero errors on both directions.
  • Found and fixed: EmailToSmolBridge.user_exists resolved a recipient's identity at RCPT time and discarded the result, so forward() resolved the same username again moments later — doubling the network round trips (each a full TCP + Noise handshake, smolmail.py has no connection pooling) for every message. Fixed by caching the resolved identity as a contact, the same trust-on-first-use pattern smolmail.py's own CLI already uses.
  • A real scaling ceiling, not a pneumatube bug: at concurrency ≈30, throughput collapses to mass client-side timeouts. The likely cause (via code inspection, not a clean isolated reproduction): smolmail's reference server (smolmaild.py) uses Python's socketserver.ThreadingTCPServer with its default backlog of 5, uncorrected, compounding with pneumatube's own single-worker-thread serialization (worker.py, by design, for sqlite thread-safety). Worth pinning down further before relying on sustained throughput much above a handful of messages/sec against the reference server.
  • Both targets pass cleanly at healthy concurrency: correctness verified (every expected message delivered exactly once, no loss or duplication) on the direct path and through the full Postfix → LMTP → pneumatube path.

Security test (scripts/security_test.py, devbox run security)

11 checks: 8 PASS (defense verified), 3 RISK (confirmed, accepted design tradeoffs — not bugs, but worth knowing they're real), 0 BUG.

PASS:

  • Address validation (smtp_client.validate) rejects every CR/LF/NUL injection variant tried, not just the literal \r\n case.
  • The frontmatter sanitizer (email_to_smol._single_line) strips CR/LF from every payload tried.
  • Python's EmailMessage rejects embedded CR/LF in header values — confirmed as a real backstop, since smtp_client.send() has no explicit sanitizer of its own and otherwise relies entirely on smolmail's frontmatter format structurally preventing multi-line values.
  • extract_text survives a battery of malformed MIME (empty messages, non-UTF8 bytes, deeply nested multipart, multipart with no text part) without raising or stalling.
  • Found and fixed: the smolmail → email poll loop had no per-message failure isolation — unlike the email → smolmail side (smtp_server.py, already correct), one message raising an exception _relay()'s own except clause didn't cover would propagate out of poll_once() entirely. Since the poisoned message is only ever deleted on success, it would sort first on every subsequent poll and jam the entire direction forever. Not confirmed reachable via the documented frontmatter format today, but fixed regardless as defense-in-depth with a regression test proving later messages still get processed after an earlier one fails.
  • Found and fixed: the smolmail → email direction relayed any sender by default, including first-contact senders with no accept token — confirmed live (15/15 first-contact sends reached the outbound sink). Fixed with PNEUMATUBE_REQUIRE_ACCEPTED_SENDER (default 1): poll_once() now polls only the main/accepted tier (SPEC.md §5.8), so a sender the gateway account hasn't explicitly accepted is left alone. Re-verified live: 0/15 first-contact sends reached the sink with the new default. Note the gateway never replies to smolmail senders, so accepting someone is a two-step process — smolmail.py accept <address> alone doesn't retroactively unblock a message already sitting in the local inbox, and the sender's next message won't carry a valid token either until the operator also sends them one message back (that's how the token actually reaches them, per the protocol).
  • Found and fixed: Email-From (the email → smolmail direction's frontmatter field carrying the original email's MAIL FROM) has been removed entirely. MAIL FROM is never authenticated, so forwarding it into a field that looks like gateway-attested metadata let any email sender forge an apparently-trustworthy "from" inside a signed smolmail message. Re-verified live with a forged security-team@trusted-bank.example: the relayed message now carries no field reflecting it at all.
  • Found and fixed: the email → smolmail direction relayed to any registered recipient by default, with no throttling and no way for a recipient to opt out. Fixed with PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT (default 1): reuses the real smolmail accept-token mechanism (SPEC.md §5.8) rather than inventing a new one — pneumatube already needs to know locally whether it holds a token for a recipient to attach a valid accept MAC on cmd_send, and that same fact is exactly "has this recipient accepted the gateway". A recipient who hasn't is now rejected live at RCPT TO (an all-or-nothing gate, not a demotion: without it, the message would still land in the recipient's own requests tier; with it, they get nothing at all). Checked in both handle_RCPT and the Postfix lookup service, so Postfix itself rejects live rather than generating a bounce later. Re-verified live: a registered-but-never-accepted recipient is now rejected with outcome=rejected, where it previously always succeeded.

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 above (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 above (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.