diff --git a/src/email_connect/transactional.py b/src/email_connect/transactional.py index 804e391..dee539e 100644 --- a/src/email_connect/transactional.py +++ b/src/email_connect/transactional.py @@ -28,8 +28,11 @@ class SQLiteDeliveryStore: 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 + expires_at TEXT NOT NULL, consumed_at TEXT, canceled_at TEXT )""") + 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: @@ -43,7 +46,10 @@ class SQLiteDeliveryStore: 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 VALUES (?,?,?,?,?,?,?,?,?,NULL)", ( + 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, @@ -53,7 +59,7 @@ class SQLiteDeliveryStore: 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: + 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") @@ -62,6 +68,22 @@ class SQLiteDeliveryStore: 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: def __init__(self, host: str, port: int, username: str, password: str, sender: str) -> None: @@ -87,7 +109,7 @@ class TransactionalApplication: 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") or environ.get("REQUEST_METHOD") != "POST": + 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"}) @@ -99,6 +121,8 @@ class TransactionalApplication: 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) 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") @@ -129,7 +153,9 @@ class TransactionalApplication: ) self.provider.send( str(payload["normalized_email"]), "Verify your NetKingdom registration", - f"Continue securely at {self.portal_url}/registration/verify?handle={handle}\nThis link expires in 30 minutes.\n", + 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}) @@ -145,6 +171,20 @@ class TransactionalApplication: "assurance":{"mailbox_control":True}, }) + 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}, + }) + @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)))]) diff --git a/tests/test_transactional.py b/tests/test_transactional.py index 3f3c2e5..146caa7 100644 --- a/tests/test_transactional.py +++ b/tests/test_transactional.py @@ -36,3 +36,21 @@ def test_registration_verification_is_digest_only_and_single_use(tmp_path): env["wsgi.input"]=io.BytesIO(consume) replay=json.loads(b"".join(app(env,lambda s,h:result.update(status=s)))) assert replay["error"]=="verification_invalid" + +def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_path): + provider=Provider(); store=SQLiteDeliveryStore(str(tmp_path/"mail.db")); app=TransactionalApplication(store,provider,"opaque","https://users.example") + request={"registration_id":"reg-cancel","normalized_email":"person@example.test","preferred_username":"person","client_id":"coulomb-social","tenant":"tenant:coulomb","correlation_id":"corr-cancel"} + raw=json.dumps(request).encode(); result={} + app({"PATH_INFO":"/v1/registration-verifications","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(raw)),"wsgi.input":io.BytesIO(raw),"HTTP_AUTHORIZATION":"Bearer opaque"},lambda s,h:result.update(status=s)) + text=provider.calls[0][2]; handle=text.split("handle=",1)[1].splitlines()[0] + assert f"/registration/cancel?handle={handle}" in text + payload=json.dumps({"handle":handle}).encode() + cancel_env={"PATH_INFO":"/v1/registration-verifications/cancel","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(payload)),"wsgi.input":io.BytesIO(payload),"HTTP_AUTHORIZATION":"Bearer opaque"} + canceled=json.loads(b"".join(app(cancel_env,lambda s,h:result.update(status=s)))) + assert canceled["purpose"]=="public-registration-cancel" + consume_env={"PATH_INFO":"/v1/registration-verifications/consume","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(payload)),"wsgi.input":io.BytesIO(payload),"HTTP_AUTHORIZATION":"Bearer opaque"} + 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)))) + assert replay["error"]=="verification_invalid" diff --git a/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md b/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md index 016d569..142b159 100644 --- a/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md +++ b/workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: netkingdom created: "2026-08-08" -updated: "2026-08-08" +updated: "2026-08-10" depends_on: - NK-WP-0024 state_hub_workstream_id: "a37e5e4d-090a-4a75-88d2-aacd0c0fd235" @@ -68,6 +68,13 @@ SQLite retains a SHA-256 digest, evidence is purpose/binding scoped, expiry is enforced, and consumption is atomic and single-use. All 23 repository tests pass. +2026-08-10 cancellation increment: verification messages now include a fixed +portal cancellation link using the same opaque handle. The SQLite store keeps +only its digest and atomically records either consumption or cancellation, so +verification and cancellation invalidate one another and replay fails closed. +Purpose-bound cancellation evidence lets user-engine abandon the matching +registration without accepting browser identity claims. All 24 tests pass. + ## T03 - Establish custody and deploy the service ```task