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 lives | Example | What escapes if it is wrong |
|---|---|---|
| A field in the request body | mail_settings.sandbox_mode.enable | A real send to real recipients, from one wrong code path |
| The credential or server you authenticate with | A sandbox server chosen at creation | A real send, if the wrong token is in the environment |
| The address itself, plus the credential | A non-routable .mail.invalid inbox | Nothing. 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 domain | Reserved by | MX query today | What a sender does with it |
|---|---|---|---|
anything.invalid | RFC 2606 §2, RFC 6761 §6.4 | NXDOMAIN | Lookup fails permanently. No connection attempted |
anything.test | RFC 2606 §2, RFC 6761 §6.2 | NXDOMAIN | Same |
anything.example | RFC 2606 §2, RFC 6761 §6.5 | NXDOMAIN | Same |
example.com, .net, .org | RFC 2606 §3 | NOERROR, 0 . | Resolves, reads a null MX, refuses permanently |
test.com | Nothing | NOERROR, no records | No MX and no A record either, so nothing to connect to |
mail.com | Nothing | NOERROR, real MX | Connects and delivers to a live consumer provider |
anything.local | RFC 6762 (multicast DNS) | Host-dependent | Not 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
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.
Test an Agent's Inbound Email Loop in CI
A routable test mailbox needs a message actually transmitted. Inject one instead: a CI job asserting received, parsed, threaded and replied.
Agent Email on a Subdomain Without Breaking Company Mail
Run agent mail on agents.yourcompany.com: inspect existing MX, publish ownership, SPF, DKIM and receiving records, and leave employee routing untouched.