Transactional Email skill for Claude Code
Receipts and resets that reach the inbox. Claude sets up app email like a deliverability engineer: a dedicated sending subdomain with aligned SPF, DKIM and DMARC (checked by a bundled dig script), React Email or MJML templates with plain-text parts, one queued and idempotent send function, and signature-verified bounce and complaint webhooks that feed a suppression list before every send.
- name
transactional-email - size226 lines · ≈2,650 tokens
- files3
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Sends transactional email (receipts, password resets, magic links, alerts) through Resend, Postmark, or Amazon SES with React Email or MJML templates, SPF/DKIM/DMARC alignment, bounce and complaint webhooks, suppression handling, and retry-safe sending. Use when adding email sending to an app, choosing an email API, building email templates, setting up DNS for a sending domain, or debugging emails landing in spam or never arriving. Try asking
- Set up Resend for password reset and receipt emails with React Email templates
- Our verification emails go to spam in Gmail — check our DNS and fix it
- Handle SES bounces and complaints so we stop emailing dead addresses
Use it when
- Adding email sending to an app or switching email APIs
- Configuring SPF, DKIM, and DMARC for a new sending domain
- Emails land in spam, arrive twice, or silently never arrive
- Building or fixing email templates for major clients
Skip it when
- You are planning onboarding or marketing sequences and their copy — use email-campaigns instead
- You need general inbound webhook infrastructure — use webhook-handling instead
How to check the result
The DNS script exits 0, Gmail's Show original reports SPF, DKIM and DMARC pass for your domain, and a send to the provider's bounce test address ends up on the suppression list.
The common mistake it prevents
Adding a second SPF record for the new provider instead of merging includes, which makes SPF permerror for every message.
Done well means: mail is sent from a dedicated subdomain that passes SPF and DKIM with DMARC alignment, every send is queued and idempotent, bounces and complaints flow back through verified webhooks into a suppression list, and every template has a plain-text part and renders in the major clients.
Ground rules
- API keys in env vars (
RESEND_API_KEY,POSTMARK_SERVER_TOKEN, AWS credentials via IAM role). Scope keys to sending only; one key per environment. - Send transactional mail from a subdomain (
mail.example.comornotify.example.com), separate from marketing streams, so a campaign complaint spike does not hurt password resets. - Never send from the request path synchronously when the provider may be slow: enqueue and return. Exception: magic links where the user waits, and even then set a short timeout.
- Never put secrets, full tokens in logs, or PII beyond need into provider metadata/tags.
- Do not use transactional infrastructure for marketing. Mailbox providers and ToS treat them differently (unsubscribe rules, consent).
Choose the provider
| Situation | Default | Notes |
|---|---|---|
| TypeScript app, React templates, fast setup | Resend | First-class React Email; Svix-signed webhooks |
| Deliverability-first transactional, strict separation of streams | Postmark | Message Streams (outbound vs. broadcast); fast delivery |
| High volume, AWS-native, lowest unit cost | Amazon SES (v2 API) | You build more: event destinations, suppression, warm-up |
| Already on a platform with built-in mail (Supabase Auth, Clerk) | Use it for auth mail only, custom SMTP for branding | Default shared senders have low limits |
Workflow
- [ ] 1. Inventory: existing sends, templates, provider, sending domain
- [ ] 2. Set up the sending subdomain DNS (SPF, DKIM, DMARC, optional return-path)
- [ ] 3. Build templates with a plain-text part; preview in clients
- [ ] 4. Implement a single send function: queue, idempotency, error handling
- [ ] 5. Wire bounce/complaint/delivery webhooks with signature verification
- [ ] 6. Maintain suppression; respect it before every send
- [ ] 7. Verify DNS, headers, rendering, and webhook paths
1. Inventory
grep -rnE "nodemailer|resend|postmark|@aws-sdk/client-sesv2|sendgrid|mailgun" package.json requirements*.txt pyproject.toml 2>/dev/null
grep -rn "sendMail\|emails.send\|sendEmail\|SendEmailCommand" --exclude-dir=node_modules . | head -30
2. DNS for the sending domain
Minimum for any provider, enforced by Gmail, Yahoo (since February 2024) and Outlook (since May 2025) for bulk senders, and good practice at any volume:
| Record | Requirement |
|---|---|
| SPF | Exactly one v=spf1 TXT per hostname, ≤ 10 DNS lookups, ends in ~all or -all |
| DKIM | Provider-issued CNAME/TXT keys at <selector>._domainkey, 2048-bit where offered |
| DMARC | _dmarc.<org domain> TXT, start p=none; rua=mailto:..., move to quarantine/reject once reports are clean |
| Alignment | DKIM d= or SPF return-path domain shares the org domain with the visible From: |
| PTR / TLS | Handled by the provider unless self-hosting SMTP |
Check what is published:
sh scripts/check_email_dns.sh example.com mail.example.com resend # domain, sending host, DKIM selector(s)
Record syntax, alignment examples, DMARC rollout steps, and per-provider DNS specifics: read references/dns-authentication.md when setting up a new domain or diagnosing spam placement.
3. Templates
Default: React Email (@react-email/components) for TS projects; MJML for other stacks.
// emails/password-reset.tsx
import { Html, Head, Preview, Body, Container, Text, Button } from "@react-email/components";
export default function PasswordReset({ url, minutes = 30 }: { url: string; minutes?: number }) {
return (
<Html lang="en">
<Head />
<Preview>Reset your password (link expires in {minutes} minutes)</Preview>
<Body style={{ fontFamily: "Arial, sans-serif", backgroundColor: "#ffffff" }}>
<Container style={{ maxWidth: 560, padding: 24 }}>
<Text>Someone requested a password reset for your account.</Text>
<Button href={url} style={{ background: "#111", color: "#fff", padding: "12px 20px" }}>Reset password</Button>
<Text>If you did not request this, ignore this email. Your password will not change.</Text>
</Container>
</Body>
</Html>
);
}
import { render } from "@react-email/render"; // async in current versions
const html = await render(<PasswordReset url={url} />);
const text = await render(<PasswordReset url={url} />, { plainText: true });
Template rules:
- Max width 600 px, inline styles (the components handle this), table-based layout via the components; no flexbox/grid reliance, no JS, no web fonts without fallbacks.
- Always include a text part and a meaningful
<Preview>. - Absolute HTTPS URLs for images and links; images have
alt; content readable with images off. Dark mode: avoid transparent PNG logos on dark backgrounds. - Keep HTML under ~100 KB or Gmail clips it.
- Preview locally with the React Email dev server (
email devscript from thereact-emailpackage) and test in Gmail, Outlook (desktop renders with Word), Apple Mail.
4. One send function
// lib/email.ts
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendTransactional(msg: {
to: string; subject: string; html: string; text: string; tag: string; dedupeKey: string;
}) {
if (await isSuppressed(msg.to)) return { skipped: "suppressed" as const };
const { data, error } = await resend.emails.send(
{
from: "Acme <[email protected]>",
replyTo: "[email protected]",
to: msg.to, subject: msg.subject, html: msg.html, text: msg.text,
tags: [{ name: "category", value: msg.tag }],
},
{ idempotencyKey: msg.dedupeKey }, // e.g. `password-reset/${resetTokenId}`
);
if (error) throw new EmailError(error.name, error.message); // let the job retry
await recordSend(msg.dedupeKey, data!.id);
return { id: data!.id };
}
Provider equivalents:
- Postmark (
postmarkpackage):new ServerClient(token).sendEmail({ From, To, Subject, HtmlBody, TextBody, MessageStream: "outbound", Tag }). - SES:
@aws-sdk/client-sesv2SendEmailCommandwithContent.Simple(orRaw) and aConfigurationSetNamethat routes events.
Call it from a background job with retries and exponential backoff on 429/5xx only. 4xx
validation errors (bad address, unverified domain) will never succeed: log and stop.
If the provider has no idempotency support, dedupe with your own sent_emails table keyed
on dedupeKey before calling the API.
5. Event webhooks
| Provider | Signature | Events to handle |
|---|---|---|
| Resend | Svix headers svix-id, svix-timestamp, svix-signature | email.bounced, email.complained, email.delivered, email.delivery_delayed |
| Postmark | No HMAC: protect with HTTP basic auth in the webhook URL plus a long random path; optionally IP allowlist | Bounce (check Type), SpamComplaint, Delivery |
| SES | SNS message signature (verify cert URL host is sns.<region>.amazonaws.com, check TopicArn), confirm subscription | Bounce (Permanent vs. Transient), Complaint, Delivery |
// Resend webhook, verified with the svix package
import { Webhook } from "svix";
export async function POST(req: Request) {
const payload = await req.text();
const headers = Object.fromEntries(["svix-id", "svix-timestamp", "svix-signature"].map(h => [h, req.headers.get(h) ?? ""]));
let evt: any;
try { evt = new Webhook(process.env.RESEND_WEBHOOK_SECRET!).verify(payload, headers); }
catch { return new Response("bad signature", { status: 400 }); }
if (evt.type === "email.bounced" || evt.type === "email.complained") await suppress(evt.data.to, evt.type);
return new Response("ok");
}
6. Suppression policy
- Hard bounce (permanent, invalid mailbox): suppress immediately and permanently.
- Soft bounce (mailbox full, transient): retry per provider; suppress after ~3–5 consecutive.
- Complaint: suppress from everything non-essential at once. Security mail (password reset the user just requested) may still be sent; nothing else.
- Keep spam complaint rate under 0.1% (Gmail’s hard ceiling is 0.3%); watch Google Postmaster Tools for the domain.
- Expose suppression in admin tooling so support can lift it after the user fixes the address.
Gotchas
- Second SPF record added for the new provider instead of merging into the existing one:
both fail (
permerror). - DMARC
p=rejectturned on before checking reports: legit mail from a forgotten tool (CRM, helpdesk) starts bouncing. From:on the apex domain while DKIM signs only the subdomain with relaxed alignment off: DMARC fails.- Sending password resets in the same stream as newsletters.
- No-reply addresses that bounce: users reply with real issues; set
Reply-Toto a monitored inbox. - Link tracking rewrites magic links and password reset URLs; disable click tracking for auth mail (corporate scanners also pre-click links, so make tokens survive one GET or use a confirm button page).
- SES sandbox: only verified recipients until production access is granted.
- Retrying a timed-out send without an idempotency key: duplicate receipts.
Verification
sh scripts/check_email_dns.sh <domain> <sending-host> <selector>exits 0.- Send a real message to a Gmail account; “Show original” reports SPF, DKIM, DMARC all PASS with the expected domains.
- Bad signature to the webhook returns 400; a provider “test event” lands in the suppression
path. Send to the provider’s bounce test address (Resend
[email protected], SES[email protected]) and confirm the address is suppressed. - The same job retried twice produces one email.
- Templates checked in Gmail web, Outlook desktop, and iOS Mail, with images off.
Output contract
**Provider:** Resend (stream: transactional, domain mail.acme.com)
**DNS:** SPF pass, DKIM pass (selector resend), DMARC p=none with rua → plan quarantine after 2 weeks of clean reports
**Templates:** password-reset, receipt, invite (HTML + text, previewed in 3 clients)
**Sending:** lib/email.ts via job queue, idempotency key per business event
**Webhooks:** /api/webhooks/email (svix-verified) → suppression table
**Open items:** enable Google Postmaster Tools, raise DMARC policy Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/dns-authentication.md
# Email DNS authentication
Read this when configuring a new sending domain or when mail fails SPF/DKIM/DMARC.
## Records by example
Sending subdomain `mail.acme.com`, visible From `[email protected]`.
```dns
; SPF on the envelope (return-path) host. Provider tells you the include.
mail.acme.com. TXT "v=spf1 include:amazonses.com ~all"
; DKIM: provider gives selector names and values; often CNAMEs so they can rotate keys
resend._domainkey.mail.acme.com. TXT "p=MIGfMA0GCSq..."
abc123._domainkey.mail.acme.com. CNAME abc123.dkim.amazonses.com.
; DMARC lives on the organizational domain (covers subdomains unless sp= says otherwise)
_dmarc.acme.com. TXT "v=DMARC1; p=none; rua=mailto:[email protected]; adkim=r; aspf=r"
```
Provider-specific values (exact hostnames, return-path CNAMEs such as Postmark's
`pm-bounces` or a custom MAIL FROM for SES) come from the provider dashboard; copy them
exactly rather than composing from memory.
## SPF rules
- One `v=spf1` record per hostname. Merge includes into a single record.
- At most 10 DNS-querying mechanisms (`include`, `a`, `mx`, `ptr`, `exists`, `redirect`),
counted recursively. Over 10 → `permerror` → SPF fails everywhere.
- SPF checks the envelope sender (Return-Path), not the visible From. Most API providers use
their own return-path unless you configure a custom one; then SPF alignment comes from
that custom host.
- `~all` (softfail) is fine with DMARC enforcement; `-all` is stricter. Never `+all`.
## DKIM rules
- 2048-bit keys where the provider allows. Rotate by publishing a new selector, switching,
then removing the old one after a few days.
- Signing domain `d=` must align with the From domain for DMARC (relaxed: same org domain).
- Long TXT values may need splitting into 255-char strings; most DNS UIs handle it.
## DMARC rollout
1. `p=none; rua=mailto:...` and collect aggregate reports for 2–4 weeks (use a report
processor; raw XML is unreadable at volume).
2. Identify every legit sender in reports (CRM, helpdesk, billing, marketing tool). Fix each
to pass aligned DKIM.
3. `p=quarantine; pct=25` → `pct=100` → `p=reject`. Pause at each step for a week of
clean reports.
4. Add `sp=` only if subdomains need a different policy.
Bulk-sender requirements (Gmail/Yahoo 2024, Outlook 2025, roughly >5,000 messages/day to
that provider): SPF and DKIM pass, DMARC record at least `p=none`, alignment, valid
forward/reverse DNS, TLS, spam rate < 0.3%, and for marketing/subscribed mail one-click
unsubscribe (`List-Unsubscribe` + `List-Unsubscribe-Post: List-Unsubscribe=One-Click`)
honored within 2 days. Transactional mail is exempt from the unsubscribe header but not from
authentication.
## Diagnosing a failure
| Symptom in "Show original" / headers | Cause |
|---|---|
| `spf=permerror` | Two SPF records, or > 10 lookups |
| `spf=pass` but `dmarc=fail` | SPF passed for provider's return-path domain, not yours; rely on aligned DKIM |
| `dkim=fail (body hash did not verify)` | Something modified the message after signing (footer injection, forwarding gateway) |
| `dkim=pass header.d=resend.dev` style third-party domain | Custom domain DKIM not verified; mail signed with provider domain → DMARC unaligned |
| `dmarc=fail p=REJECT` from your own tool | A sender missing from step 2 of rollout |
Useful lookups:
```bash
dig +short TXT mail.acme.com
dig +short TXT _dmarc.acme.com
dig +short TXT resend._domainkey.mail.acme.com
dig +short CNAME abc123._domainkey.mail.acme.com
```
Remember DNS TTLs: changes can take up to the old record's TTL to be seen.
## BIMI (optional)
Requires DMARC at `quarantine` or `reject` (pct=100) and, for Gmail's logo display, a VMC or
CMC certificate. Treat it as a branding extra, not a deliverability fix.
scripts/check_email_dns.sh
#!/bin/sh
# Check SPF, DKIM, and DMARC records for an email sending domain.
# Usage: sh check_email_dns.sh <org-domain> [sending-host] [dkim-selector ...]
# org-domain domain whose _dmarc record applies (e.g. acme.com)
# sending-host host used as From/return-path domain (default: org-domain)
# dkim-selector one or more selectors to look up at <selector>._domainkey.<sending-host>
# Requires: dig. Exits 1 if any check fails, 0 otherwise (warnings do not fail).
set -u
if [ $# -lt 1 ]; then
echo "usage: sh $0 <org-domain> [sending-host] [dkim-selector ...]" >&2
exit 2
fi
if ! command -v dig >/dev/null 2>&1; then
echo "error: dig not found (install bind-utils / dnsutils)" >&2
exit 2
fi
ORG=$1
HOST=${2:-$1}
[ $# -ge 2 ] && shift 2 || shift 1
FAIL=0
pass() { echo "PASS $*"; }
warn() { echo "WARN $*"; }
fail() { echo "FAIL $*"; FAIL=1; }
txt() {
# Print TXT records one per line, joining split strings.
dig +short TXT "$1" 2>/dev/null | sed 's/" "//g' | tr -d '"'
}
# ---- SPF -------------------------------------------------------------------
SPF=$(txt "$HOST" | grep -i '^v=spf1' || true)
SPF_COUNT=$(printf '%s\n' "$SPF" | grep -ci '^v=spf1' || true)
if [ "$SPF_COUNT" -eq 0 ]; then
fail "SPF: no v=spf1 TXT record at $HOST"
elif [ "$SPF_COUNT" -gt 1 ]; then
fail "SPF: $SPF_COUNT v=spf1 records at $HOST (must be exactly one; merge them)"
else
pass "SPF: $SPF"
case "$SPF" in
*"+all"*) fail "SPF: '+all' authorizes the whole internet" ;;
*"-all"*|*"~all"*) : ;;
*redirect=*) : ;;
*) warn "SPF: record does not end in ~all or -all" ;;
esac
# Count DNS-querying mechanisms, following include/redirect (limit is 10).
QUEUE=$HOST
LOOKUPS=0
SEEN=0
while [ -n "$QUEUE" ] && [ "$SEEN" -lt 40 ]; do
CUR=${QUEUE%% *}
if [ "$CUR" = "$QUEUE" ]; then QUEUE=""; else QUEUE=${QUEUE#* }; fi
SEEN=$((SEEN + 1))
REC=$(txt "$CUR" | grep -i '^v=spf1' | head -n 1)
for TERM in $REC; do
T=$(printf '%s' "$TERM" | sed 's/^[-~?+]//' | tr 'A-Z' 'a-z')
case "$T" in
include:*) LOOKUPS=$((LOOKUPS + 1)); QUEUE="$QUEUE ${T#include:}" ;;
redirect=*) LOOKUPS=$((LOOKUPS + 1)); QUEUE="$QUEUE ${T#redirect=}" ;;
a|a:*|a/*|mx|mx:*|mx/*|ptr|ptr:*|exists:*) LOOKUPS=$((LOOKUPS + 1)) ;;
esac
done
QUEUE=$(printf '%s' "$QUEUE" | sed 's/^ *//')
done
if [ "$LOOKUPS" -gt 10 ]; then
fail "SPF: $LOOKUPS DNS lookups (max 10) -> permerror"
elif [ "$LOOKUPS" -ge 8 ]; then
warn "SPF: $LOOKUPS DNS lookups (close to the limit of 10)"
else
pass "SPF: $LOOKUPS DNS lookups"
fi
fi
# ---- DKIM ------------------------------------------------------------------
if [ $# -eq 0 ]; then
warn "DKIM: no selector given; pass the selector(s) from your provider dashboard"
fi
for SEL in "$@"; do
NAME="$SEL._domainkey.$HOST"
CNAME=$(dig +short CNAME "$NAME" 2>/dev/null | head -n 1)
REC=$(txt "$NAME" | head -n 1)
if [ -n "$REC" ] && printf '%s' "$REC" | grep -q 'p='; then
if printf '%s' "$REC" | grep -q 'p=;'; then
fail "DKIM: $NAME has an empty p= (key revoked)"
else
pass "DKIM: $NAME${CNAME:+ (via CNAME $CNAME)}"
fi
elif [ -n "$CNAME" ]; then
warn "DKIM: $NAME is a CNAME to $CNAME but no key resolved yet"
else
fail "DKIM: no key at $NAME"
fi
done
# ---- DMARC -----------------------------------------------------------------
DMARC=$(txt "_dmarc.$HOST" | grep -i '^v=DMARC1' | head -n 1)
WHERE="_dmarc.$HOST"
if [ -z "$DMARC" ] && [ "$HOST" != "$ORG" ]; then
DMARC=$(txt "_dmarc.$ORG" | grep -i '^v=DMARC1' | head -n 1)
WHERE="_dmarc.$ORG"
fi
if [ -z "$DMARC" ]; then
fail "DMARC: no v=DMARC1 record at _dmarc.$HOST or _dmarc.$ORG"
else
pass "DMARC ($WHERE): $DMARC"
POLICY=$(printf '%s' "$DMARC" | tr -d ' ' | tr ';' '\n' | grep -i '^p=' | head -n 1 | cut -d= -f2)
case "$POLICY" in
reject|quarantine) pass "DMARC: enforcing policy p=$POLICY" ;;
none) warn "DMARC: p=none (monitoring only); plan quarantine/reject after reviewing reports" ;;
*) fail "DMARC: missing or invalid p= tag" ;;
esac
printf '%s' "$DMARC" | grep -qi 'rua=' || warn "DMARC: no rua= address, so you receive no aggregate reports"
fi
# ---- MX (for bounces/replies on the sending host) --------------------------
MX=$(dig +short MX "$HOST" 2>/dev/null | head -n 1)
[ -n "$MX" ] && pass "MX: $HOST -> $MX" || warn "MX: none at $HOST (fine if provider handles return-path elsewhere)"
echo
if [ "$FAIL" -ne 0 ]; then
echo "RESULT: FAIL"
exit 1
fi
echo "RESULT: OK"
exit 0
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.