Implement transactional invitation mail service
This commit is contained in:
parent
eda3896228
commit
980b9fab44
6 changed files with 274 additions and 0 deletions
9
Containerfile
Normal file
9
Containerfile
Normal 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
41
WORK-RECORDS.md
Normal 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 |
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
99
src/email_connect/transactional.py
Normal file
99
src/email_connect/transactional.py
Normal 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()
|
||||
23
tests/test_transactional.py
Normal file
23
tests/test_transactional.py
Normal 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"
|
||||
101
workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md
Normal file
101
workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue