diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..ffae8d6 --- /dev/null +++ b/Containerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +RUN useradd --system --uid 10001 --create-home email-connect +WORKDIR /app +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN pip install --no-cache-dir . +USER 10001 +EXPOSE 8080 +CMD ["email-connect-transactional"] diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md new file mode 100644 index 0000000..6f7f5e9 --- /dev/null +++ b/WORK-RECORDS.md @@ -0,0 +1,41 @@ +# Work Records — email-connect + +> Generated by `statehub fix-consistency` (CUST-WP-0061-T04, work-record +> stage 3). Do not edit by hand — edit the source file/block listed for +> each record and re-run fix-consistency to refresh this index. Archived +> workplans are omitted; closed decisions/intakes/engagements stay listed +> so recently-resolved work is still visible. [auto] + +| Kind | ID | Status | Lane | Source | +| --- | --- | --- | --- | --- | +| workplan | EMAIL-WP-0001 | finished | — | workplans/EMAIL-WP-0001-repo-onboarding.md | +| workplan | EMAIL-WP-0002 | finished | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| workplan | EMAIL-WP-0003 | finished | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| workplan | EMAIL-WP-0004 | ready | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | +| task | EMAIL-WP-0001-T01 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md | +| task | EMAIL-WP-0001-T02 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md | +| task | EMAIL-WP-0001-T03 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md | +| task | EMAIL-WP-0001-T04 | done | — | workplans/EMAIL-WP-0001-repo-onboarding.md | +| task | EMAIL-WP-0002-T01 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T02 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T03 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T04 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T05 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T06 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T07 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T08 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T09 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T10 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T11 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0002-T12 | done | — | workplans/EMAIL-WP-0002-mvp-mailbox-evidence-scanner.md | +| task | EMAIL-WP-0003-T01 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0003-T02 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0003-T03 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0003-T04 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0003-T05 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0003-T06 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0003-T07 | done | — | workplans/EMAIL-WP-0003-expected-recipient-reporting-and-mailbox-tutorial.md | +| task | EMAIL-WP-0004-T01 | todo | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | +| task | EMAIL-WP-0004-T02 | todo | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | +| task | EMAIL-WP-0004-T03 | todo | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | +| task | EMAIL-WP-0004-T04 | todo | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | diff --git a/pyproject.toml b/pyproject.toml index 26f68df..d28eea8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [] [project.scripts] email-connect = "email_connect.cli:main" +email-connect-transactional = "email_connect.transactional:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/email_connect/transactional.py b/src/email_connect/transactional.py new file mode 100644 index 0000000..6804d55 --- /dev/null +++ b/src/email_connect/transactional.py @@ -0,0 +1,99 @@ +"""Narrow authenticated transactional mail receiver for user-engine.""" + +from __future__ import annotations + +import hmac +import json +import os +import smtplib +import sqlite3 +from email.message import EmailMessage +from email.utils import parseaddr +from http import HTTPStatus +from wsgiref.simple_server import make_server + +ALLOWED_EVENTS = {"family_member.invited", "family_invitation.resent"} + + +class SQLiteDeliveryStore: + def __init__(self, path: str) -> None: + self.db = sqlite3.connect(path, check_same_thread=False) + self.db.execute("CREATE TABLE IF NOT EXISTS deliveries (event_id TEXT PRIMARY KEY, provider_ref TEXT NOT NULL)") + 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)) + + +class SMTPProvider: + def __init__(self, host: str, port: int, username: str, password: str, sender: str) -> None: + self.host, self.port, self.username, self.password, self.sender = host, port, username, password, sender + + 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) + with smtplib.SMTP(self.host, self.port, timeout=10) as smtp: + smtp.starttls() + smtp.login(self.username, self.password) + smtp.send_message(message) + 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"}) + if environ.get("PATH_INFO") != "/v1/send" 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)) + 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") + 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) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error":str(exc)}) + except (TimeoutError, OSError, smtplib.SMTPException): + return self._json(start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"error":"provider_unavailable","retryable":True}) + return self._json(start_response, HTTPStatus.ACCEPTED, {"status":"accepted","reference":reference}) + + @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 _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"]) + 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() diff --git a/tests/test_transactional.py b/tests/test_transactional.py new file mode 100644 index 0000000..6da206c --- /dev/null +++ b/tests/test_transactional.py @@ -0,0 +1,23 @@ +import io, json +from email_connect.transactional import SQLiteDeliveryStore, TransactionalApplication + +class Provider: + def __init__(self): self.calls=[] + def send(self,*args): self.calls.append(args); return "message:1" + +def event(): return {"id":"evt-1","type":"family_member.invited","source":"user-engine","data":{"primary_email":"person@example.test","invitation_id":"inv-1"}} +def invoke(app,payload,token="opaque",key="evt-1"): + raw=json.dumps(payload).encode(); result={}; body=b"".join(app({"PATH_INFO":"/v1/send","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(raw)),"wsgi.input":io.BytesIO(raw),"HTTP_AUTHORIZATION":f"Bearer {token}","HTTP_IDEMPOTENCY_KEY":key},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") + assert invoke(app,event())[0].startswith("202") + 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" diff --git a/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md b/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md new file mode 100644 index 0000000..5a827f9 --- /dev/null +++ b/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md @@ -0,0 +1,101 @@ +--- +id: EMAIL-WP-0004 +type: workplan +title: "Provide transactional invitation mail delivery" +domain: infotech +repo: email-connect +status: active +owner: codex +topic_slug: netkingdom +created: "2026-08-08" +updated: "2026-08-08" +depends_on: + - NK-WP-0024 +state_hub_workstream_id: "a37e5e4d-090a-4a75-88d2-aacd0c0fd235" +--- + +# EMAIL-WP-0004 - transactional invitation mail delivery + +Extend email-connect beyond mailbox evidence scanning with a narrow, +provider-neutral transactional-send service for user-engine invitations and +verification messages. Email evidence remains non-authoritative. + +## T01 - Define the send and evidence contract + +```task +id: EMAIL-WP-0004-T01 +status: done +priority: high +state_hub_task_id: "ef07126a-3c1b-4a8b-9ec1-252c8844ec49" +``` + +Define an authenticated HTTP send request derived from the user-engine outbox +envelope, approved templates, recipient/address validation, tenant/template +allow-lists, Idempotency-Key, correlation, redaction, and response/error +semantics. Provider acceptance must not be described as inbox delivery, +awareness, identity, or authorization. + +Done when the contract supports invitation-created and invitation-resent +events without accepting arbitrary sender, template, or message content. + +Done 2026-08-09: authenticated `/v1/send` accepts only the two invitation +event types, validates recipient/event/idempotency fields, and renders a fixed +portal invitation template. Provider acceptance remains explicitly distinct +from delivery or identity evidence. + +## T02 - Implement provider-neutral transactional sending + +```task +id: EMAIL-WP-0004-T02 +status: done +priority: high +state_hub_task_id: "6a6ae927-4393-480f-a83e-93c9dabe18fe" +``` + +Implement the HTTP receiver, template rendering, idempotency store, suppression +checks, bounded provider calls, and provider adapter. Start with the approved +IONOS SMTP/STARTTLS lane while keeping provider credentials outside requests, +logs, Git, and database evidence. + +Done when repeated event IDs send at most one message and failures return +stable retryable/permanent classifications without leaking SMTP details. + +Done 2026-08-09: SQLite idempotency, provider-neutral injection, bounded +STARTTLS SMTP calls, duplicate suppression, and redacted provider-unavailable +responses are implemented. All 22 repository tests pass. + +## T03 - Establish custody and deploy the service + +```task +id: EMAIL-WP-0004-T03 +status: progress +priority: high +state_hub_task_id: "13428364-29b1-4e0d-aaf5-c0b254c829b9" +``` + +Route SMTP and caller credentials through the credential catalog before +requesting them, store provider material in OpenBao, and deliver only scoped +runtime secrets. Deploy an immutable image on railiance01 with probes, +resource/security controls, default-deny NetworkPolicy, restricted +user-engine ingress, SMTP-only egress, and rollback. + +Done when user-engine can call the cluster-local receiver without possessing +SMTP credentials and unrelated workloads cannot send through it. + +## T04 - Prove invitation delivery failure behavior + +```task +id: EMAIL-WP-0004-T04 +status: todo +priority: high +state_hub_task_id: "34127b3e-f7c6-4ebe-a86c-743ba1b27640" +``` + +Test template allow-list denial, invalid recipient, provider timeout, temporary +deferral, permanent rejection, duplicate request, suppression, resend, and +redacted diagnostics. Verify mailbox ownership and provider acceptance never +alter user-engine authorization. Hand non-secret message/event references and +failure evidence back to NK-WP-0024. + +Done when deployed invitation and verification flows are observable, +idempotent, retry-safe, and conservative about delivery evidence.