Developers · September 22, 2026

Is a mail.invalid Test Email Address Safe?

Why a mail.invalid test email address is safe: the .invalid TLD can never resolve, so a misrouted test send is unsendable rather than merely intercepted.

Most test mailboxes are real mailboxes with a warning label. The address resolves, some mail server will accept a connection for it, and the only thing standing between your test suite and a customer is a boolean in a request body or a server setting somebody remembered to pick. That is a configuration promise. Configuration promises fail in the one direction you cannot undo, because an email that has left is gone.

A mail.invalid test email address is safe in a stronger sense than that, and the reason is not the one usually given. What follows is what the suffix on a test inbox address is, what the reserved .invalid top-level domain guarantees at the DNS layer, the two things it pointedly does not guarantee, and how the whole approach compares with a sandbox that holds real recipient addresses behind a flag. If you want the sandbox workflow rather than the addressing, test an agent’s email without sending real mail covers inbound simulation, approvals and webhook signatures end to end.

What a .mail.invalid address actually is

Create an inbox in a test project and the address comes back looking like this:

{
  "id": "ibx_...",
  "address": "3f7c1b0e-...@a91d7c22-....mail.invalid",
  "environment": "test",
  "domain_kind": "test",
  "status": "active"
}

Two UUIDs, and they are UUIDs you already have. The address is composed in packages/domain/src/services.ts:1359 as ${inboxId}@${projectId}.mail.invalid, using the raw database identifiers. The id and project_id you get back in the same response are those identical UUIDs with a type prefix glued on, ibx_ and prj_ (packages/contracts/src/ids.ts:33). Strip the prefix from either and you have the corresponding half of the address, which is the quickest way to tie an address in a log line back to the inbox it belongs to without a lookup. No setting produced any of it, so no setting can change it.

The suffix is the load-bearing part. .invalid is one of the four top-level domains reserved by RFC 2606 section 2, and RFC 6761 section 6.4 gives it normative special-use handling: caching DNS servers SHOULD generate immediate NXDOMAIN responses for names under it rather than querying anything, authoritative servers SHOULD behave the same way, and registries MUST NOT grant registration of invalid names to any person or entity. The name is not unregistered by accident. It is unregisterable by specification.

Checked on 2026-09-22 against three public resolvers, a name under mail.invalid returns NXDOMAIN from all of them:

for r in 1.1.1.1 8.8.8.8 9.9.9.9; do
  dig @"$r" +noall +comment MX a91d7c22.mail.invalid | grep -i status
done
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 35027
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 16507
# ;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 14155

What that means for a sending mail server is concrete. MX resolution fails with NXDOMAIN, which is a permanent condition rather than a temporary one, so the message is not queued for a later attempt and no connection is made to anything. There is no host to connect to and nowhere for a retry to go.

Salesforce reaches for the same trick from the other end, and their version is worth studying because of where it leaks. Creating, refreshing or cloning a sandbox appends .invalid to the whole stored address rather than replacing the domain, so atester@universalcontainers.com in production becomes atester@universalcontainers.com.invalid in the sandbox and cannot be mailed. Read their own documentation to the end, though, and there is a carve-out: the admin who initiated the refresh keeps their production address unchanged, so precisely one routable recipient survives inside an otherwise unroutable org. Verified against Salesforce Help article 000386507 on 2026-09-22.

That carve-out is the general shape of the problem. A guarantee produced by rewriting data has an exception list, because somebody always needs the exception. A guarantee produced by the namespace has no place to put one.

Is a mail.invalid test email address safe to use?

Yes, and the useful version of that answer names the two separate properties doing the work, because only one of them is the address.

Nothing can route in. No MX record can exist for the inbox, so no sender on the internet can reach it. This is why a test inbox has an injection endpoint at all: POST /v1/projects/{project_id}/test/inbound puts a synthetic message directly into the mailbox because transmission is not an option. That path needs test:simulate and messages:read, and it refuses anything that is not a test project and a test inbox with HTTP 403 test_mode_required. Wiring that into a CI job is the usual reason to want it.

Nothing routes out. A send from a test inbox never reaches the live mail provider, which is a property of the transport rather than the address, and the next section but one shows the line of code that decides it.

Be equally clear about what the suffix does not cover. It says nothing about the recipients you put in a fixture. Put ap@realcustomer.example in to and the recipient row is stored with that address exactly as written. There is no check against reserved domains at the API boundary, and adding one would be a false comfort anyway, since the same fixture file eventually gets pointed at a live credential. What stops that message arriving is the transport, not the address, which is precisely why the choice of recipient domain in a fixture is still a discipline worth having.

It also says nothing about content. Test mail is stored: up to 100 retained messages and 10 MiB per workspace, expiring after 7 days, with up to 3 active test inboxes. Exceeding any of those is HTTP 403 test_limit_exceeded. A non-routable address is not a reason to paste a production secret into a fixture body.

Unsendable, or merely intercepted

The interesting distinction between sandbox designs is not whether they stop mail. They all stop mail. It is where the decision lives, and therefore what has to go wrong for the mail to escape.

Where the decision livesExampleWhat escapes if it is wrong
A field in the request bodymail_settings.sandbox_mode.enableA real send to real recipients, from one wrong code path
The credential or server you authenticate withA sandbox server chosen at creationA real send, if the wrong token is in the environment
The address itself, plus the credentialA non-routable .mail.invalid inboxNothing. There is no destination to reach

Row by row, verified against vendor documentation on 2026-09-22.

SendGrid documents sandbox mode as mail_settings.sandbox_mode.enable, a boolean in the Mail Send body, which validates each parameter and the structure of the payload without delivering the message. Their docs are unambiguous that the email will never be delivered while the flag is set, and a sandboxed request is distinguishable on the way out: it answers 200 OK rather than the usual 202 Accepted, emits no webhook events and consumes no credits. That is genuinely useful. It is also a per-request decision, which means a second code path that assembles the payload slightly differently does not inherit it, and the 200 that would have told you so only shows up if something is reading status codes.

Postmark separates two mechanisms. A test token, POSTMARK_API_TEST, passed in the X-Postmark-Server-Token header, validates a request without sending it. Separately, a server is created as Live or Sandbox and the type cannot be changed afterwards. Messages from a sandbox server go to a black hole, while still appearing as Delivered in the UI, the API and webhooks, and their documentation is explicit that those messages do count towards your monthly sending volume. That second design is the closest analogue to a test project here, and the immutability is the right call, because it removes the per-request failure mode entirely. Verified against postmarkapp.com developer documentation on 2026-09-22.

The billing line is a real difference rather than a detail. Sandbox sends at Postmark consume quota; test mail here is never charged against the free tier’s 100 email units a month, and a test send does not consume an allowance that a live send would need. Whether that matters depends entirely on how chatty your test suite is.

Where the designs actually diverge is the mailbox’s own identity. A Postmark sandbox server sends from and to real, routable addresses and simply discards the result on their side, which means it can show you a realistic delivery narrative. A test inbox here has an address that cannot exist in DNS, which means it cannot show you that narrative at all. Neither is strictly better. A discarded message tells you more about message shape and reporting; an unroutable one removes an entire class of accident. Pick according to which risk you are actually carrying, and note that if what you need is evidence about real delivery, what “accepted” proves and what it does not is the piece to read before you trust any sandbox’s status field.

The 422 you get for trying to customise a test address

Passing local_part or domain_id when creating an inbox in a test project is HTTP 422 test_mode_unsupported, not a silently ignored field:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes" \
  -H "Authorization: Bearer $EFA_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Triage under test","local_part":"triage"}'
{
  "error": {
    "code": "test_mode_unsupported",
    "message": "Test inboxes use generated non-routable addresses.",
    "request_id": "req_...",
    "retryable": false,
    "details": {}
  }
}

The check is in packages/domain/src/services.ts:1342, before the project’s active status is even evaluated, and the status mapping for the code is in packages/contracts/src/errors.ts:58.

Refusing is the whole point of the design, and accepting would have been worse. If the request took your local_part and generated something else anyway, you would walk away holding an address you believe is yours, in an environment where no address is reachable, and the belief would only be tested the first time the same code ran against a live project. A 422 at inbox creation costs you one line of config. The alternative costs you a wrong assumption that survives until it matters.

Two neighbouring operations are refused for the same reason. Attaching a domain to a non-live project raises test_mode_unsupported with the message “Test mode cannot attach domains. Test addresses remain on .mail.invalid.” (packages/domain/src/custom-domains.ts:203), and verifying one raises the same code with the shorter “Test mode does not attach domains.” (packages/domain/src/custom-domains.ts:367). There is no DNS under .invalid to verify against and nothing a verified domain could route to, so the operation has no meaning rather than merely no effect. Domain work belongs in a live project, and what the subdomain and DNS setup involves is a separate exercise.

A project’s environment is also fixed at creation, and it is worth being exact about where that is enforced, because it is not in the API. A BEFORE UPDATE trigger on projects compares the new environment with the old and raises environment_immutable with SQLSTATE restrict_violation if they differ (supabase/migrations/20260919120400_rls_triggers.sql:70). No REST route attempts the update, so you will not meet this error by calling the public API. It exists to catch anything that writes the column directly. The practical consequence is that a test project cannot be promoted. Live work needs a second project and a second credential, and the .mail.invalid addresses stay behind in the first one.

Why the transport, not the address, is the real guarantee

The address stops inbound. The transport stops outbound, and it is one line:

const provider = snapshot.environment === "live" ? this.opts.liveMail! : this.opts.mail;

That is packages/domain/src/services.ts:4417. The live branch is the Resend provider, which is only constructed at all when the live mail integration is enabled and an API key is present. The other branch is an in-process fake that records the submission in a map and returns an outcome, with no network call (packages/domain/src/worker-runtime.ts:73). A test send is not a live send with the socket closed. The code that would open a socket is not reachable from that branch.

The fake carries the convention through to the message identity. An accepted test submission gets an RFC message identifier of the form <providerIdempotencyKey@mail.invalid> (packages/mail/src/fake.ts:107), so if one of those strings ever turns up in a log, a header dump or a threading bug report, its provenance is unambiguous on sight.

It is also not a provider that only ever says yes, which matters if you are testing the unhappy paths. The fake keys its submissions on providerIdempotencyKey and returns the stored outcome for a repeat, so a retried send is a replay rather than a second message, the same property the live path gets from Idempotency-Key. And it can be scripted to return outcomes other than acceptance: a permanent rejection, a throttle that is retryable before submission, and an accept-then-timeout that resolves as unknown. That last one is the case worth rehearsing, because an unknown submission is the state where you genuinely do not know whether mail went out, and it is very hard to provoke on purpose against a real provider. Retrying an agent’s email safely covers what your code should do when it lands there.

Stacked with the credential rule, three independent things would have to fail together for a test send to reach a person. The environment is encoded in the key prefix, so an efa_test_ key resolves only to test projects and test inboxes and there is no flag that lets it cross to live. The project’s environment is immutable. And the provider selection is a branch on the environment read from the message snapshot, not on anything the caller supplied.

One honest caveat on what you are watching. The states a test send moves through, queued and then accepted, are real states written by the real pipeline, but the acceptance came from a simulated transport. accepted in test means the fake provider took it. It carries none of the meaning that the same word carries in live, where it represents a real submission to a real provider that can still fail per recipient afterwards.

Choosing recipient domains for your fixtures

Since nothing validates the to field against reserved namespaces, the fixture discipline is yours. Here is what the common choices actually do, with DNS observed on 2026-09-22:

Fixture domainReserved byMX query todayWhat a sender does with it
anything.invalidRFC 2606 §2, RFC 6761 §6.4NXDOMAINLookup fails permanently. No connection attempted
anything.testRFC 2606 §2, RFC 6761 §6.2NXDOMAINSame
anything.exampleRFC 2606 §2, RFC 6761 §6.5NXDOMAINSame
example.com, .net, .orgRFC 2606 §3NOERROR, 0 .Resolves, reads a null MX, refuses permanently
test.comNothingNOERROR, no recordsNo MX and no A record either, so nothing to connect to
mail.comNothingNOERROR, real MXConnects and delivers to a live consumer provider
anything.localRFC 6762 (multicast DNS)Host-dependentNot reserved for this. Behaviour depends on the host

Three of those rows look identical from inside a test suite, because the send fails in all three. They are not the same thing, and the difference is the whole subject of this page. Mail failing is not the same as mail being impossible.

.invalid is the absence of any possible record. The protocol forbids the name from existing, a resolver is told to answer NXDOMAIN without asking anyone, and no registry may sell it. There is nothing to change and nobody who could change it.

example.com is a record that says no. It resolves perfectly well, is delegated to real nameservers, and answers an MX query with 0 ., the null MX of RFC 7505, an explicit machine-readable refusal of mail. That is deliberate and it is strong, and it is still a DNS record published by an operator, which is a different category of object from a name the protocol forbids. IANA could publish something else tomorrow. They will not, but the mechanism that stops your mail is a choice rather than a law.

test.com is the row that catches people, and not for the reason usually given. It is a real registration created on 1997-06-18, held through Network Solutions, delegated to safesecureweb.com nameservers. Queried on 2026-09-22 it publishes no MX record at all, which is NOERROR with an empty answer section rather than NXDOMAIN, and it publishes no A record either, so a sender that falls back to the implicit MX of RFC 5321 §5.1 also finds nothing. Mail to it fails today. It fails by accident. There is no null MX declaring an intent to refuse mail, only an absence, and the registrant can publish an MX record any morning they like and start receiving a decade of other people’s test fixtures. mail.com, which shows up in fixtures for the same looks-fake-enough reason, already publishes live MX records and accepts the mail.

So the ordering by strength is: unregisterable, then refusing, then merely empty, then accepting. Most advice on the internet flattens the first three into “use a fake domain”, and the distinction only surfaces on the day somebody’s fixture reaches a person.

The practical rule is short. Use a .invalid address for anything that must never be deliverable even in principle, including every recipient in an automated test. Use example.com in documentation and screenshots, where you want an address that reads as an ordinary human address to a human. Never use an unreserved domain you do not own, and never use a colleague’s real address, because a fixture outlives the environment it was written for.

What the suffix does not buy you

A green test suite against .mail.invalid addresses proves your integration, and it is worth being exact about where that proof stops.

It proves nothing about deliverability. No message crosses a transport, so SPF, DKIM, DMARC alignment, reputation and inbox placement are all untested. There is no delivery guarantee and no published uptime SLA here, and correct DNS on a live domain is configuration evidence rather than a statement about where mail lands.

It proves nothing about provider behaviour. Real bounces, real suppression and a genuine interrupted submission come from a real provider, and the fake produces scripted outcomes instead.

It proves nothing about the agent’s judgement. A sandbox exercises your code. Whether the model chooses sensibly when a stranger emails it is a separate question with separate evidence.

And it is not a compliance control. Non-routable test addresses, environment-scoped credentials, scoped grants, an approval gate and idempotent sends are shipped technical behaviour you can verify by reading the responses. They are not certificates, and no certification is claimed here. Those are different kinds of claim and conflating them is how honest engineering copy turns into something a reader should not trust.

The next step is to create the test project and watch the address come back before you create anything live. The quickstart walks through the first inbox and the first message, the developer surface is the hub for the REST and MCP paths, and the full API reference has the request shapes for everything above.


Keep reading

All guides · Documentation · Developers