Skip to content

Mailgun says the message was queued and nobody receives it: sandbox domain limits

The sandbox domain accepts every send and delivers only to five addresses you authorised. Recognise it, use it deliberately, and know when to stop.

Mailgun4 min readships at docs/solutions/mailgun/sandbox-domain-limits.md

Tags: mailgun · email · sandbox · authorized-recipients · deliverability · onboarding

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.

  1. 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.
  2. 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.
  3. Add the DNS records Mailgun shows: two DKIM TXT records, an SPF record on the subdomain, a CNAME for tracking, and MX records if you want to receive mail on it. Add a DMARC record on the root domain: start at p=none with a reporting address, and tighten it once the reports are clean.
  4. Wait for Active. Not "added". Sending from an unverified domain is rejected outright, not filtered.
  5. Update MAILGUN_DOMAIN and EMAIL_FROM together. EMAIL_FROM's domain must equal MAILGUN_DOMAIN or 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 sendEmail with 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.