Testing signup emails as a developer: real inboxes with an API
Your signup tests pass, then a new user writes in: the verification email never came, or the code in it was blank. Your tests only checked that your app tried to send it. To test signup emails properly, give each test a real inbox that your code can read.
With an email testing API, each test creates an address, signs up with it, waits for the email, reads the code or link, then deletes the inbox. That checks the whole path from your app to a real mailbox.
Why a real inbox beats a fake mail server
A common shortcut is a local fake mail server that catches everything your app sends. It is quick, but it only proves your code handed a message over. A real inbox also tests what breaks in production: your email provider accepting the message, the delivery, and a code or link that actually works.
A missing template variable or a link that points at localhost then shows up as a failed test, not a support ticket.
What a test email inbox for automated tests needs
Four things separate a reliable email test from a flaky one:
- A fresh address for each test, so tests never read each other's mail.
- A way to read messages from code, with the code already pulled out.
- A way to wait for mail without hammering a server.
- Cleanup, so a busy suite does not pile up inboxes.
Create an inbox and read the code with the freemail API
Make an API key on your Account page, then send it on every request to https://api.freemail.site/v1 as the header Authorization: Bearer YOUR_API_KEY.
Pro keys can only read. Creating and deleting inboxes, as the example below does, needs Max.
POST /addresses creates an inbox. Every field is optional: leave out localPart and you get a generated name like calm.otter47, so your test never has to invent one. The reply includes the inbox's id and its full address.
GET /addresses/:id/messages lists the mail in an inbox. Each message already carries an otp field with the detected code and a confidence score, so there is nothing to parse. It is only filled in when the API is at least 0.8 sure. Below that, otp is null and hiddenCode holds just the score.
// api() is your own helper that sends YOUR_API_KEY with each request
const inbox = await api("POST", "/addresses", { label: "signup test" });
await signUpInYourApp(inbox.address);
const code = await waitForCode(inbox.id);
async function waitForCode(inboxId) {
for (let attempt = 0; attempt < 30; attempt++) {
const { items } = await api("GET", `/addresses/${inboxId}/messages`);
const hit = items.find((m) => m.otp?.confidence >= 0.8);
if (hit) return hit.otp.code;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error("No code arrived within a minute");
}The loop waits two seconds between tries and gives up after a minute. Go too fast and you get a 429 with a Retry-After header saying how many seconds to wait. When the test is done, DELETE /addresses/:id removes the inbox and all of its mail.
Magic links instead of codes
Read the link field instead. It holds the url, the host and a kind such as Verify email. Like otp, it is only filled in at 0.8 or more, and it is held back at any score when the sending server fails SPF, DKIM or DMARC.
If link stays empty in staging, the reason in hiddenLink says why: confidence or sender-checks. The auth field shows each check's result, so every test doubles as a free check on your sending setup.
For the wording itself, GET /messages/:id returns the full text, the html with scripts removed, and any attachments.
Skip the polling with webhooks
On Max, a webhook tells your server within seconds when mail arrives. It is set up in the app, not with an API key. Each delivery is a POST with an x-freemail-signature header in the form t=TIMESTAMP,v1=SIGNATURE, where the timestamp is in Unix seconds.
The signature is a hex HMAC with SHA256 over the timestamp, a dot and the raw body, keyed with the secret you see once, when you create the webhook. Check it, and reject timestamps more than five minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
// header: the signature header's value; body: the raw request body as text
function isGenuine(header, body, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
if (Date.now() / 1000 - Number(t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
return v1?.length === expected.length &&
timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}Webhooks need an https address that the internet can reach, which suits a staging server. On a laptop, polling is simpler.
Pro reads your test inboxes, Max creates them too
Pro, at $4 a month, lets your tests list inboxes with GET /addresses and read their mail, but not create or delete them. So make a few inboxes in the app, with names you choose, for your tests to reuse.
To tell new mail from old, note the time just before you trigger the email, minus a few seconds for clock differences, and only read messages with a later receivedAt.
Max, at $10 a month, adds full API access: every test can create and delete its own inbox, with room for 150 inboxes when tests run in parallel, plus the webhooks above.
Addresses never expire on any plan, so a seeded demo user can still get its password reset months later. That is where accounts made with a 10 minute email get lost.
Compare Pro and Max side by side
Common questions
Can I receive emails in tests without running a mail server?
Yes. Create a real inbox through an email testing API, sign up with its address, and read the message from your test. Your app sends mail just as it does in production, and the test reads what actually arrived.
How do I get the verification code out of a test email?
Every message in the freemail inbox list carries an otp field with the detected code, filled in only when the API is at least 0.8 sure, so you can use it as it is. If there is no code, read the link field or the full message text.
Does the free plan include API access?
No. The API starts on Pro, which can read inboxes and messages, and Max adds full access and webhooks. On the free plan you can still check a signup email by hand and copy its code in one click.
A permanent inbox, ready now
No signup and no password. Mail arrives live, codes copy in one click, and the address is still yours next month.
Get your free inbox