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:

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:

LimitValueError when exceeded
Active test inboxes per workspace3HTTP 403 test_limit_exceeded
Retained test messages100HTTP 403 test_limit_exceeded
Retained test content10 MiBHTTP 403 test_limit_exceeded
Test content expiry7 dayscontent 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:

  1. Your verification returns true for the real signature.
  2. 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.
  3. It returns false for a timestamp outside the five-minute window.
  4. 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.

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

All guides · Documentation · Developers