feat: add sender/recipient accept gates, security+stress test harnesses, nix flake, and docs

This commit is contained in:
randogoth 2026-10-03 18:03:43 +03:00
parent a0950fab52
commit 5dda6567af
18 changed files with 1764 additions and 44 deletions

View file

@ -1,12 +1,19 @@
"""pneumatube: a gateway bridging one smolmail account and an SMTP mailbox.
smolmail -> email: a message the gateway receives with an Email-To
frontmatter field is relayed to that address over SMTP.
frontmatter field is relayed to that address over SMTP. By default this only
happens for senders the gateway account has accepted (SPEC.md S5.8;
PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=0 to relay first-contact senders too).
email -> smolmail: mail sent to <username>@PNEUMATUBE_EMAIL_DOMAIN is
relayed as a smolmail message to <username>@PNEUMATUBE_SMOLMAIL_DOMAIN, if
that username is registered there, with the original sender recorded in an
Email-From frontmatter field.
that username is registered there and (by default) has accepted the gateway
(SPEC.md S5.8; PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 for the softer,
smolmail-native requests-tier demotion instead of an outright reject -- see
email_to_smol.py's module docstring). The relayed message carries no claim
about the original email sender -- an email's MAIL FROM is unauthenticated,
so forwarding it into the message would let any sender forge an
apparently-trustworthy "from" inside a signed smolmail message.
See config.py for the full list of environment variables, including
PNEUMATUBE_LISTEN_PROTOCOL (lmtp, behind a real MTA, or smtp for local-dev

View file

@ -5,10 +5,11 @@ import os
from dataclasses import dataclass
from pathlib import Path
# Frontmatter is case-insensitive and flat (smolmail SPEC.md S5.5); these two
# custom keys are the only contract between the two directions of the bridge.
# Frontmatter is case-insensitive and flat (smolmail SPEC.md S5.5); this is
# the only contract the smolmail -> email direction relies on. The reverse
# direction deliberately carries no analogous custom field -- see
# email_to_smol.py's module docstring for why.
FRONTMATTER_EMAIL_KEY = "Email-To" # smolmail -> email: where to deliver
FRONTMATTER_SENDER_KEY = "Email-From" # email -> smolmail: who originally sent it
def _env(name: str) -> str:
@ -54,6 +55,17 @@ class Config:
gateway_from_address: str # From: header used on mail forwarded to SMTP
# If true (the default), only relay smolmail messages from senders the
# gateway account has accepted (SPEC.md S5.8) -- a first-contact sender
# with no accept token lands in the requests tier and is left alone.
# Set to false to run an intentionally open bridge instead.
require_accepted_sender: bool
# If true (the default), only relay inbound email to a smolmail user who
# has accepted the gateway (SPEC.md S5.8) -- an all-or-nothing RCPT-time
# reject, not a demotion. See email_to_smol.py's module docstring.
require_accepted_recipient: bool
def load() -> Config:
listen_protocol = os.environ.get("PNEUMATUBE_LISTEN_PROTOCOL", "lmtp")
@ -80,4 +92,7 @@ def load() -> Config:
smtp_relay_password=os.environ.get("PNEUMATUBE_SMTP_RELAY_PASSWORD"),
smtp_relay_starttls=os.environ.get("PNEUMATUBE_SMTP_RELAY_STARTTLS", "1") != "0",
gateway_from_address=_env("PNEUMATUBE_GATEWAY_FROM_ADDRESS"),
require_accepted_sender=os.environ.get("PNEUMATUBE_REQUIRE_ACCEPTED_SENDER", "1") != "0",
require_accepted_recipient=os.environ.get(
"PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT", "1") != "0",
)

View file

@ -1,9 +1,37 @@
"""email -> smolmail direction.
Confirms a username is registered on the configured smolmail server and, if
so, relays an inbound email to it as a smolmail message, carrying the
original sender's email address in the Email-From frontmatter field
(SPEC.md S5.5).
Confirms a username is registered on the configured smolmail server, and
that they have accepted the gateway's own smolmail identity (SPEC.md S5.8),
before relaying an inbound email to them as a smolmail message. This is an
all-or-nothing gate, not a demotion: without it, the gateway's SEND would
still go through and land in the recipient's own requests tier (still
delivered, just quota-limited); with it, pneumatube refuses the RCPT
entirely, so an unaccepted recipient gets nothing at all. Set
PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT=0 to fall back to that softer,
smolmail-native tiering instead.
Acceptance is checked locally, with no network round trip: cmd_send already
needs to know whether it holds a token for the recipient (SELECT token FROM
tokens WHERE address = ?) to decide whether to attach a valid accept MAC,
and that same row's presence is exactly "has this recipient accepted the
gateway". The recipient must do two things before this check passes, not
one: accept the gateway's address on their own smolmail account (which only
affects their own server's tiering), and then actually send the gateway
something -- the token only reaches pneumatube's local `tokens` table via a
message's signed Accept frontmatter field (learn_token(), run at FETCH time).
Accepting alone does not make pneumatube aware of it.
The relayed message deliberately carries no claim about the original email
sender: smolmail's own signature already tells the recipient this message
is authentically from the gateway's registered identity, which is the only
thing anyone here can vouch for. An email's MAIL FROM is never authenticated
(SMTP has no mechanism for that without SPF/DKIM enforcement upstream, which
this project does not configure), so forwarding it into a frontmatter field
that looks like gateway-attested metadata would let any email sender forge
an apparently-trustworthy "from" inside a signed smolmail message -- a
sender-spoofing vector, not a feature. If the recipient needs to know who
sent the original email, that has to come from the body text itself, which
is clearly just quoted content rather than something the signature backs.
"""
from __future__ import annotations
@ -12,7 +40,7 @@ import logging
from email.message import Message
from types import ModuleType
from .config import Config, FRONTMATTER_SENDER_KEY
from .config import Config
logger = logging.getLogger(__name__)
@ -30,28 +58,51 @@ class EmailToSmolBridge:
def user_exists(self, username: str) -> bool | None:
"""True/False for a definite answer; None when resolving itself
failed (pinning/network), which is not the same as "no such user"
and should not be treated as a permanent bounce."""
and should not be treated as a permanent bounce.
Caches the resolved identity as a contact (trust-on-first-use, same
as smolmail.py's own trust_key) so forward()'s cmd_send -- called a
moment later for the same username -- hits that cache instead of
paying for a second full connect-and-RESOLVE round trip. Each one is
a fresh TCP + Noise handshake (smolmail.py has no connection
pooling), so skipping the duplicate roughly halves the network cost
of this direction's hot path.
"""
try:
addr = self.smol.Address.parse(f"{username}@{self.config.smolmail_domain}")
with self.smol.connect(self.store, addr, require_pin=True) as conn:
self.smol.do_resolve(conn, username)
identity, _chain = self.smol.do_resolve(conn, username)
except self.smol.SmolError as exc:
if "unknown user" in str(exc):
return False
logger.warning("resolve for %s failed: %s", username, exc)
return None
self.store.save_contact(addr.short, identity, verified=False)
return True
def forward(self, username: str, sender: str, subject: str, body: str) -> None:
# Subject and sender end up as frontmatter values (SPEC.md S5.5: one
# `Key: value` per line); a bare CR/LF smuggled through email headers
# would otherwise let a sender inject extra frontmatter fields.
def recipient_accepted_gateway(self, username: str) -> bool:
"""Whether pneumatube holds an accept token for this recipient --
see this module's docstring for exactly what that does and doesn't
mean. A pure local lookup against the gateway's own db, no network.
Addresses are stored (by learn_token()) in Address.short's
normalized form, which drops an explicit default port -- build the
lookup key the same way rather than concatenating smolmail_domain
directly, or a configured "host:1961" would never match.
"""
addr = self.smol.Address.parse(f"{username}@{self.config.smolmail_domain}")
return self.store.one("SELECT token FROM tokens WHERE address = ?", addr.short) is not None
def forward(self, username: str, subject: str, body: str) -> None:
# Subject ends up as a frontmatter value (SPEC.md S5.5: one
# `Key: value` per line); a bare CR/LF smuggled through the email
# Subject header would otherwise let a sender inject extra
# frontmatter fields.
send_args = argparse.Namespace(
key=str(self.config.smolmail_key), db=str(self.config.smolmail_db),
address=f"{username}@{self.config.smolmail_domain}",
body=body, subject=_single_line(subject), reply_to=None,
header=[f"{FRONTMATTER_SENDER_KEY}: {_single_line(sender)}"],
anonymous=False, no_pad=False)
header=[], anonymous=False, no_pad=False)
self.smol.cmd_send(send_args, self.store)
@staticmethod

View file

@ -1,10 +1,11 @@
"""Postfix tcp_table(5) lookup service, backing check_recipient_access.
Without this, Postfix's smtpd accepts a recipient into its queue before ever
asking pneumatube whether the username exists — the live RESOLVE check only
runs later, when the queue manager delivers over LMTP, so an unknown user
gets a backscatter-prone bounce instead of a live SMTP-time reject. Wiring
this service into smtpd_recipient_restrictions moves that same RESOLVE check
asking pneumatube whether the username exists and has accepted the gateway
(SPEC.md S5.8) — those checks only run later, when the queue manager
delivers over LMTP, so a reject becomes a backscatter-prone bounce instead
of a live SMTP-time reject. Wiring this service into
smtpd_recipient_restrictions moves the same checks handle_RCPT already does
to RCPT TO time, before Postfix commits to accepting the message.
Protocol (tcp_table(5)): one request per line, `get <key>`, where key and
@ -33,12 +34,19 @@ async def _lookup(bridge: EmailToSmolBridge, worker: SmolWorker, config: Config,
# to a bare-domain lookup after a miss) — no opinion.
return "500 not found"
exists = await worker.run(bridge.user_exists, username.lower())
username = username.lower()
exists = await worker.run(bridge.user_exists, username)
if exists is None:
return "400 temporary failure resolving recipient"
if exists:
return "200 " + quote("OK")
return "200 " + quote("550 5.1.1 no such user")
if not exists:
return "200 " + quote("550 5.1.1 no such user")
if config.require_accepted_recipient:
accepted = await worker.run(bridge.recipient_accepted_gateway, username)
if not accepted:
return "200 " + quote("550 5.7.1 recipient has not accepted mail from this gateway")
return "200 " + quote("OK")
async def _handle_connection(bridge, worker, config, reader, writer) -> None:

View file

@ -5,6 +5,27 @@ the Email-To frontmatter field (SPEC.md S5.5) to a real mailbox over SMTP.
A message without that field is left in the local inbox rather than dropped,
so it stays visible to `smolmail.py list`/`read` against the gateway's own
db for manual follow-up.
By default (PNEUMATUBE_REQUIRE_ACCEPTED_SENDER=1, config.require_accepted_
sender), only the main tier is relayed: a first-contact sender with no
accept token (SPEC.md S5.8) lands in the requests tier and is left alone,
so the gateway can't be used as an open relay by anyone who merely learns
its smolmail address. Setting the flag to false restores the earlier "relay
anyone" behavior for a deliberately open/public bridge.
Unblocking a sender takes two steps, not one -- confirmed live, since it is
easy to assume `accept` alone is enough:
1. `smolmail.py --key gateway.key --db gateway.db accept <address-or-smol://-uri>`
records the acceptance locally and uploads the token to the server, but
does NOT retroactively reclassify a message already sitting in this
gateway's local inbox (its tier was decided at fetch time) or do
anything the sender can see.
2. The sender only learns the token when it arrives in a reply's Accept
frontmatter field (SPEC.md S5.8) -- which pneumatube itself never sends,
since this direction only relays outward to SMTP. The operator must
send at least one message back from the gateway's own identity (e.g.
`echo hi | smolmail.py --key gateway.key --db gateway.db send <address>`)
before the sender's *next* message will actually carry a valid token.
"""
from __future__ import annotations
@ -29,10 +50,20 @@ class SmolToEmailBridge:
def poll_once(self) -> None:
self.smol.cmd_fetch(self.fetch_args, self.store)
# "all" rather than "inbox": a gateway must relay unsolicited senders
# too, so requests-tier mail (SPEC.md S5.8) is not silently skipped.
for mid, envelope, _received_at, _recipient in self.store.mail("all"):
self._relay(mid, envelope)
# "inbox" (main tier only) when gating on acceptance; "all" includes
# the requests tier too, for a deliberately open bridge.
box = "inbox" if self.config.require_accepted_sender else "all"
for mid, envelope, _received_at, _recipient in self.store.mail(box):
try:
self._relay(mid, envelope)
except Exception:
# One message's failure must not block every message behind
# it: _relay() only deletes on success, so a message that
# keeps raising here (e.g. smtp_client.send() rejecting a
# value _relay()'s own except clause doesn't cover) would
# otherwise jam this entire direction forever -- it always
# sorts first by received_at on the next poll.
logger.exception("failed to relay %s; left for manual review", mid.hex()[:16])
def _relay(self, mid: bytes, envelope: bytes) -> None:
try:

View file

@ -26,6 +26,14 @@ def validate(address: str) -> str:
def send(config: Config, *, to_addr: str, subject: str, body: str, info_header: str) -> None:
to_addr = validate(to_addr)
message = EmailMessage()
# Fixed, not derived from the smolmail sender: that sender's address
# isn't a deliverable email address and isn't authorized (SPF/DKIM) to
# send as anything but this one, operator-configured address. A
# per-sender plus-address under this same domain (e.g.
# "gateway+alice@example.com") would stay within that authorization
# while still distinguishing senders and could route a reply back to
# the originating smolmail identity -- a real option if useful later,
# just not implemented.
message["From"] = config.gateway_from_address
message["To"] = to_addr
message["Subject"] = subject

View file

@ -8,9 +8,12 @@ the internet it has no TLS, rate limiting or rDNS checks.
handle_RCPT does a live smolmail RESOLVE per recipient, so an unknown
username is rejected inline during the conversation rather than bounced
later. handle_DATA replies once per accepted recipient (joined by CRLF)
instead of a single pass/fail for the whole message, which is what makes
LMTP's per-recipient status semantics real rather than cosmetic.
later. By default it also requires the recipient to have accepted the
gateway (PNEUMATUBE_REQUIRE_ACCEPTED_RECIPIENT; see email_to_smol.py's
module docstring) -- an all-or-nothing reject, not a demotion. handle_DATA
replies once per accepted recipient (joined by CRLF) instead of a single
pass/fail for the whole message, which is what makes LMTP's per-recipient
status semantics real rather than cosmetic.
"""
from __future__ import annotations
@ -42,12 +45,16 @@ class GatewayHandler:
if not exists:
return "550 no such user"
if self.config.require_accepted_recipient:
accepted = await self.worker.run(self.bridge.recipient_accepted_gateway, username)
if not accepted:
return "550 5.7.1 recipient has not accepted mail from this gateway"
envelope.rcpt_tos.append(f"{username}@{domain}")
return "250 OK"
async def handle_DATA(self, server, session, envelope):
message = email_module.message_from_bytes(envelope.content)
sender = envelope.mail_from
subject = str(message.get("Subject", "(no subject)"))
body = self.bridge.extract_text(message)
@ -55,7 +62,7 @@ class GatewayHandler:
for address in envelope.rcpt_tos:
username = address.split("@", 1)[0]
try:
await self.worker.run(self.bridge.forward, username, sender, subject, body)
await self.worker.run(self.bridge.forward, username, subject, body)
replies.append("250 2.1.5 delivered")
except Exception:
logger.exception("failed to forward mail for %s", username)