feat: add Postfix/pneumatube container rig with live recipient rejection
This commit is contained in:
parent
722394a7bf
commit
a0950fab52
9 changed files with 306 additions and 2 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
10
Dockerfile
Normal 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
55
docker-compose.yml
Normal 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
14
docker/postfix/Dockerfile
Normal 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"]
|
||||||
60
docker/postfix/entrypoint.sh
Normal file
60
docker/postfix/entrypoint.sh
Normal 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
|
||||||
|
|
@ -10,7 +10,11 @@ Email-From frontmatter field.
|
||||||
|
|
||||||
See config.py for the full list of environment variables, including
|
See config.py for the full list of environment variables, including
|
||||||
PNEUMATUBE_LISTEN_PROTOCOL (lmtp, behind a real MTA, or smtp for local-dev
|
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
|
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
|
would, against the same --key/--db paths this reads (PNEUMATUBE_SMOLMAIL_KEY
|
||||||
/ PNEUMATUBE_SMOLMAIL_DB):
|
/ PNEUMATUBE_SMOLMAIL_DB):
|
||||||
|
|
@ -30,6 +34,7 @@ from aiosmtpd.lmtp import LMTP
|
||||||
from aiosmtpd.smtp import SMTP as PlainSMTP
|
from aiosmtpd.smtp import SMTP as PlainSMTP
|
||||||
|
|
||||||
from . import config as config_module
|
from . import config as config_module
|
||||||
|
from . import lookup_server
|
||||||
from . import vendor
|
from . import vendor
|
||||||
from .email_to_smol import EmailToSmolBridge
|
from .email_to_smol import EmailToSmolBridge
|
||||||
from .smol_to_email import SmolToEmailBridge
|
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)
|
hostname=cfg.listen_host, port=cfg.listen_port)
|
||||||
controller.start()
|
controller.start()
|
||||||
logger.info("%s listener on %s:%s", cfg.listen_protocol.upper(), cfg.listen_host, cfg.listen_port)
|
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:
|
try:
|
||||||
await _poll_forever(worker, outbound, cfg.fetch_interval)
|
await asyncio.gather(*tasks)
|
||||||
finally:
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
controller.stop()
|
controller.stop()
|
||||||
worker.shutdown()
|
worker.shutdown()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ def _int(name: str, default: int) -> int:
|
||||||
|
|
||||||
_LISTEN_PROTOCOLS = ("lmtp", "smtp")
|
_LISTEN_PROTOCOLS = ("lmtp", "smtp")
|
||||||
_LMTP_DEFAULT_PORT, _SMTP_DEFAULT_PORT = 2424, 1025
|
_LMTP_DEFAULT_PORT, _SMTP_DEFAULT_PORT = 2424, 1025
|
||||||
|
_LOOKUP_DEFAULT_PORT = 2526
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -39,6 +40,12 @@ class Config:
|
||||||
listen_host: str
|
listen_host: str
|
||||||
listen_port: int
|
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_host: str
|
||||||
smtp_relay_port: int
|
smtp_relay_port: int
|
||||||
smtp_relay_user: str | None
|
smtp_relay_user: str | None
|
||||||
|
|
@ -65,6 +72,8 @@ def load() -> Config:
|
||||||
listen_protocol=listen_protocol,
|
listen_protocol=listen_protocol,
|
||||||
listen_host=os.environ.get("PNEUMATUBE_LISTEN_HOST", "127.0.0.1"),
|
listen_host=os.environ.get("PNEUMATUBE_LISTEN_HOST", "127.0.0.1"),
|
||||||
listen_port=_int("PNEUMATUBE_LISTEN_PORT", default_port),
|
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_host=_env("PNEUMATUBE_SMTP_RELAY_HOST"),
|
||||||
smtp_relay_port=_int("PNEUMATUBE_SMTP_RELAY_PORT", 587),
|
smtp_relay_port=_int("PNEUMATUBE_SMTP_RELAY_PORT", 587),
|
||||||
smtp_relay_user=os.environ.get("PNEUMATUBE_SMTP_RELAY_USER"),
|
smtp_relay_user=os.environ.get("PNEUMATUBE_SMTP_RELAY_USER"),
|
||||||
|
|
|
||||||
76
src/pneumatube/lookup_server.py
Normal file
76
src/pneumatube/lookup_server.py
Normal 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()
|
||||||
54
tests/test_lookup_server.py
Normal file
54
tests/test_lookup_server.py
Normal 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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue