email-connect/src/email_connect/transactional.py
tegwick 89fd13ac2d
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
EMAIL-WP-0005-T02: add GreenMail test harness
Adds tests/harness/docker-compose.yml running GreenMail 2.1.12 (digest-pinned)
with SMTP 3025, IMAP 3143 and the API bound to 127.0.0.1 only, plus a
config/harness-imap.yml scanner profile and harness README. Auth is disabled
and no users are declared, so a mailbox is created on first login and per-test
users need no provisioning.

GreenMail standalone offers no STARTTLS, only plaintext or implicit TLS, while
SMTPProvider hardcoded starttls() -- so no send could reach it. SMTPProvider
now takes a security mode via EMAIL_CONNECT_SMTP_SECURITY, defaulting to
starttls. plaintext is refused for any non-loopback host, and hostnames are
never resolved to decide that, so a misconfigured deployment fails at startup
rather than sending credentials in the clear. Trusting GreenMail's self-signed
cert was rejected as the wider risk; see DECISIONS.md.

Verified end to end against the live harness: SMTPProvider.send -> GreenMail ->
ImapMailboxSource, and the documented scan-mailbox CLI. Suite: 52 passed with
the harness down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:41:38 +02:00

409 lines
17 KiB
Python

"""Narrow authenticated transactional mail receiver for user-engine."""
from __future__ import annotations
import hmac
import hashlib
import ipaddress
import json
import os
import smtplib
import sqlite3
import secrets
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email.utils import parseaddr
from http import HTTPStatus
from threading import RLock
from wsgiref.simple_server import make_server
ALLOWED_EVENTS = {"family_member.invited", "family_invitation.resent"}
class ProviderError(Exception):
"""Provider outcome with stable redacted code and retry classification."""
def __init__(self, code: str, *, retryable: bool) -> None:
super().__init__(code)
self.code = code
self.retryable = retryable
class SQLiteDeliveryStore:
def __init__(self, path: str) -> None:
self.db = sqlite3.connect(path, check_same_thread=False)
self.lock = RLock()
self.db.execute(
"CREATE TABLE IF NOT EXISTS deliveries (event_id TEXT PRIMARY KEY, provider_ref TEXT NOT NULL)"
)
self.db.execute(
"""CREATE TABLE IF NOT EXISTS verifications (
request_id TEXT PRIMARY KEY, handle_hash TEXT UNIQUE 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,
expires_at TEXT NOT NULL, consumed_at TEXT, canceled_at TEXT
)"""
)
self.db.execute(
"""CREATE TABLE IF NOT EXISTS suppressions (
email TEXT PRIMARY KEY NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL
)"""
)
columns = {row[1] for row in self.db.execute("PRAGMA table_info(verifications)")}
if "canceled_at" not in columns:
self.db.execute("ALTER TABLE verifications ADD COLUMN canceled_at TEXT")
self.db.commit()
def reference(self, event_id: str) -> str | None:
row = self.db.execute(
"SELECT provider_ref FROM deliveries WHERE event_id=?", (event_id,)
).fetchone()
return row[0] if row else None
def record(self, event_id: str, reference: str) -> None:
with self.db:
self.db.execute("INSERT INTO deliveries VALUES (?, ?)", (event_id, reference))
def is_suppressed(self, email: str) -> bool:
row = self.db.execute(
"SELECT 1 FROM suppressions WHERE email=?", (email.lower(),)
).fetchone()
return row is not None
def suppress(self, email: str, reason: str = "manual") -> None:
with self.db:
self.db.execute(
"INSERT OR REPLACE INTO suppressions VALUES (?,?,?)",
(email.lower(), reason, datetime.now(timezone.utc).isoformat()),
)
def create_verification(self, payload: dict, handle_hash: str, expires_at: str) -> str:
request_id = f"vrq_{secrets.token_hex(12)}"
with self.db:
self.db.execute(
"""INSERT INTO verifications
(request_id, handle_hash, registration_id, email, username,
client_id, tenant, display_name, expires_at, consumed_at, canceled_at)
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)""",
(
request_id,
handle_hash,
payload["registration_id"],
payload["normalized_email"],
payload["preferred_username"],
payload["client_id"],
payload["tenant"],
payload.get("display_name"),
expires_at,
),
)
return request_id
def consume_verification(self, handle_hash: str) -> dict:
with self.lock, self.db:
row = self.db.execute(
"SELECT * FROM verifications WHERE handle_hash=?", (handle_hash,)
).fetchone()
if row is None or row[9] is not None or row[10] is not None:
raise ValueError("verification_invalid")
if datetime.fromisoformat(row[8]) <= datetime.now(timezone.utc):
raise ValueError("verification_expired")
self.db.execute(
"UPDATE verifications SET consumed_at=? WHERE request_id=?",
(datetime.now(timezone.utc).isoformat(), row[0]),
)
return {
"request_id": row[0],
"registration_id": row[2],
"email": row[3],
"preferred_username": row[4],
"client_id": row[5],
"tenant": row[6],
"display_name": row[7],
}
def cancel_verification(self, handle_hash: str) -> dict:
with self.lock, self.db:
row = self.db.execute(
"SELECT * FROM verifications WHERE handle_hash=?", (handle_hash,)
).fetchone()
if row is None or row[9] is not None or row[10] is not None:
raise ValueError("verification_invalid")
if datetime.fromisoformat(row[8]) <= datetime.now(timezone.utc):
raise ValueError("verification_expired")
self.db.execute(
"UPDATE verifications SET canceled_at=? WHERE request_id=?",
(datetime.now(timezone.utc).isoformat(), row[0]),
)
return {
"request_id": row[0],
"registration_id": row[2],
"email": row[3],
"preferred_username": row[4],
"client_id": row[5],
"tenant": row[6],
"display_name": row[7],
}
class SMTPProvider:
#: STARTTLS is the only transport allowed against a remote provider.
#: `plaintext` exists for loopback test servers that cannot offer TLS and is
#: refused for any other host, so a misconfiguration cannot put credentials
#: on the wire in the clear.
SECURITY_MODES = ("starttls", "plaintext")
def __init__(
self,
host: str,
port: int,
username: str,
password: str,
sender: str,
security: str = "starttls",
) -> None:
if security not in self.SECURITY_MODES:
raise ValueError(f"Unsupported SMTP security mode: {security}")
if security == "plaintext" and not _is_loopback_host(host):
raise ValueError("Plaintext SMTP is only permitted for loopback hosts.")
self.host, self.port, self.username, self.password, self.sender = (
host,
port,
username,
password,
sender,
)
self.security = security
def send(self, recipient: str, subject: str, text: str) -> str:
message = EmailMessage()
message["From"], message["To"], message["Subject"] = self.sender, recipient, subject
message.set_content(text)
try:
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
if self.security == "starttls":
smtp.starttls()
smtp.login(self.username, self.password)
smtp.send_message(message)
except TimeoutError as exc:
raise ProviderError("provider_timeout", retryable=True) from exc
except smtplib.SMTPRecipientsRefused as exc:
# 5xx recipient refusals are permanent; never surface SMTP text.
raise ProviderError("permanent_rejection", retryable=False) from exc
except smtplib.SMTPResponseException as exc:
code = int(getattr(exc, "smtp_code", 0) or 0)
if 400 <= code < 500:
raise ProviderError("temporary_deferral", retryable=True) from exc
if 500 <= code < 600:
raise ProviderError("permanent_rejection", retryable=False) from exc
raise ProviderError("provider_unavailable", retryable=True) from exc
except (OSError, smtplib.SMTPException) as exc:
raise ProviderError("provider_unavailable", retryable=True) from exc
return message["Message-ID"] or f"smtp:{abs(hash((recipient, subject)))}"
class TransactionalApplication:
def __init__(self, store: SQLiteDeliveryStore, provider, bearer_token: str, portal_url: str) -> None:
self.store, self.provider, self.token = store, provider, bearer_token
self.portal_url = portal_url.rstrip("/")
def __call__(self, environ, start_response):
if environ.get("PATH_INFO") in ("/healthz", "/readyz"):
return self._json(start_response, HTTPStatus.OK, {"status": "ok"})
path = environ.get("PATH_INFO")
if path not in (
"/v1/send",
"/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(
str(environ.get("HTTP_AUTHORIZATION", "")), f"Bearer {self.token}"
):
return self._json(start_response, HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"})
try:
length = int(environ.get("CONTENT_LENGTH") or 0)
if length <= 0 or length > 128 * 1024:
raise ValueError("invalid_size")
payload = json.loads(environ["wsgi.input"].read(length))
if path == "/v1/registration-verifications":
return self._request_verification(start_response, payload)
if path == "/v1/registration-verifications/consume":
return self._consume_verification(start_response, payload)
if path == "/v1/registration-verifications/cancel":
return self._cancel_verification(start_response, payload)
return self._send_invitation(start_response, environ, payload)
except ProviderError as exc:
status = (
HTTPStatus.SERVICE_UNAVAILABLE if exc.retryable else HTTPStatus.UNPROCESSABLE_ENTITY
)
return self._json(
start_response,
status,
{"error": exc.code, "retryable": exc.retryable},
)
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)})
except (TimeoutError, OSError, smtplib.SMTPException):
# Safety net: never leak provider exception text.
return self._json(
start_response,
HTTPStatus.SERVICE_UNAVAILABLE,
{"error": "provider_unavailable", "retryable": True},
)
def _send_invitation(self, start_response, environ, payload):
event_id = str(payload["id"])
if environ.get("HTTP_IDEMPOTENCY_KEY") != event_id:
raise ValueError("idempotency_key_mismatch")
if payload.get("source") != "user-engine" or payload.get("type") not in ALLOWED_EVENTS:
raise ValueError("template_not_allowed")
recipient = str(payload.get("data", {}).get("primary_email", ""))
if not _valid_address(recipient):
raise ValueError("invalid_recipient")
if self.store.is_suppressed(recipient):
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", ""))
if not invitation_id:
raise ValueError("invitation_id_required")
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)
# Provider acceptance is transport evidence only — not identity or auth.
return self._json(
start_response,
HTTPStatus.ACCEPTED,
{
"status": "accepted",
"reference": reference,
"event_id": event_id,
"evidence_ceiling": "provider_accepted",
},
)
def _request_verification(self, start_response, payload):
required = (
"registration_id",
"normalized_email",
"preferred_username",
"client_id",
"tenant",
"correlation_id",
)
if any(not payload.get(key) for key in required) or not _valid_address(
str(payload["normalized_email"])
):
raise ValueError("invalid_verification_request")
email = str(payload["normalized_email"])
if self.store.is_suppressed(email):
raise ValueError("recipient_suppressed")
handle = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
request_id = self.store.create_verification(
payload, hashlib.sha256(handle.encode()).hexdigest(), expires_at.isoformat()
)
self.provider.send(
email,
"Verify your NetKingdom registration",
f"Continue securely at {self.portal_url}/registration/verify?handle={handle}\n"
f"Cancel this request at {self.portal_url}/registration/cancel?handle={handle}\n"
"These links expire in 30 minutes.\n",
)
return self._json(
start_response, HTTPStatus.ACCEPTED, {"request_id": request_id, "accepted": True}
)
def _consume_verification(self, start_response, payload):
handle = str(payload.get("handle") or "")
if len(handle) < 32:
raise ValueError("verification_invalid")
evidence = self.store.consume_verification(hashlib.sha256(handle.encode()).hexdigest())
# mailbox_control is channel evidence only; never an authorization decision.
return self._json(
start_response,
HTTPStatus.OK,
{
"purpose": "public-registration",
"verification_id": f"fvr_{secrets.token_hex(12)}",
**evidence,
"source_system": "email-connect",
"assurance": {"mailbox_control": True},
"authorization": False,
"evidence_ceiling": "mailbox_challenge_consumed",
},
)
def _cancel_verification(self, start_response, payload):
handle = str(payload.get("handle") or "")
if len(handle) < 32:
raise ValueError("verification_invalid")
evidence = self.store.cancel_verification(hashlib.sha256(handle.encode()).hexdigest())
return self._json(
start_response,
HTTPStatus.OK,
{
"purpose": "public-registration-cancel",
"verification_id": f"fvc_{secrets.token_hex(12)}",
**evidence,
"source_system": "email-connect",
"assurance": {"mailbox_control": True},
"authorization": False,
"evidence_ceiling": "mailbox_challenge_canceled",
},
)
@staticmethod
def _json(start_response, status, payload):
body = json.dumps(payload).encode()
start_response(
f"{status.value} {status.phrase}",
[("Content-Type", "application/json"), ("Content-Length", str(len(body)))],
)
return [body]
def _is_loopback_host(host: str) -> bool:
candidate = (host or "").strip().strip("[]")
if candidate.lower() == "localhost":
return True
try:
return ipaddress.ip_address(candidate).is_loopback
except ValueError:
# Never resolve names here: a hostname that happens to point at 127.0.0.1
# today is not a durable guarantee that credentials stay on the host.
return False
def _valid_address(value: str) -> bool:
_, address = parseaddr(value)
return address == value and "@" in address and "\n" not in address and "\r" not in address
def main() -> None:
provider = SMTPProvider(
os.environ["EMAIL_CONNECT_SMTP_HOST"],
int(os.environ.get("EMAIL_CONNECT_SMTP_PORT", "587")),
os.environ["EMAIL_CONNECT_SMTP_USERNAME"],
os.environ["EMAIL_CONNECT_SMTP_PASSWORD"],
os.environ["EMAIL_CONNECT_SENDER"],
security=os.environ.get("EMAIL_CONNECT_SMTP_SECURITY", "starttls"),
)
app = TransactionalApplication(
SQLiteDeliveryStore(os.environ.get("EMAIL_CONNECT_DATABASE_PATH", "/data/email-connect.db")),
provider,
os.environ["EMAIL_CONNECT_INGEST_TOKEN"].strip(),
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:
server.serve_forever()