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:

OperationScopes requiredRoles that hold them
Create an exportexports:write and messages:readowner, admin, developer
List, read or download exportsexports:read and messages:readowner, admin, developer
Create or read a deletion jobmembers:manageowner, 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:

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:

ConditionError
More than 5,000 messages in scopequota_exceeded
More than 500 inboxes in scopequota_exceeded
Workspace already holds 256 MiB of export files not yet purgedquota_exceeded
You already requested two exports in the last hourrate_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.

stateMeaning
pendingQueued; no batch has run yet
preparingBatches are being written
preparedComplete; download_path, manifest and manifest_sha256 are now populated
failedStopped; read error_code
expiredWas 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:

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 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 exportConsequence for your archive
original_mimeYou cannot re-import the file into a mail client or rebuild headers
bcc, envelope_recipientsHidden recipients of agent sends are not in the copy
attachment_binary_contentOnly filename, size, type and scan status; binary_included: false
draftsUnsent drafts, including ones waiting on approval, are not exported
credentials, webhook_secretsNothing that could authenticate as you travels in the file
audit_logsPolicy 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:

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:

So the sequence is fixed:

StepActionDone when
1Pause the agent, or accept that later mail misses the exportNo new inbound you care about
2Download needed attachments from the thread viewerFiles saved locally
3Create the export (project_messages if you need bodies)state is prepared
4Download and run the checksum scriptEvery part hash matches
5Store the file somewhere you controlIt is outside the product
6Create the deletion jobstate is tombstoned
7If you need erasure, file a privacy requestYou 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.

DataAfter a deletion job
Stored mail and attachmentsRemain; per the privacy notice, deactivation starts a manual review, not an erasure
Restricted originals, quarantine, delivery evidenceKept while needed for safety, custody or abuse handling, then reviewed
Security, usage, billing and request recordsKept for fraud prevention, accounting and disputes
Address tombstone and free-allowance recordsKept, so an address is not handed to someone else and the free tier is not re-granted
Prepared export filesDownload 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 productYour 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

All guides · Documentation · Controls