# 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.

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

```ts
// 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:

```ts
// src/lib/email/mailgun.ts
/** True for the throwaway sandbox domain, which only mails authorised addresses. */
export function isSandboxDomain(): boolean {
  return sendingDomain().startsWith("sandbox");
}
```

```ts
// 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:

```ts
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:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
