Transactional Email skill for Codex
Sent once, after commit, into the inbox. Codex wires password resets, receipts and alerts through an outbox so each email is sent once and only after the database commit. Templates ship with a text part and escaped variables, bounce and complaint webhooks feed a suppression list, and a bundled linter checks SPF and DMARC records for lookup limits, +all and missing policies. Real sends and DNS edits wait for your confirmation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- transactional-email Installs to ~/.agents/skills/transactional-email/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install transactional-email The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project transactional-email Installs to .agents/skills/transactional-email/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/transactional-email.zipunzip -o transactional-email.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) transactional-email PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $transactional-email, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sends transactional email (password resets, receipts, verification codes, alerts) from an app through Resend, Amazon SES v2 or Postmark - outbox-driven sending after commit, idempotency, React Email or MJML templates with plain-text parts, bounce and complaint webhooks feeding a suppression list, and SPF, DKIM and DMARC alignment that meets Gmail, Yahoo and Outlook sender rules. Use when adding email to a backend, emails land in spam, SES is stuck in sandbox, users get duplicate emails, or DNS records for a sending domain need checking.
Use it when
- Adding verification, reset, receipt or alert emails to a backend
- Emails land in spam or fail Gmail and Yahoo sender requirements
- Duplicate or missing emails appear after retries or rollbacks
- Bounces and complaints are not handled or suppressed
Use something else when
- You are planning newsletters or marketing campaigns: use email-marketing
- You need HTML email layout and visual design: use email-template-design
How to check the result
Two identical business events produce one send in an integration test against Mailpit, the DNS linter reports no errors for the sending domain, and a simulated bounce adds the address to the suppression list.
The mistake it prevents
Calling the email API inside the database transaction, so rollbacks still send email and retries send it twice.
Done well means: an email is sent exactly once per business event and only after the transaction that caused it commits, it renders in major clients with a text alternative, bounces and complaints suppress future sends automatically, and the sending domain passes SPF or DKIM with DMARC alignment so it reaches the inbox.
Before changing anything
- Read
AGENTS.md, existing mail code (rg -n "nodemailer|resend|SESv2|postmark|send_mail|ActionMailer" -S), the job queue in use, and where secrets live. - Ask which provider and which sending domain are approved. Never send real email from a dev environment or to real customers during testing; use a local catcher.
- Any step that sends to real recipients, changes production DNS, or requests SES production access is outward-facing: get explicit confirmation first.
Provider choice
| Need | Default |
|---|---|
| Small team, fast setup, React templates | Resend |
| High volume, already on AWS, lowest cost | Amazon SES v2 (more setup: sandbox exit, configuration sets, SNS/EventBridge events) |
| Strict separation of transactional and broadcast mail, strong deliverability tooling | Postmark (message streams) |
| Portability across providers | Wrap the provider in one sendEmail(message) interface; keep templates provider-independent |
Keep marketing and transactional mail on separate streams or subdomains
(notify.example.com vs news.example.com) so a campaign’s complaints cannot sink
password-reset delivery.
Architecture
request -> DB transaction: write business row + email_outbox row (idempotency_key UNIQUE)
-> commit
worker -> claim outbox rows -> render template -> provider API (idempotency key) -> mark sent
webhook -> bounce / complaint / delivery events -> suppression list + metrics
- Never call the provider inside the request’s database transaction: a rollback after a successful send emails the user about something that did not happen, and a slow provider holds the transaction open.
- The idempotency key derives from the business event (
password-reset:<token-id>,receipt:<order-id>), so retries and duplicate jobs collapse to one email. - Check the suppression list before sending; skip and record, do not error.
Sending code
Resend:
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send(
{
from: "Acme <[email protected]>",
to: [user.email],
subject: `Receipt for order ${order.number}`,
html,
text,
tags: [{ name: "category", value: "receipt" }],
},
{ idempotencyKey: `receipt:${order.id}` },
);
if (error) throw new EmailSendError(error.name, error.message); // let the job retry
The idempotency option maps to Resend’s Idempotency-Key header; on an SDK version
without it, call the REST API with that header or rely on the outbox alone.
Amazon SES v2:
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
const ses = new SESv2Client({});
await ses.send(new SendEmailCommand({
FromEmailAddress: "Acme <[email protected]>",
Destination: { ToAddresses: [user.email] },
Content: { Simple: {
Subject: { Data: subject, Charset: "UTF-8" },
Body: { Html: { Data: html, Charset: "UTF-8" }, Text: { Data: text, Charset: "UTF-8" } },
} },
ConfigurationSetName: "transactional", // routes bounce/complaint events
}));
Postmark: client.sendEmail({ From, To, Subject, HtmlBody, TextBody, MessageStream: "outbound", Tag: "receipt" });
use a separate broadcast stream for anything non-transactional.
Retry policy: retry on network errors, 429 and 5xx with exponential backoff and jitter;
do not retry 4xx validation errors (bad address, unverified sender). SES has no
idempotency key, so the outbox row’s sent_at plus the claim lock is what prevents
duplicates there.
SES starts in sandbox (verified recipients only, low daily quota). Requesting production access is a manual console or support request that the account owner submits.
Templates
- Build with React Email (
@react-email/components,render()to HTML andrender(..., { plainText: true })for text) or MJML. Both produce table-based HTML that survives Outlook. - Always send a text part. Keep the HTML under ~100 KB; Gmail clips larger messages.
- Escape user-provided values; templates are an HTML injection surface.
- Absolute HTTPS URLs for links and images; no JavaScript, forms or external CSS.
- Put the action and any code near the top; preview text (the first visible line) shows in the inbox list.
- Links with tokens (reset, magic link) are single-use and short-lived (15-60 minutes), and the email should say so.
Authentication and DNS
| Record | Where | Purpose |
|---|---|---|
SPF TXT v=spf1 include:<provider> -all | Return-path / MAIL FROM domain (often a provider subdomain) | Authorizes sending IPs |
| DKIM (CNAME or TXT from the provider) | <selector>._domainkey.notify.acme.com | Signs messages with your domain |
DMARC TXT v=DMARC1; p=none; rua=mailto:... | _dmarc.acme.com (covers subdomains unless sp= overrides) | Requires SPF or DKIM aligned with the visible From domain |
| Custom MAIL FROM / return path | Provider setting plus MX and SPF | Makes SPF align with your domain |
Mailbox-provider sender rules (Gmail and Yahoo since 2024, Outlook.com since 2025) for
bulk senders: SPF and DKIM pass, DMARC published (at least p=none) with From alignment,
one-click unsubscribe on marketing mail, and spam complaint rate kept under 0.3%
(aim for under 0.1%) in Google Postmaster Tools. Transactional mail is not exempt from
authentication.
Check records with the bundled linter (it needs network for --domain; offline, paste the
record text):
python3 scripts/check_email_dns.py --domain acme.com
python3 scripts/check_email_dns.py --spf "v=spf1 include:amazonses.com -all" \
--dmarc "v=DMARC1; p=none; rua=mailto:[email protected]"
It flags multiple SPF records, more than 10 top-level lookups, +all, missing DMARC
policy or reporting address, and exits 1 on errors. Move DMARC from p=none to
quarantine then reject only after aggregate reports show all legitimate senders pass.
DNS edits are production changes: propose the exact records and let the user apply them.
Bounces, complaints and suppression
- Subscribe to provider events: Resend webhooks (signed; verify the signature), SES configuration-set event destinations (SNS or EventBridge), Postmark bounce and spam complaint webhooks.
- Hard bounce or complaint: add the address to your suppression table and stop sending. Soft bounce: retry per provider behaviour; suppress after repeated failures.
- Verify webhook signatures and make handlers idempotent (events can repeat).
- Track delivery, bounce and complaint rates per category; alert when bounces exceed ~2% or complaints exceed 0.1%.
Local development and tests
- Run Mailpit (
docker run -p 1025:1025 -p 8025:8025 axllent/mailpit) and point SMTP there, or use the provider’s test addresses (Resend[email protected], SES mailbox simulator[email protected]). - Unit-test rendering: snapshot the text part, assert links are absolute and the token is present.
- Integration-test the outbox: two identical events produce one outbox row and one send.
Checklist
- Outbox row written in the same transaction; worker sends after commit
- Idempotency key per business event; suppression checked before send
- HTML plus text, escaped variables, absolute links, size under 100 KB
- SPF, DKIM, DMARC aligned on the sending subdomain; linter clean
- Bounce and complaint webhooks verified and wired to suppression
- No real sends from dev or CI; production sends and DNS changes confirmed by the user
Gotchas
noreply@addresses irritate users and hide replies about problems; use a monitored reply-to for receipts and support-related mail.- Changing the From domain resets reputation; warm up gradually.
- Link-tracking redirects on a shared provider domain can trip spam filters; use a custom tracking domain or turn tracking off for security emails.
- Logging full email bodies stores reset tokens and personal data in logs.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Transactional Email"
short_description: "Sent once, after commit, into the inbox"
default_prompt: "$transactional-email Add password reset emails through Resend with a proper outbox" scripts/check_email_dns.py
#!/usr/bin/env python3
"""Lint SPF and DMARC records for a sending domain.
Usage:
python3 check_email_dns.py --domain example.com # looks records up with dig (needs network)
python3 check_email_dns.py --spf "v=spf1 include:amazonses.com -all" \
--dmarc "v=DMARC1; p=none; rua=mailto:[email protected]"
Checks: exactly one SPF record, top-level DNS-lookup count (limit 10, includes are not
expanded), a terminating all, no +all or ptr; DMARC syntax, policy, reporting address,
pct and alignment tags. Prints findings and exits 1 if any ERROR is found.
Standard library only; --domain shells out to `dig`.
"""
import argparse
import shutil
import subprocess
import sys
LOOKUP_MECHS = ("include:", "exists:", "redirect=", "ptr")
def dig_txt(name: str) -> list[str]:
if not shutil.which("dig"):
sys.exit("dig not found; pass --spf/--dmarc record text instead")
try:
out = subprocess.run(["dig", "+short", "+time=3", "+tries=1", "TXT", name],
capture_output=True, text=True, timeout=15)
except subprocess.TimeoutExpired:
sys.exit(f"DNS lookup for {name} timed out (network off?); pass --spf/--dmarc record text instead")
if out.returncode != 0:
sys.exit(f"dig failed for {name} (network off?): {(out.stderr or out.stdout).strip()}")
records = []
for line in out.stdout.splitlines():
# dig prints long TXT records as several quoted strings; join them.
parts = [p for p in line.split('"') if p.strip()]
records.append("".join(parts).strip())
return [r for r in records if r]
def check_spf(records: list[str], findings: list[tuple[str, str]]) -> None:
spf = [r for r in records if r.lower().startswith("v=spf1")]
if not spf:
findings.append(("ERROR", "no SPF record (v=spf1) found"))
return
if len(spf) > 1:
findings.append(("ERROR", f"{len(spf)} SPF records found; receivers treat this as permerror"))
terms = spf[0].split()[1:]
lookups = 0
for t in terms:
bare = t.lstrip("+-~?").lower()
if bare.startswith(LOOKUP_MECHS) or bare in ("a", "mx") or bare.startswith(("a:", "a/", "mx:", "mx/")):
lookups += 1
if bare.startswith("ptr"):
findings.append(("WARN", "ptr mechanism is deprecated and slow (RFC 7208)"))
if lookups > 10:
findings.append(("ERROR", f"{lookups} DNS-lookup terms at top level (limit 10)"))
else:
findings.append(("INFO", f"{lookups} top-level DNS-lookup terms (includes may add more; limit 10)"))
last = terms[-1].lower() if terms else ""
if "+all" in [t.lower() for t in terms] or last == "all":
findings.append(("ERROR", "+all lets anyone send as this domain"))
elif not (last.endswith("all") or last.startswith("redirect=")):
findings.append(("WARN", "record does not end with -all or ~all"))
elif last == "?all":
findings.append(("WARN", "?all is neutral and gives no protection"))
if len(spf[0]) > 450:
findings.append(("WARN", "SPF record is long; DNS responses over 512 bytes may fall back to TCP"))
def check_dmarc(records: list[str], findings: list[tuple[str, str]]) -> None:
dm = [r for r in records if r.lower().startswith("v=dmarc1")]
if not dm:
findings.append(("ERROR", "no DMARC record at _dmarc.<domain>"))
return
if len(dm) > 1:
findings.append(("ERROR", "multiple DMARC records; receivers ignore all of them"))
tags = {}
for part in dm[0].split(";"):
if "=" in part:
k, v = part.split("=", 1)
tags[k.strip().lower()] = v.strip()
p = tags.get("p", "").lower()
if p not in ("none", "quarantine", "reject"):
findings.append(("ERROR", f"missing or invalid p= tag ({p or 'absent'})"))
elif p == "none":
findings.append(("WARN", "p=none monitors only; move to quarantine then reject once reports are clean"))
else:
findings.append(("INFO", f"policy p={p}"))
if "rua" not in tags:
findings.append(("WARN", "no rua= aggregate report address; you will not see failures"))
pct = tags.get("pct")
if pct and pct != "100":
findings.append(("INFO", f"pct={pct}: policy applies to a sample only"))
for k in ("adkim", "aspf"):
if tags.get(k, "r").lower() == "s":
findings.append(("INFO", f"{k}=s strict alignment: subdomain senders will fail"))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--domain")
ap.add_argument("--spf", help="SPF record text")
ap.add_argument("--dmarc", help="DMARC record text")
a = ap.parse_args()
if not (a.domain or a.spf or a.dmarc):
ap.error("give --domain or at least one of --spf/--dmarc")
findings: list[tuple[str, str]] = []
spf_records = [a.spf] if a.spf else (dig_txt(a.domain) if a.domain else None)
dmarc_records = [a.dmarc] if a.dmarc else (dig_txt(f"_dmarc.{a.domain}") if a.domain else None)
if spf_records is not None:
check_spf(spf_records, findings)
if dmarc_records is not None:
check_dmarc(dmarc_records, findings)
for level, msg in findings:
print(f"{level:5} {msg}")
errors = sum(1 for lvl, _ in findings if lvl == "ERROR")
print(f"\n{errors} error(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.