import io import json import smtplib import pytest from email_connect.transactional import ( ProviderError, SMTPProvider, SQLiteDeliveryStore, TransactionalApplication, ) class Provider: def __init__(self): self.calls = [] def send(self, *args): self.calls.append(args) return "message:1" class FailingProvider: def __init__(self, error: ProviderError): self.error = error self.calls = 0 def send(self, *args): self.calls += 1 raise self.error def event(event_id="evt-1", typ="family_member.invited", email="person@example.test"): return { "id": event_id, "type": typ, "source": "user-engine", "data": {"primary_email": email, "invitation_id": "inv-1"}, } def invoke(app, payload, token="opaque", key=None, path="/v1/send"): raw = json.dumps(payload).encode() result = {} env = { "PATH_INFO": path, "REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(raw)), "wsgi.input": io.BytesIO(raw), "HTTP_AUTHORIZATION": f"Bearer {token}", } if path == "/v1/send": env["HTTP_IDEMPOTENCY_KEY"] = key if key is not None else payload.get("id", "evt-1") body = b"".join(app(env, lambda s, h: result.update(status=s))) return result["status"], json.loads(body) def test_sends_fixed_template_once(tmp_path): provider = Provider() app = TransactionalApplication( SQLiteDeliveryStore(str(tmp_path / "mail.db")), provider, "opaque", "https://users.example" ) status, body = invoke(app, event()) assert status.startswith("202") assert body["evidence_ceiling"] == "provider_accepted" assert body["event_id"] == "evt-1" assert invoke(app, event())[1]["status"] == "duplicate" assert len(provider.calls) == 1 and "/invitations/inv-1" in provider.calls[0][2] def test_rejects_auth_recipient_template_and_key(tmp_path): app = TransactionalApplication( SQLiteDeliveryStore(str(tmp_path / "mail.db")), Provider(), "opaque", "https://users.example" ) assert invoke(app, event(), token="bad")[0].startswith("401") bad = event() bad["data"]["primary_email"] = "bad\n@example.test" assert invoke(app, bad)[1]["error"] == "invalid_recipient" bad = event() bad["type"] = "arbitrary.send" assert invoke(app, bad)[1]["error"] == "template_not_allowed" assert invoke(app, event(), key="other")[1]["error"] == "idempotency_key_mismatch" def test_resend_is_separate_event_and_sends_again(tmp_path): provider = Provider() app = TransactionalApplication( SQLiteDeliveryStore(str(tmp_path / "mail.db")), provider, "opaque", "https://users.example" ) assert invoke(app, event("evt-1", "family_member.invited"))[0].startswith("202") status, body = invoke(app, event("evt-2", "family_invitation.resent")) assert status.startswith("202") and body["status"] == "accepted" assert len(provider.calls) == 2 def test_suppression_blocks_send(tmp_path): store = SQLiteDeliveryStore(str(tmp_path / "mail.db")) store.suppress("person@example.test", "complaint") provider = Provider() app = TransactionalApplication(store, provider, "opaque", "https://users.example") status, body = invoke(app, event()) assert status.startswith("400") assert body["error"] == "recipient_suppressed" assert provider.calls == [] def test_provider_timeout_temporary_and_permanent_are_redacted(tmp_path): cases = [ (ProviderError("provider_timeout", retryable=True), "503", True), (ProviderError("temporary_deferral", retryable=True), "503", True), (ProviderError("permanent_rejection", retryable=False), "422", False), (ProviderError("provider_unavailable", retryable=True), "503", True), ] for err, code_prefix, retryable in cases: app = TransactionalApplication( SQLiteDeliveryStore(str(tmp_path / f"{err.code}.db")), FailingProvider(err), "opaque", "https://users.example", ) status, body = invoke(app, event(event_id=f"evt-{err.code}")) assert status.startswith(code_prefix), (err.code, status, body) assert body["error"] == err.code assert body["retryable"] is retryable # Diagnostics must stay redacted: no SMTP host, password, or exception text. blob = json.dumps(body) assert "smtp" not in blob.lower() assert "password" not in blob.lower() assert "exception" not in blob.lower() def test_smtp_provider_maps_recipient_refused_to_permanent(): class Boom(smtplib.SMTP): def __init__(self, *a, **k): pass def __enter__(self): return self def __exit__(self, *a): return False def starttls(self): return None def login(self, *a): return None def send_message(self, msg): raise smtplib.SMTPRecipientsRefused({"x@y.z": (550, b"user unknown")}) import email_connect.transactional as mod original = mod.smtplib.SMTP mod.smtplib.SMTP = Boom try: provider = mod.SMTPProvider("smtp.example", 587, "u", "p", "from@example") try: provider.send("x@y.z", "s", "b") assert False, "expected ProviderError" except ProviderError as exc: assert exc.code == "permanent_rejection" and exc.retryable is False finally: mod.smtplib.SMTP = original def test_registration_verification_is_digest_only_and_single_use(tmp_path): provider = Provider() store = SQLiteDeliveryStore(str(tmp_path / "mail.db")) app = TransactionalApplication(store, provider, "opaque", "https://users.example") request = { "registration_id": "reg-1", "normalized_email": "person@example.test", "preferred_username": "person", "client_id": "coulomb-social", "tenant": "tenant:coulomb", "correlation_id": "corr-1", } raw = json.dumps(request).encode() result = {} response = json.loads( b"".join( app( { "PATH_INFO": "/v1/registration-verifications", "REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(raw)), "wsgi.input": io.BytesIO(raw), "HTTP_AUTHORIZATION": "Bearer opaque", }, lambda s, h: result.update(status=s), ) ) ) assert result["status"].startswith("202") and response["accepted"] text = provider.calls[0][2] handle = text.split("handle=", 1)[1].splitlines()[0] stored = store.db.execute("SELECT handle_hash FROM verifications").fetchone()[0] assert handle not in stored and stored consume = json.dumps({"handle": handle}).encode() env = { "PATH_INFO": "/v1/registration-verifications/consume", "REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(consume)), "wsgi.input": io.BytesIO(consume), "HTTP_AUTHORIZATION": "Bearer opaque", } evidence = json.loads(b"".join(app(env, lambda s, h: result.update(status=s)))) assert evidence["purpose"] == "public-registration" assert evidence["email"] == "person@example.test" assert evidence["authorization"] is False assert evidence["evidence_ceiling"] == "mailbox_challenge_consumed" env["wsgi.input"] = io.BytesIO(consume) replay = json.loads(b"".join(app(env, lambda s, h: result.update(status=s)))) assert replay["error"] == "verification_invalid" def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_path): provider = Provider() store = SQLiteDeliveryStore(str(tmp_path / "mail.db")) app = TransactionalApplication(store, provider, "opaque", "https://users.example") request = { "registration_id": "reg-cancel", "normalized_email": "person@example.test", "preferred_username": "person", "client_id": "coulomb-social", "tenant": "tenant:coulomb", "correlation_id": "corr-cancel", } raw = json.dumps(request).encode() result = {} app( { "PATH_INFO": "/v1/registration-verifications", "REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(raw)), "wsgi.input": io.BytesIO(raw), "HTTP_AUTHORIZATION": "Bearer opaque", }, lambda s, h: result.update(status=s), ) text = provider.calls[0][2] handle = text.split("handle=", 1)[1].splitlines()[0] assert f"/registration/cancel?handle={handle}" in text payload = json.dumps({"handle": handle}).encode() cancel_env = { "PATH_INFO": "/v1/registration-verifications/cancel", "REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(payload)), "wsgi.input": io.BytesIO(payload), "HTTP_AUTHORIZATION": "Bearer opaque", } canceled = json.loads(b"".join(app(cancel_env, lambda s, h: result.update(status=s)))) assert canceled["purpose"] == "public-registration-cancel" assert canceled["authorization"] is False consume_env = { "PATH_INFO": "/v1/registration-verifications/consume", "REQUEST_METHOD": "POST", "CONTENT_LENGTH": str(len(payload)), "wsgi.input": io.BytesIO(payload), "HTTP_AUTHORIZATION": "Bearer opaque", } rejected = json.loads(b"".join(app(consume_env, lambda s, h: result.update(status=s)))) assert rejected["error"] == "verification_invalid" cancel_env["wsgi.input"] = io.BytesIO(payload) replay = json.loads(b"".join(app(cancel_env, lambda s, h: result.update(status=s)))) assert replay["error"] == "verification_invalid" def test_mailbox_evidence_is_not_authorization(tmp_path): """Provider acceptance and mailbox challenge never grant user-engine authority.""" provider = Provider() app = TransactionalApplication( SQLiteDeliveryStore(str(tmp_path / "mail.db")), provider, "opaque", "https://users.example" ) _, accepted = invoke(app, event()) assert accepted.get("authorization") is None or accepted.get("authorization") is False assert accepted["evidence_ceiling"] == "provider_accepted" # Ceiling is explicit; user-engine must not elevate this to authz. assert "authorized" not in accepted class RecordingSMTP: """Minimal smtplib.SMTP stand-in that records the handshake it was given.""" instances = [] def __init__(self, host, port, timeout=None): self.host, self.port, self.timeout = host, port, timeout self.starttls_calls = 0 self.logins = [] self.sent = [] RecordingSMTP.instances.append(self) def __enter__(self): return self def __exit__(self, *exc): return False def starttls(self): self.starttls_calls += 1 def login(self, username, password): self.logins.append((username, password)) def send_message(self, message): self.sent.append(message) def test_smtp_provider_defaults_to_starttls(monkeypatch): monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP) RecordingSMTP.instances = [] provider = SMTPProvider("smtp.example.com", 587, "user", "pass", "noreply@example.com") assert provider.security == "starttls" provider.send("person@example.test", "Subject", "body") assert RecordingSMTP.instances[0].starttls_calls == 1 def test_plaintext_security_skips_starttls_on_loopback(monkeypatch): monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP) RecordingSMTP.instances = [] provider = SMTPProvider("127.0.0.1", 3025, "user", "pass", "noreply@example.com", security="plaintext") provider.send("person@example.test", "Subject", "body") smtp = RecordingSMTP.instances[0] assert smtp.starttls_calls == 0 assert smtp.logins == [("user", "pass")] assert len(smtp.sent) == 1 @pytest.mark.parametrize("host", ["127.0.0.1", "127.0.1.1", "::1", "[::1]", "localhost", "LOCALHOST"]) def test_plaintext_security_is_allowed_for_loopback_hosts(host): assert SMTPProvider(host, 3025, "user", "pass", "noreply@example.com", security="plaintext") @pytest.mark.parametrize("host", ["smtp.example.com", "10.0.0.5", "::ffff:10.0.0.5", "", "localhost.example.com"]) def test_plaintext_security_is_refused_for_non_loopback_hosts(host): with pytest.raises(ValueError, match="loopback"): SMTPProvider(host, 3025, "user", "pass", "noreply@example.com", security="plaintext") def test_unsupported_security_mode_is_refused(): with pytest.raises(ValueError, match="security mode"): SMTPProvider("127.0.0.1", 3025, "user", "pass", "noreply@example.com", security="none")