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 classWho else can produce itWhat it proves
RFC message identitythe recipient, their MTA, the senderthe message existed
Headers and body hashanyone holding the DKIM public keythe content is intact
Recipient seteach recipient, for their own entrywho was addressed
Scopes and grantsnobody but youwhat was permitted
Approver and timestampnobody but youwho said yes
Payload digestrecipient, if they still hold a copywhat 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.

  1. RFC message identity and thread position. The Message-ID, In-Reply-To and References values as transmitted, plus your own thread identifier and the position within it. This is the join key between your record and everyone else’s.
  2. 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.
  3. The originating principal, with the scopes that were active. Not just which agent, but which grant, at which revision. An agent that held drafts:write but not messages:send could 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.
  4. 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.
  5. 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”.
  6. 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.
  7. 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.
  8. 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.

FieldRecorded inReadable via APIIn an export
RFC Message-ID, In-Reply-Toapp.messagesnono
Direction, environment, simulatedapp.messagesyesyes
Originating principal and scopesapp.send_intentsnono
Authorising principal, authorization_kindapp.send_intentsnono
payload_sha256intents and approvalsnono
Idempotency keysapp.send_intentsnono
Per-recipient delivery_statusapp.message_recipientsyesno
Approver identity and decision timeapp.approvalsnono

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

All guides · Documentation · Controls