Mailgun (now Sinch Mailgun) offers high-volume transactional and bulk email, address validation, and routing. This guide uses the official mailgun.js SDK. Read the email overview first: it defines the shared EmailSender, emailPlugin(), headerSafe, escapeHtml(), and the email.send / email.suppress jobs used below.
Send through the mailgun.js SDK
Route handlermg clientMailgun APIWebhooks
01requestRoute handlermg clientmg.messages.create(DOMAIN, { from, to, subject })client keyed with MAILGUN_API_KEY
02requestmg clientMailgun APIPOST /v3/{domain}/messagesUS api.mailgun.net or EU api.eu.mailgun.net (fixed per domain)
03responseMailgun APIRoute handler{ id, message }return { id } on success
04asyncMailgun APIWebhooksfailed, complained, unsubscribed eventsverify HMAC-SHA256(timestamp + token) before trusting
The region is fixed per domain (US or EU), so set url accordingly. On edge runtimes pass useFetch: true so the SDK uses native fetch. Webhook payloads are HMAC-signed; verify them and reject replayed tokens before acting.
1. Provision
Add and verify your sending domain (a subdomain such as mg.acme.example.com) under Sending -> Domains -> Add new domain. Add the SPF and DKIM TXT records and the tracking CNAME Mailgun lists. The MX records are for receiving mail through Mailgun (routes, inbound parsing); you can skip them if the subdomain only sends, though some receiving servers run sender-domain checks that expect an MX to exist.
Choose your region: https://api.mailgun.net (US, default) or https://api.eu.mailgun.net (EU). The region is fixed per domain.
Create a Sending API key from API Security, scoped to that domain, and store it as MAILGUN_API_KEY. Copy the separate Webhook Signing Key from the same page; it is not your API key.
2. Install
bash
pnpm add mailgun.js
bash
npm install mailgun.js
bash
yarn add mailgun.js
bash
bun add mailgun.js
On Node 24 (and Bun, Deno, Workers) the global FormData is enough, so you do not need the form-data package.
3. Environment variables
.envdotenv
# Older accounts may still have keys starting with "key-"; current keys do not, so don't validate a prefix.MAILGUN_API_KEY=your-domain-sending-keyMAILGUN_WEBHOOK_SIGNING_KEY=your-webhook-signing-keyMAILGUN_DOMAIN=mg.acme.example.comEMAIL_FROM="Acme <no-reply@mg.acme.example.com>"# Optional: EU region# MAILGUN_URL=https://api.eu.mailgun.net
Add MAILGUN_API_KEY, MAILGUN_WEBHOOK_SIGNING_KEY, and MAILGUN_DOMAIN (each z.string().min(1)) plus MAILGUN_URL: z.url().optional() to the overview's defineConfig() schema.
4. Sender
src/email/mailgun.tsts
import Mailgun from "mailgun.js";import type { EmailSender } from "../plugins/email.ts";import { config } from "../config.ts";const mailgun = new Mailgun(FormData); // the global FormDataexport const mg = mailgun.client({ username: "api", key: config.MAILGUN_API_KEY, url: config.MAILGUN_URL, // omit for the default US endpoint timeout: 10_000,});export const sender: EmailSender = { async send(msg) { const extraHeaders = Object.fromEntries( Object.entries(msg.headers ?? {}).map(([name, value]) => [`h:${name}`, value]), ); const res = await mg.messages.create(config.MAILGUN_DOMAIN, { from: config.EMAIL_FROM, to: [msg.to], subject: msg.subject, ...(msg.html !== undefined ? { html: msg.html, text: msg.text } : { text: msg.text ?? "" }), ...(msg.replyTo ? { "h:Reply-To": msg.replyTo } : {}), ...extraHeaders, }); return { id: res.id ?? "" }; },};
Mailgun's send API has no idempotency key, so queue sends through the overview's email.send job and do not auto-retry a timed-out send.
5. Use it in a route
Invites legitimately go to an address the user types, which makes them a favourite spam vector. So the inviter must be signed in and allowed to invite into that team, the content is built server-side from stored names, and two limiters apply: one per inviting user and one per invitee address:
src/routes/invites.tsts
import { z } from "zod";import { App, ForbiddenError, UnauthorizedError, every, jobIdempotencyKey, rateLimit } from "@daloyjs/core";import { escapeHtml } from "../plugins/email.ts";import { sendGuards } from "../email/guards.ts";export function inviteRoutes(app: App) { app.post( "/teams/:teamId/invites", { operationId: "inviteToTeam", hooks: every( sendGuards, // auth + per-IP + per-user limits, see the overview rateLimit({ windowMs: 24 * 60 * 60_000, max: 3, // per invitee address per day keyGenerator: (ctx) => `invite-to:${String((ctx as { body?: { email?: string } }).body?.email ?? "").toLowerCase()}`, }), ), request: { params: z.object({ teamId: z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/) }), body: z.object({ email: z.email() }).strict(), }, responses: { 202: { description: "Queued", body: z.object({ jobId: z.string() }) }, }, }, async ({ params, body, state }) => { const sub = state.user?.sub; if (!sub) throw new UnauthorizedError(); const team = await findTeamWhereUserCanInvite(params.teamId, sub); if (!team) throw new ForbiddenError(); const invite = await createInvite(team.id, body.email, sub); // stores a single-use token const { job } = await app.jobs!.enqueue({ name: "email.send", payload: { to: body.email, // Names were validated with headerSafe when they were stored. subject: `${invite.inviterName} invited you to ${team.name} on Acme`, text: `${invite.inviterName} invited you to ${team.name}. Join: ${invite.url}`, html: `<p>${escapeHtml(invite.inviterName)} invited you to <strong>${escapeHtml(team.name)}</strong>.</p><p><a href="${escapeHtml(invite.url)}">Accept the invite</a></p>`, }, idempotencyKey: jobIdempotencyKey({ name: "email.send", key: `invite-${invite.id}` }), }); return { status: 202, body: { jobId: job.id } }; }, );}declare function findTeamWhereUserCanInvite(teamId: string, userId: string): Promise<{ id: string; name: string } | null>;declare function createInvite( teamId: string, email: string, inviterId: string,): Promise<{ id: string; url: string; inviterName: string }>;
Templates
Create a stored template in Sending -> Templates with Handlebars syntax (double braces escape HTML), then reference it by name and pass variables as a JSON string:
ts
await mg.messages.create(config.MAILGUN_DOMAIN, { from: config.EMAIL_FROM, to: [user.email], // looked up server-side subject: "Welcome", template: "welcome", "h:X-Mailgun-Variables": JSON.stringify({ name: user.name }),});
Webhooks: bounces and complaints
Mailgun signs every event webhook. The JSON body has a signature object (timestamp, token, signature) next to event-data. The signature is the hex HMAC-SHA256 of timestamp and token joined with no separator, keyed with your Webhook Signing Key. Core's verifyWebhookSignature() handles the constant-time compare. Pass the concatenated string as the payload; do not use its timestamp option, which signs timestamp.payload with a dot.
The signature does not cover event-data, so a captured token could be replayed with a different body. Reject stale timestamps and never accept a token twice. Below the token is the job's idempotency key, so a replayed token with a different payload throws JobIdempotencyConflictError and is rejected:
Register a no-op (or audit) email.mailgun_event handler next to the overview's handlers, so every verified token lands in the job store and the replay check covers non-bounce events too. Job idempotency only lasts as long as your store keeps completed jobs, so keep their retention above the timestamp window.
Suppressions
Mailgun keeps per-domain suppression lists for bounces, complaints, and unsubscribes and stops delivering to those addresses (later sends fail with a failed event). Read and manage them with the SDK, for example mg.suppressions.list(domain, "bounces"), mg.suppressions.get(domain, "complaints", address), and mg.suppressions.destroy(domain, "bounces", address) once a user fixes their address. Mirror hard failures into your own undeliverable flag through the webhook above.
Runtimes
Node / Bun / AWS Lambda: works with the configuration above.
Cloudflare Workers: pass useFetch: true (mailgun.js 12.1 and later) so the SDK uses the platform fetch instead of axios's Node transport. Proxy settings and the form-data package are not supported in this mode, which is fine because the global FormData is what you pass anyway:
src/email/mailgun-workers.tsts
import Mailgun from "mailgun.js";import { env } from "cloudflare:workers";const mg = new Mailgun(FormData).client({ username: "api", key: env.MAILGUN_API_KEY, url: env.MAILGUN_URL, useFetch: true,});