Controls · September 23, 2026
Agent Email Audit Trail: What to Log
The eight fields an agent email audit trail needs, which ones a second party can corroborate, and the ones this build records but never hands back.
Six weeks after the fact, someone asks who authorised the email your agent sent, what exactly it said, and whether a human read it first. If the only record is your own application log, you are answering with a file your service wrote about its own behaviour, stored on infrastructure you control, with nobody else holding a copy that could contradict it.
This settles what to log in an agent email audit trail: eight fields, which of them a second party can corroborate, and the gap that matters more than the list. Most of the eight are recorded in this build and not readable back out of it, so the fields you must capture at call time are not the fields the product will return later. For the mechanics of the approval gate itself, read human approval before an AI agent sends email.
Why a mail log is better evidence than an application log
Because every entry has a witness who is not you.
An application log is self-attested. Your service decided what to write, when to write it, and where to keep it. Tamper-evidence helps a little: a SHA-256 hash chain, which is what the IETF Agent Audit Trail draft and most vendor guidance reach for, proves that the log has not been edited since you wrote it. It does not prove that what you wrote was true at the moment you wrote it. A chain over fabricated records is a perfectly valid chain.
A mail log inverts that. The act being logged is a transmission to a counterparty, and
the counterparty keeps their own copy. Their MTA stamped its own Received headers.
Their mailbox holds the body. The DKIM signature covers selected headers and a hash of
the body, so anyone with the signing domain’s public key can check, years later, that
the message they hold is the message that was sent. The value of a mail record as
evidence comes from the fact that a second party can reproduce it, not from the fact
that you stored it carefully. Corroboration is a stronger property than integrity,
and it is the one property an internal log can never buy.
This only holds for messages that crossed a transport. Three carve-outs, stated plainly:
drafts, approvals and rejections have no counterparty, so they are as self-attested as
any application log and deserve the same scepticism. Test-mode traffic has no
counterparty at all, because a test inbox uses a non-routable .mail.invalid address
and a simulated transport, and cannot reach an internet recipient. And an inbound
message is witnessed by the sender, not by you, which is exactly why the headers on
inbound mail are worth keeping verbatim rather than summarising.
The corroboration test, and what the standard field lists miss
Apply one question to every field you are about to log: could a party who is not you produce the same value independently?
The published field lists for agent auditing are good on the internal half and silent on
the external half. They ask for agent_id, session_id, action_type, trust_level,
prev_hash, prompt classifications, token counts and output hashes. Every one of those
is a value only your system can produce. Not one of them can be checked against a
third party. That is a coherent design for a tool-call log, where there is genuinely no
outside witness to a filesystem read. It is the wrong design for email, where an outside
witness is the entire point of the medium.
| Field class | Who else can produce it | What it proves |
|---|---|---|
| RFC message identity | the recipient, their MTA, the sender | the message existed |
| Headers and body hash | anyone holding the DKIM public key | the content is intact |
| Recipient set | each recipient, for their own entry | who was addressed |
| Scopes and grants | nobody but you | what was permitted |
| Approver and timestamp | nobody but you | who said yes |
| Payload digest | recipient, if they still hold a copy | what was authorised |
Design the trail so the self-attested rows are pinned to the corroborable ones. A payload digest nobody can check is a number in a database. The same digest sitting beside the RFC message ID of a message the recipient still has is a claim they can falsify, which is what makes it worth something.
What to log in an agent email audit trail: the eight fields
Eight, in the order a reviewer will want them.
- RFC message identity and thread position. The
Message-ID,In-Reply-ToandReferencesvalues as transmitted, plus your own thread identifier and the position within it. This is the join key between your record and everyone else’s. - Direction, and whether it was simulated. Inbound and outbound records carry different evidential weight, and a simulated message carries none. Never let them share a field without a discriminator.
- The originating principal, with the scopes that were active. Not just which agent,
but which grant, at which revision. An agent that held
drafts:writebut notmessages:sendcould not have sent the message, and the log should make that checkable rather than assumed. The full scope vocabulary is covered in agent email permissions and scopes. - The authorising principal, and the kind of authorisation. Direct send and human-approved send are different acts. Record which one happened, and if a human approved it, record which human and when.
- A digest of the exact bytes that were authorised. A hash over the canonical payload, computed at approval time and again at send time. Without it, “the reviewer approved this message” means “the reviewer approved a message with the same subject line”.
- The idempotency identity. The key the caller supplied and the key handed to the provider. This is the only way a reviewer can tell one message retried four times apart from four messages.
- Per-recipient outcome, kept separate from submission outcome. Provider acceptance is not delivery, and a single message can be accepted once and then bounce for one of five recipients. The distinction is unpacked in accepted vs delivered email.
- A request correlation ID. One identifier that ties the HTTP call, the emitted event, and any support interaction to the same moment.
Fields 1, 2 and 7 are corroborable. Fields 3 through 6 are not, and they are the ones that answer the question an auditor actually asks. Pin them to the first group.
Recorded is not readable, and that decides where you log
Every one of the eight is written down in this build. Most of them cannot be read back through any public surface, which means the trail you keep has to be written by your client at call time, not reconstructed from the product afterwards. That is the difference between this page and the field lists it agrees with.
The outbound spine is app.send_intents, defined in
supabase/migrations/20260919120200_mail_resources.sql:352. It carries
originating_principal_type and originating_principal_id,
originating_grant_revision, originating_required_scopes,
authorizing_principal_type and authorizing_principal_id, authorization_kind
constrained to direct or human_approval, payload_sha256,
idempotency_record_key, provider_idempotency_key, and a state drawn from
queued, submitting, accepted, failed, submission_unknown and canceled.
That covers fields 4, 5 and 6 on every send, and field 3 only partly. The insert at
packages/domain/src/services.ts:1904 never writes originating_grant_revision, so
the column is always null. originating_required_scopes holds the single scope the
send path needed, messages:send for a direct send or drafts:submit for an approved
one, not the scope set the credential held. The stored idempotency_record_key is a
SHA-256 digest of your key (services.ts:1709), not the key itself, and the provider
key is the intent’s own ID. No endpoint returns any of it. Grepping packages/api and
packages/domain for those column names finds the insert and the send worker reading
them back at services.ts:4383, and nothing else.
app.messages in the same file holds rfc_message_id, in_reply_to and
references_json. Those are field 1, the single most corroborable value in the whole
trail, and they are not projected into the message list, the message detail, or the
export. The API returns thread_id and thread_sequence instead, which are internal
identifiers no counterparty has ever seen.
app.approvals holds payload_sha256, policy_version, reason_codes,
requested_by_type, requested_by_id, decided_by, decision_at, decision_note,
and an expires_at set to seven days after the request (services.ts:1662). The
approvals endpoint returns id, project_id, inbox_id, draft_id, draft_version,
state, reason_codes, created_at, expires_at, review_html and payload. Who
decided, when, and what they wrote in the note are recorded and not among them.
| Field | Recorded in | Readable via API | In an export |
|---|---|---|---|
RFC Message-ID, In-Reply-To | app.messages | no | no |
Direction, environment, simulated | app.messages | yes | yes |
| Originating principal and scopes | app.send_intents | no | no |
Authorising principal, authorization_kind | app.send_intents | no | no |
payload_sha256 | intents and approvals | no | no |
| Idempotency keys | app.send_intents | no | no |
Per-recipient delivery_status | app.message_recipients | yes | no |
| Approver identity and decision time | app.approvals | no | no |
So capture what you can only see once. The send response is
{ result, message_id, thread_id, state, request_id }, and a held send returns
{ result: "approval_required", draft_id, draft_version, approval_id, reason_codes, request_id }. Log both verbatim alongside the Idempotency-Key you generated and the
scope set on the credential you used, because after the call those associations exist
only in your process. There is also an app.audit_log table
(supabase/migrations/20260919120300_jobs_events_billing.sql:291) with actor_type,
actor_id, action, target_type, target_id, before_diff, after_diff,
request_id and support_access_reason. It is written for human control-plane actions
only, it has no read path anywhere in packages/api, and it is named in the export
exclusion list. Treat that as a limitation rather than a design principle.
Field 8 is the x-request-id header. The API reads it from your request if you send one
and generates it otherwise (packages/api/src/index.ts:50), echoes it on the response,
and returns it in the request_id field of send responses. Supply your own and you get
a correlation ID you chose, which is worth more than one you have to look up.
Reading the trail: poll events, or take a signed webhook
Two delivery paths, and they do not carry the same record. Poll if the trail matters.
Polling is a cursor walk over a per-project sequence:
curl -s "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/events?limit=100&cursor=$LAST" \
-H "Authorization: Bearer $EFA_API_KEY"
The credential needs events:read. Each item returns id, type, sequence,
occurred_at, data, environment and api_version, with next_cursor and
has_more alongside. limit is 1 to 100 and defaults to 25; anything else is a 400
invalid_request. A malformed cursor is invalid_cursor. A cursor that points before
the oldest surviving event is cursor_expired, returned as a 410 and flagged
non-retryable, so your handler must restart from the first remaining event rather than
back off. Store next_cursor transactionally with whatever you wrote from the batch, or
a crash between the two will silently skip events.
The webhook body is smaller than the polled record, and this is the part worth knowing
before you build on it. It is exactly
{"id","type","api_version","data"} (packages/domain/src/webhook-jobs.ts:54).
There is no sequence and no occurred_at in a webhook payload, so a
webhook-only consumer has neither an ordering key nor the time the event happened. The
webhook-timestamp header is not a substitute: it is generated when that attempt is
prepared, so a record that lands on the fourth attempt is stamped hours after the fact,
and the same event arrives under a different timestamp and a different signature on
every retry. The webhook-id header stays constant, because it is the event ID, which
is why it is the only safe deduplication key.
Three headers accompany the body: webhook-id, webhook-timestamp, and
webhook-signature. The signature is v1, followed by the base64 HMAC-SHA256 of
{id}.{timestamp}.{raw body} under the endpoint secret, and verification rejects a
timestamp more than 300 seconds from now (packages/domain/src/webhook-sign.ts:18).
Verify against the raw bytes, before JSON parsing, or a re-serialised body will fail a
signature that was valid.
Delivery is at-least-once and finite. Attempts back off at 10s, 60s, 5m, 30m, 2h, 6h and
12h, stop after eight, and stop immediately on a 4xx other than 408, 409, 425 or 429
(webhook-jobs.ts:72). An exhausted delivery is a lost webhook, not a lost event,
because the event stays in the pollable stream with its sequence intact. Reconcile from
the events endpoint on a schedule even when webhooks are your primary path. Deduplicate
on event ID, not on idempotency key, for the reason set out in
duplicate webhook events. The surface is
documented at events and webhooks.
The approval events that carry the human decision
Two of them fire, and you should build against exactly those two.
approval.requested is emitted when a send is held, at
packages/domain/src/services.ts:1678, with approval_id and draft_id in the
payload. approval.rejected is emitted when a human declines, at
packages/domain/src/services.ts:2094, with approval_id. Those are the two approval
events this build produces.
approval.approved and approval.invalidated are declared in
packages/contracts/src/events.ts and are never emitted by any code path. They appear
in the catalogue, so a reader who greps the contracts package reasonably assumes they
fire. They do not, and a declared event does not belong in an audit design.
So how do you record an approval? Through the message, not through an approval event.
When a reviewer approves, approveAndSend commits the held draft down the same
commitSend path as a direct send, with authorization_kind set to human_approval,
and the stream produces message.queued for it. The terminal outcome follows as
message.accepted, message.failed or message.submission_unknown, emitted as
`message.${state}` at services.ts:4460 when the provider answers. Nothing in
that sequence distinguishes an approved send from a direct one.
That makes linking an approval to the message it released a real limitation. The
message.queued payload is exactly message_id, thread_id and inbox_id
(services.ts:1965), with no approval_id or draft_id, although app.send_intents
stores the approval_id internally. The approve call requires a human
(services.ts:2012) and returns the ordinary send response, so an agent or pipeline
that did not make it never sees the pairing. It can see the approval move to consumed
(services.ts:1771) on the approvals endpoint, with the held payload but no
message_id, which leaves matching recipients and subject against listed messages: an
ambiguous join when the same subject goes to the same person twice. Log the pairing
where the approve call is made instead: the approval_id from the held send, the
message_id from the approve response, and the approver from that session, because
decided_by is stored and not returned.
The complete emitted vocabulary today is inbox.created, message.queued,
message.received, message.accepted, message.failed, message.submission_unknown,
message.delivery_updated, approval.requested, approval.rejected, domain.updated
and webhook.test. The email.* names in the contracts file are provider webhook types
consumed from Resend, not events this product puts on your stream.
Exporting the trail, and what the export drops
Three endpoints, a checksummed NDJSON stream, and a message record that is thinner than the messages API.
POST /v1/projects/{project_id}/exports starts a job with a scope of either
project_metadata or project_messages. GET /v1/projects/{project_id}/exports/{export_id}
reports status. GET /v1/projects/{project_id}/exports/{export_id}/download streams the
result as application/x-ndjson, with the first line an export_manifest record and the
manifest’s own SHA-256 repeated in the x-export-manifest-sha256 response header. Parts
carry per-part digests, and the download verifies each part’s length and hash before
emitting it, so a truncated or altered part fails the download rather than producing a
quietly short file. exports:write creates and exports:read reads, but both also
require messages:read and a human identity: requireHuman rejects a non-human
principal with permission_denied, so an API key cannot export at all
(packages/domain/src/exports.ts:91).
The exported message record carries id, inbox_id, thread_id, direction,
subject, from, to, cc, state, safety_status, created_at, received_at,
sent_at, environment, simulated and an attachment list of filename, byte count,
content type and scan status with binary_included: false. It does not carry the RFC
message identity, and its recipient entries drop the delivery_status that the same
recipients carry on the messages API. If the export is your archive of record, per
-recipient outcome is a field you have to capture from the live surface before you
archive, not something the archive will give you later.
The manifest states its own exclusions: original_mime, bcc, envelope_recipients,
attachment_binary_content, drafts, credentials, webhook_secrets and
audit_logs. It also sets raw_mime_included: false,
attachment_binary_content_included: false and content_is_untrusted: true, and
reports omitted_deleted_message_count so a short export is visible rather than silent.
Message bodies appear only under project_messages scope and only when safety_status
is allowed; otherwise a body_omitted_reason of metadata_only or
message_not_allowed takes their place.
The limits, from packages/domain/src/exports.ts:8: 5,000 messages, 64 MiB total, 2 MiB
per record, 500 inboxes, two export requests per hour, and a download that expires 24
hours after preparation. One more that is easy to trip: the download stream carries a
ten-minute wall-clock deadline and errors with export_download_time_limit if it is
still open after that, so pull a 64 MiB export over a fast link rather than trickling it
into a slow archive target. If your retention obligation is longer than the mail you
keep in the product, export on a schedule and store the NDJSON plus its manifest digest
yourself. Project deactivation and the deletion-job path are covered at
data controls.
Three ways the trail loses entries quietly
Each of these is a real gap, and none of them announces itself.
Cursor expiry. Events are not retained forever. A consumer that stops for long
enough gets cursor_expired on resume, and the events between its last cursor and the
oldest surviving event are gone from the stream. They are not gone from the messages,
which is why reconciliation should read messages and threads, not only events.
Test-mode expiry. Test inboxes keep the last 100 messages, up to 10 MB, and content expires after seven days, with up to three active test inboxes per workspace. Nothing you did in test mode is evidence of anything a week later, by design.
Deletion. A deletion job tombstones records and the physical purge follows. An audit trail you expect to outlive the project has to live outside the project, which is the whole argument for scheduled exports.
Controls are not certificates
Everything above describes mechanism that ships: scoped grants, an approval gate with two emitted events, per-recipient outcome tracking, idempotency identity on every send, a checksummed export. None of it is an audit opinion. There is no SOC 2 certification here, no ISO 27001 certification, and this is not an independent security review. No uptime SLA is published either.
That distinction is worth being explicit about, because “we log everything” is the easiest sentence in this category to write and the hardest to check. The check that separates the two is the one this page ran on itself: for each field, name the table it is written to, then name the endpoint that gives it back. Where the second name is missing, the field is the vendor’s record and not yours, and you log it at call time or you do not have it. Run that on any vendor’s trail, including this one. The controls surface describes what is enforced, and what is and is not claimed about security describes the rest.
Keep reading
Human Approval Before an AI Agent Sends Email
When to require review, why approval binds to one exact draft version, how recipient rules and daily caps work, and what editing a draft does to its approval.
Scoped Credentials for Agent Email: Read, Draft, Send
Give an agent the smallest useful email permission: project and inbox scoping, one-time secrets, draft-only grants, rotation, and revocation you can verify.
Accepted vs Delivered: What an Email Status Proves
Queued, accepted, delivered, bounced, unknown: what each email status actually proves, what it does not, and the correct next action for every one of them.