Developers · September 21, 2026
Test an Agent's Email Without Sending Real Mail
Use non-routable test inboxes to simulate inbound mail, exercise drafts, approvals and webhooks, and verify agent behavior before a live message goes out.
You can unit-test an agent’s reasoning. You cannot unit-test what happens when a real message lands in a real inbox, a real model decides what to do with it, and a real recipient receives the result. Somewhere between those two, you need an environment where everything is real except the delivery.
That is what test projects are for: addresses that physically cannot reach the internet, synthetic inbound mail that produces genuine events, real approvals, real error codes, and a real signed webhook you can verify against your production verification code. This is how to use them, and — just as important — what they cannot tell you. If the underlying model is still fuzzy, email for AI agents covers inboxes, permissions and the send pipeline first.
Why a sandbox matters more when an agent is the sender
When a human writes an email, the review step is built in: they read it before pressing send. When an agent writes one, there is no such step unless you build it, and the failure mode is not a typo. It is a confidently worded, well-formatted message to the wrong person, or the right person with the wrong content, sent in the milliseconds after a prompt went sideways.
Three specific things go wrong in the first week of an agent email integration, and all three are catchable in a sandbox:
- The agent replies to the wrong recipient, usually by taking a
Toline out of quoted text rather than from the message record. - Retry logic generates a fresh idempotency key per HTTP attempt, so a timeout becomes two emails.
- A webhook handler acts on a redelivered event and repeats the send that produced it.
Not one of these is a model-quality problem. They are integration problems, and they are cheap to find before a recipient is involved.
Test projects and non-routable addresses
A project carries an environment — test or live — and it is fixed at creation.
There is no promotion; attempting to change it returns environment_immutable. So you
keep two projects and swap credentials.
Create an inbox in the test project and the address comes back on .mail.invalid:
{
"id": "ibx_...",
"address": "3f7c...@a91d....mail.invalid",
"environment": "test",
"domain_kind": "test",
"status": "active"
}
.invalid is reserved by RFC 6761 precisely so that it can never resolve. This is
stronger than a flag that suppresses sending: there is nowhere for the mail to go. Even
if every guard in the application failed at once, DNS has no answer.
You cannot customise a test address — passing local_part or domain_id to a test
project returns HTTP 422 test_mode_unsupported, “Test inboxes use generated
non-routable addresses” — and test projects cannot attach custom domains at all.
The caps are enforced in code, not documented as guidance:
| Limit | Value | Error when exceeded |
|---|---|---|
| Active test inboxes per workspace | 3 | HTTP 403 test_limit_exceeded |
| Retained test messages | 100 | HTTP 403 test_limit_exceeded |
| Retained test content | 10 MiB | HTTP 403 test_limit_exceeded |
| Test content expiry | 7 days | content ages out |
Three inboxes is enough for one agent under test, one for the counterparty role, and one spare. If you hit the message cap mid-suite, that is usually a sign the suite is accumulating fixtures it should be cleaning up.
Simulating an inbound message
Real inbound mail cannot arrive at a .mail.invalid address, so you create it:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/test/inbound" \
-H "Authorization: Bearer $EFA_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbox_id": "ibx_...",
"from": {"email": "dana@example.org", "name": "Dana Okafor"},
"to": [{"email": "triage@example.invalid"}],
"subject": "Invoice 4471 looks wrong",
"text": "The October line item appears twice. Can you check before I pay?"
}'
The response:
{
"id": "msg_...",
"thread_id": "thd_...",
"environment": "test",
"simulated": true,
"state": "received"
}
Three things make this a genuine test rather than a mock.
It requires the test:simulate and messages:read scopes, and it only works when
both the project and the inbox are test — anything else is HTTP 403 test_mode_required.
You cannot accidentally point it at production.
It creates a real thread and a real message row, with recipients, a normalised body and a sanitised HTML variant, through the same storage path live inbound mail uses.
It emits a real message.received event carrying "environment": "test" and
"simulated": true. Your event consumer, your cursor handling and your webhook
signature verification all run the code path they will run in production. Nothing is
stubbed on your side.
Use RFC 2606 example domains for the counterparty — example.com, example.org,
example.net — or a .invalid address. Never put a colleague’s real address in a
fixture; it will end up in a live run eventually.
Worth simulating deliberately: an HTML-only message, a message with an address in quoted text that differs from the real sender, a subject at the 998-character limit, and a reply arriving on an existing thread. The second one catches the wrong-recipient bug described above.
Watching a draft and an approval in test
Now have the agent respond. With a credential holding drafts:submit but not
messages:send:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_DRAFT_KEY" \
-H "Idempotency-Key: reply-to-msg_7f21c-v1" \
-H "Content-Type: application/json" \
-d '{
"to": [{"email":"dana@example.org"}],
"subject": "Re: Invoice 4471 looks wrong",
"reply_to_message_id": "msg_7f21c...",
"text": "You are right — October is duplicated. A corrected invoice follows."
}'
HTTP 202, and:
{
"result": "approval_required",
"draft_id": "drf_...",
"draft_version": 1,
"approval_id": "apr_...",
"reason_codes": ["draft_only_principal"],
"request_id": "req_..."
}
draft_only_principal means the credential itself could not send, whatever the inbox
policy said. Other reason codes come from policy rules: always,
new_recipient_domain, attachments.
Three behaviours to exercise while you are here, because each one is a real branch in your integration:
Approve it. POST /v1/projects/{project_id}/approvals/{approval_id}/approve with
its own Idempotency-Key header — the header is required, and a missing or over-long
one is HTTP 400. Confirm the message goes queued and then accepted through the test
provider, and that your code treats accepted as “the provider took it”, not
“delivered”.
Reject it, with a note, and confirm your agent does not immediately re-submit the same draft in a loop.
Modify and re-approve. The approval is bound to the SHA-256 of the exact canonical
payload. Change the draft and the approval no longer matches — the send is refused with
version_conflict. This is the property that makes review meaningful, and it is worth
seeing fail once so you recognise it in production.
Approvals expire after 7 days, so a test suite that leaves them pending will accumulate expired records rather than a growing backlog of live sends.
Then exercise the retry path deliberately: replay the same send with the same
idempotency key and confirm you get the identical message_id back rather than a
second message. Then replay it with the same key and a changed body, and confirm you
get HTTP 409 idempotency_conflict. If your client library swallows that 409 and mints
a fresh key, you have found the duplicate-send bug before a customer did —
the full idempotency rules are here.
Firing a synthetic webhook event and verifying your signature check
Register an endpoint, save the signing secret shown once, then:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/webhooks/$EFA_WEBHOOK_ID/test" \
-H "Authorization: Bearer $EFA_API_KEY"
This creates a real webhook.test event and a single real delivery. The body your
endpoint receives is the standard envelope:
{
"id": "evt_...",
"type": "webhook.test",
"api_version": "2026-09-19",
"data": {
"webhook_id": "wh_...",
"simulated": true,
"environment": "test",
"kind": "webhook.test"
}
}
with webhook-id, webhook-timestamp and webhook-signature headers. The signature
is v1, followed by the base64 HMAC-SHA256 of id.timestamp.rawBody, keyed on your
secret. Verify against the exact raw bytes, before parsing, and reject a timestamp more
than five minutes from now. The reference implementation is in
the events and webhooks docs.
Four things to check with this one call:
- Your verification returns true for the real signature.
- It returns false when you flip one byte of the body — this catches the classic bug of verifying against a re-serialised JSON object instead of the raw request body.
- It returns false for a timestamp outside the five-minute window.
- Your handler is idempotent. Replay the same body and headers into your endpoint and confirm nothing happens twice.
Point four is not optional. Delivery is retried up to eight attempts with a backoff of 10s, 60s, 5m, 30m, 2h, 6h and 12h, and 4xx responses are terminal except 408, 409, 425 and 429. A duplicate callback is normal operation. Deduplicate on the event ID — handling email webhooks without processing events twice covers the dedupe table, why a payload hash is the wrong key, and secret rotation.
Two operational details: test events are rate-limited to one per endpoint per 60
seconds — a second one inside the window returns HTTP 429 rate_limited — and the
endpoint must be a public HTTPS URL, so a localhost tunnel that resolves to a private
address will be refused. Creating an endpoint and queueing a test event does not prove
delivery; check the endpoint’s recorded delivery attempts and confirm your server
actually authenticated the callback.
What test mode cannot prove
Be clear about the boundary, because a green sandbox creates false confidence.
It cannot prove deliverability. No message leaves, so nothing is learned about SPF, DKIM, reputation or inbox placement. Correct DNS is configuration evidence, never a guarantee about where mail lands, and there is no delivery guarantee or published uptime SLA here.
It cannot prove provider behaviour. Test sends use the internal fake provider. The
states you exercise are real states, but the provider-side outcomes — real bounces,
real suppression, a genuine submission_unknown from an interrupted submission — are
simulated rather than observed.
It cannot prove your recipients’ mail systems. Their filters, their auto-replies, their out-of-office loops are not in scope.
It cannot prove the model behaves. A sandbox exercises your integration. Whether the agent decides sensibly is a separate question with separate evidence, and inbound mail is untrusted input written by strangers no matter which environment it is in.
Custom domains are not testable here at all. They require a live project and an eligible paid subscription, and paid checkout is currently disabled, so that path cannot be exercised today from either environment.
Keeping test and live credentials apart
The separation is enforced, not conventional, which means you can lean on it.
API keys carry the environment in the prefix: efa_test_ or efa_live_, followed by
eight hex characters and a 43-character secret. The effective permissions of a key are
recomputed per request and intersected with the projects and inboxes that match the
key’s environment. An efa_test_ key aimed at a live project ID resolves to nothing —
it does not partially work.
Three practices that make this useful:
Use different environment variable names, not the same name with different values.
EFA_TEST_KEY and EFA_LIVE_KEY cannot be confused by a deploy script the way one
EFA_API_KEY can.
Give test keys the narrowest scopes that still exercise your code, including
test:simulate, which live keys have no use for. A key with test:simulate and
without messages:send cannot do damage in either environment.
Assert the environment at startup. GET /v1/me returns the caller’s identity and
granted scopes; refuse to boot if the environment is not the one the deployment
expects. This is three lines and it prevents the single worst outcome in this space.
A pre-live checklist
Run all of it in the test project before pointing anything at a real recipient.
-
GET /v1/mereports the expected environment and no scope you did not intend. - The agent’s credential is
readorread_draft. Widen only after you have read a batch of its drafts. - Inbound simulation produces
message.received, and your consumer handles it. - Your event cursor is persisted only after the batch is committed, and you handle
HTTP 410
cursor_expiredby restarting without a cursor. - A draft submission returns
approval_required, and your UI surfaces it as pending rather than sent. - Approve, reject and modify-then-approve all behave; the last one returns
version_conflict. - Replaying a send with the same key returns the same
message_id; replaying with a changed body returns HTTP 409idempotency_conflict. - Your webhook verification passes a genuine
webhook.test, fails a mutated body, fails a stale timestamp, and is idempotent on replay. - Your code distinguishes
acceptedfrommessage.delivery_updated, and never auto-resends onsubmission_unknown. - Every recipient in every fixture is an RFC 2606 example domain or
.invalid. - The inbox policy for the first live inbox sets an approval mode and a
daily_recipient_limityou would be comfortable seeing reached. - You have revoked at least one credential and confirmed HTTP 401 afterwards.
When that list is green, the remaining unknowns are real deliverability and real model behaviour — which is exactly the right place to be before the first live send. The setup path for that inbox is in how to give an AI agent its own email address, and the integration shape is in the agent email API loop.
Keep reading
Agent Email API: Send, Receive and Reply in One Loop
Build the full two-way loop over REST: scoped keys, idempotent sends, the states a message moves through, inbound events, and replies that thread correctly.
Idempotency Keys: Stop an Agent Sending Duplicate Emails
How send idempotency works: canonical payload hashing, the 30-day replay window, conflict versus expiry, and why retrying an unknown outcome is unsafe.
Handle Email Webhooks Without Processing Events Twice
Verify the signature against the raw body, deduplicate by event ID, enqueue durably, and keep a webhook retry from re-sending the email that produced it.