diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f112fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.jj +.venv +.pytest_cache +__pycache__ +*.egg-info +*.key +*.db +*.db-wal +*.db-shm +data +tests +docker diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a73aebc --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1ac9cef --- /dev/null +++ b/docker-compose.yml @@ -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 +# docker compose run --rm pneumatube \ +# uv run /smolmail/smolmail.py --key /data/gateway.key --db /data/gateway.db register @ + +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" diff --git a/docker/postfix/Dockerfile b/docker/postfix/Dockerfile new file mode 100644 index 0000000..7b413b2 --- /dev/null +++ b/docker/postfix/Dockerfile @@ -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"] diff --git a/docker/postfix/entrypoint.sh b/docker/postfix/entrypoint.sh new file mode 100644 index 0000000..866ae7f --- /dev/null +++ b/docker/postfix/entrypoint.sh @@ -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 diff --git a/src/pneumatube/__main__.py b/src/pneumatube/__main__.py index bf9a15c..1bf4131 100644 --- a/src/pneumatube/__main__.py +++ b/src/pneumatube/__main__.py @@ -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() diff --git a/src/pneumatube/config.py b/src/pneumatube/config.py index 95a3967..c01971e 100644 --- a/src/pneumatube/config.py +++ b/src/pneumatube/config.py @@ -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"), diff --git a/src/pneumatube/lookup_server.py b/src/pneumatube/lookup_server.py new file mode 100644 index 0000000..a0a1c11 --- /dev/null +++ b/src/pneumatube/lookup_server.py @@ -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 `, 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() diff --git a/tests/test_lookup_server.py b/tests/test_lookup_server.py new file mode 100644 index 0000000..6c2bd6f --- /dev/null +++ b/tests/test_lookup_server.py @@ -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"