Mail Merge skill for Codex

Spreadsheet in, proofed letters out, nothing skipped. Codex treats a merge as a data job first: it reconciles template fields with spreadsheet columns, normalizes names, dates and IDs, writes rejects instead of dropping rows, proofs edge-case records, then renders per-record or combined outputs and checks them against a manifest. Email goes out as reviewable .eml drafts, and nothing is sent without your explicit confirmation.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- mail-merge

Installs to ~/.agents/skills/mail-merge/ 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 $mail-merge, 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

Runs mail merges that turn a CSV, Excel or database export into personalized letters, certificates, labels and email drafts, as one .docx/PDF per record or a single combined file, using docxtpl, Word MERGEFIELD templates (docx-mailmerge2) and Python's email package for .eml drafts. Use when asked to generate letters from a spreadsheet, produce a batch of certificates, merge addresses into a Word template, or prepare personalized emails for review before sending.

Use it when

  • A Word template must be filled once per row of a CSV, Excel sheet or query result
  • You need certificates, labels or letters in bulk as PDF or .docx
  • Personalized emails with attachments must be drafted and reviewed before sending
  • An existing Word Mailings template with MERGEFIELDs must run outside Word

Use something else when

  • You are designing one complex template with loops, tables and conditionals: use docx-templates
  • The output is a single data-driven report with charts rather than many letters: use report-generation

How to check the result

Output count equals accepted records plus rejects equals source rows, a scan of every rendered file finds no leftover placeholders, None or nan, and edge-case records were proofed as PDFs.

The mistake it prevents

Reading the spreadsheet with default types turns IDs and postcodes into numbers and drops leading zeros, so letters go out with wrong account numbers.

~/.agents/skills/mail-merge/SKILL.md

--- name: mail-merge description: Runs mail merges that turn a CSV, Excel or database export into personalized letters, certificates, labels and email drafts, as one .docx/PDF per… license: MIT ---

A merge is done when every record produced exactly one correct output, no record was silently skipped, no placeholder survived, and a human has proofed a sample before anything leaves the building. Sending is a separate, explicitly confirmed step.

Hard rules

  • Never send email, upload to a mail service, or print-to-queue without an explicit yes from the user for that batch. Produce drafts (.eml, PDFs) and a manifest first.
  • Validate the data before rendering. Most merge defects are data defects.
  • Every output file name must be deterministic and unique (record ID, not row number alone), so reruns overwrite instead of duplicating.
  • Personal data stays out of logs and chat; report counts and IDs.

Workflow

- [ ] 1. Read the template and the data; list fields on both sides
- [ ] 2. Validate and normalize the data
- [ ] 3. Choose the output shape
- [ ] 4. Render one record, proof it, then render all
- [ ] 5. Check outputs against the manifest
- [ ] 6. Hand over (and only on confirmation, send)

1. Inventory fields on both sides

Template fields must equal data columns, or the gap must be explained.

# docxtpl / Jinja-style template ({{ first_name }})
from docxtpl import DocxTemplate
tpl = DocxTemplate("letter.docx")
template_fields = tpl.get_undeclared_template_variables()

# Word-native MERGEFIELD template (built with Word's Mailings tab)
from mailmerge import MailMerge      # pip install docx-mailmerge2
template_fields = MailMerge("letter.docx").get_merge_fields()
import csv
with open("recipients.csv", newline="", encoding="utf-8-sig") as f:  # -sig strips Excel BOM
    rows = list(csv.DictReader(f))
data_fields = set(rows[0].keys())
print("missing in data:", set(template_fields) - data_fields)
print("unused columns:", data_fields - set(template_fields))

For .xlsx sources read with openpyxl.load_workbook(path, data_only=True) so formula cells give values, or pandas.read_excel(..., dtype=str) so ZIP codes and IDs keep leading zeros.

2. Validate and normalize

Check every record and write rejects to rejects.csv with a reason, never drop silently:

CheckRule
Required fieldsNon-empty after .strip() for every field the template uses unconditionally
DuplicatesSame email or same ID twice: stop and ask
EncodingNo é-style mojibake (re-read as UTF-8 or cp1252); no stray \xa0
NamesKeep the source casing; do not title-case (McDonald, van der Berg, O'Neil)
Dates and moneyParse, then format per locale in code, not in the sheet
IDs and postcodesRead as strings; 02134 must stay 02134
SalutationDerive a fallback (Dear customer) when the name is missing, instead of Dear ,

Format values in Python, not in template logic, so the template stays simple:

from datetime import date
from decimal import Decimal

def prepare(row: dict) -> dict:
    ctx = {k: (v or "").strip() for k, v in row.items()}
    ctx["salutation"] = f"Dear {ctx['first_name']}" if ctx.get("first_name") else "Dear customer"
    ctx["due_date"] = date.fromisoformat(ctx["due_date"]).strftime("%-d %B %Y")  # POSIX only
    ctx["amount"] = f"{Decimal(ctx['amount']):,.2f}"
    return ctx

3. Choose the output shape

NeedOutputTool
Letters to print and postOne combined PDF, page break per record, sorted by postcodedocxtpl per record, then convert and concatenate
Letters to email or archive individuallyOne PDF per record named <id>-<slug>.pdfdocxtpl + LibreOffice headless
Template already uses Word MERGEFIELDsKeep it; do not rebuilddocx-mailmerge2 merge_templates
Address labelsLabel grid document (Avery layout)docxtpl with a table loop
Personalized email.eml drafts plus manifest, reviewed before sendPython email.message

Combined Word file from a MERGEFIELD template:

from mailmerge import MailMerge
with MailMerge("letter.docx") as doc:
    doc.merge_templates([prepare(r) for r in rows], separator="page_break")
    doc.write("letters-combined.docx")

Per-record files with docxtpl (loading the template per record costs little and keeps images, subdocs and rich text from one record out of the next):

from pathlib import Path
from docxtpl import DocxTemplate

out = Path("out/docx"); out.mkdir(parents=True, exist_ok=True)
for row in rows:
    ctx = prepare(row)
    tpl = DocxTemplate("letter.docx")
    tpl.render(ctx, autoescape=True)  # escapes & < > in names like "Smith & Sons"
    tpl.save(out / f"{ctx['customer_id']}.docx")

Convert to PDF in one LibreOffice call (much faster than one call per file):

soffice --headless --convert-to pdf --outdir out/pdf out/docx/*.docx
qpdf --empty --pages out/pdf/*.pdf -- letters-combined.pdf   # optional single print file

LibreOffice must be installed; if soffice is missing, ask before installing it. Run only one soffice instance at a time or pass a separate profile with -env:UserInstallation=file:///tmp/lo-profile-1.

4. Render one, proof, then render all

Render the first record plus the edge cases (longest name, missing optional field, non-ASCII characters, largest amount). Convert to PDF and inspect the pages. Only after that looks right, render the whole set.

5. Check outputs against the manifest

Write manifest.csv with id, recipient, output_path, status. Then:

ls out/pdf/*.pdf | wc -l                 # equals accepted records
unzip -p out/docx/A1001.docx word/document.xml | grep -c "{{\|MERGEFIELD\|«"   # expect 0

In Python, scan every rendered .docx for leftover {{, {%, « or MERGEFIELD and for the literal strings None and nan, which mean a missing value slipped through.

6. Email drafts, and sending only on confirmation

Build drafts first; they can be opened in any mail client for review:

from email.message import EmailMessage
from pathlib import Path

def draft(ctx, pdf_path: Path, sender: str) -> EmailMessage:
    msg = EmailMessage()
    msg["From"] = sender
    msg["To"] = ctx["email"]
    msg["Subject"] = f"Your statement for {ctx['period']}"
    msg.set_content(f"{ctx['salutation']},\n\nPlease find your statement attached.\n")
    msg.add_attachment(pdf_path.read_bytes(), maintype="application",
                       subtype="pdf", filename=pdf_path.name)
    return msg

Path("out/eml").mkdir(parents=True, exist_ok=True)
for ctx in contexts:
    m = draft(ctx, Path(f"out/pdf/{ctx['customer_id']}.pdf"), "[email protected]")
    Path(f"out/eml/{ctx['customer_id']}.eml").write_bytes(bytes(m))

Before any send, show the user: count of messages, sender address, subject line, one rendered sample, attachments per message, and the rejects list. Send only after an explicit yes, throttle to the provider’s rate limit, record each result in the manifest, and make the sender idempotent (skip IDs already marked sent). Sending needs network access and SMTP or API credentials from the environment, never hard-coded.

Gotchas

  • Word splits {{ first_name }} across runs when it was typed with autocorrect or edited later; retype the whole tag in one go (see docx-templates for details).
  • Excel exports dates as serial numbers and drops leading zeros; read with dtype=str.
  • CSV from Excel on Windows is often cp1252, not UTF-8. Detect mojibake before merging.
  • strftime("%-d") fails on Windows; use f"{d.day} {d:%B %Y}" for portable code.
  • Duplicate recipients produce duplicate letters; dedupe on a stable key before rendering.
  • Mixed-language batches need per-record locale for dates, decimals and salutation.

Output: merge report

## Merge report: renewal-letters-2026-11
- Source: recipients.xlsx (sheet "Active"), 1,204 rows
- Accepted: 1,198  Rejected: 6 (rejects.csv: 4 missing address, 2 duplicate IDs)
- Outputs: out/pdf/<customer_id>.pdf (1,198), letters-combined.pdf (2,396 pages)
- Proofed: A1001 (baseline), A1044 (longest name), A1102 (no first name), A1180 (accents)
- Leftover placeholder scan: 0 hits
- Emails: 1,198 .eml drafts in out/eml, NOT sent (awaiting confirmation)

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

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: "Mail Merge"
  short_description: "Spreadsheet in, proofed letters out, nothing skipped"
  default_prompt: "$mail-merge Generate a renewal letter PDF for each customer in customers.xlsx using renewal-template.docx"

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
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem