CLAUDE.md@apps/mail-adapter · git:20260825.911d432 · 2026-08-25 · sha256 63df3b428c9b64d5
CLAUDE.md@apps/mail-adapter git:20260825.911d432A
Immutable. This exact content is served forever at /api/v1/blob/63df3b428c9b64d5.
# CLAUDE.md - apps/mail-adapter The email channel adapter: an AgentMail inbox bridged to a Curie channel binding. Full behavior spec lives in `apps/mail-adapter/README.md`; this file is the enforceable-rule summary. ## Load-bearing invariants - **The adapter holds no platform API key, no queue credential, and no platform database access.** Its only credentials are `CURIE_CHANNEL_TOKEN` (presented as `X-API-Key` on ingress), `CURIE_EGRESS_SECRET` (checked on every inbound POST) and `AGENTMAIL_API_KEY`. Do not add `CURIE_API_KEY`, a Valkey client, or a DB session to the platform here; a capability the adapter does not hold cannot be stolen from it, and re-minting an expired `chn` token is an operator step for exactly that reason. Its local SQLite file is delivery state, not a platform capability, and must never contain any of the three credentials. - **The reply target is `target.reply_ref` off the event, never the conversation record.** The record is overwritten by every inbound message in the thread, so deriving the target from it sends turn one's answer to message two. The record exists for two things only: the accumulated reply text, and as the existence gate that gives the inbound checks their transitive reach into egress. - **Nothing is recorded as replied until the provider has accepted the send.** A `turn.completed` whose AgentMail send failed acks 502 and one whose duplicate is still in flight acks 503. Acking 200 in either case makes the worker clear its durable completion record (`kernel.py` `clear_completion`, on any 2xx) and the email is gone with no retry and no dead letter. Do not collapse 502 and 503 into one code: they mean different things in the worker's log. - **A completion claim is a timed, reclaimable durable lease.** A crash may leave a live lease, so restart or expiry must reclaim it and consult the provider-visible event witness before deciding whether to send. An unreadable witness or a completion with no admitted reply row is 502/no send; an active lease owned by this process is 503. Never turn either case into 200 or a permanent 503. - **The inbound gate is two checks, in order: the provider's verdict labels, then the allow-list.** They are not equivalent and the ordering is not incidental. The provider's filtering is the real control; the label check is defense in depth that should never fire in a correct install; the allow-list is a filter on an attacker-controlled `From` header. **Never describe the allow-list as authenticating a sender** in code, comments, docs or chart values: Curie performs no sender authentication. - **`list_messages` always sends all three `include_*=false`.** They are constants in `agentmail.py`, not parameters and not config, so no caller and no operator can turn them on. Sending them when they are already the provider's default is the point: a changed default cannot silently widen the install. - **`conversations` is written only after both inbound checks pass.** Pre-seeding it from the poll listing, however convenient, silently removes the allow-list's protection of egress. - **Admission is bounded before state or body allocation.** At the pending or state-byte cap, leave provider mail unclaimed and unmodified so later capacity can recover it. Never evict an unresolved delivery merely to admit a newer one. - **Logs carry no raw mail PII.** Do not log sender addresses, subjects, bodies, provider message/thread ids, or reply text. Use a one-way correlation token and a reason/state label so operators can join retries without copying mail content into the cluster log-retention system. - **Empty allow-list plus ingress enabled is a boot failure, not deny-all.** The inbox is a public mailbox by construction, so fail-open would make every install an open trigger for agent turns. Allow-all must be written as `*`. - **One SQLite file has one serialized writer and the chart pins one replica.** Every poller and egress transaction uses the adapter-owned lock. Adding a second replica or changing `Recreate` to rolling update creates two writers; horizontal scale needs a separately accepted shared-store design. ## Config surface `MailAdapterConfig()` (a frozen `pydantic_settings.BaseSettings` using `AliasOnlyEnvSource`) reads `AGENTMAIL_*`, `CURIE_API_URL`, `CURIE_CHANNEL_TOKEN`, `CURIE_EGRESS_SECRET`, `ADAPTER_INGRESS_ENABLED` and the `CURIE_MAIL_*` knobs. Full table in `apps/mail-adapter/README.md`, and `tests/test_config.py` fails if the table and the code drift apart. A new field means a new README row. ## Verify (AgentMail-free) ```bash uv run pytest apps/mail-adapter/tests -q ``` Only the two external dependencies are faked, both as real local `ThreadingHTTPServer` instances: AgentMail's API and the platform's channel ingress. Nothing inside `curie_mail_adapter` is patched. The fake AgentMail server reproduces the provider's documented filtering rather than serving whatever it is handed, because a test built on a fake that serves labeled mail the real provider would have withheld proves nothing about production. A new test that patches an internal function instead of driving it through those servers does not meet this package's bar.