Integrations No. 422

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.

  • nametransactional-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.

~/.claude/skills/transactional-email/SKILL.md

--- name: transactional-email description: Sends transactional email (receipts, password resets, magic links, alerts) through Resend, Postmark, or Amazon SES with React Email or MJML… license: MIT ---

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.com or notify.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

SituationDefaultNotes
TypeScript app, React templates, fast setupResendFirst-class React Email; Svix-signed webhooks
Deliverability-first transactional, strict separation of streamsPostmarkMessage Streams (outbound vs. broadcast); fast delivery
High volume, AWS-native, lowest unit costAmazon 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 brandingDefault 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:

RecordRequirement
SPFExactly one v=spf1 TXT per hostname, ≤ 10 DNS lookups, ends in ~all or -all
DKIMProvider-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
AlignmentDKIM d= or SPF return-path domain shares the org domain with the visible From:
PTR / TLSHandled 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 dev script from the react-email package) 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 (postmark package): new ServerClient(token).sendEmail({ From, To, Subject, HtmlBody, TextBody, MessageStream: "outbound", Tag }).
  • SES: @aws-sdk/client-sesv2 SendEmailCommand with Content.Simple (or Raw) and a ConfigurationSetName that 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

ProviderSignatureEvents to handle
ResendSvix headers svix-id, svix-timestamp, svix-signatureemail.bounced, email.complained, email.delivered, email.delivery_delayed
PostmarkNo HMAC: protect with HTTP basic auth in the webhook URL plus a long random path; optionally IP allowlistBounce (check Type), SpamComplaint, Delivery
SESSNS message signature (verify cert URL host is sns.<region>.amazonaws.com, check TopicArn), confirm subscriptionBounce (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=reject turned 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-To to 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

  1. sh scripts/check_email_dns.sh <domain> <sending-host> <selector> exits 0.
  2. Send a real message to a Gmail account; “Show original” reports SPF, DKIM, DMARC all PASS with the expected domains.
  3. 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.
  4. The same job retried twice produces one email.
  5. 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 83 lines · 3.7 KB
# 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 129 lines · 4.4 KB
#!/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.

esc