You sign up for Mailgun, copy the code sample from the getting-started page,
send yourself a test, and get back a message id and a 200. Nothing arrives.
The dashboard log says queued, or rejected: Sandbox subdomains are for
test purposes only. Please add your own domain or add the address to authorized
recipients. Your code is correct. Your key is correct. The mail is not coming.
Look at your MAILGUN_DOMAIN. If it starts with sandbox (something like
sandbox1a2b3c4d.mailgun.org) you are on the throwaway domain Mailgun gives
every new account, and it does exactly what its name says.
What the sandbox domain actually is
Every Mailgun account gets one sandbox subdomain, pre-verified, ready to send the moment you sign up. That immediacy is the point: you can prove your credentials work without waiting on DNS.
The trade is that it will only deliver to authorised recipients: addresses you explicitly add and that then confirm by clicking a link in an email Mailgun sends them. There are five slots. Everything else is accepted by the API and then dropped.
Two more limits people hit later:
- The sandbox has a low daily message cap (a few hundred), enough for testing and nothing else.
- It has no reputation and never will. It shares infrastructure with every other free account's test traffic, so nothing you learn about placement on the sandbox transfers to your real domain.
The failure mode is the problem. A send to an unauthorised recipient does not throw. Your code sees success, your logs see a message id, and the absence is invisible until someone asks why they never got the invite.
The wrong fix
// Retrying, because the first one "must have got lost".
await sendEmail({ to: user.email, subject: "Welcome", react: WelcomeEmail(...) });
await sendEmail({ to: user.email, subject: "Welcome", react: WelcomeEmail(...) });
// Or: switching to SMTP, which fails identically.
// Or: adding a delay, on the theory that it is a queue.
// Or, worst: shipping it, because "it works in the dashboard".
None of these help, and the last one means your production app has a sending domain that mails five people.
The fix, for now: authorise the addresses on purpose
If you genuinely want to keep testing before doing DNS work, add the recipients:
Sending → Domain settings → Authorized Recipients (with the sandbox domain selected), add an address, and have the owner of that mailbox click the confirmation link. Only then does the address receive anything.
Make the constraint loud in code so nobody loses an afternoon to it twice:
// src/lib/email/mailgun.ts
/** True for the throwaway sandbox domain, which only mails authorised addresses. */
export function isSandboxDomain(): boolean {
return sendingDomain().startsWith("sandbox");
}
// src/lib/email/index.ts, inside sendEmail()
if (isSandboxDomain()) {
// The sandbox domain silently accepts a send and then refuses to deliver to
// anyone not on the five-address authorised list. Saying so up front saves
// an hour of staring at a "queued" status.
console.warn(
"[email] sending from the Mailgun sandbox domain, only authorised recipients will receive this",
);
}
And surface it in the environment check so it appears in verify output rather
than only in a log nobody is reading:
return isSandboxDomain()
? `${domain} (${region()}): sandbox, authorised recipients only`
: `${domain} (${region()}) active`;
The real fix: your own sending domain
The sandbox is a five-minute convenience. Anything past your first test needs a verified domain, and it is worth doing early because DNS propagation is the slow part.
- Sending → Domains → Add new domain. Use a subdomain:
mail.yourdomain.com, never the root. Transactional mail then builds its own reputation, and a bad marketing week cannot take password resets down with it. It also leaves your root domain's SPF record free for whatever else sends as you. - Pick the region deliberately before you create it. A domain's region is fixed at creation, and the wrong one produces a 401 that looks like a bad key.
- Add the DNS records Mailgun shows: two DKIM
TXTrecords, an SPF record on the subdomain, aCNAMEfor tracking, andMXrecords if you want to receive mail on it. Add a DMARC record on the root domain: start atp=nonewith a reporting address, and tighten it once the reports are clean. - Wait for Active. Not "added". Sending from an unverified domain is rejected outright, not filtered.
- Update
MAILGUN_DOMAINandEMAIL_FROMtogether.EMAIL_FROM's domain must equalMAILGUN_DOMAINor Mailgun refuses the message.
Then confirm the switch actually happened:
bun run verify # prints the domain, its region and its state
bun run email:send-test you@example.com
The verify check fails if the domain is not active, which is the loud version
of the failure you just spent an hour on.
Sandbox for automated tests?
Tempting, and usually the wrong tool. Five authorised recipients does not cover a test suite, and the daily cap will bite in CI.
Better options, in order:
- Do not send at all in unit tests. Assert that your code called
sendEmailwith the right arguments. That is the part you own. - Use a catch-all inbox for end-to-end tests: a service that gives you disposable addresses on a domain you can authorise once, or a real mailbox with plus-addressing on your verified domain.
- Keep the sandbox for the first five minutes of a new environment, which is the job it is good at.
The one-line summary
queued plus silence plus a domain starting with sandbox is not a bug. Add
your own domain, wait for Active, and treat any code path that can send from
a sandbox domain in production as a defect.