Skip to content

Email

Next.js boilerplate with Mailgun

The veteran sending API. EU data residency and suppression lists kept server-side.

Transactional email through Mailgun, behind one provider-agnostic send function. React Email templates are rendered to HTML before sending. Suppression lists are read straight from Mailgun's own API. Mail routes to the EU or US region, and the event webhook is signature-verified.

What Mailgun adds to the agent layer: 1 rule · 3 skills · 5 solution docs

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Mailgun?

Pick it if

Teams that need EU data residency, or want suppression and analytics held by the provider instead of their own database. Also teams sending enough volume to care about dedicated IPs and warm-up support. And the pragmatic choice when an existing system already speaks Mailgun.

Watch out for

  • Region is chosen when the domain is created and cannot change. An EU domain must use api.eu.mailgun.net. The US host returns a 401 that reads like a bad key.
  • The API is form-encoded, not JSON, which is why the SDK needs form-data as a peer. It is an older API and it shows in places.
Show 3 more
  • No React rendering on Mailgun's side. Templates are rendered to HTML in your app before sending. Mailgun's own templates are Handlebars stored in the dashboard, a different workflow this battery does not use.
  • No built-in idempotency key. A retried job can send twice unless you deduplicate, so this battery attaches a deterministic Message-Id and a custom variable.
  • Suppression lists live in Mailgun and are queryable through the API. No local table to maintain and one less thing to keep in sync.

What it costs

Free plan: 100 emails a day and 1 custom domain. Basic starts at $15/month for 10,000 emails. Foundation is $35/month for 50,000 and Scale $90/month for 100,000. Logs are kept 1 day on Free and Basic, 5 days on Foundation and 30 days on Scale.

Prices change. Check with Mailgun before you commit.

registry/tested.yaml

Tested with Mailgun

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Admin panel
Admin panel
Error tracking
Sentry
Customer support
Crisp

What it adds

What Mailgun adds to the repo

Read straight from the mailgun manifest, so it is exactly what lands in your repo.

Environment variables

  • MAILGUN_API_KEYRequired

    Private API key from Settings -> API Keys. It can send mail, read every event and delete domains, so it is server-side only. This is not the same value as the webhook signing key.

    Placeholder
    key-replace_me
  • MAILGUN_DOMAINRequired

    The sending domain, exactly as it appears in the Mailgun dashboard. Use a subdomain such as mail.example.com so transactional mail builds its own reputation. A sandbox domain works for the first test but can only send to five addresses you have authorised.

    Placeholder
    mail.example.com
  • MAILGUN_REGIONOptional

    "us" (default) or "eu". Chosen when the domain was created and fixed thereafter. An EU domain addressed at the US host returns 401 Unauthorized, which reads exactly like a wrong API key and costs people an afternoon.

    Placeholder
    us
  • EMAIL_FROMRequired

    The From address, in "Name <address@domain>" form. Its domain must be MAILGUN_DOMAIN and must be verified (SPF, DKIM and DMARC in place) or the message is rejected rather than filtered.

    Placeholder
    my-app <hello@mail.example.com>
  • MAILGUN_WEBHOOK_SIGNING_KEYOptional

    HTTP webhook signing key, a separate value from MAILGUN_API_KEY and found next to it in Settings. Mailgun signs events as HMAC-SHA256(timestamp + token) with this key. Optional locally; without it the event route refuses every delivery, which is the correct default.

    Placeholder
    whsk-replace_me
  • EMAIL_OUTBOX_DIROptional

    Development and tests only. When set, sendEmail writes each message as a JSON file in this folder instead of sending it, so sign-in and reset links work with no inbox and no provider account. The end-to-end tests read their links from it (.e2e/outbox). Ignored on a deployment: it works in development and in a production build served on localhost.

    Placeholder
  • REPLY_TOOptional

    Default Reply-To for every send. Falls back to EMAIL_FROM when unset. Point it at a mailbox a human reads: replies to a black hole hurt deliverability and infuriate the person who tried.

    Placeholder
    support@example.com

Dependencies

  • @react-email/render^2.1.0
  • form-data^4.0.6
  • mailgun.js^14.0.1
  • react-email^6.11.0
  • server-only^0.0.1

Scripts

  • bun run email:dev

    bunx email dev --dir src/lib/email/templates

  • bun run email:send-test

    bun --conditions=react-server scripts/email/send-test.ts

  • bun run email:suppressions

    bun --conditions=react-server scripts/email/suppressions.ts

Files it writes

22 files, at these exact paths.

  • scripts/3 files
    • email/3 files
      • render-samples.ts
      • send-test.ts
      • suppressions.ts
  • src/15 files
    • app/1 file
      • api/1 file
        • webhooks/1 file
          • mailgun/1 file
            • route.ts
    • lib/14 files
      • email/14 files
        • templates/7 files
          • layout.tsx
          • magic-link.tsx
          • receipt.tsx
          • reset-password.tsx
          • theme.ts
          • verify-email.tsx
          • welcome.tsx
        • address.ts
        • index.ts
        • mailgun.ts
        • outbox.ts
        • retry.ts
        • suppression.ts
        • tags.ts
  • tests/2 files
    • unit/2 files
      • email-outbox.test.ts
      • email.test.ts
  • variants/2 files
    • errors-none/1 file
      • src/1 file
        • lib/1 file
          • email/1 file
            • observability.ts
    • errors-sentry/1 file
      • src/1 file
        • lib/1 file
          • email/1 file
            • observability.ts

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot env-required
  • @slot legal-processors
  • @slot verify-checks

The differentiator

What Mailgun teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (1)

Loaded when the agent opens a matching file.

One send function, a verified domain, a reply-to, and no secrets in the body

Loads onsrc/lib/email/**src/app/api/webhooks/mailgun/**src/lib/auth/**src/lib/billing/**.claude/rules/email-sending-discipline.md
One surface

sendEmail() from @/lib/email is the only way this app sends mail. Nothing else imports mailgun.js, constructs a client, or reads MAILGUN_API_KEY. The Resend battery exports sendEmail, SendEmailOptions, SendEmailResult, EmailAttachment and EmailSuppressedError with the same shapes, so a caller written against one compiles against the other, and the rules below are enforced in one file instead of being remembered in twenty.

The one field that is not shared is tracking below, because Resend has no per-message tracking switch: open and click tracking are a per-domain setting in its dashboard. Everything else you can pass here, you can pass there.

import { sendEmail } from "@/lib/email";
import ReceiptEmail from "@/lib/email/templates/receipt";

await sendEmail({
  to: user.email,
  subject: `Receipt ${payment.reference}`,
  react: ReceiptEmail({ reference: payment.reference, /* ... */ }),
  idempotencyKey: `receipt:${payment.id}`,
});
Never send from an unverified domain

from is not a parameter. It is EMAIL_FROM, whose domain must equal MAILGUN_DOMAIN and must show active in the dashboard with DKIM, SPF and a DMARC record in place. Mailgun rejects a From address outside the sending domain outright, and mailbox providers filter mail that fails authentication.

Use a subdomain (mail.example.com), never the root domain: transactional mail then builds its own reputation and a bad campaign cannot take password resets down with it.

Never put a customer's address in from to make a message look like it came from them. It fails DMARC on their domain, lands in spam, and is technically indistinguishable from spoofing. Set replyTo instead.

Be explicit about the region. MAILGUN_REGION decides the API host, a domain's region is fixed when it is created, and the wrong host answers 401, which reads exactly like a bad key.

Always set a reply-to

sendEmail fills h:Reply-To from REPLY_TO, falling back to EMAIL_FROM. Callers may override it; they may not remove it. A no-reply address is a deliverability penalty and a support failure: people answer transactional mail and the answer has to reach a human.

Never put a secret in an email body

Messages sit unencrypted in mailboxes, get forwarded, get indexed, and pass through scanners that click every link. None of the following may appear in a message this app sends:

  • API keys, database URLs, signing keys, or any environment value.
  • A raw session token or cookie value: anything that authenticates a request as an existing session.
  • A password, including a temporary one you generated.
  • A full or self-masked card number, or a payment token. cardLast4, as supplied by the payment provider, is the limit.

A magic link is the one credential an email may carry, and only because it is single-use and short-lived. Pass the URL in as a prop, never log it, and state the expiry in the body.

Use an idempotency key on transactional sends

Mailgun has no server-side idempotency, so sendEmail derives a deterministic Message-Id from the key and attaches it as a custom variable. That collapses duplicates in most clients and makes the rest findable in the logs.

Scope the key to the causing event (receipt:${paymentId}, welcome:${userId}) never to a timestamp or a random value, which is the same as having no key.

tags is Record<string, string> here as well, flattened to Mailgun's name:value tag form by src/lib/email/tags.ts, which also enforces the three-tag limit by name instead of letting the send fail. Never put an address, a name or an id in one.

Deliberately omit it where a repeat is the point: a user who requests a second sign-in link must receive one.

Let the suppression lists do their job

sendEmail checks Mailgun's bounce, complaint and unsubscribe lists before sending and throws EmailSuppressedError. Do not catch that and retry through another path.

That check is an optimisation, not the enforcement. Mailgun refuses a listed address on its own, which is why a failed lookup (a 5xx, a timeout, a DNS blip) answers "not suppressed" and lets the send proceed. Never change that to rethrow: it would turn a thirty-second provider hiccup into a total sign-in outage, because a magic link is the one message a user cannot work around. A run of suppression-unavailable incidents is the signal that the safety net is off.

unsuppress() is for an explicit request from the mailbox owner. Never clear lists in bulk: those addresses bounced for a reason, and re-sending is how a sending domain gets blocked.

suppress() writes into a list an operator reads next to entries Mailgun wrote itself, so it records a sentence rather than the app's internal reason code. Keep it that way: a row saying hard_bounce is indistinguishable from a real SMTP failure, and the next person cannot tell whether the mailbox is dead or whether support blocked it.

One message per recipient

to accepts an array, and an array produces one message per address. Nothing in this app ever puts two customers in one To header, because the recipients would see each other, and Mailgun would need recipient-variables to fan out, which this battery deliberately does not set. SendEmailResult.ids carries one id per address in order; id is the first of them.

An idempotencyKey is suffixed with the address when a send fans out, because a repeated Message-Id is treated as a duplicate by receiving servers and the second recipient would silently never get the mail.

Retries and rate limits

sendEmail retries a 429, a 5xx and a transport failure up to three attempts with jittered backoff, and never retries anything else: a 400 or a 401 fails identically every time. The budget is deliberately small because a request path is waiting on it. Bulk work belongs in a job.

Headers and attachments

headers is how List-Unsubscribe and List-Unsubscribe-Post get set, which Google and Yahoo require of bulk senders; each entry becomes an h: field. From, To, Subject and Reply-To are dropped from that map: they are owned by sendEmail and a duplicate header is a malformed message, not an override.

attachments takes { filename, content, contentType? } and becomes Mailgun's attachment field. The ceiling is 25MB for the whole message, lower than most people expect, so anything large should be a signed URL in the body.

Tracking is off unless someone decided otherwise

tracking defaults to false. Open tracking is a 1x1 pixel that reports when and roughly where a message was read; click tracking rewrites every link through a Mailgun domain. Both are personal data in the EU and both have deliverability costs: a rewritten link whose text and href disagree is a phishing signal.

Never enable tracking on a magic link, a password reset or a receipt. There is no product question those answer that is worth the rewritten URL.

Templates, not string concatenation

Mail HTML lives in src/lib/email/templates as React Email components with PreviewProps, rendered to HTML and text by sendEmail. Do not build HTML with template literals, and never interpolate user input into markup by hand: a name containing <script> is stored XSS in every webmail client that renders it.

Templates take a locale prop, and the receipt uses it for both the currency and the date. Pass the recipient's own locale rather than accepting the "en" default: a EUR receipt formatted for en-US on a document that declares lang="en" is wrong twice for a German customer.

Skills (3)

Invoked by name.

  • /add-email-template

    Add a React Email template on the shared layout, preview it, wire it into a sendEmail call, and confirm it renders as HTML and as text in a real inbox.

    .claude/skills/add-email-template/SKILL.md

  • /diagnose-email-delivery

    Work out why a Mailgun message did not arrive (region, key, suppression, authentication or placement) in the order that finds it fastest.

    .claude/skills/diagnose-email-delivery/SKILL.md

  • /test-email

    Prove a send actually works: check the region and domain, send one of each template, read the Mailgun logs, inspect the suppression lists and confirm placement in a real inbox.

    .claude/skills/test-email/SKILL.md

Solution docs (5)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

How it fits

What Mailgun needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

Nothing. Mailgun stands on its own.

Pairs well with

Nothing extra. Add any tested battery alongside Mailgun.

Compared with the alternatives

Build a repo with Mailgun

Free and MIT. The builder opens with Mailgun picked. You download the zip right away, and we email you the link too.