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.

  • Backend & APIs
  • $transactional-email
  • 182 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- transactional-email

Installs to ~/.agents/skills/transactional-email/ for every project on this machine.

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

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.

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

--- name: transactional-email description: Sends transactional email (password resets, receipts, verification codes, alerts) from an app through Resend, Amazon SES v2 or Postmark -… license: MIT ---

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

  1. 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.
  2. 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.
  3. Any step that sends to real recipients, changes production DNS, or requests SES production access is outward-facing: get explicit confirmation first.

Provider choice

NeedDefault
Small team, fast setup, React templatesResend
High volume, already on AWS, lowest costAmazon SES v2 (more setup: sandbox exit, configuration sets, SNS/EventBridge events)
Strict separation of transactional and broadcast mail, strong deliverability toolingPostmark (message streams)
Portability across providersWrap 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 and render(..., { 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

RecordWherePurpose
SPF TXT v=spf1 include:<provider> -allReturn-path / MAIL FROM domain (often a provider subdomain)Authorizes sending IPs
DKIM (CNAME or TXT from the provider)<selector>._domainkey.notify.acme.comSigns 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 pathProvider setting plus MX and SPFMakes 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.yamlgenerated from this page

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.py124 lines, 5.4 KB
#!/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.

esc