Claude Code with Mailgun: Transactional Email at Scale
Why Mailgun without CLAUDE.md generates code that calls the wrong region
Mailgun has been the workhorse transactional email platform for high-volume senders since long before Resend or Postmark existed. The deliverability infrastructure is mature, the pricing scales sanely from 100 emails a day to 10 million emails a day, and the suppression list automation handles bounces and complaints without your application needing to track them itself.
The problem when Claude Code generates Mailgun code is that the API has historical quirks that are not obvious from the SDK type definitions. The most common Claude defaults: hitting the US API endpoint (api.mailgun.net) when your domain is provisioned in the EU region (api.eu.mailgun.net, which returns 404 for US domain credentials and vice versa), sending the request body as JSON when the messages endpoint expects multipart/form-data or application/x-www-form-urlencoded, omitting the o:tracking option (so opens and clicks are not tracked), forgetting that batched sends use recipient variables in a specific syntax, and skipping webhook signature verification because the documentation puts it on a separate page.
This guide covers the CLAUDE.md configuration that locks Claude Code into Mailgun's actual operating model: regional endpoint selection, form-encoded payloads, recipient variables for batched personalisation, signature-verified webhooks, and suppression list awareness. For the broader email integration pattern, Claude Code with Resend covers the alternative for smaller-scale senders, and Claude Code with SendGrid shows a third option if you are comparing platforms before committing.
The Mailgun CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Mailgun integration it needs to declare: the SDK version, the regional endpoint, the API key environment variable, the domain configuration, the form-encoded request shape, the webhook signature verification pattern, and the hard rules that block the mistakes Claude makes most often.
# Mailgun email rules
## Stack
- mailgun.js ^10.x (official Node.js SDK)
- form-data ^4.x (peer dependency for the SDK)
- Node.js 20+, TypeScript 5.x strict
- MAILGUN_API_KEY in .env.local (NEVER hardcode)
- MAILGUN_DOMAIN in .env.local (e.g. mg.claudify.tech)
- MAILGUN_REGION in .env.local (us OR eu, MATCHES dashboard region)
- MAILGUN_WEBHOOK_SIGNING_KEY in .env.local (separate from API key)
## Regional endpoints (CRITICAL)
- US region: https://api.mailgun.net
- EU region: https://api.eu.mailgun.net
- Domain region is set when the domain is created, NOT changeable later
- Using the WRONG region returns 401 Unauthorized (looks like a credential issue)
- ALWAYS check the dashboard to confirm region before configuring
## Project structure
- src/lib/mailgun.ts , SDK client singleton with regional URL
- src/lib/send-email.ts , shared wrapper with error handling
- src/app/api/webhooks/mg/ , webhook handler with signature verification
- src/emails/ , React Email templates
- src/db/suppressions.ts , mirror Mailgun suppression list locally
## API conventions
- Messages endpoint: POST /v3/<domain>/messages
- Content-Type: multipart/form-data (with attachments) or
application/x-www-form-urlencoded (without)
- NEVER application/json on the messages endpoint
- Field names use Mailgun's quirky shape: 'h:Reply-To' (with prefix and dash)
- Optional tracking flags: o:tracking, o:tracking-opens, o:tracking-clicks
## Send pattern (MANDATORY fields)
- from: 'Team <team@yourdomain.com>' (verified domain MUST be in dashboard)
- to: string | string[] (recipient address(es))
- subject: string (non-empty)
- text: string (plain-text body)
- html: string (HTML body)
- ALWAYS include BOTH text and html, even if one is auto-generated
Optional but commonly needed:
- 'h:Reply-To': string (header, note the h: prefix)
- 'o:tag': string | string[] (analytics segmentation, max 3 per email)
- 'o:tracking': 'yes' | 'no' (string, not boolean)
- 'o:tracking-opens': 'yes' | 'no'
- 'o:tracking-clicks': 'yes' | 'no' (or 'htmlonly')
- 'v:custom-key': string (custom variable, available in webhooks)
## Recipient variables (BATCHED PERSONALISATION)
- Up to 1000 recipients per send call
- 'recipient-variables': JSON string mapping email to per-recipient values
- Reference in body with %recipient.firstName%
- Each recipient receives ONE email, not 1000 copies
## Webhook signature verification (MANDATORY)
- Header: NONE, signature is in the request body itself
- Body contains: signature.timestamp, signature.token, signature.signature
- Compute HMAC-SHA256 of timestamp+token using MAILGUN_WEBHOOK_SIGNING_KEY
- Compare with crypto.timingSafeEqual, NEVER plain string equality
- Reject if timestamp is older than 5 minutes (prevents replay attacks)
## Hard rules
- NEVER use api.mailgun.net for an EU-region domain (and vice versa)
- NEVER send JSON to the messages endpoint, use form-urlencoded or multipart
- NEVER skip webhook signature verification
- NEVER send to addresses on the suppression list (Mailgun rejects them anyway)
- NEVER hardcode MAILGUN_API_KEY
- ALWAYS include text: alongside html: (deliverability)
- ALWAYS pass o:tracking-opens and o:tracking-clicks as strings ('yes'/'no'), not booleans
Three rules here prevent the majority of production failures Claude generates without them.
The regional endpoint rule is the single most expensive Claude mistake. Mailgun's EU and US infrastructures are entirely separate, with separate credentials. A domain provisioned in the EU has credentials that only work against api.eu.mailgun.net. A 401 error from api.mailgun.net with valid EU credentials looks identical to a 401 with invalid credentials, which sends developers (and Claude) on a credential-rotation chase. The CLAUDE.md rule forces region declaration up front and surfaces it in the singleton.
The form-encoded rule is the next most common. Most modern APIs accept JSON. The Mailgun messages endpoint accepts multipart/form-data (when attachments are present) or application/x-www-form-urlencoded (when they are not). Sending JSON returns a 400 with an unhelpful error message. The SDK abstracts this, but if Claude generates raw fetch calls (which it often does for one-off scripts), the form encoding must be explicit.
The tracking-as-string rule is a small but persistent gotcha. Mailgun's options that look like booleans are actually strings: 'yes', 'no', or 'htmlonly' for click tracking. Passing true or false causes the option to be silently ignored. Claude generates o: { tracking: true } from TypeScript instincts. The correct form is o:tracking: 'yes' at the top level.
Install and SDK setup
Install the SDK:
npm i mailgun.js form-data
Set environment variables in .env.local:
MAILGUN_API_KEY=your_api_key_here
MAILGUN_DOMAIN=mg.claudify.tech
MAILGUN_REGION=eu
MAILGUN_WEBHOOK_SIGNING_KEY=your_webhook_signing_key
Create the singleton client:
// src/lib/mailgun.ts
import Mailgun from 'mailgun.js';
import FormData from 'form-data';
if (!process.env.MAILGUN_API_KEY) {
throw new Error('MAILGUN_API_KEY is not defined');
}
if (!process.env.MAILGUN_DOMAIN) {
throw new Error('MAILGUN_DOMAIN is not defined');
}
const region = process.env.MAILGUN_REGION || 'us';
const url = region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';
const mailgun = new Mailgun(FormData);
export const mg = mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY,
url,
});
export const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN;
The startup checks (if (!process.env.MAILGUN_API_KEY)) surface missing credentials immediately rather than at the moment of the first send. Without these checks, a deployment with missing env vars looks fine until a user hits the signup flow hours later. Claude omits the checks because they are not required by the SDK. The CLAUDE.md singleton example with the checks present makes Claude reproduce them.
Add a singleton pattern to CLAUDE.md:
## SDK singleton
- src/lib/mailgun.ts exports a configured client (mg) and the domain (MAILGUN_DOMAIN)
- Every send imports: import { mg, MAILGUN_DOMAIN } from '@/lib/mailgun'
- NEVER instantiate Mailgun() inline in route handlers
- The singleton selects the regional URL from MAILGUN_REGION env var
- Validate MAILGUN_API_KEY, MAILGUN_DOMAIN at startup, not at first send
Sending an email
The basic send call with all mandatory fields:
// src/lib/send-email.ts
import { mg, MAILGUN_DOMAIN } from '@/lib/mailgun';
interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
text: string;
replyTo?: string;
tags?: string[];
trackOpens?: boolean;
trackClicks?: boolean;
customVars?: Record<string, string>;
}
export async function sendEmail(options: SendEmailOptions) {
const payload: Record<string, unknown> = {
from: 'Team Claudify <hello@claudify.tech>',
to: options.to,
subject: options.subject,
text: options.text,
html: options.html,
};
if (options.replyTo) {
payload['h:Reply-To'] = options.replyTo;
}
if (options.tags?.length) {
// Mailgun allows up to 3 tags per email
payload['o:tag'] = options.tags.slice(0, 3);
}
payload['o:tracking'] = 'yes';
payload['o:tracking-opens'] = options.trackOpens !== false ? 'yes' : 'no';
payload['o:tracking-clicks'] = options.trackClicks !== false ? 'yes' : 'no';
if (options.customVars) {
for (const [key, value] of Object.entries(options.customVars)) {
payload[`v:${key}`] = value;
}
}
try {
const result = await mg.messages.create(MAILGUN_DOMAIN, payload);
return { id: result.id, message: result.message };
} catch (error) {
console.error('[Mailgun] Send failed:', error);
throw new Error(`Email delivery failed: ${(error as Error).message}`);
}
}
Calling this from a Next.js route handler:
// src/app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendEmail } from '@/lib/send-email';
export async function POST(req: NextRequest) {
const { name, email, message } = await req.json();
await sendEmail({
to: 'support@claudify.tech',
subject: `Contact form: ${name}`,
html: `<p><strong>${name}</strong> (${email}) says:</p><p>${message}</p>`,
text: `${name} (${email}) says:\n\n${message}`,
replyTo: email,
tags: ['contact-form'],
customVars: { source: 'contact_form', user_email: email },
});
return NextResponse.json({ ok: true });
}
The v: prefix on custom variables matters. These variables are echoed back in delivery webhooks, so you can correlate the webhook event to the originating context (which user, which campaign, which application action). Without the v: prefix Mailgun treats them as unknown options and discards them silently.
Add a send pattern to CLAUDE.md:
## Send pattern
- Import: { mg, MAILGUN_DOMAIN } from '@/lib/mailgun'
- mg.messages.create(MAILGUN_DOMAIN, payload) returns { id, message }
- Headers use the 'h:' prefix: 'h:Reply-To', 'h:X-Custom-Header'
- Options use the 'o:' prefix: 'o:tag', 'o:tracking', 'o:tracking-opens'
- Custom variables use the 'v:' prefix: 'v:user_id', 'v:campaign_id'
- Tracking options are STRINGS: 'yes' | 'no' | 'htmlonly' (NOT booleans)
- Tags are limited to 3 per email, ALWAYS slice if more passed
- text AND html together for deliverability (both required)
Batched sends with recipient variables
The personalised batched send is one of Mailgun's most useful features. A single API call can send up to 1000 emails, each with per-recipient personalisation, while still appearing as individual messages to recipients (no other addresses visible in the To: line).
The pattern uses recipient-variables:
import { mg, MAILGUN_DOMAIN } from '@/lib/mailgun';
interface Recipient {
email: string;
firstName: string;
unsubscribeUrl: string;
}
export async function sendWelcomeBatch(recipients: Recipient[]) {
if (recipients.length === 0) return;
if (recipients.length > 1000) {
throw new Error('Batch size exceeds Mailgun limit of 1000');
}
const recipientVariables: Record<string, Record<string, string>> = {};
for (const r of recipients) {
recipientVariables[r.email] = {
firstName: r.firstName,
unsubscribeUrl: r.unsubscribeUrl,
};
}
const payload = {
from: 'Team Claudify <hello@claudify.tech>',
to: recipients.map((r) => r.email),
subject: 'Welcome to Claudify, %recipient.firstName%',
html: `
<p>Hi %recipient.firstName%,</p>
<p>Welcome to Claudify. Your account is ready.</p>
<p><a href="https://claudify.tech/login">Log in</a></p>
<p style="font-size: 12px; color: #999;">
<a href="%recipient.unsubscribeUrl%">Unsubscribe</a>
</p>
`,
text: `
Hi %recipient.firstName%,
Welcome to Claudify. Your account is ready.
Log in: https://claudify.tech/login
Unsubscribe: %recipient.unsubscribeUrl%
`.trim(),
'recipient-variables': JSON.stringify(recipientVariables),
'o:tracking': 'yes',
'o:tag': ['welcome', 'batch'],
};
return mg.messages.create(MAILGUN_DOMAIN, payload);
}
// For lists larger than 1000, chunk
export async function sendToLargeList(recipients: Recipient[]) {
const CHUNK_SIZE = 1000;
for (let i = 0; i < recipients.length; i += CHUNK_SIZE) {
const chunk = recipients.slice(i, i + CHUNK_SIZE);
await sendWelcomeBatch(chunk);
if (i + CHUNK_SIZE < recipients.length) {
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
}
The recipient-variables field is a JSON string (not an object). The body uses %recipient.firstName% syntax, which Mailgun substitutes per-recipient. Importantly, each recipient receives an email addressed only to them. The other 999 addresses in the batch are not visible in any header.
Add a batch section to CLAUDE.md:
## Batched sends with recipient variables
- Max 1000 recipients per call, chunk larger lists with 250ms pauses
- recipient-variables is a JSON STRING, not an object: JSON.stringify(...)
- Reference in body: %recipient.firstName%, %recipient.unsubscribeUrl%
- Each recipient sees ONLY their address in the To: header
- Use for: welcome batches, weekly digests, password reset waves
- NEVER loop mg.messages.create for personalised sends, use recipient-variables
Get Claudify. The CLAUDE.md template Mailgun customers use to ship batched sends that scale to millions.
Webhook signature verification
Mailgun fires webhooks for delivery events: delivered, failed, opened, clicked, unsubscribed, complained. The webhook body includes a signature block that you verify with HMAC-SHA256 using your webhook signing key.
The signature lives in the body, not in a header. The verification pattern:
// src/app/api/webhooks/mg/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
interface MailgunWebhookBody {
signature: {
timestamp: string;
token: string;
signature: string;
};
'event-data': {
event: string;
timestamp: number;
id: string;
recipient: string;
'user-variables'?: Record<string, string>;
[key: string]: unknown;
};
}
export async function POST(req: NextRequest) {
if (!signingKey) {
return NextResponse.json({ error: 'Signing key not configured' }, { status: 500 });
}
const body = (await req.json()) as MailgunWebhookBody;
const { timestamp, token, signature } = body.signature;
if (!timestamp || !token || !signature) {
return NextResponse.json({ error: 'Missing signature fields' }, { status: 400 });
}
// Reject events older than 5 minutes (replay protection)
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (ageSeconds > 300) {
return NextResponse.json({ error: 'Stale event' }, { status: 400 });
}
// Verify signature
const hmac = crypto.createHmac('sha256', signingKey);
const digest = hmac.update(timestamp + token).digest('hex');
const signatureBuffer = Buffer.from(signature, 'utf8');
const digestBuffer = Buffer.from(digest, 'utf8');
if (
signatureBuffer.length !== digestBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, digestBuffer)
) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Idempotency: every event has a stable id
const eventId = body['event-data'].id;
const existing = await db.processedEvents.findUnique({ where: { id: eventId } });
if (existing) {
return NextResponse.json({ ok: true });
}
await processEvent(body['event-data']);
await db.processedEvents.create({ data: { id: eventId, processedAt: new Date() } });
return NextResponse.json({ ok: true });
}
async function processEvent(event: MailgunWebhookBody['event-data']) {
switch (event.event) {
case 'failed':
await handleBounce(event.recipient);
break;
case 'complained':
await suppressAddress(event.recipient);
break;
case 'unsubscribed':
await unsubscribeAddress(event.recipient);
break;
case 'delivered':
case 'opened':
case 'clicked':
await logEvent(event);
break;
}
}
async function handleBounce(address: string) { /* mark address undeliverable */ }
async function suppressAddress(address: string) { /* add to local suppression list */ }
async function unsubscribeAddress(address: string) { /* remove from marketing lists */ }
async function logEvent(event: any) { /* persist for analytics */ }
Four points worth highlighting. First, the signature components are inside body.signature, not in HTTP headers. Second, the digest is computed over timestamp + token (concatenated as strings), not over the full body. Third, the 5-minute timestamp check prevents replay attacks. Fourth, every event has a stable event-data.id that you check for idempotency before processing.
Add a webhook section to CLAUDE.md:
## Webhook signature verification
- Signature is in body.signature (NOT in HTTP headers)
- Required fields: signature.timestamp, signature.token, signature.signature
- digest = HMAC-SHA256(MAILGUN_WEBHOOK_SIGNING_KEY, timestamp + token)
- Compare with crypto.timingSafeEqual (NEVER plain ===)
- Reject events older than 5 minutes (replay protection)
- Idempotency: persist event-data.id, skip if already processed
- Return 200 for all valid events, 400 for invalid signature
- NEVER process the event before verifying the signature
Suppression lists
Mailgun maintains three suppression lists per domain: bounces (permanent delivery failures), complaints (recipient marked spam), and unsubscribes (recipient clicked unsubscribe). Mailgun automatically refuses to send to addresses on these lists, but your application should mirror them locally for two reasons: faster rejection (no API round-trip), and visibility (you can show "this user unsubscribed" in admin tools).
Fetching the suppression lists periodically:
// src/scripts/sync-suppressions.ts
import { mg, MAILGUN_DOMAIN } from '@/lib/mailgun';
async function syncSuppressionList(type: 'bounces' | 'complaints' | 'unsubscribes') {
let nextPage: string | undefined;
let count = 0;
do {
const response = await mg.suppressions.list(MAILGUN_DOMAIN, type, {
limit: 1000,
page: nextPage,
});
for (const item of response.items) {
await db.suppressions.upsert({
where: { address_type: { address: item.address, type } },
create: {
address: item.address,
type,
createdAt: new Date(item.createdAt),
reason: 'reason' in item ? (item.reason as string) : null,
},
update: {
createdAt: new Date(item.createdAt),
},
});
count++;
}
nextPage = response.pages?.next?.url;
} while (nextPage);
console.log(`Synced ${count} ${type}`);
}
async function syncAll() {
await syncSuppressionList('bounces');
await syncSuppressionList('complaints');
await syncSuppressionList('unsubscribes');
}
syncAll().catch(console.error);
Checking suppressions before sending:
async function checkSuppression(address: string): Promise<boolean> {
const suppression = await db.suppressions.findFirst({
where: { address: address.toLowerCase() },
});
return suppression !== null;
}
// Before send:
if (await checkSuppression(recipientEmail)) {
logger.warn({ recipientEmail }, 'Skipped suppressed address');
return;
}
await sendEmail({ to: recipientEmail, /* ... */ });
Suppression list summary:
| List | Triggered by | Behaviour |
|---|---|---|
| Bounces | Permanent delivery failure | Mailgun rejects future sends, returns 4xx |
| Complaints | Recipient marked as spam | Mailgun rejects future sends, returns 4xx |
| Unsubscribes | Recipient clicked unsubscribe | Mailgun rejects future MARKETING sends |
| Whitelisted | You added them explicitly | Sends through even if on bounce list |
For the broader auth-aware mailing list pattern, Claude Code with Supabase covers the user profile structure that maps email preferences to send rules.
Common Claude Code mistakes with Mailgun
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Wrong regional endpoint
Claude generates: mailgun.client({ key, url: 'https://api.mailgun.net' }) for an EU domain.
Correct pattern: detect region from env, default to US, switch to https://api.eu.mailgun.net when MAILGUN_REGION=eu.
2. JSON body on messages endpoint
Claude generates: fetch('/v3/<domain>/messages', { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ... }) }).
Correct pattern: use the SDK, or form-encode the body manually with application/x-www-form-urlencoded.
3. Booleans on tracking options
Claude generates: { o: { tracking: true, 'tracking-opens': true } }.
Correct pattern: { 'o:tracking': 'yes', 'o:tracking-opens': 'yes' }. Options use string-typed values, not booleans.
4. Missing 'h:' prefix on reply-to
Claude generates: { replyTo: 'support@example.com' }.
Correct pattern: { 'h:Reply-To': 'support@example.com' }. Headers carry the h: prefix.
5. Plain string signature comparison
Claude generates: if (signature === digest) { ... }.
Correct pattern: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest)) with a length check first.
6. No idempotency on webhooks
Claude generates: webhook handler that processes every delivery event without checking the event ID.
Correct pattern: persist event-data.id, return 200 immediately on duplicate.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude responds better to before/after comparisons than to abstract rules.
Permission hooks for Mailgun operations
A Mailgun integration accumulates scripts: domain DNS verification, suppression list sync, batch send dispatch, list export. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/sync-suppressions.js*)",
"Bash(node scripts/check-domain-dns.js*)",
"Bash(node scripts/list-domains.js*)"
],
"deny": [
"Bash(node scripts/send-batch.js*)",
"Bash(node scripts/purge-suppressions.js*)",
"Bash(node scripts/delete-domain.js*)",
"Bash(node scripts/whitelist-bounce.js*)"
]
}
}
Reading suppressions and checking DNS records are safe. Sending a batch to the full user list, purging suppressions (which would allow re-sending to bounced addresses), and whitelisting individual bounces require explicit confirmation. The deny list forces Claude to surface those operations as prompts.
Building Mailgun integrations that deliver and stay deliverable
The Mailgun CLAUDE.md in this guide produces integrations where the regional endpoint matches the domain region, the messages API receives correctly-encoded form data, tracking options pass as strings rather than booleans, webhook signatures are verified with timing-safe comparison, suppression lists are mirrored locally for fast rejection, and dangerous scripts require explicit confirmation.
The underlying principle is the same as any email integration with Claude Code. Mailgun without a CLAUDE.md produces code that looks correct and may even compile, but fails in production: emails that disappear because the region is wrong, tracking options that are silently ignored because they were passed as booleans, webhooks that fire repeatedly because the handler returned 500 on a missing-user error, and suppressed addresses that receive duplicate emails because the suppression check was skipped. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.
For the broader email stack, Mailgun works alongside Claude Code with Resend for shops migrating between providers, and Claudify includes a Mailgun-specific CLAUDE.md template with the regional endpoint switch, form-encoded send pattern, signature verification, suppression list mirror, and all six common-mistake rules pre-configured.
Get Claudify. The CLAUDE.md template Mailgun customers use to ship high-volume email that lands in the inbox.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify