Controls · September 27, 2026
Export an Agent's Email History, Then Deactivate
Export agent email history as NDJSON, verify the checksums, then deactivate the project, knowing the deletion job erases no stored mail.
You gave an agent its own inbox, it ran for three months, and now the project is ending.
Someone has to export agent email history before the inbox goes away, and someone has to
answer the question the security reviewer will ask next: once you delete it, what is
actually gone? The short answer, stated up front: in this build, nothing. The deletion
job deactivates the project and revokes access; it does not erase stored mail, and its
own response says so with data_erased: false.
This guide walks the exit path in order: request an export, poll it, download and verify the file, then run a deletion job, with every limit stated as a limit. The console flow for the same controls is in the data controls documentation. If what you need is the audit trail rather than the mail, read what an agent email audit trail should log first, because the export deliberately leaves the audit log out.
Who can export, and why the agent itself cannot
Exports and deletions are human-only operations. Every export endpoint calls a
human-principal check before anything else, so an efa_live_ or efa_test_ API key, or an
agent’s OAuth connection grant, gets permission_denied with the message “Only a human
member can perform this action.” That is deliberate. The credential an agent runs on is
the credential most likely to be leaked or prompt-injected, and a bulk copy of every
message in a project is the single most valuable thing an attacker could ask it for. None
of the six MCP tools exports or deletes anything either.
The scopes involved:
| Operation | Scopes required | Roles that hold them |
|---|---|---|
| Create an export | exports:write and messages:read | owner, admin, developer |
| List, read or download exports | exports:read and messages:read | owner, admin, developer |
| Create or read a deletion job | members:manage | owner, admin |
operator, viewer and billing members cannot export. The full role-to-scope map is in
the permission scopes guide.
Two further checks make exports narrower than the scope table suggests. An export covers
only the inboxes the requesting member can currently access, and only the person who
requested an export can see it. A second admin listing exports on the same project sees
their own jobs, not yours. Authority is re-read from current database state on every
request, so if your role is downgraded or an inbox is removed from your grant while an
export is being prepared, the job fails with export_authorization_revoked rather than
finishing with data you are no longer allowed to hold.
The curl examples below use $EFA_SESSION_TOKEN. That stands for the bearer token of a
signed-in human member’s session, not an API key, and this guide does not describe a way
to mint one. If you do not already have one, use the console, which makes the same calls
on your behalf under Settings & usage, Data and project access. If you belong to more
than one workspace, the API picks your first membership unless you send an
x-efa-workspace-id header.
Export agent email history: request the job
POST /v1/projects/{project_id}/exports takes one optional field, scope:
project_metadata(the default): message records and attachment metadata, no bodies.project_messages: the same records plus stored plain text and sanitized HTML, for messages whose safety status permits it.
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/exports" \
-H "Authorization: Bearer $EFA_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope":"project_messages"}'
A 201 returns the job, already describing what it will and will not contain:
{
"id": "exp_...",
"scope": "project_messages",
"state": "pending",
"created_at": "2026-09-27T09:00:00.000Z",
"format": "application/x-ndjson",
"selected_message_count": 1842,
"row_count": 0,
"byte_count": 0,
"inbox_ids": ["ibx_..."],
"download_path": null,
"download_expiry": null,
"raw_mime_included": false,
"message_bodies_included": true,
"attachment_binary_content_included": false,
"limits": {
"max_messages": 5000,
"max_bytes": 67108864,
"max_record_bytes": 2097152,
"expires_after_hours": 24,
"max_retained_bytes_per_workspace": 268435456
},
"exclusions": ["original_mime", "bcc", "envelope_recipients",
"attachment_binary_content", "drafts", "credentials", "webhook_secrets", "audit_logs"],
"error_code": null,
"manifest": null,
"manifest_sha256": null
}
The selection is fixed at this moment. The request records the IDs of messages already committed in the inboxes you can reach, and mail that arrives afterwards is not in this export. If the agent is still live, that matters: stop routing first or plan a second, smaller export later.
The request can be refused before any job exists:
| Condition | Error |
|---|---|
| More than 5,000 messages in scope | quota_exceeded |
| More than 500 inboxes in scope | quota_exceeded |
| Workspace already holds 256 MiB of export files not yet purged | quota_exceeded |
| You already requested two exports in the last hour | rate_limited |
Over-limit exports fail rather than truncate. The 5,000-message error says it outright:
“no partial export was created.” The request body accepts scope and nothing else (an
unknown field is rejected), so there is no pagination parameter or date filter in this build, so a project over the limit needs an operator-assisted export,
not a cleverer request. The two-per-hour limit counts every export you requested in the
workspace in the last hour, across projects, including ones that failed. Do not burn both
attempts on a test run.
Poll until the export is prepared
GET /v1/projects/{project_id}/exports/{export_id} returns the same object with a current
state. There is no export event in the 18-type event vocabulary, so polling is the only
way to learn the job finished.
state | Meaning |
|---|---|
pending | Queued; no batch has run yet |
preparing | Batches are being written |
prepared | Complete; download_path, manifest and manifest_sha256 are now populated |
failed | Stopped; read error_code |
expired | Was prepared, but the 24-hour download window has passed |
Preparation runs in bounded batches of up to 20 messages, written into parts of at most 4 MiB, and the job re-checks the requester’s current permissions before committing each batch. A few seconds between polls is plenty.
The error_code values a failed job can carry are worth recognising, because most of them
are permanent for that selection:
export_message_too_large: one message record exceeds 2 MiB. Retry withproject_metadata, which drops bodies.export_size_limit_exceeded: total records exceed 64 MiB. Same remedy.export_storage_limit_exceeded: the workspace’s temporary export storage filled up during preparation. Wait for old exports to be cleaned up.export_authorization_revoked: your grant changed mid-job.export_project_inactive: the project was deactivated mid-job (see below).export_content_integrity_failedorexport_content_integrity_unavailable: a stored body did not match its recorded digest. The export refuses to ship it rather than shipping a silently different body.
GET /v1/projects/{project_id}/exports lists your last 100 jobs on the project, wrapped
in a data array.
Download the NDJSON and check that it is complete
When the state is prepared, fetch the path the job gave you:
curl -fsS -D headers.txt -o export.ndjson \
"https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/exports/$EXPORT_ID/download" \
-H "Authorization: Bearer $EFA_SESSION_TOKEN"
GET /v1/projects/{project_id}/exports/{export_id}/download streams
application/x-ndjson; charset=utf-8 with a content-disposition filename of
exp_....ndjson, an exact content-length, cache-control: private, no-store, and an
x-export-manifest-sha256 header. It is an authenticated stream, not a pre-signed link
you can hand to someone else: the same requester and permission checks run on every
download, so a colleague cannot fetch your export with their own token.
Three limits apply to the download itself:
- The window is 24 hours from preparation. After that the job reads
expiredand the endpoint returnsinvalid_request, “The export is not ready or has expired. Create a new export.” - One download must finish within 10 minutes. The server errors the stream past that deadline, so pull it onto a machine with a decent link, not through a slow proxy.
- You can download more than once inside the window. Nothing marks the job consumed.
The first line is an export_manifest record. It repeats the selection snapshot (each
inbox’s id, address, display_name and environment), gives selected_message_count,
message_count and omitted_deleted_message_count, lists each part’s sha256, bytes
and message_count, and sets content_is_untrusted: true. The remaining lines are the
parts concatenated in order. That is enough to verify the whole file offline:
import hashlib, json
raw = open("export.ndjson", "rb").read()
header, rest = raw.split(b"\n", 1)
header += b"\n"
manifest = json.loads(header)
expected = open("headers.txt").read().lower().split("x-export-manifest-sha256:")[1].split()[0]
assert hashlib.sha256(header).hexdigest() == expected, "manifest altered"
offset = 0
for part in manifest["parts"]:
chunk = rest[offset:offset + part["bytes"]]
assert hashlib.sha256(chunk).hexdigest() == part["sha256"], f"part {part['ordinal']}"
offset += part["bytes"]
assert offset == len(rest), "trailing bytes"
print(manifest["message_count"], "messages,",
manifest["omitted_deleted_message_count"], "deleted before their batch")
The manifest hash covers the first line including its trailing newline, which is why the
script adds it back. A non-zero omitted_deleted_message_count is not corruption: the
export reads content during preparation rather than from an atomic snapshot, so a message
deleted after the request but before its batch is left out and counted.
What the file contains, and what it never will
Each message line has kind: "message", id, inbox_id, thread_id, direction,
subject, from, to, cc, state, safety_status, created_at, received_at,
sent_at, environment, simulated and an attachments array. With project_messages,
it also carries text and sanitized_html. When a body is withheld the record says why in
body_omitted_reason: metadata_only for the metadata scope, message_not_allowed for a
message whose safety status does not permit release.
The exclusions are fixed, listed in every job response, and not configurable:
| Not in the export | Consequence for your archive |
|---|---|
original_mime | You cannot re-import the file into a mail client or rebuild headers |
bcc, envelope_recipients | Hidden recipients of agent sends are not in the copy |
attachment_binary_content | Only filename, size, type and scan status; binary_included: false |
drafts | Unsent drafts, including ones waiting on approval, are not exported |
credentials, webhook_secrets | Nothing that could authenticate as you travels in the file |
audit_logs | Policy edits and approval decisions stay in the workspace |
So this is not an MBOX and not a mailbox backup. It is a normalized record of what the agent received and sent, designed to be safe to hand to another system. If you need an attachment’s bytes, download it from the thread viewer before you deactivate anything; a file only downloads there if its scan came back clean.
Treat every exported body as untrusted input. It is mail written by strangers, and loading it into another model is the same exposure as the agent reading it, as the guide to prompt injection by email explains.
Test projects need one extra note. Test inboxes keep the last 100 messages, up to 10 MB, and test content expires after 7 days. An export of a test project is therefore only ever a copy of the last week or so of simulated mail. If a CI run’s inbound traffic is evidence you want to keep, export it within that window.
What a deletion job actually does: it deactivates
POST /v1/deletion-jobs deactivates a project or a whole workspace. It requires a human
member with members:manage, which means owner or admin, and an explicit confirmation
string:
curl -X POST "https://api.emailforagents.ai/v1/deletion-jobs" \
-H "Authorization: Bearer $EFA_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"confirmation":"DELETE","scope":"project","project_id":"prj_..."}'
scope defaults to workspace. A workspace-scoped request must not include
project_id, and a project-scoped one must. Read the response carefully:
{
"id": "del_...",
"state": "pending",
"physical_purge_blocked": true,
"operation": "deactivate",
"data_erased": false,
"note": "The selected scope will be made inactive. Physical purge remains pending."
}
data_erased: false is the honest answer, not a placeholder. The endpoint is named for
deletion, and what it performs in this build is deactivation. When the job runs it:
- marks the project (or workspace) and its inboxes as deleting; a workspace also has sending paused,
- disables routing for every address in scope and records a tombstone for each,
- revokes every API key and every agent connection in scope,
- sets the job’s
statetotombstoned, withphysical_purge_blockedstilltrue, and stamps it internally with the error codephysical_purge_not_implemented.
It does not erase anything. Stored mail, attachments, provider copies and backups all
remain. The GET response does not return that internal error code, but it is the plainest
statement in the codebase of the step that has not happened. Asking twice
returns the existing job with “A deletion job already exists. Physical purge is not
automatic.” rather than a second one. GET /v1/deletion-jobs/{deletion_id} reports
scope, state, physical_purge_blocked, data_erased and requested_at; its
completed_at is not set by the tombstone step in this build, so poll on state. There is
no self-service undo. There is also no DELETE route anywhere in the REST API, so you
cannot delete individual messages or inboxes over the API as a workaround.
The order that does not lose your export
Once the deletion job runs, exports are cut off, and not only new ones:
- Project scope. Every export endpoint requires the project to be active. After
deactivation, list, status and download all fail, even for an export that was
preparedfive minutes earlier and never downloaded. A job still preparing fails withexport_project_inactive. - Workspace scope. A member’s API calls outside the deletion-jobs endpoints return
permission_denied, “This workspace is being deleted. API access is tombstoned.”
So the sequence is fixed:
| Step | Action | Done when |
|---|---|---|
| 1 | Pause the agent, or accept that later mail misses the export | No new inbound you care about |
| 2 | Download needed attachments from the thread viewer | Files saved locally |
| 3 | Create the export (project_messages if you need bodies) | state is prepared |
| 4 | Download and run the checksum script | Every part hash matches |
| 5 | Store the file somewhere you control | It is outside the product |
| 6 | Create the deletion job | state is tombstoned |
| 7 | If you need erasure, file a privacy request | You have a written outcome |
The case where the order flips is a compromised credential. If an agent key is leaking mail right now, deactivate first: it revokes every key and connection in scope in one step. Because deactivation does not erase the stored mail, a copy can still be asked for through a privacy request afterwards, while mail that has already leaked cannot be pulled back. For a narrower response, revoke only the affected credential, as described in how to revoke an agent’s email access, and export afterwards while the project is still active.
Agent email data retention, stated plainly
This section describes mechanics, not compliance with any particular privacy law, and the product does not claim any certification. The retention criteria are set out in the privacy notice; here is how they line up with the mechanics above.
| Data | After a deletion job |
|---|---|
| Stored mail and attachments | Remain; per the privacy notice, deactivation starts a manual review, not an erasure |
| Restricted originals, quarantine, delivery evidence | Kept while needed for safety, custody or abuse handling, then reviewed |
| Security, usage, billing and request records | Kept for fraud prevention, accounting and disputes |
| Address tombstone and free-allowance records | Kept, so an address is not handed to someone else and the free tier is not re-granted |
| Prepared export files | Download access ends at 24 hours; a cleanup job later queues the parts for removal, and the privacy notice does not treat the 24-hour deadline as an erasure deadline |
| Copies outside the product | Your downloaded export, recipients’ copies and any webhook consumer’s store are out of reach entirely |
Permanent erasure is a manual request. There is no automatic end-to-end purge in this build, and the product does not describe one. The privacy notice explains how to file a request, which is reviewed and answered with what was removed, what was retained and why. The security overview covers the rest of the data handling, and the controls overview sets exports and deactivation alongside approvals and sending policy.
Two facts to put in front of your reviewer rather than have them discover: the export is complete or it fails, and the deletion job switches everything off without erasing anything. Both are documented behaviour, and the full endpoint reference is in the API documentation.
Keep reading
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.
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.
How to Revoke an Agent's Email Access Instantly
How to revoke an agent's email access: five kill switches ranked by blast radius, the exact calls, and what happens to mail already queued or held.