Implement P05 checked services and safe selected delivery recovery

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 22:11:49 +02:00
parent 21eebffc56
commit 0fcfddc342
5 changed files with 459 additions and 67 deletions

View file

@ -0,0 +1,21 @@
name: Transactional mail acceptance
on:
push:
branches: [main]
paths: ["src/**", "tests/**", "pyproject.toml", ".forgejo/workflows/acceptance.yaml"]
workflow_dispatch:
jobs:
mail:
runs-on: ubuntu-latest
container:
image: python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9
steps:
- name: Test exact source commit
run: |
set -eu
mkdir source
python -c 'import os, urllib.request; urllib.request.urlretrieve("https://forgejo.coulomb.social/" + os.environ["GITHUB_REPOSITORY"] + "/archive/" + os.environ["GITHUB_SHA"] + ".tar.gz", "source.tar.gz")'
tar xzf source.tar.gz -C source --strip-components=1
cd source
pip install . pytest
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src python -m pytest -p no:cacheprovider -q

View file

@ -0,0 +1,56 @@
name: Build and Publish Container Image
# Modelled on tenant-engine/.forgejo/workflows/image.yaml. Images are built
# by CI from a tarball of the pushed commit, never from a workstation tree.
on:
push:
branches:
- main
paths:
- ".forgejo/workflows/image.yaml"
- "Containerfile"
- "src/**"
- "pyproject.toml"
- "README.md"
- "LICENSE"
workflow_dispatch:
env:
REGISTRY: forgejo.coulomb.social
IMAGE_NAME: coulomb/email-connect
DOCKER_HOST: tcp://127.0.0.1:2375
jobs:
build-and-push:
runs-on: container-build
steps:
- name: Build and push image
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
REF="${GITHUB_SHA:-main}"
SHORT="${REF:0:7}"
mkdir -p buildctx "${HOME}/bin"
wget -qO /tmp/repo.tar.gz \
"https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz"
tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1
wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
| tar xz --strip-components=1 -C "${HOME}/bin" docker/docker
export PATH="${HOME}/bin:${PATH}"
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker build -f buildctx/Containerfile -t "${IMAGE}:latest" -t "${IMAGE}:main-${SHORT}" buildctx
docker push "${IMAGE}:latest"
docker push "${IMAGE}:main-${SHORT}"
echo "pushed ${IMAGE}:latest and ${IMAGE}:main-${SHORT}"
- name: Report immutable digest
run: |
set -eu
export PATH="${HOME}/bin:${PATH}"
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SHORT="${GITHUB_SHA:0:7}"
docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:main-${SHORT}"

View file

@ -1,4 +1,4 @@
FROM python:3.12-slim FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9
RUN useradd --system --uid 10001 --create-home email-connect RUN useradd --system --uid 10001 --create-home email-connect
WORKDIR /app WORKDIR /app
COPY pyproject.toml README.md LICENSE ./ COPY pyproject.toml README.md LICENSE ./

View file

@ -8,6 +8,8 @@ import ipaddress
import json import json
import os import os
import smtplib import smtplib
import ssl
import time
import sqlite3 import sqlite3
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -36,26 +38,72 @@ class SQLiteDeliveryStore:
self.db.execute( self.db.execute(
"CREATE TABLE IF NOT EXISTS deliveries (event_id TEXT PRIMARY KEY, provider_ref TEXT NOT NULL)" "CREATE TABLE IF NOT EXISTS deliveries (event_id TEXT PRIMARY KEY, provider_ref TEXT NOT NULL)"
) )
self.db.execute( self.db.execute("""CREATE TABLE IF NOT EXISTS verifications (
"""CREATE TABLE IF NOT EXISTS verifications (
request_id TEXT PRIMARY KEY, handle_hash TEXT UNIQUE NOT NULL, request_id TEXT PRIMARY KEY, handle_hash TEXT UNIQUE NOT NULL,
registration_id TEXT NOT NULL, email TEXT NOT NULL, username TEXT NOT NULL, registration_id TEXT NOT NULL, email TEXT NOT NULL, username TEXT NOT NULL,
client_id TEXT NOT NULL, tenant TEXT NOT NULL, display_name TEXT, client_id TEXT NOT NULL, tenant TEXT NOT NULL, display_name TEXT,
expires_at TEXT NOT NULL, consumed_at TEXT, canceled_at TEXT expires_at TEXT NOT NULL, consumed_at TEXT, canceled_at TEXT
)""" )""")
) self.db.execute("""CREATE TABLE IF NOT EXISTS suppressions (
self.db.execute(
"""CREATE TABLE IF NOT EXISTS suppressions (
email TEXT PRIMARY KEY NOT NULL, email TEXT PRIMARY KEY NOT NULL,
reason TEXT NOT NULL, reason TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
)""" )""")
) columns = {
columns = {row[1] for row in self.db.execute("PRAGMA table_info(verifications)")} row[1] for row in self.db.execute("PRAGMA table_info(verifications)")
}
if "canceled_at" not in columns: if "canceled_at" not in columns:
self.db.execute("ALTER TABLE verifications ADD COLUMN canceled_at TEXT") self.db.execute("ALTER TABLE verifications ADD COLUMN canceled_at TEXT")
self.db.execute(
"CREATE TABLE IF NOT EXISTS send_attempts (event_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, state TEXT NOT NULL)"
)
self.db.commit() self.db.commit()
def reserve_send(self, event_id, fingerprint):
with self.lock:
self.db.execute("BEGIN IMMEDIATE")
try:
row = self.db.execute(
"SELECT fingerprint,state FROM send_attempts WHERE event_id=?",
(event_id,),
).fetchone()
if row and row[0] != fingerprint:
raise ValueError("idempotency_payload_mismatch")
if self.reference(event_id):
return False
if row and row[1] == "in_flight":
raise ProviderError("delivery_outcome_unknown", retryable=False)
self.db.execute(
"INSERT OR REPLACE INTO send_attempts VALUES (?,?,?)",
(event_id, fingerprint, "in_flight"),
)
return True
finally:
self.db.commit()
def send_failed(self, event_id, definitive):
if definitive:
with self.db:
self.db.execute(
"UPDATE send_attempts SET state='failed' WHERE event_id=?",
(event_id,),
)
def delivery_status(self, event_id):
reference = self.reference(event_id)
if reference:
return {"state": "provider_accepted", "reference": reference}
row = self.db.execute(
"SELECT state FROM send_attempts WHERE event_id=?", (event_id,)
).fetchone()
return {
"state": (
"unknown"
if row and row[0] == "in_flight"
else "failed" if row else "not_found"
)
}
def reference(self, event_id: str) -> str | None: def reference(self, event_id: str) -> str | None:
row = self.db.execute( row = self.db.execute(
"SELECT provider_ref FROM deliveries WHERE event_id=?", (event_id,) "SELECT provider_ref FROM deliveries WHERE event_id=?", (event_id,)
@ -64,7 +112,9 @@ class SQLiteDeliveryStore:
def record(self, event_id: str, reference: str) -> None: def record(self, event_id: str, reference: str) -> None:
with self.db: with self.db:
self.db.execute("INSERT INTO deliveries VALUES (?, ?)", (event_id, reference)) self.db.execute(
"INSERT INTO deliveries VALUES (?, ?)", (event_id, reference)
)
def is_suppressed(self, email: str) -> bool: def is_suppressed(self, email: str) -> bool:
row = self.db.execute( row = self.db.execute(
@ -79,7 +129,9 @@ class SQLiteDeliveryStore:
(email.lower(), reason, datetime.now(timezone.utc).isoformat()), (email.lower(), reason, datetime.now(timezone.utc).isoformat()),
) )
def create_verification(self, payload: dict, handle_hash: str, expires_at: str) -> str: def create_verification(
self, payload: dict, handle_hash: str, expires_at: str
) -> str:
request_id = f"vrq_{secrets.token_hex(12)}" request_id = f"vrq_{secrets.token_hex(12)}"
with self.db: with self.db:
self.db.execute( self.db.execute(
@ -177,63 +229,111 @@ class SMTPProvider:
) )
self.security = security self.security = security
def check(self):
"""Authenticate the configured SMTP lane without submitting a message."""
with smtplib.SMTP(self.host, self.port, timeout=5) as smtp:
if self.security == "starttls":
smtp.starttls(context=ssl.create_default_context())
smtp.login(self.username, self.password)
smtp.noop()
def send(self, recipient: str, subject: str, text: str) -> str: def send(self, recipient: str, subject: str, text: str) -> str:
message = EmailMessage() message = EmailMessage()
message["From"], message["To"], message["Subject"] = self.sender, recipient, subject message["From"], message["To"], message["Subject"] = (
self.sender,
recipient,
subject,
)
# RFC 5322 wants a Message-ID on outgoing mail, and it is the only handle # RFC 5322 wants a Message-ID on outgoing mail, and it is the only handle
# that ties an accepted send to a message later observed in a mailbox. # that ties an accepted send to a message later observed in a mailbox.
# Without it the reference below fell back to hash(), which Python # Without it the reference below fell back to hash(), which Python
# randomizes per process and which collides for equal recipient/subject. # randomizes per process and which collides for equal recipient/subject.
message["Message-ID"] = make_msgid(domain=self.sender.rpartition("@")[2] or None) message["Message-ID"] = make_msgid(
domain=self.sender.rpartition("@")[2] or None
)
message.set_content(text) message.set_content(text)
submitted = False
accepted = False
def failure(code, *, retryable):
error = ProviderError(code, retryable=retryable)
error.definitive = not submitted or (
not accepted and code in {"temporary_deferral", "permanent_rejection"}
)
return error
try: try:
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp: with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
if self.security == "starttls": if self.security == "starttls":
smtp.starttls() smtp.starttls(context=ssl.create_default_context())
smtp.login(self.username, self.password) smtp.login(self.username, self.password)
submitted = True
smtp.send_message(message) smtp.send_message(message)
accepted = True
except TimeoutError as exc: except TimeoutError as exc:
raise ProviderError("provider_timeout", retryable=True) from exc raise failure("provider_timeout", retryable=True) from exc
except smtplib.SMTPRecipientsRefused as exc: except smtplib.SMTPRecipientsRefused as exc:
# 5xx recipient refusals are permanent; never surface SMTP text. # 5xx recipient refusals are permanent; never surface SMTP text.
raise ProviderError("permanent_rejection", retryable=False) from exc raise failure("permanent_rejection", retryable=False) from exc
except smtplib.SMTPResponseException as exc: except smtplib.SMTPResponseException as exc:
code = int(getattr(exc, "smtp_code", 0) or 0) code = int(getattr(exc, "smtp_code", 0) or 0)
if 400 <= code < 500: if 400 <= code < 500:
raise ProviderError("temporary_deferral", retryable=True) from exc raise failure("temporary_deferral", retryable=True) from exc
if 500 <= code < 600: if 500 <= code < 600:
raise ProviderError("permanent_rejection", retryable=False) from exc raise failure("permanent_rejection", retryable=False) from exc
raise ProviderError("provider_unavailable", retryable=True) from exc raise failure("provider_unavailable", retryable=True) from exc
except (OSError, smtplib.SMTPException) as exc: except (OSError, smtplib.SMTPException) as exc:
raise ProviderError("provider_unavailable", retryable=True) from exc raise failure("provider_unavailable", retryable=True) from exc
return message["Message-ID"] or f"smtp:{abs(hash((recipient, subject)))}" return message["Message-ID"] or f"smtp:{abs(hash((recipient, subject)))}"
class TransactionalApplication: class TransactionalApplication:
def __init__(self, store: SQLiteDeliveryStore, provider, bearer_token: str, portal_url: str) -> None: def __init__(
self, store: SQLiteDeliveryStore, provider, bearer_token: str, portal_url: str
) -> None:
self.store, self.provider, self.token = store, provider, bearer_token self.store, self.provider, self.token = store, provider, bearer_token
self.portal_url = portal_url.rstrip("/") self.portal_url = portal_url.rstrip("/")
self._status_cache = None
self._status_time = 0.0
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
if environ.get("PATH_INFO") in ("/healthz", "/readyz"): if environ.get("PATH_INFO") in ("/healthz", "/readyz"):
return self._json(start_response, HTTPStatus.OK, {"status": "ok"}) return self._json(start_response, HTTPStatus.OK, {"status": "ok"})
path = environ.get("PATH_INFO") path = environ.get("PATH_INFO")
if path not in ( if (
"/v1/send", path
"/v1/registration-verifications", not in (
"/v1/registration-verifications/consume", "/v1/send",
"/v1/registration-verifications/cancel", "/v1/status",
) or environ.get("REQUEST_METHOD") != "POST": "/v1/delivery-status",
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error": "not_found"}) "/v1/registration-verifications",
"/v1/registration-verifications/consume",
"/v1/registration-verifications/cancel",
)
or environ.get("REQUEST_METHOD") != "POST"
):
return self._json(
start_response, HTTPStatus.NOT_FOUND, {"error": "not_found"}
)
if not hmac.compare_digest( if not hmac.compare_digest(
str(environ.get("HTTP_AUTHORIZATION", "")), f"Bearer {self.token}" str(environ.get("HTTP_AUTHORIZATION", "")), f"Bearer {self.token}"
): ):
return self._json(start_response, HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) return self._json(
start_response, HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}
)
try: try:
length = int(environ.get("CONTENT_LENGTH") or 0) length = int(environ.get("CONTENT_LENGTH") or 0)
if length <= 0 or length > 128 * 1024: if length <= 0 or length > 128 * 1024:
raise ValueError("invalid_size") raise ValueError("invalid_size")
payload = json.loads(environ["wsgi.input"].read(length)) payload = json.loads(environ["wsgi.input"].read(length))
if path == "/v1/status":
return self._status(start_response)
if path == "/v1/delivery-status":
return self._json(
start_response,
HTTPStatus.OK,
self.store.delivery_status(str(payload["event_id"])),
)
if path == "/v1/registration-verifications": if path == "/v1/registration-verifications":
return self._request_verification(start_response, payload) return self._request_verification(start_response, payload)
if path == "/v1/registration-verifications/consume": if path == "/v1/registration-verifications/consume":
@ -243,7 +343,9 @@ class TransactionalApplication:
return self._send_invitation(start_response, environ, payload) return self._send_invitation(start_response, environ, payload)
except ProviderError as exc: except ProviderError as exc:
status = ( status = (
HTTPStatus.SERVICE_UNAVAILABLE if exc.retryable else HTTPStatus.UNPROCESSABLE_ENTITY HTTPStatus.SERVICE_UNAVAILABLE
if exc.retryable
else HTTPStatus.UNPROCESSABLE_ENTITY
) )
return self._json( return self._json(
start_response, start_response,
@ -251,7 +353,9 @@ class TransactionalApplication:
{"error": exc.code, "retryable": exc.retryable}, {"error": exc.code, "retryable": exc.retryable},
) )
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)}) return self._json(
start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)}
)
except (TimeoutError, OSError, smtplib.SMTPException): except (TimeoutError, OSError, smtplib.SMTPException):
# Safety net: never leak provider exception text. # Safety net: never leak provider exception text.
return self._json( return self._json(
@ -260,31 +364,77 @@ class TransactionalApplication:
{"error": "provider_unavailable", "retryable": True}, {"error": "provider_unavailable", "retryable": True},
) )
def _status(self, start_response):
if self._status_cache is None or time.monotonic() - self._status_time >= 30:
checks = {"database": "failed", "smtp_authentication": "failed"}
try:
with self.store.lock:
self.store.db.execute("SELECT 1").fetchone()
checks["database"] = "ok"
except Exception:
pass
try:
self.provider.check()
checks["smtp_authentication"] = "ok"
except Exception:
pass
self._status_cache = {
"checks": checks,
"checked_at": datetime.now(timezone.utc).isoformat(),
"message_sent": False,
"inbox_receipt_verified": False,
}
self._status_time = time.monotonic()
return self._json(start_response, HTTPStatus.OK, self._status_cache)
def _send_invitation(self, start_response, environ, payload): def _send_invitation(self, start_response, environ, payload):
event_id = str(payload["id"]) event_id = str(payload["id"])
if environ.get("HTTP_IDEMPOTENCY_KEY") != event_id: if environ.get("HTTP_IDEMPOTENCY_KEY") != event_id:
raise ValueError("idempotency_key_mismatch") raise ValueError("idempotency_key_mismatch")
if payload.get("source") != "user-engine" or payload.get("type") not in ALLOWED_EVENTS: if (
payload.get("source") != "user-engine"
or payload.get("type") not in ALLOWED_EVENTS
):
raise ValueError("template_not_allowed") raise ValueError("template_not_allowed")
recipient = str(payload.get("data", {}).get("primary_email", "")) recipient = str(payload.get("data", {}).get("primary_email", ""))
if not _valid_address(recipient): if not _valid_address(recipient):
raise ValueError("invalid_recipient") raise ValueError("invalid_recipient")
if self.store.is_suppressed(recipient): if self.store.is_suppressed(recipient):
raise ValueError("recipient_suppressed") raise ValueError("recipient_suppressed")
existing = self.store.reference(event_id)
if existing:
return self._json(
start_response, HTTPStatus.OK, {"status": "duplicate", "reference": existing}
)
invitation_id = str(payload.get("data", {}).get("invitation_id", "")) invitation_id = str(payload.get("data", {}).get("invitation_id", ""))
if not invitation_id: if not invitation_id:
raise ValueError("invitation_id_required") raise ValueError("invitation_id_required")
reference = self.provider.send( fingerprint = hashlib.sha256(
recipient, json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
"Your NetKingdom invitation", ).hexdigest()
f"You have been invited. Continue securely at {self.portal_url}/invitations/{invitation_id}\n", if not self.store.reserve_send(event_id, fingerprint):
) return self._json(
self.store.record(event_id, reference) start_response,
HTTPStatus.OK,
{"status": "duplicate", "reference": self.store.reference(event_id)},
)
try:
reference = self.provider.send(
recipient,
"Your NetKingdom invitation",
f"You have been invited. Continue securely at {self.portal_url}/invitations/{invitation_id}\n",
)
self.store.record(event_id, reference)
except ProviderError as exc:
definitive = getattr(
exc,
"definitive",
exc.code in {"temporary_deferral", "permanent_rejection"},
)
self.store.send_failed(event_id, definitive)
if not definitive:
raise ProviderError(
"delivery_outcome_unknown", retryable=False
) from None
raise
except Exception:
# Preserve the durable reservation: SMTP may have accepted the message.
raise ProviderError("delivery_outcome_unknown", retryable=False) from None
# Provider acceptance is transport evidence only — not identity or auth. # Provider acceptance is transport evidence only — not identity or auth.
return self._json( return self._json(
start_response, start_response,
@ -326,14 +476,18 @@ class TransactionalApplication:
"These links expire in 30 minutes.\n", "These links expire in 30 minutes.\n",
) )
return self._json( return self._json(
start_response, HTTPStatus.ACCEPTED, {"request_id": request_id, "accepted": True} start_response,
HTTPStatus.ACCEPTED,
{"request_id": request_id, "accepted": True},
) )
def _consume_verification(self, start_response, payload): def _consume_verification(self, start_response, payload):
handle = str(payload.get("handle") or "") handle = str(payload.get("handle") or "")
if len(handle) < 32: if len(handle) < 32:
raise ValueError("verification_invalid") raise ValueError("verification_invalid")
evidence = self.store.consume_verification(hashlib.sha256(handle.encode()).hexdigest()) evidence = self.store.consume_verification(
hashlib.sha256(handle.encode()).hexdigest()
)
# mailbox_control is channel evidence only; never an authorization decision. # mailbox_control is channel evidence only; never an authorization decision.
return self._json( return self._json(
start_response, start_response,
@ -353,7 +507,9 @@ class TransactionalApplication:
handle = str(payload.get("handle") or "") handle = str(payload.get("handle") or "")
if len(handle) < 32: if len(handle) < 32:
raise ValueError("verification_invalid") raise ValueError("verification_invalid")
evidence = self.store.cancel_verification(hashlib.sha256(handle.encode()).hexdigest()) evidence = self.store.cancel_verification(
hashlib.sha256(handle.encode()).hexdigest()
)
return self._json( return self._json(
start_response, start_response,
HTTPStatus.OK, HTTPStatus.OK,
@ -392,7 +548,12 @@ def _is_loopback_host(host: str) -> bool:
def _valid_address(value: str) -> bool: def _valid_address(value: str) -> bool:
_, address = parseaddr(value) _, address = parseaddr(value)
return address == value and "@" in address and "\n" not in address and "\r" not in address return (
address == value
and "@" in address
and "\n" not in address
and "\r" not in address
)
def main() -> None: def main() -> None:
@ -405,10 +566,14 @@ def main() -> None:
security=os.environ.get("EMAIL_CONNECT_SMTP_SECURITY", "starttls"), security=os.environ.get("EMAIL_CONNECT_SMTP_SECURITY", "starttls"),
) )
app = TransactionalApplication( app = TransactionalApplication(
SQLiteDeliveryStore(os.environ.get("EMAIL_CONNECT_DATABASE_PATH", "/data/email-connect.db")), SQLiteDeliveryStore(
os.environ.get("EMAIL_CONNECT_DATABASE_PATH", "/data/email-connect.db")
),
provider, provider,
os.environ["EMAIL_CONNECT_INGEST_TOKEN"].strip(), os.environ["EMAIL_CONNECT_INGEST_TOKEN"].strip(),
os.environ["EMAIL_CONNECT_PORTAL_URL"], os.environ["EMAIL_CONNECT_PORTAL_URL"],
) )
with make_server("0.0.0.0", int(os.environ.get("EMAIL_CONNECT_HTTP_PORT", "8080")), app) as server: with make_server(
"0.0.0.0", int(os.environ.get("EMAIL_CONNECT_HTTP_PORT", "8080")), app
) as server:
server.serve_forever() server.serve_forever()

View file

@ -51,7 +51,9 @@ def invoke(app, payload, token="opaque", key=None, path="/v1/send"):
"HTTP_AUTHORIZATION": f"Bearer {token}", "HTTP_AUTHORIZATION": f"Bearer {token}",
} }
if path == "/v1/send": if path == "/v1/send":
env["HTTP_IDEMPOTENCY_KEY"] = key if key is not None else payload.get("id", "evt-1") 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))) body = b"".join(app(env, lambda s, h: result.update(status=s)))
return result["status"], json.loads(body) return result["status"], json.loads(body)
@ -59,7 +61,10 @@ def invoke(app, payload, token="opaque", key=None, path="/v1/send"):
def test_sends_fixed_template_once(tmp_path): def test_sends_fixed_template_once(tmp_path):
provider = Provider() provider = Provider()
app = TransactionalApplication( app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "mail.db")), provider, "opaque", "https://users.example" SQLiteDeliveryStore(str(tmp_path / "mail.db")),
provider,
"opaque",
"https://users.example",
) )
status, body = invoke(app, event()) status, body = invoke(app, event())
assert status.startswith("202") assert status.startswith("202")
@ -71,7 +76,10 @@ def test_sends_fixed_template_once(tmp_path):
def test_rejects_auth_recipient_template_and_key(tmp_path): def test_rejects_auth_recipient_template_and_key(tmp_path):
app = TransactionalApplication( app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "mail.db")), Provider(), "opaque", "https://users.example" SQLiteDeliveryStore(str(tmp_path / "mail.db")),
Provider(),
"opaque",
"https://users.example",
) )
assert invoke(app, event(), token="bad")[0].startswith("401") assert invoke(app, event(), token="bad")[0].startswith("401")
bad = event() bad = event()
@ -86,7 +94,10 @@ def test_rejects_auth_recipient_template_and_key(tmp_path):
def test_resend_is_separate_event_and_sends_again(tmp_path): def test_resend_is_separate_event_and_sends_again(tmp_path):
provider = Provider() provider = Provider()
app = TransactionalApplication( app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "mail.db")), provider, "opaque", "https://users.example" SQLiteDeliveryStore(str(tmp_path / "mail.db")),
provider,
"opaque",
"https://users.example",
) )
assert invoke(app, event("evt-1", "family_member.invited"))[0].startswith("202") assert invoke(app, event("evt-1", "family_member.invited"))[0].startswith("202")
status, body = invoke(app, event("evt-2", "family_invitation.resent")) status, body = invoke(app, event("evt-2", "family_invitation.resent"))
@ -141,7 +152,7 @@ def test_smtp_provider_maps_recipient_refused_to_permanent():
def __exit__(self, *a): def __exit__(self, *a):
return False return False
def starttls(self): def starttls(self, *, context=None):
return None return None
def login(self, *a): def login(self, *a):
@ -251,7 +262,9 @@ def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_p
"wsgi.input": io.BytesIO(payload), "wsgi.input": io.BytesIO(payload),
"HTTP_AUTHORIZATION": "Bearer opaque", "HTTP_AUTHORIZATION": "Bearer opaque",
} }
canceled = json.loads(b"".join(app(cancel_env, lambda s, h: result.update(status=s)))) canceled = json.loads(
b"".join(app(cancel_env, lambda s, h: result.update(status=s)))
)
assert canceled["purpose"] == "public-registration-cancel" assert canceled["purpose"] == "public-registration-cancel"
assert canceled["authorization"] is False assert canceled["authorization"] is False
consume_env = { consume_env = {
@ -261,7 +274,9 @@ def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_p
"wsgi.input": io.BytesIO(payload), "wsgi.input": io.BytesIO(payload),
"HTTP_AUTHORIZATION": "Bearer opaque", "HTTP_AUTHORIZATION": "Bearer opaque",
} }
rejected = json.loads(b"".join(app(consume_env, lambda s, h: result.update(status=s)))) rejected = json.loads(
b"".join(app(consume_env, lambda s, h: result.update(status=s)))
)
assert rejected["error"] == "verification_invalid" assert rejected["error"] == "verification_invalid"
cancel_env["wsgi.input"] = io.BytesIO(payload) cancel_env["wsgi.input"] = io.BytesIO(payload)
replay = json.loads(b"".join(app(cancel_env, lambda s, h: result.update(status=s)))) replay = json.loads(b"".join(app(cancel_env, lambda s, h: result.update(status=s))))
@ -272,10 +287,15 @@ def test_mailbox_evidence_is_not_authorization(tmp_path):
"""Provider acceptance and mailbox challenge never grant user-engine authority.""" """Provider acceptance and mailbox challenge never grant user-engine authority."""
provider = Provider() provider = Provider()
app = TransactionalApplication( app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "mail.db")), provider, "opaque", "https://users.example" SQLiteDeliveryStore(str(tmp_path / "mail.db")),
provider,
"opaque",
"https://users.example",
) )
_, accepted = invoke(app, event()) _, accepted = invoke(app, event())
assert accepted.get("authorization") is None or accepted.get("authorization") is False assert (
accepted.get("authorization") is None or accepted.get("authorization") is False
)
assert accepted["evidence_ceiling"] == "provider_accepted" assert accepted["evidence_ceiling"] == "provider_accepted"
# Ceiling is explicit; user-engine must not elevate this to authz. # Ceiling is explicit; user-engine must not elevate this to authz.
assert "authorized" not in accepted assert "authorized" not in accepted
@ -299,7 +319,8 @@ class RecordingSMTP:
def __exit__(self, *exc): def __exit__(self, *exc):
return False return False
def starttls(self): def starttls(self, *, context=None):
assert context.check_hostname
self.starttls_calls += 1 self.starttls_calls += 1
def login(self, username, password): def login(self, username, password):
@ -312,7 +333,9 @@ class RecordingSMTP:
def test_smtp_provider_defaults_to_starttls(monkeypatch): def test_smtp_provider_defaults_to_starttls(monkeypatch):
monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP) monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP)
RecordingSMTP.instances = [] RecordingSMTP.instances = []
provider = SMTPProvider("smtp.example.com", 587, "user", "pass", "noreply@example.com") provider = SMTPProvider(
"smtp.example.com", 587, "user", "pass", "noreply@example.com"
)
assert provider.security == "starttls" assert provider.security == "starttls"
provider.send("person@example.test", "Subject", "body") provider.send("person@example.test", "Subject", "body")
@ -322,7 +345,9 @@ def test_smtp_provider_defaults_to_starttls(monkeypatch):
def test_plaintext_security_skips_starttls_on_loopback(monkeypatch): def test_plaintext_security_skips_starttls_on_loopback(monkeypatch):
monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP) monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP)
RecordingSMTP.instances = [] RecordingSMTP.instances = []
provider = SMTPProvider("127.0.0.1", 3025, "user", "pass", "noreply@example.com", security="plaintext") provider = SMTPProvider(
"127.0.0.1", 3025, "user", "pass", "noreply@example.com", security="plaintext"
)
provider.send("person@example.test", "Subject", "body") provider.send("person@example.test", "Subject", "body")
smtp = RecordingSMTP.instances[0] smtp = RecordingSMTP.instances[0]
@ -331,17 +356,142 @@ def test_plaintext_security_skips_starttls_on_loopback(monkeypatch):
assert len(smtp.sent) == 1 assert len(smtp.sent) == 1
@pytest.mark.parametrize("host", ["127.0.0.1", "127.0.1.1", "::1", "[::1]", "localhost", "LOCALHOST"]) @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): def test_plaintext_security_is_allowed_for_loopback_hosts(host):
assert SMTPProvider(host, 3025, "user", "pass", "noreply@example.com", security="plaintext") 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"]) @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): def test_plaintext_security_is_refused_for_non_loopback_hosts(host):
with pytest.raises(ValueError, match="loopback"): with pytest.raises(ValueError, match="loopback"):
SMTPProvider(host, 3025, "user", "pass", "noreply@example.com", security="plaintext") SMTPProvider(
host, 3025, "user", "pass", "noreply@example.com", security="plaintext"
)
def test_unsupported_security_mode_is_refused(): def test_unsupported_security_mode_is_refused():
with pytest.raises(ValueError, match="security mode"): with pytest.raises(ValueError, match="security mode"):
SMTPProvider("127.0.0.1", 3025, "user", "pass", "noreply@example.com", security="none") SMTPProvider(
"127.0.0.1", 3025, "user", "pass", "noreply@example.com", security="none"
)
def test_status_authenticates_without_sending_and_redacts_errors(tmp_path):
class Diagnostic(Provider):
def check(self):
pass
provider = Diagnostic()
app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "status.db")),
provider,
"opaque",
"https://users.example",
)
assert invoke(app, {}, path="/v1/status", token="bad")[0].startswith("401")
status, body = invoke(app, {}, path="/v1/status")
assert body["checks"] == {"database": "ok", "smtp_authentication": "ok"}
assert body["message_sent"] is False and provider.calls == []
def unavailable():
raise RuntimeError("private-smtp-password")
provider.check = unavailable
app._status_cache = None
body = invoke(app, {}, path="/v1/status")[1]
assert body["checks"][
"smtp_authentication"
] == "failed" and "private-smtp" not in json.dumps(body)
def test_lost_post_send_record_never_resends_after_restart(tmp_path):
path = str(tmp_path / "lost.db")
store = SQLiteDeliveryStore(path)
provider = Provider()
app = TransactionalApplication(store, provider, "opaque", "https://users.example")
def failed_write(*args):
raise RuntimeError("private-db-error")
store.record = failed_write
assert invoke(app, event())[1]["error"] == "delivery_outcome_unknown"
app = TransactionalApplication(
SQLiteDeliveryStore(path), provider, "opaque", "https://users.example"
)
assert invoke(app, event())[1]["error"] == "delivery_outcome_unknown"
assert len(provider.calls) == 1
assert (
invoke(app, {"event_id": "evt-1"}, path="/v1/delivery-status")[1]["state"]
== "unknown"
)
def test_definitive_failure_retries_but_changed_payload_is_rejected(tmp_path):
provider = FailingProvider(ProviderError("temporary_deferral", retryable=True))
app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "retry.db")),
provider,
"opaque",
"https://users.example",
)
assert invoke(app, event())[0].startswith("503")
app.provider = Provider()
assert invoke(app, event())[0].startswith("202")
assert (
invoke(app, event(email="different@example.test"))[1]["error"]
== "idempotency_payload_mismatch"
)
assert len(app.provider.calls) == 1
def test_disconnect_after_smtp_acceptance_never_releases_reservation(
tmp_path, monkeypatch
):
import smtplib
class SMTP:
sends = 0
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, *args):
raise smtplib.SMTPResponseException(550, b"private quit failure")
def login(self, *args):
pass
def send_message(self, message):
self.__class__.sends += 1
monkeypatch.setattr("email_connect.transactional.smtplib.SMTP", SMTP)
from email_connect.transactional import SMTPProvider
app = TransactionalApplication(
SQLiteDeliveryStore(str(tmp_path / "quit.db")),
SMTPProvider(
"127.0.0.1",
3025,
"fixture",
"fixture",
"from@example.test",
security="plaintext",
),
"opaque",
"https://users.example",
)
for _ in range(2):
status, body = invoke(app, event())
assert body["error"] == "delivery_outcome_unknown"
assert "private" not in str(body)
assert SMTP.sends == 1