CLAUDE.md@apps/mail-adapter · git:20260825.0566de3 · 2026-08-25 · sha256 7852d08a75c1a1bd
CLAUDE.md@apps/mail-adapter git:20260825.0566de3A
Immutable. This exact content is served forever at /api/v1/blob/7852d08a75c1a1bd.
# 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 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 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. - **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. - **`in_flight` is cleaned up in a `finally`.** The set is added to before an outbound call and removed after it, so an exception between the two would leak the `event_id` and make every later redelivery of that turn take the in-flight branch and never send. The mutation proof is `tests/test_egress.py::test_an_unexpected_exception_does_not_poison_the_event_id`. - **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. - **`seen` is bounded and every polled id enters it before the allow-list decision.** That ordering is what makes the bound necessary: without it anyone who can mail a public inbox grows the map until the pod is OOMKilled, with no inbound gate in the way. - **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 `*`. - **All state is process-local and the chart pins one replica.** Adding a second replica splits `reply.update` and `turn.completed` across pods. Horizontal scale needs shared state, which is a different ticket. ## 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.