feat: add Postfix/pneumatube container rig with live recipient rejection

This commit is contained in:
randogoth 2026-10-03 17:46:39 +03:00
parent 722394a7bf
commit a0950fab52
9 changed files with 306 additions and 2 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.jj
.venv
.pytest_cache
__pycache__
*.egg-info
*.key
*.db
*.db-wal
*.db-shm
data
tests
docker

10
Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM python:3.13-slim
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src ./src
RUN uv sync --frozen --no-dev
CMD ["uv", "run", "pneumatube"]

55
docker-compose.yml Normal file
View file

@ -0,0 +1,55 @@
# Local test rig for the Postfix -> LMTP -> pneumatube wiring, matching the
# shape of a real deployment: Postfix owns the SMTP side, pneumatube is its
# LMTP delivery backend. This is NOT the recommended production topology —
# a real deployment runs Postfix on the host with real TLS/DNS, not in a
# container (see the project's Postfix wiring notes); this rig has no TLS,
# no abuse limits, and trusts every connecting host (mynetworks=0.0.0.0/0).
#
# Requires PNEUMATUBE_SMOLMAIL_DOMAIN and PNEUMATUBE_SMTP_RELAY_HOST set in
# the environment or a .env file. The gateway's own smolmail account must
# already be registered against that server before pneumatube can run — the
# same bootstrap as any smolmail client, using the mounted sibling checkout
# and ./data as --key/--db:
#
# docker compose run --rm pneumatube \
# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db keygen
# docker compose run --rm pneumatube \
# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db trust <host> <server-key>
# docker compose run --rm pneumatube \
# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db register <user>@<host>
services:
pneumatube:
build: .
volumes:
- ../smolmail:/smolmail:ro
- ./data:/data
environment:
PNEUMATUBE_SMOLMAIL_REPO: /smolmail
PNEUMATUBE_SMOLMAIL_KEY: /data/gateway.key
PNEUMATUBE_SMOLMAIL_DB: /data/gateway.db
PNEUMATUBE_SMOLMAIL_DOMAIN: ${PNEUMATUBE_SMOLMAIL_DOMAIN:?set the smolmail server address}
PNEUMATUBE_EMAIL_DOMAIN: ${PNEUMATUBE_EMAIL_DOMAIN:-gateway.example}
PNEUMATUBE_LISTEN_PROTOCOL: lmtp
PNEUMATUBE_LISTEN_HOST: 0.0.0.0
PNEUMATUBE_LISTEN_PORT: "2424"
PNEUMATUBE_LOOKUP_HOST: 0.0.0.0
PNEUMATUBE_LOOKUP_PORT: "2526"
PNEUMATUBE_SMTP_RELAY_HOST: ${PNEUMATUBE_SMTP_RELAY_HOST:?set an outbound relay host}
PNEUMATUBE_SMTP_RELAY_PORT: ${PNEUMATUBE_SMTP_RELAY_PORT:-587}
PNEUMATUBE_SMTP_RELAY_USER: ${PNEUMATUBE_SMTP_RELAY_USER:-}
PNEUMATUBE_SMTP_RELAY_PASSWORD: ${PNEUMATUBE_SMTP_RELAY_PASSWORD:-}
PNEUMATUBE_SMTP_RELAY_STARTTLS: ${PNEUMATUBE_SMTP_RELAY_STARTTLS:-1}
PNEUMATUBE_GATEWAY_FROM_ADDRESS: ${PNEUMATUBE_GATEWAY_FROM_ADDRESS:-gateway@gateway.example}
postfix:
build: ./docker/postfix
depends_on:
- pneumatube
environment:
POSTFIX_RELAY_DOMAIN: ${PNEUMATUBE_EMAIL_DOMAIN:-gateway.example}
PNEUMATUBE_HOST: pneumatube
PNEUMATUBE_PORT: "2424"
PNEUMATUBE_LOOKUP_PORT: "2526"
ports:
- "2525:25"

14
docker/postfix/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM debian:bookworm-slim
# "No configuration" skips postfix's interactive debconf setup during install;
# the entrypoint configures main.cf/transport itself via postconf.
RUN echo "postfix postfix/main_mailer_type select No configuration" | debconf-set-selections \
&& DEBIAN_FRONTEND=noninteractive apt-get update \
&& apt-get install -y --no-install-recommends postfix \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 25
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,60 @@
#!/bin/sh
# Configures Postfix to relay one domain to pneumatube over LMTP, per the
# plan discussed for the real deployment (main.cf relay_domains/
# transport_maps, master.cf's lmtp client service). This is a test/demo
# harness, not production hardening: no TLS, no abuse limits, open to any
# connecting host on the compose network.
set -e
: "${POSTFIX_RELAY_DOMAIN:?set POSTFIX_RELAY_DOMAIN to the email domain pneumatube accepts}"
: "${PNEUMATUBE_HOST:?set PNEUMATUBE_HOST to pneumatube's hostname on the compose network}"
PNEUMATUBE_PORT="${PNEUMATUBE_PORT:-2424}"
# "No configuration" at install time (Dockerfile) skips main.cf generation
# entirely, so start from Debian's template before postconf-ing it.
[ -f /etc/postfix/main.cf ] || cp /usr/share/postfix/main.cf.debian /etc/postfix/main.cf
# Postfix logs via syslog by default, which is silent in a minimal
# container with no syslog daemon; maillog_file (3.4+) writes directly.
postconf -e "maillog_file=/dev/stdout"
postconf -e "myhostname=${POSTFIX_MYHOSTNAME:-postfix.local}"
# Postfix's default "dns" host-lookup method queries the nameserver with
# the bare hostname, bypassing /etc/resolv.conf's search domain. Container
# DNS (e.g. podman's aardvark-dns) often only answers the FQDN form, so the
# compose service name alone resolves via getaddrinfo() ("native") but not
# via Postfix's own resolver. native also respects /etc/hosts.
postconf -e "smtp_host_lookup=native"
postconf -e "lmtp_host_lookup=native"
postconf -e "mydestination="
postconf -e "relay_domains=${POSTFIX_RELAY_DOMAIN}"
postconf -e "transport_maps=hash:/etc/postfix/transport"
# Split in two: relay_restrictions decides only whether this domain is ours
# to relay at all; recipient_restrictions then asks pneumatube's tcp_table
# lookup service about the specific user, live, before RCPT TO is accepted —
# this is what turns an unknown user into an inline reject instead of a
# queued-then-bounced message (see lookup_server.py for the other side).
postconf -e "smtpd_relay_restrictions=permit_mynetworks,reject_unauth_destination"
postconf -e "smtpd_recipient_restrictions=check_recipient_access tcp:${PNEUMATUBE_HOST}:${PNEUMATUBE_LOOKUP_PORT:-2526}"
# Test network only: every connecting host is trusted. Do not carry this
# setting into a real deployment (see the project's Postfix wiring notes).
postconf -e "mynetworks=0.0.0.0/0"
echo "${POSTFIX_RELAY_DOMAIN} lmtp:inet:${PNEUMATUBE_HOST}:${PNEUMATUBE_PORT}" > /etc/postfix/transport
postmap /etc/postfix/transport
grep -q '^lmtp[[:space:]]' /etc/postfix/master.cf \
|| echo 'lmtp unix - - n - - lmtp' >> /etc/postfix/master.cf
# The shipped master.cf chroots smtpd and the lmtp client by default, which
# hides /etc/resolv.conf and /etc/nsswitch.conf from them — resolving the
# pneumatube hostname then fails ("Temporary failure in name resolution")
# regardless of *_host_lookup, since getaddrinfo() itself can't see the
# chroot's host config. smtpd needs this too: it's the one connecting out to
# the tcp_table lookup service. Rewrite the chroot column (5th field)
# directly; postconf -P appends a "-o chroot=n" command-line override
# instead, which is not a valid override and is simply ignored.
awk '($1 == "lmtp" && $2 == "unix") || ($1 == "smtp" && $2 == "inet") { $5 = "n" } { print }' \
/etc/postfix/master.cf > /etc/postfix/master.cf.new
mv /etc/postfix/master.cf.new /etc/postfix/master.cf
exec postfix start-fg

View file

@ -10,7 +10,11 @@ Email-From frontmatter field.
See config.py for the full list of environment variables, including
PNEUMATUBE_LISTEN_PROTOCOL (lmtp, behind a real MTA, or smtp for local-dev
only), PNEUMATUBE_LISTEN_HOST and PNEUMATUBE_LISTEN_PORT. Before running,
only), PNEUMATUBE_LISTEN_HOST/PORT, and PNEUMATUBE_LOOKUP_HOST/PORT. In lmtp
mode, the lookup service answers Postfix's check_recipient_access (a
tcp_table(5) query backed by the same RESOLVE check as handle_RCPT) so an
unknown user is rejected live at RCPT TO instead of bouncing after Postfix
has already queued the message. Before running,
register the gateway's own smolmail account the same way any smolmail client
would, against the same --key/--db paths this reads (PNEUMATUBE_SMOLMAIL_KEY
/ PNEUMATUBE_SMOLMAIL_DB):
@ -30,6 +34,7 @@ from aiosmtpd.lmtp import LMTP
from aiosmtpd.smtp import SMTP as PlainSMTP
from . import config as config_module
from . import lookup_server
from . import vendor
from .email_to_smol import EmailToSmolBridge
from .smol_to_email import SmolToEmailBridge
@ -85,9 +90,17 @@ async def _run(cfg: config_module.Config) -> None:
hostname=cfg.listen_host, port=cfg.listen_port)
controller.start()
logger.info("%s listener on %s:%s", cfg.listen_protocol.upper(), cfg.listen_host, cfg.listen_port)
tasks = [asyncio.create_task(_poll_forever(worker, outbound, cfg.fetch_interval))]
if cfg.listen_protocol == "lmtp":
# Only meaningful behind Postfix's check_recipient_access; the plain
# SMTP dev mode already rejects unknown users live in handle_RCPT.
tasks.append(asyncio.create_task(lookup_server.serve(inbound, worker, cfg)))
try:
await _poll_forever(worker, outbound, cfg.fetch_interval)
await asyncio.gather(*tasks)
finally:
for task in tasks:
task.cancel()
controller.stop()
worker.shutdown()

View file

@ -24,6 +24,7 @@ def _int(name: str, default: int) -> int:
_LISTEN_PROTOCOLS = ("lmtp", "smtp")
_LMTP_DEFAULT_PORT, _SMTP_DEFAULT_PORT = 2424, 1025
_LOOKUP_DEFAULT_PORT = 2526
@dataclass(frozen=True)
@ -39,6 +40,12 @@ class Config:
listen_host: str
listen_port: int
# Postfix tcp_table(5) lookup service backing check_recipient_access, so
# an unknown user is rejected live during RCPT TO instead of bouncing
# after Postfix has already accepted the message (lmtp mode only).
lookup_host: str
lookup_port: int
smtp_relay_host: str
smtp_relay_port: int
smtp_relay_user: str | None
@ -65,6 +72,8 @@ def load() -> Config:
listen_protocol=listen_protocol,
listen_host=os.environ.get("PNEUMATUBE_LISTEN_HOST", "127.0.0.1"),
listen_port=_int("PNEUMATUBE_LISTEN_PORT", default_port),
lookup_host=os.environ.get("PNEUMATUBE_LOOKUP_HOST", "127.0.0.1"),
lookup_port=_int("PNEUMATUBE_LOOKUP_PORT", _LOOKUP_DEFAULT_PORT),
smtp_relay_host=_env("PNEUMATUBE_SMTP_RELAY_HOST"),
smtp_relay_port=_int("PNEUMATUBE_SMTP_RELAY_PORT", 587),
smtp_relay_user=os.environ.get("PNEUMATUBE_SMTP_RELAY_USER"),

View file

@ -0,0 +1,76 @@
"""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
to RCPT TO time, before Postfix commits to accepting the message.
Protocol (tcp_table(5)): one request per line, `get <key>`, where key and
the response text are %XX-quoted. A response code of 200 carries a result
that Postfix applies as an access(5) action (e.g. "OK" or "550 ... text");
500 means "no opinion, fall through to the next restriction"; 400 is a
temporary error (Postfix defers the command rather than rejecting it).
"""
from __future__ import annotations
import asyncio
import logging
from urllib.parse import quote, unquote
from .config import Config
from .email_to_smol import EmailToSmolBridge
from .worker import SmolWorker
logger = logging.getLogger(__name__)
async def _lookup(bridge: EmailToSmolBridge, worker: SmolWorker, config: Config, key: str) -> str:
username, _, domain = key.partition("@")
if domain.lower() != config.email_domain.lower():
# Not a key shape we own (e.g. check_recipient_access's own fallback
# to a bare-domain lookup after a miss) — no opinion.
return "500 not found"
exists = await worker.run(bridge.user_exists, username.lower())
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")
async def _handle_connection(bridge, worker, config, reader, writer) -> None:
peer = writer.get_extra_info("peername")
try:
while True:
line = await reader.readline()
if not line:
break
request = line.decode("ascii", "replace").strip()
if not request:
continue
command, _, rest = request.partition(" ")
if command != "get":
writer.write(b"400 unsupported request\n")
await writer.drain()
continue
reply = await _lookup(bridge, worker, config, unquote(rest))
writer.write(reply.encode("ascii") + b"\n")
await writer.drain()
except (ConnectionError, asyncio.IncompleteReadError):
pass
except Exception:
logger.exception("lookup connection from %s failed", peer)
finally:
writer.close()
async def serve(bridge: EmailToSmolBridge, worker: SmolWorker, config: Config) -> None:
server = await asyncio.start_server(
lambda r, w: _handle_connection(bridge, worker, config, r, w),
host=config.lookup_host, port=config.lookup_port)
logger.info("Postfix recipient-lookup service on %s:%s", config.lookup_host, config.lookup_port)
async with server:
await server.serve_forever()

View file

@ -0,0 +1,54 @@
import asyncio
from types import SimpleNamespace
from urllib.parse import unquote
import pytest
from pneumatube.lookup_server import _lookup
class _StubBridge:
def __init__(self, known):
self.known = known
def user_exists(self, username):
if username == "broken":
return None
return username in self.known
class _InlineWorker:
async def run(self, fn, *args):
return fn(*args)
def _config():
return SimpleNamespace(email_domain="gateway.example")
def test_lookup_known_user_returns_ok():
bridge = _StubBridge({"alice"})
reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "alice@gateway.example"))
code, _, text = reply.partition(" ")
assert code == "200"
assert unquote(text) == "OK"
def test_lookup_unknown_user_returns_reject_action():
bridge = _StubBridge({"alice"})
reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "nobody@gateway.example"))
code, _, text = reply.partition(" ")
assert code == "200"
assert unquote(text) == "550 5.1.1 no such user"
def test_lookup_resolve_failure_is_temporary():
bridge = _StubBridge({"alice"})
reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "broken@gateway.example"))
assert reply.startswith("400 ")
def test_lookup_other_domain_has_no_opinion():
bridge = _StubBridge({"alice"})
reply = asyncio.run(_lookup(bridge, _InlineWorker(), _config(), "alice@other.example"))
assert reply == "500 not found"