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:
parent
21eebffc56
commit
0fcfddc342
5 changed files with 459 additions and 67 deletions
21
.forgejo/workflows/acceptance.yaml
Normal file
21
.forgejo/workflows/acceptance.yaml
Normal 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
|
||||
56
.forgejo/workflows/image.yaml
Normal file
56
.forgejo/workflows/image.yaml
Normal 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}"
|
||||
|
|
@ -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
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import ipaddress
|
|||
import json
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
import time
|
||||
import sqlite3
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
|
@ -36,26 +38,72 @@ class SQLiteDeliveryStore:
|
|||
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 (
|
||||
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 (
|
||||
)""")
|
||||
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)")}
|
||||
)""")
|
||||
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.execute(
|
||||
"CREATE TABLE IF NOT EXISTS send_attempts (event_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, state TEXT NOT NULL)"
|
||||
)
|
||||
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:
|
||||
row = self.db.execute(
|
||||
"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:
|
||||
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:
|
||||
row = self.db.execute(
|
||||
|
|
@ -79,7 +129,9 @@ class SQLiteDeliveryStore:
|
|||
(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)}"
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
|
|
@ -177,63 +229,111 @@ class SMTPProvider:
|
|||
)
|
||||
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:
|
||||
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
|
||||
# that ties an accepted send to a message later observed in a mailbox.
|
||||
# Without it the reference below fell back to hash(), which Python
|
||||
# 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)
|
||||
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:
|
||||
with smtplib.SMTP(self.host, self.port, timeout=10) as smtp:
|
||||
if self.security == "starttls":
|
||||
smtp.starttls()
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.login(self.username, self.password)
|
||||
submitted = True
|
||||
smtp.send_message(message)
|
||||
accepted = True
|
||||
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:
|
||||
# 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:
|
||||
code = int(getattr(exc, "smtp_code", 0) or 0)
|
||||
if 400 <= code < 500:
|
||||
raise ProviderError("temporary_deferral", retryable=True) from exc
|
||||
raise failure("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
|
||||
raise failure("permanent_rejection", retryable=False) from exc
|
||||
raise failure("provider_unavailable", retryable=True) from 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)))}"
|
||||
|
||||
|
||||
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.portal_url = portal_url.rstrip("/")
|
||||
self._status_cache = None
|
||||
self._status_time = 0.0
|
||||
|
||||
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 (
|
||||
path
|
||||
not in (
|
||||
"/v1/send",
|
||||
"/v1/status",
|
||||
"/v1/delivery-status",
|
||||
"/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"})
|
||||
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/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":
|
||||
return self._request_verification(start_response, payload)
|
||||
if path == "/v1/registration-verifications/consume":
|
||||
|
|
@ -243,7 +343,9 @@ class TransactionalApplication:
|
|||
return self._send_invitation(start_response, environ, payload)
|
||||
except ProviderError as exc:
|
||||
status = (
|
||||
HTTPStatus.SERVICE_UNAVAILABLE if exc.retryable else HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
HTTPStatus.SERVICE_UNAVAILABLE
|
||||
if exc.retryable
|
||||
else HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
)
|
||||
return self._json(
|
||||
start_response,
|
||||
|
|
@ -251,7 +353,9 @@ class TransactionalApplication:
|
|||
{"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)})
|
||||
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(
|
||||
|
|
@ -260,31 +364,77 @@ class TransactionalApplication:
|
|||
{"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):
|
||||
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:
|
||||
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)
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
if not self.store.reserve_send(event_id, fingerprint):
|
||||
return self._json(
|
||||
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.
|
||||
return self._json(
|
||||
start_response,
|
||||
|
|
@ -326,14 +476,18 @@ class TransactionalApplication:
|
|||
"These links expire in 30 minutes.\n",
|
||||
)
|
||||
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):
|
||||
handle = str(payload.get("handle") or "")
|
||||
if len(handle) < 32:
|
||||
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.
|
||||
return self._json(
|
||||
start_response,
|
||||
|
|
@ -353,7 +507,9 @@ class TransactionalApplication:
|
|||
handle = str(payload.get("handle") or "")
|
||||
if len(handle) < 32:
|
||||
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(
|
||||
start_response,
|
||||
HTTPStatus.OK,
|
||||
|
|
@ -392,7 +548,12 @@ def _is_loopback_host(host: str) -> bool:
|
|||
|
||||
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
|
||||
return (
|
||||
address == value
|
||||
and "@" in address
|
||||
and "\n" not in address
|
||||
and "\r" not in address
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
@ -405,10 +566,14 @@ def main() -> None:
|
|||
security=os.environ.get("EMAIL_CONNECT_SMTP_SECURITY", "starttls"),
|
||||
)
|
||||
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,
|
||||
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:
|
||||
with make_server(
|
||||
"0.0.0.0", int(os.environ.get("EMAIL_CONNECT_HTTP_PORT", "8080")), app
|
||||
) as server:
|
||||
server.serve_forever()
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ def invoke(app, payload, token="opaque", key=None, path="/v1/send"):
|
|||
"HTTP_AUTHORIZATION": f"Bearer {token}",
|
||||
}
|
||||
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)))
|
||||
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):
|
||||
provider = Provider()
|
||||
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())
|
||||
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):
|
||||
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")
|
||||
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):
|
||||
provider = Provider()
|
||||
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")
|
||||
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):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
def starttls(self, *, context=None):
|
||||
return None
|
||||
|
||||
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),
|
||||
"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["authorization"] is False
|
||||
consume_env = {
|
||||
|
|
@ -261,7 +274,9 @@ def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_p
|
|||
"wsgi.input": io.BytesIO(payload),
|
||||
"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"
|
||||
cancel_env["wsgi.input"] = io.BytesIO(payload)
|
||||
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 = Provider()
|
||||
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())
|
||||
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"
|
||||
# Ceiling is explicit; user-engine must not elevate this to authz.
|
||||
assert "authorized" not in accepted
|
||||
|
|
@ -299,7 +319,8 @@ class RecordingSMTP:
|
|||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
def starttls(self, *, context=None):
|
||||
assert context.check_hostname
|
||||
self.starttls_calls += 1
|
||||
|
||||
def login(self, username, password):
|
||||
|
|
@ -312,7 +333,9 @@ class RecordingSMTP:
|
|||
def test_smtp_provider_defaults_to_starttls(monkeypatch):
|
||||
monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP)
|
||||
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"
|
||||
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):
|
||||
monkeypatch.setattr(smtplib, "SMTP", RecordingSMTP)
|
||||
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")
|
||||
smtp = RecordingSMTP.instances[0]
|
||||
|
|
@ -331,17 +356,142 @@ def test_plaintext_security_skips_starttls_on_loopback(monkeypatch):
|
|||
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):
|
||||
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):
|
||||
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():
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue