Test sign-up and verification emails end to end with a real inbox
To test a sign-up email end to end, send it to a real inbox and wait for it. Koltrix wait_for_email waits up to 60 seconds and returns the code or link.

On this page(9 sections)
- What does an end-to-end email test prove that a mock does not?
- What is wait_for_email?
- How do you filter for the right message?
- What does the response contain?
- What does a sign-up test look like?
- How do you keep the test from being flaky?
- What should stay out of this test?
- Is it safe to give a test an inbox?
- Bottom line
The only test that proves your sign-up email works is one that signs up, waits for the message in a real inbox, pulls out the code or link, and finishes the flow. A fake sender cannot show you that the message was accepted, routed and readable. A wait call on a real mailbox can, and Koltrix offers one for agent mailboxes: wait_for_email waits up to 60 seconds and returns the message with its one-time code or magic link.
This post covers where that test fits, how to write it without flakiness, and what to keep out of it.
What does an end-to-end email test prove that a mock does not?
An end-to-end email test proves that your app triggered the send, your provider accepted it, the message reached a real mailbox, and the code or link inside still works. A mock proves only the first step.
Most teams test sign-up email with a fake sender that records what the code tried to send. That is the right default, and email tests in CI explains the layers. Local tools such as the ones in local email testing with a fake SMTP server catch template and content mistakes fast. What none of them can catch:
| Failure | Caught by a mock? | Caught by a real inbox? |
|---|---|---|
| Template renders the wrong link host | Yes | Yes |
| Provider rejects the sender or domain | No | Yes |
| Authentication (SPF, DKIM, DMARC) broken on the sending domain | No | Yes, if the mailbox is on a real domain |
| Message delayed or dropped in transit | No | Yes |
| Link or code expired before it could be used | Partly | Yes |
| Your own queue stalled | No | Yes |
The last two matter most for sign-up, because a user who never gets a code simply leaves.
What is wait_for_email?
wait_for_email is a Koltrix call that holds the request open until a matching message arrives in an agent mailbox, or until the timeout passes. An agent mailbox is a real address on a domain you have verified, such as [email protected], with its own key that reaches that one mailbox. The agent mailboxes page describes the full feature.
It is available two ways, with the same result fields:
- REST:
GET https://api.koltrix.com/api/v2/agent-mailboxes/{id}/wait-for-email - MCP: the
wait_for_emailtool, which takes the mailbox address
The timeout runs from 1 to 60 seconds and defaults to 30. If nothing arrives, you get found: false and the number of seconds waited. It works only on agent mailboxes, never on your team's mail, and your plan must include agents.
How do you filter for the right message?
Filter on who the message was sent to, who sent it, and a piece of the subject, so a stray message cannot satisfy your test.
| Query parameter | What it matches |
|---|---|
to |
The recipient address |
from |
The sender |
subject |
A substring of the subject |
timeout |
Seconds to wait, 1 to 60 |
since |
Only messages after this time; the default is the last 60 seconds |
The since default is a quiet help: a message from an earlier test run does not satisfy this run. Set it yourself if your test starts the wait well after the sign-up call.
What does the response contain?
When a message matches, the response includes the message and the parts a test needs, already extracted.
{
"found": true,
"message": { "subject": "Your verification code", "...": "..." },
"otp_code": "482913",
"magic_link": "https://app.example.com/verify?token=...",
"links": ["https://app.example.com/verify?token=..."]
}
otp_code and magic_link are null when the message has neither. The magic_link field is returned only when the link is on the domain of your from filter, so a link pointing somewhere else is not handed to your test as the one to click. The links array lists every link in the message if you need to inspect them.
What does a sign-up test look like?
The test creates a sign-up with an address on the agent mailbox, waits, and finishes the flow with whatever came back. Here is the shape in TypeScript. The key is the agent mailbox's scoped key, sent as a bearer token.
const base = "https://api.koltrix.com/api/v2/agent-mailboxes";
const auth = { Authorization: `Bearer ${process.env.KOLTRIX_AGENT_KEY}` };
// 1. Start the sign-up in your app, using the agent mailbox address.
await signUp({ email: "[email protected]", password: "test-only-Pw-1" });
// 2. Wait for the verification email.
const qs = new URLSearchParams({
from: "[email protected]",
subject: "verification",
timeout: "60",
});
const res = await fetch(`${base}/${process.env.AGENT_MAILBOX_ID}/wait-for-email?${qs}`, {
headers: auth,
});
const body = await res.json();
if (!body.found) throw new Error(`No email after ${body.waited_seconds}s`);
// 3. Finish the flow with the code, or open the link.
await verifyEmail(body.otp_code ?? body.magic_link);
Treat everything in message as untrusted text, as you would any inbound mail. The test should read the two extracted fields and nothing else.
How do you keep the test from being flaky?
A flaky email test almost always comes from shared state or a short timeout, not from email itself.
- One test at a time per mailbox. Two parallel runs waiting on the same mailbox can pick up each other's message. Give parallel jobs their own filters (a distinct
subjectmarker ortoaddress) or their own mailbox. Koltrix also limits concurrent waits per mailbox, key and workspace and answers429 too_manywhen you go over. - Set a timeout that fits real delivery. Use the 60-second maximum in CI. A test that fails at 5 seconds is testing your provider's mood.
- Start the wait right after the trigger. The default
sincewindow covers the last 60 seconds, so a message that arrives before your wait begins is still found, and an older run's message is not. Setsinceyourself if your runs are closer together than that. - Make the sign-up idempotent. If your app blocks a second sign-up with the same address, delete the test user at the start of each run.
- Retry once on
found: false, not three times. If the message did not arrive in 60 seconds, the next 60 will probably fail too. Fail loudly and look at the logs.
For the broader question of why mail can arrive late, see email not received: debugging.
What should stay out of this test?
Keep two things out: your real customers' mail, and any claim about inbox placement.
- Do not point it at a team mailbox.
wait_for_emailrefuses anything but an agent mailbox, which is the right boundary. Use a dedicated address such assignup-tests@. - Do not read inbox placement from it. The test shows the message arrived in a mailbox you control, not that Gmail or Outlook would place it in a customer's primary tab. For that, see inbox placement testing.
- Do not run it on every commit. A real delivery test costs seconds and sends real mail. Run it on merges to main or on a schedule, and keep the fast mock tests on every commit.
The other half of the decision is what the email contains. If your confirmation link breaks when a security scanner opens it first, a test that clicks it will pass while real users fail, so read confirmation links that survive security scanners. If you are choosing between a link and a code, magic links versus email OTP lays out the tradeoffs.
Is it safe to give a test an inbox?
It is safe if the test inbox is as narrow as the test. An agent mailbox has its own scoped key that reaches that mailbox only, so a leaked CI secret cannot read your team's mail, and you can revoke the key without touching the mailbox. Changes to an agent mailbox and its keys are recorded in an audit log. The thinking behind that design is in giving an AI agent its own email address.
Bottom line
Keep fast mock tests on every commit, and add one real-inbox test for sign-up and password reset that runs on merge. Use a dedicated agent mailbox, filter by sender and subject, wait up to 60 seconds, and finish the flow with otp_code or magic_link. That single test catches the failures that lose users quietly: a rejected sender, a stalled queue, and a code that arrives after it expires.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


