Implement transactional invitation mail service
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-09 21:28:41 +02:00
parent eda3896228
commit 980b9fab44
6 changed files with 274 additions and 0 deletions

9
Containerfile Normal file
View file

@ -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"]

41
WORK-RECORDS.md Normal file
View file

@ -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 |

View file

@ -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"]

View file

@ -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()

View file

@ -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"

View file

@ -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.