import asyncio from types import SimpleNamespace import pytest from pneumatube.smtp_server import GatewayHandler class _StubBridge: def __init__(self, known=("alice", "bob", "broken"), accepted=("alice", "bob", "broken")): self.forwarded = [] self.known = set(known) self.accepted = set(accepted) def forward(self, username, subject, body): self.forwarded.append(username) if username == "broken": raise RuntimeError("smolmail server unreachable") def user_exists(self, username): return username in self.known def recipient_accepted_gateway(self, username): return username in self.accepted @staticmethod def extract_text(message): return message.get_payload(decode=True).decode("utf-8", "replace") class _InlineWorker: """Runs synchronously: handle_DATA's per-recipient logic doesn't depend on real threading, just on worker.run's (fn, *args) -> result contract.""" async def run(self, fn, *args): return fn(*args) def _envelope(rcpt_tos): return SimpleNamespace( content=b"Subject: hi\r\n\r\nbody text\r\n", mail_from="sender@realmail.example", rcpt_tos=rcpt_tos, ) def _config(**overrides): defaults = {"email_domain": "gateway.example", "require_accepted_recipient": True} return SimpleNamespace(**{**defaults, **overrides}) def test_handle_rcpt_rejects_wrong_domain(): handler = GatewayHandler(_StubBridge(), _InlineWorker(), config=_config()) reply = asyncio.run(handler.handle_RCPT(None, None, _envelope([]), "alice@other.example", None)) assert reply.startswith("550") def test_handle_rcpt_rejects_unknown_user(): handler = GatewayHandler(_StubBridge(known=()), _InlineWorker(), config=_config()) reply = asyncio.run(handler.handle_RCPT(None, None, _envelope([]), "ghost@gateway.example", None)) assert reply.startswith("550") def test_handle_rcpt_rejects_unaccepted_recipient_by_default(): """The all-or-nothing gate: a recipient who exists but has not accepted the gateway (no token held) must be rejected live, not silently demoted to the recipient's own requests tier.""" bridge = _StubBridge(known=("alice",), accepted=()) handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) reply = asyncio.run(handler.handle_RCPT(None, None, _envelope([]), "alice@gateway.example", None)) assert reply.startswith("550") def test_handle_rcpt_allows_unaccepted_recipient_when_disabled(): bridge = _StubBridge(known=("alice",), accepted=()) handler = GatewayHandler(bridge, _InlineWorker(), config=_config(require_accepted_recipient=False)) envelope = _envelope([]) reply = asyncio.run(handler.handle_RCPT(None, None, envelope, "alice@gateway.example", None)) assert reply.startswith("250") assert envelope.rcpt_tos == ["alice@gateway.example"] def test_handle_rcpt_accepts_known_accepted_recipient(): bridge = _StubBridge(known=("alice",), accepted=("alice",)) handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) envelope = _envelope([]) reply = asyncio.run(handler.handle_RCPT(None, None, envelope, "alice@gateway.example", None)) assert reply.startswith("250") assert envelope.rcpt_tos == ["alice@gateway.example"] def test_handle_data_replies_once_per_recipient(): bridge = _StubBridge() handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) envelope = _envelope(["alice@gateway.example", "broken@gateway.example"]) reply = asyncio.run(handler.handle_DATA(None, None, envelope)) lines = reply.split("\r\n") assert len(lines) == 2 assert lines[0].startswith("250") assert lines[1].startswith("451") assert bridge.forwarded == ["alice", "broken"] def test_handle_data_all_succeed(): bridge = _StubBridge() handler = GatewayHandler(bridge, _InlineWorker(), config=_config()) envelope = _envelope(["alice@gateway.example", "bob@gateway.example"]) reply = asyncio.run(handler.handle_DATA(None, None, envelope)) assert reply == "250 2.1.5 delivered\r\n250 2.1.5 delivered"