Claude Code for Transactional Email: CLAUDE.md Rules
TL;DR
- Claude Code writes email code that compiles and sends, but by default it skips the parts that keep mail out of spam and out of the wrong inbox: domain authentication, a plain-text part, error handling, signed webhooks, duplicate protection and sandbox routing.
- One CLAUDE.md fixes that for any provider. Put the shared rules (send contract, idempotency, webhook handling, test routing) in one skeleton, then add a short block for the provider you use.
- The providers differ most in webhook security: Resend signs with Svix headers, SendGrid with ECDSA, Mailgun and Mailtrap with HMAC-SHA256, and Postmark does not sign webhooks at all, so you protect that endpoint with Basic Auth and an IP allowlist.
- They also differ in how you test: Resend has magic test addresses, Postmark has sandbox servers, SendGrid has a sandbox_mode flag, Mailgun has a sandbox domain limited to authorized recipients, and Mailtrap is a full test inbox product.
- Every provider fact in this guide was checked against that provider's documentation or pricing page on 21 October 2026.
Claude Code can build a production-grade transactional email integration in an afternoon, as long as your CLAUDE.md tells it which rules are non-negotiable. This guide gives you that file. It starts with the skeleton every email integration shares, compares Resend, Postmark, SendGrid, Mailgun and Mailtrap side by side, and then lists only the rules that are specific to each provider, so you copy one shared block plus one provider block instead of five overlapping templates.
If you are new to project configuration, our guide to CLAUDE.md explains how Claude Code loads the file at the start of every session, and the Claude Code setup guide covers the rest of the configuration stack.
Why Claude Code needs email rules in CLAUDE.md
Email is a domain where "it works" and "it is correct" are far apart. A send that returns 200 can still land in spam, reach a customer from a staging server, or fire twice because a webhook retried. Claude Code cannot see any of that from the type definitions, so it produces the shortest code that satisfies the SDK.
Across all five providers, the same six failure patterns show up when there is no CLAUDE.md:
- Unauthenticated sending domains. Claude copies the quickstart sender (a shared or sandbox address) into production code. Mail from a domain without SPF and DKIM set up is far more likely to be filtered.
- HTML-only messages. The SDKs do not force a plain-text part, so Claude rarely writes one. Some providers derive it for you; others send HTML only.
- Swallowed errors. Claude awaits the send and moves on. If the provider rejected the message, nothing logs it and the user never gets their password reset.
- Unverified webhooks. Claude writes a handler that parses the body and updates your database. Without verification, anyone who finds the URL can post fake bounce or delivery events.
- No duplicate protection. A retried request, a double-clicked button or a re-delivered webhook sends the same receipt twice.
- Test mail reaching real people. One credential set is used everywhere, so a seed script on a laptop emails real customers, or production quietly sends into a test inbox.
None of these are exotic. They are what the SDK allows by default. The fix is to make the correct pattern the only pattern Claude sees, which is exactly what a CLAUDE.md is for.
Which transactional email provider should you use with Claude Code?
All five work well with Claude Code; the right one depends on your stack and how you want to test. The table compares what changes how you write the integration. Prices are the published entry points on each provider's pricing page as of 21 October 2026 and change often, so treat them as a starting point, not a quote.
| Resend | Postmark | SendGrid | Mailgun | Mailtrap | |
|---|---|---|---|---|---|
| API style | REST, JSON body | REST, JSON body, X-Postmark-Server-Token header |
REST v3 Mail Send, JSON body with personalizations |
REST, form-encoded body, regional base URL | REST, JSON body, separate sandbox and sending hosts |
| Official Node SDK | resend |
postmark |
@sendgrid/mail |
mailgun.js |
mailtrap |
| Webhook verification | Signed via Svix (svix-id, svix-timestamp, svix-signature) |
Not signed; use HTTP Basic Auth in the URL plus an IP allowlist | Signed with ECDSA (X-Twilio-Email-Event-Webhook-Signature plus timestamp header) |
HMAC-SHA256 of timestamp plus token, account-wide signing key | HMAC-SHA256, Mailtrap-Signature header, per-webhook secret |
| Testing story | Test addresses such as delivered@resend.dev, bounced@resend.dev |
Sandbox servers: messages are processed but never delivered | mail_settings.sandbox_mode validates a request without delivering |
Sandbox domain sends only to authorized recipients (up to 5) | Email Sandbox product: captures mail in test inboxes |
| Free tier | 3,000 emails/month, 100/day | 100 emails/month | Free trial, no credit card | 100 emails/day | 4,000 emails/month, 150/day |
| Entry paid plan | Pro, $20/month for 50,000 | Basic, $15/month for 10,000 | Paid plans after the trial | Basic, from $15/month for 10,000 | Basic, $15/month for 10,000 |
Sources: Resend pricing and webhook verification docs, Postmark pricing and webhooks overview, SendGrid webhook security and sandbox mode, Mailgun pricing and webhook security, Mailtrap pricing and webhook docs. All read on 21 October 2026.
A quick way to choose:
- Resend if you are on Next.js or another TypeScript stack and want the smallest API surface plus React Email support.
- Postmark if transactional deliverability is the priority and you like a hard split between transactional and broadcast mail.
- SendGrid if you need high volume, dedicated IPs, unsubscribe groups and per-recipient personalization in one request.
- Mailgun if you want regional data residency (domains in either the US or EU region) and batch sends with recipient variables.
- Mailtrap if testing is the hard part: it gives you a real test inbox in development and a sending API in production from one SDK.
The shared CLAUDE.md skeleton for sending email
This block is provider-neutral. It goes in your project's CLAUDE.md as-is, then you append the provider block from the sections further down. Keep the provider-specific code behind one adapter file so Claude never scatters SDK calls across route handlers.
# Transactional email rules
## Architecture
- src/lib/email/provider.ts , the ONLY file that imports the provider SDK
- src/lib/email/send.ts , sendEmail(): the only function the app calls
- src/emails/ , templates (React Email, MJML or provider templates)
- src/app/api/webhooks/email/ , the delivery and bounce webhook handler
- src/db/email-events.ts , processed webhook event IDs and suppressions
## Credentials
- Provider credentials live in environment variables, NEVER in source
- Validate every required variable at startup and fail fast if one is missing
- Separate credentials per environment (development, preview, production)
## Send contract (MANDATORY on every send)
- from: a verified address on OUR authenticated domain, never a shared or sandbox sender
- to, subject: non-empty
- html AND text: always both
- idempotencyKey: derived from the business event, e.g. `order-receipt:${orderId}`
- tags or category: the email type ("welcome", "password_reset", "receipt")
- Return the provider message ID and log it with the idempotency key
## Error handling
- Inspect every send result; NEVER await a send and ignore the outcome
- Retry only on network errors, 429 and 5xx, with backoff
- NEVER retry on 4xx validation or authentication errors, surface them
## Webhooks
- Verify authenticity BEFORE parsing or acting on the payload
- Deduplicate on the provider event ID; return 2xx immediately for duplicates
- Hard bounces and spam complaints add the address to our suppression table
- Respond fast (2xx), do heavy work asynchronously
## Testing
- Non-production environments route to the provider's test mechanism, never to real inboxes
- Startup asserts the environment and the credential type match
- Seed and batch scripts require explicit confirmation (see permission hooks)
## Hard rules
- NEVER send from an unverified domain in production
- NEVER send bulk lists with a loop of single sends; use the provider's batch mechanism
- NEVER put marketing content through the transactional path
- NEVER log full message bodies or API keys
Each section of that block exists because of one of the six failure patterns above. The rest of this section explains the four pieces that need more than a one-line rule.
Authenticate the sending domain first
Every provider here asks you to verify the domain you send from by adding DNS records, typically SPF and DKIM, and recommends a DMARC policy on top. This is setup work Claude cannot do for you, because the records live at your DNS host, but it can generate the checklist and a script that confirms the records resolve. Two rules make Claude useful here: tell it which subdomain you send from (many teams use a dedicated one such as mail.yourdomain.com so transactional reputation is isolated from the root domain), and tell it that no production code may reference a sender outside that domain.
A useful CLAUDE.md addition:
## Sending domain
- Production sender domain: mail.yourdomain.com (SPF, DKIM, DMARC verified in the provider dashboard)
- Allowed from addresses: no-reply@mail.yourdomain.com, support@mail.yourdomain.com
- Any other from address in production code is a bug
One send function, one adapter
The single most effective structural rule is that only one file imports the provider SDK. Claude follows local patterns, so if the first route handler it sees calls the SDK directly, it will do the same in the next twenty. A thin wrapper gives it one obvious thing to call:
// src/lib/email/send.ts
import { provider } from './provider';
import { isSuppressed } from '@/db/email-events';
export interface SendEmailInput {
to: string;
subject: string;
html: string;
text: string;
category: 'welcome' | 'password_reset' | 'receipt' | 'alert';
idempotencyKey: string;
}
export async function sendEmail(input: SendEmailInput): Promise<{ messageId: string } | { skipped: 'suppressed' }> {
if (!input.text.trim()) throw new Error('text part is required');
if (await isSuppressed(input.to)) return { skipped: 'suppressed' };
const result = await provider.send(input);
console.info('email.sent', { category: input.category, key: input.idempotencyKey, messageId: result.messageId });
return result;
}
Swapping providers later becomes a change to provider.ts only, which is also what makes the provider blocks below short.
Idempotency: stop duplicate sends
Duplicate emails come from three places: a client retrying a request, a background job re-running after a crash, and a provider re-delivering a webhook that your code turns into another send. The fix is the same in all three cases: derive a stable key from the business event and refuse to send twice for the same key.
Resend supports this natively: its send endpoint accepts an Idempotency-Key header, and keys expire after 24 hours (Resend send-email reference). For any provider where you have not confirmed a native idempotency mechanism, keep your own table:
// inside provider-agnostic send logic
const inserted = await db.emailSends.insertIfAbsent({ key: input.idempotencyKey });
if (!inserted) return { skipped: 'duplicate' };
Tell Claude in CLAUDE.md that the key comes from the business event (receipt:${orderId}), never from a random UUID generated at send time, because a fresh UUID on every attempt defeats the purpose.
Webhooks: verify, deduplicate, suppress
Every provider retries webhooks it thinks failed, so your handler will see the same event more than once. The safe order of operations is always the same:
// src/app/api/webhooks/email/route.ts (shape only; verification is provider-specific)
export async function POST(req: Request) {
const raw = await req.text(); // keep the raw body for signature checks
const event = await verifyAndParse(req, raw); // throws if verification fails
if (await alreadyProcessed(event.id)) return new Response('ok');
if (event.type === 'bounce' || event.type === 'complaint') {
await addSuppression(event.recipient, event.type);
}
await markProcessed(event.id);
return new Response('ok');
}
Two details trip Claude up repeatedly. First, signature checks run over the raw request body, so the handler must read the body as text before any JSON parsing middleware touches it. Second, comparisons of computed signatures should use a constant-time function such as Node's crypto.timingSafeEqual, not ===. The provider sections below give the exact verification scheme for each.
Testing without emailing customers
Each provider solves "test without real delivery" differently, which is why the testing row in the comparison table matters more than it looks. The shared rule is environment routing: development and preview builds must be physically unable to deliver to a real inbox. Write that as a startup assertion, not a convention:
// src/lib/email/provider.ts
const isProd = process.env.VERCEL_ENV === 'production' || process.env.NODE_ENV === 'production';
if (!isProd && process.env.EMAIL_MODE !== 'test') {
throw new Error('Non-production builds must run with EMAIL_MODE=test');
}
What "test mode" means is then defined per provider below.
Resend rules for Claude Code
Resend is the smallest API of the five and the most natural fit for TypeScript and React projects. The rules Claude needs that are specific to Resend:
- Handle the
{ data, error }result. The Node SDK returns an object withdataanderrorrather than throwing on every failure. Claude's habit ofawait resend.emails.send(...)with no destructuring silently drops errors. Make the pattern explicit:const { data, error } = await resend.emails.send({...}); if (error) throw new Error(error.message);. - Know the reply-to naming. The REST API field is
reply_to; the Node SDK option isreplyTo(camelCase). Tell Claude which layer your code uses, because a rawfetchcall withreplyTowill not set the header. - Never ship the onboarding sender. The quickstart uses
onboarding@resend.dev. It is for trying the API, not for production; production sends come from your verified domain. - Plain text is generated if you omit it, but write your own anyway. Resend's API reference says that if
textis not provided, a plain-text version is generated from the HTML. Generated text from complex HTML is often unreadable, so the shared rule (always send both) still applies. - Batch, do not loop. The batch endpoint accepts up to 100 emails per call (Resend batch reference). Chunk larger lists into groups of 100.
- Use the native idempotency key. Pass
Idempotency-Keyon sends tied to business events; keys last 24 hours. - Test with the magic addresses. Sending to
delivered@resend.dev,bounced@resend.devorcomplained@resend.devsimulates each outcome, so you can exercise your dashboard, webhook and API handling without hurting your domain reputation. Test sends still count against your sending quota (Resend test emails). - Verify webhooks with Svix. Resend webhooks carry
svix-id,svix-timestampandsvix-signatureheaders, and the signing secret is on the webhook's details page.
## Resend
- SDK: resend (Node). One client in src/lib/email/provider.ts
- RESEND_API_KEY, RESEND_WEBHOOK_SECRET in env
- ALWAYS destructure { data, error } and throw on error
- REST field reply_to, Node SDK option replyTo
- NEVER use onboarding@resend.dev outside local experiments
- Batch: resend.batch.send() with at most 100 emails per call
- Pass the Idempotency-Key header on business-event sends
- Test mode: send to delivered@resend.dev, bounced@resend.dev, complained@resend.dev
- Webhooks: verify svix-id, svix-timestamp, svix-signature with RESEND_WEBHOOK_SECRET over the raw body
React Email pairs naturally with Resend: the SDK accepts a react property, so templates can live as components in src/emails/. If your app sends receipts from payment webhooks, our Claude Code with Stripe guide shows where the send call belongs in that flow.
Postmark rules for Claude Code
Postmark is built around protecting transactional deliverability, and most of its rules follow from that design:
- Always set the message stream. Every send can carry a
MessageStream; if you omit it, Postmark uses the default transactional stream,outbound(Postmark email API). Broadcast or marketing-style mail belongs on a separate broadcast stream so it cannot drag down the reputation of password resets and receipts. Make the stream an explicit argument so Claude has to choose. - Tokens are per server. The
X-Postmark-Server-Tokenheader carries a server-level token. Create one Postmark server per environment so a leaked development token cannot send as production. - Use templates by ID or alias. Templated sends go to the
/email/withTemplateendpoint with aTemplateIdorTemplateAliasand aTemplateModelobject for the variables (Postmark templates API). In the Node SDK that isclient.sendEmailWithTemplate(...). Tell Claude never to build HTML by string interpolation when a template exists. - Webhooks are not signed. This is the rule most often written wrong. Postmark's own documentation states that it does not currently support HMAC webhook signature verification and recommends HTTP Basic Authentication, set by putting the credentials in the webhook URL (
https://user:pass@example.com/webhook), plus restricting traffic to Postmark's published webhook IPs. Do not let Claude invent anX-Postmark-Signaturecheck; validate the Basic Auth credentials and the payload shape instead. - Test with a sandbox server. Postmark sandbox servers process messages without delivering them, and they show as delivered in the UI and webhooks. Note that sandbox sends still count toward your monthly volume (Postmark sandbox mode).
## Postmark
- SDK: postmark (Node), ServerClient in src/lib/email/provider.ts
- POSTMARK_SERVER_TOKEN per environment (one Postmark server each for dev, preview, prod)
- ALWAYS pass MessageStream explicitly: "outbound" for transactional, the broadcast stream for announcements
- Templated sends: sendEmailWithTemplate with TemplateAlias + TemplateModel
- Webhooks are NOT signed: require HTTP Basic Auth credentials from the webhook URL, allowlist Postmark IPs, validate payload shape
- Test mode: point non-production builds at a sandbox server token
- Retry only on 5xx and network errors; a 422 means the request itself is wrong
A minimal Basic Auth guard for the Postmark webhook route:
function assertPostmarkAuth(req: Request) {
const expected = 'Basic ' + Buffer.from(`${process.env.POSTMARK_WEBHOOK_USER}:${process.env.POSTMARK_WEBHOOK_PASS}`).toString('base64');
const got = req.headers.get('authorization') ?? '';
const a = Buffer.from(got);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Response('unauthorized', { status: 401 });
}
}
SendGrid rules for Claude Code
SendGrid's v3 Mail Send API is the most configurable of the five, which also gives Claude the most ways to get it subtly wrong:
- One personalization per recipient. The
personalizationsarray works like a set of envelopes: each object applies to one recipient with its owntoand template data, and a single request can carry up to 1,000 of them (SendGrid personalizations). Putting several addresses in onetoarray puts them in one envelope, so recipients see each other. For batches, generate one personalization per address. - Dynamic templates own the subject. With a
templateId(thed-prefixed ID) anddynamicTemplateData, the subject comes from the template or from the personalization, not from a top-levelsubject. Claude frequently sets both and then wonders why the subject is ignored. - Attach an unsubscribe group to customer-facing mail. The
asmobject (groupId,groupsToDisplay) ties a send to a suppression group so opt-outs are honored. Mark in CLAUDE.md which email types need it. - Verify the Event Webhook with ECDSA. SendGrid generates an ECDSA key pair, signs each event post, and sends the signature in
X-Twilio-Email-Event-Webhook-Signaturewith the timestamp inX-Twilio-Email-Event-Webhook-Timestamp; you verify with the public key from your webhook settings (SendGrid webhook security). Some guides still call this Ed25519; the current documentation says ECDSA. - Validate requests with sandbox mode. Setting
mail_settings.sandbox_mode.enabletotruevalidates the request body without delivering anything, which is ideal for CI checks of your payload shape. - Set the API key once.
sgMail.setApiKey()belongs in the provider module at load time, never inside a route handler.
## SendGrid
- SDK: @sendgrid/mail, setApiKey() once in src/lib/email/provider.ts
- SENDGRID_API_KEY, SENDGRID_WEBHOOK_PUBLIC_KEY in env
- Batches: one personalizations[] entry per recipient, at most 1,000 per request
- With templateId: no top-level subject; use dynamicTemplateData
- Customer-facing sends include asm.groupId
- Add categories and customArgs (user_id) for event correlation
- Webhooks: verify the ECDSA signature (X-Twilio-Email-Event-Webhook-Signature + -Timestamp) over the raw body
- CI: mail_settings.sandbox_mode.enable = true to validate without sending
Mailgun rules for Claude Code
Mailgun's API predates the JSON-everywhere era, and its quirks are where Claude's defaults fail:
- Use the right regional base URL. Domains created in the US region use
https://api.mailgun.net/; domains created in the EU region usehttps://api.eu.mailgun.net/(Mailgun API overview). A domain lives in one region, so make the region an explicit environment variable that the client reads, rather than letting Claude default to the US URL. - Send form data, not JSON. The messages endpoint takes form-encoded fields. The
mailgun.jsSDK handles this when you construct it with a FormData implementation; the risk is Claude writing a one-offfetchwith a JSON body. - Mind the prefixed field names. Custom headers use an
h:prefix ('h:Reply-To'), options ano:prefix ('o:tag','o:tracking'), and custom variables av:prefix. Tracking options take string values such as'yes'and'no', not booleans. - Batch with recipient variables. A single call can address many recipients, each receiving a separate message, with
recipient-variablessupplying per-recipient values referenced as%recipient.name%in the body. - Verify webhooks with the HTTP webhook signing key. Each event carries a
signatureobject withtoken,timestampandsignature; the signature is a hex-encoded HMAC-SHA256 of timestamp plus token, keyed with your Webhook Signing Key (Mailgun securing webhooks). That key is account-wide, covers every domain, and is not your API key; you find it under Settings, API Security in the control panel. - The sandbox domain only reaches authorized recipients. Every new account gets a
sandbox...mailgun.orgdomain for testing, which can only send to authorized recipients, up to five (Mailgun sandbox domain). Use it for development; production needs your own verified domain.
## Mailgun
- SDK: mailgun.js constructed with a FormData implementation; client in src/lib/email/provider.ts
- MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_REGION (us|eu), MAILGUN_WEBHOOK_SIGNING_KEY in env
- Base URL from region: us -> https://api.mailgun.net, eu -> https://api.eu.mailgun.net
- NEVER send JSON to the messages endpoint in raw fetch calls
- Headers 'h:Reply-To', options 'o:tag' / 'o:tracking' with string values ('yes' | 'no')
- Batches: recipient-variables, never a loop of single sends
- Webhooks: HMAC-SHA256(timestamp + token) with MAILGUN_WEBHOOK_SIGNING_KEY, timing-safe compare, reject stale timestamps
- Test mode: the sandbox domain with authorized recipients only
Webhook verification in a few lines:
import crypto from 'node:crypto';
export function verifyMailgun({ timestamp, token, signature }: { timestamp: string; token: string; signature: string }) {
const digest = crypto
.createHmac('sha256', process.env.MAILGUN_WEBHOOK_SIGNING_KEY!)
.update(timestamp + token)
.digest('hex');
const a = Buffer.from(digest);
const b = Buffer.from(signature);
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}
The five-minute freshness window is our recommendation for replay protection, not a Mailgun requirement; pick a window that suits your retry tolerance.
Mailtrap rules for Claude Code
Mailtrap is two products behind one SDK: an Email Sandbox that captures mail in test inboxes, and an Email API/SMTP service that delivers it. That symmetry is useful and also the main risk, because the only thing separating a test send from a real one is configuration.
- Route sandbox versus sending explicitly. The official Node SDK switches with constructor options:
sandbox: trueplus atestInboxIdsends to a test inbox; without them the samesend()call delivers for real (mailtrap-nodejs README). Derive that flag from the environment in one place and assert it at startup, so a production build can never boot in sandbox mode and a development build can never boot in sending mode. - Bulk mail uses a separate stream. The SDK takes
bulk: truefor the bulk stream. Keep transactional and bulk clients separate, mirroring the shared rule against marketing on the transactional path. - Categorize every send. A
categoryper message ("welcome", "password_reset") is what makes the analytics and filtering useful. Use a fixed list of values, not per-customer strings. - Prefer the API over SMTP. Claude tends to reach for Nodemailer pointed at Mailtrap's SMTP host. SMTP works, but the API returns message IDs you can log and correlate with webhook events, so make it the default and keep SMTP for frameworks that require it.
- Verify webhooks with HMAC-SHA256. Mailtrap signs every webhook request, sends the signature in a
Mailtrap-Signatureheader, and gives each webhook its own signing secret (Mailtrap webhooks).
## Mailtrap
- SDK: mailtrap (Node), MailtrapClient built ONCE in src/lib/email/provider.ts
- MAILTRAP_API_KEY, MAILTRAP_INBOX_ID (sandbox only), MAILTRAP_WEBHOOK_SECRET in env
- Non-production: new MailtrapClient({ token, sandbox: true, testInboxId })
- Production: new MailtrapClient({ token }); startup FAILS if sandbox flags are set in production
- Bulk: a separate client with bulk: true, never the transactional client
- Every send has category from a fixed list
- Default to the API, not SMTP, so message IDs can be logged
- Webhooks: verify the Mailtrap-Signature header (HMAC-SHA256) with the per-webhook secret
A sketch of the environment-locked client:
import { MailtrapClient } from 'mailtrap';
const isProd = process.env.VERCEL_ENV === 'production';
const sandbox = process.env.MAILTRAP_USE_SANDBOX === 'true';
if (isProd && sandbox) throw new Error('Production cannot run in Mailtrap sandbox mode');
if (!isProd && !sandbox) throw new Error('Non-production must run in Mailtrap sandbox mode');
export const mailtrap = new MailtrapClient({
token: process.env.MAILTRAP_API_KEY!,
sandbox,
testInboxId: sandbox ? Number(process.env.MAILTRAP_INBOX_ID) : undefined,
});
Common Claude Code email mistakes, and the rule that prevents each
Claude learns fastest from before-and-after pairs, so a table like this is worth pasting into CLAUDE.md under the provider block you use.
| What Claude writes by default | Why it hurts | Rule to add |
|---|---|---|
The quickstart sender (onboarding@resend.dev, a sandbox domain) in production code |
Mail from an unauthenticated or shared domain gets filtered or rejected | Production from must be on the verified sending domain |
html with no text |
Readers on text-only clients get nothing useful; some filters penalize it | Every send includes both parts |
await provider.send(...) with no result check |
Rejected sends disappear silently | Inspect the result, log the message ID, throw on error |
| A loop of single sends for a list | Rate limits stop the loop halfway | Use the batch mechanism (Resend batch, SendGrid personalizations, Mailgun recipient variables, Mailtrap bulk) |
Several addresses in one SendGrid to array |
Recipients see each other | One personalization per recipient |
| A webhook handler that trusts the body | Anyone can post fake bounces | Verify with the provider's scheme before parsing; Basic Auth plus IP allowlist on Postmark |
signature === digest |
Timing side channel | crypto.timingSafeEqual with a length check |
| A random UUID as the idempotency key | Every retry looks new, duplicates still go out | Key derived from the business event |
| The US Mailgun base URL for an EU domain | Requests go to the wrong region for the domain | Base URL comes from MAILGUN_REGION |
| One Mailtrap or Postmark credential everywhere | Test sends reach customers, or production sends go nowhere | Separate credentials per environment, asserted at startup |
Permission hooks for email scripts
An email integration collects scripts: template previews, DNS checks, seed scripts, list sends, suppression cleanups. The read-only ones are safe to automate; the ones that reach real inboxes should always stop and ask. Claude Code's permission settings make that split enforceable rather than advisory:
{
"permissions": {
"allow": [
"Bash(npx react-email dev*)",
"Bash(node scripts/preview-email.js*)",
"Bash(node scripts/check-dns.js*)"
],
"deny": [
"Bash(node scripts/send-batch.js*)",
"Bash(node scripts/email-all-users.js*)",
"Bash(node scripts/purge-suppressions.js*)"
]
}
}
Put this in .claude/settings.local.json. The deny list means Claude has to surface those commands to you instead of running them as part of an automated task. Our guide to Claude Code hooks covers how to go further and block a send script unless an environment check passes.
Frequently asked questions
Which email API works best with Claude Code?
All five covered here work well, because each has an official Node SDK and clear documentation that Claude can follow once your CLAUDE.md pins the rules. Resend has the smallest API and fits TypeScript projects; Postmark suits teams that want strict transactional deliverability; SendGrid suits high volume; Mailgun suits EU data residency; Mailtrap suits teams whose hardest problem is testing.
Can Claude Code send emails directly?
Claude Code writes and runs the code that sends email, but it does not send mail by itself. It calls your provider's API through scripts or your application. That is why permission hooks matter: deny the scripts that reach real recipients so Claude has to ask before running them.
How do I stop Claude Code from emailing real users during development?
Route every non-production environment to the provider's test mechanism (Resend test addresses, a Postmark sandbox server, SendGrid sandbox_mode, a Mailgun sandbox domain or a Mailtrap test inbox) and add a startup assertion that refuses to boot if the environment and credentials do not match. A convention in a README is not enough; the assertion is what stops the mistake.
Does Postmark sign its webhooks?
No. Postmark's documentation says it does not currently support HMAC webhook signature verification. Protect the endpoint with HTTP Basic Auth credentials in the webhook URL, restrict it to Postmark's webhook IP addresses, and validate the payload shape before acting on it.
What happened to the separate Resend, Postmark, SendGrid, Mailgun and Mailtrap guides?
They are merged into this page. The five guides repeated the same template for most of their length, so one shared skeleton plus a short provider block is easier to maintain and easier to copy. Every provider fact on this page was checked against current documentation on 21 October 2026, including two that guides often get wrong: SendGrid signs webhooks with ECDSA, and Postmark does not sign webhooks at all.
Build email integrations that stay correct
The pattern in this guide is the same one that makes Claude Code reliable anywhere: decide the non-negotiable rules once, write them where Claude reads them every session, and keep the provider-specific code behind one file. For email, that means an authenticated domain, both message parts, checked results, idempotent sends, verified webhooks and test routing that cannot leak into production.
If you want that discipline across your whole project rather than one integration at a time, Claudify installs a complete Claude Code configuration layer: skills, persistent memory, hooks and specialist agents, ready to adapt to your stack.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify