CLAUDE.md@apps/mail-adapter · git:20261019.a5d5ca3 · 2026-10-19 · sha256 ed885a8965a980da
CLAUDE.md@apps/mail-adapter git:20261019.a5d5ca3A
Immutable. This exact content never changes and is served at /api/v1/blob/ed885a8965a980da.
# 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 credentials are `CURIE_CHANNEL_TOKEN` (presented as
`X-API-Key` on ingress), `CURIE_EGRESS_SECRET` (checked on every inbound POST),
`AGENTMAIL_API_KEY`, and the optional `CURIE_ADAPTER_PRINCIPAL` (ADR-0156,
presented only on `POST /approvals/{id}/resolve` when carrying an answer). 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.
- **Reply text and its send target are owned at `(conversation_id, reply_ref)`.**
Every update and completion uses that exact durable pair, and the send target
is only the event's `target.reply_ref`. Never derive a target or accumulated
text from conversation-global state: two turns in one thread must not clear,
inherit, or redirect one another's reply.
- **A progress body is acknowledged and ignored.** `EgressHandler.dispatch`
returns 200 for a `reply.update` or `reply.post` carrying `progress` before
it reaches `record_text`, so deliberate progress never replaces, clears or
appends to the buffered reply. Do not render it into the email.
- **Nothing is recorded as replied until the provider has accepted the send.**
A TCP connection refusal during the AgentMail witness or send returns 424
with the fixed body `{"detail":"provider egress refused"}`. The worker stores
that fixed cause against the matching outbox generation and keeps the
completion owed. Other retryable witness or send failures return 502; a
duplicate still in flight returns 503. Acking 200 in any of these cases 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. Keep 424, 502 and 503 distinct in the worker's diagnostics.
- **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. A confirmed provider thread 404 for an admitted reply is the
exception: persist a deleted receipt and return 410, including on duplicates
and restart, so the worker dead-letters it without claiming delivery.
- **Reply all only for the approval request and its outcome** (ADR-0177 amendment A5). Every other send stays sender-only, so the resumed answer reaches the requester. Never mail an address that is not already on the thread: the requester copies approvers in. The card's `Approver` fields word the email and decide nothing.
- **`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.
- **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.
Terminal `completion_events` (`delivered=1` or `deleted=1`) are compacted
oldest-first under the derived cap; unresolved and leased completion rows are
never evicted to make room.
- **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.
- **Every log record leaves through the shared service logger, and that filter
is a backstop, not a licence.** `main()` calls `bootstrap_service_telemetry`
on the *package* logger `curie_mail_adapter`, which installs one redacting
single-line-JSON stderr handler and sets `propagate=False` there; `run`,
`adapter` and `egress` each hold a `getLogger(__name__)` child, so one
bootstrap covers all three and nothing walks past it to a root handler. Do not
reintroduce `logging.basicConfig` (it installs a root handler and the same
record is then emitted twice, once unformatted), and do not attach a handler
that bypasses the service logger -- either move re-opens the unfiltered path
this closed. The shared `REDACTION_RULES` now match this adapter's credential
*shapes*: a bare `chn.{payload}.{signature}` token, an AgentMail `am_` key,
`CURIE_*_TOKEN=` / `*_SECRET=` assignments, and an `X-API-Key: <value>`
header. An unprefixed `CURIE_EGRESS_SECRET` value still needs that
assignment or header context -- the filter will not claim to recognize the
secret as an arbitrary string. The "no raw mail PII" rule above is likewise a
code-level obligation the filter cannot enforce: no rule matches an address,
subject, body or provider id. Keep credentials and mail content out of the
record in the first place; the filter only catches the shapes it knows.
Deliberately absent for now: this adapter authors **no spans**. The bootstrap
installs the resource and the exporters, so records and any future spans carry
`service.name: curie-mail-adapter`, but neither the poll loop nor the egress
path is instrumented -- traces will show nothing from it until that lands.
- **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.
## Inbound gate
**The adapter is neutral; the mail service is a provider (ADR 0228).** The
adapter calls only the `MailProvider` protocol in `provider.py`; AgentMail is
`agentmail.AgentMailProvider`. Never read a provider's raw shape (labels,
`extracted_text`, header maps, HTTP statuses) outside its provider module, and
never let a provider own approval logic.
**An email approval answer passes the same gate as a turn**, before approval
resolution: `_listing_rejection` before any fetch, `_message_rejection` after
it. The reference parser and the resolution transport are reached only from
the intake path, after a `verified` verdict whose domain equals the one From
mailbox's domain. They cannot authenticate a sender themselves and must never
be exposed to unauthenticated intake. A provider that declares
`verifies_senders` false (AgentMail) refuses every message from the listing.
The parser's ADR-0177 rules remain: an answer is never a turn, its reference
must be live and issued in this thread, it must not be sent automatically
(missing headers count as automatic), and its decision word is on the first
line of `extracted_text` only, never the full body. The reference links a reply
to its approval; it is not proof of identity. **Who may answer is the platform's
decision** (ADR-0177 amendment): the actor
is the sender's bare lowercased address, never a display name, and the
platform checks the binding's `allowed_callers` and the route's approver
`emails`. Do not add a local approver or requester filter. A
`caller_not_allowed` refusal gets nothing back. Never respond to an automatic
message. A settled card spends its reference.
**Inbound mail fails closed before a turn or approval can be admitted.** A
sender must match the allowlist and carry a `verified` sender verdict from a
provider that verifies senders. A provider may build `verified` only from
results the receiving mail system attaches and the service guarantees a sender
cannot supply. A provider label, absence of a rejection label, or any header a
sender could have written never counts as that verdict. AgentMail supplies no
trusted positive aligned verdict, guarantees neither header provenance nor
stripping, and permits DMARC failure under `p=none`; its provider therefore
declares `verifies_senders` false and every AgentMail message is refused with
`authentication_unverifiable`. Never describe the allowlist or provider
filtering as authenticating a sender. See the provider evidence in the README
before proposing a new authentication source.
**`conversations` is written only after sender authentication and allowlist
checks pass.** Never seed it from a poll listing. Every current AgentMail
message fails authentication and must not create a conversation or egress target.
**An empty allowlist fails boot while ingress is enabled.** The error asks
for explicit sender addresses or domains and never suggests `*`. A list
containing `*` fails boot unless `CURIE_MAIL_ALLOW_ALL_SENDERS=true` is explicitly
set. Its default is `false`; the chart key is `mailAdapter.allowAllSenders`.
This opt in permits wildcard configuration only and never bypasses sender
authentication. Every current AgentMail message is still refused.
## 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. `CURIE_MAIL_PROVIDER` picks the provider; a provider
reads its own settings, and only the built-in reads `AGENTMAIL_*`.
## Verify (AgentMail-free)
```bash
uv run pytest apps/mail-adapter/tests -q
```
The intake path and approvals by email are driven end to end through
`tests/_verifying_provider.py`, a provider that speaks AgentMail's wire to the
fake server and takes its verdict from a field only the fake receiving system
sets. `tests/test_provider_conformance.py` runs the conformance kit against
both providers.
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 exercises the adapter's own admission decision, including unlabelled
spoofed mail, allowlisted senders without verifiable verdicts, and approval
answers. Provider filtering cannot stand in for that decision. A new test that
patches an internal function instead of driving it through those servers does
not meet this package's bar.