My agent sent the same email 7 times in 3 minutes. So I put the fix in code.

About Will

I run Tygart Media, an AI-first agency that gets businesses cited and recommended by AI assistants — and I write about what we do, including what breaks.

Connect on LinkedIn →

Updated October 2026.

Seven identical emails. Three minutes. One morning brief.

Nothing was broken. The send succeeded on the first try, but the reply confirming it got lost. My agent, doing exactly what agents do, retried. And retried. From the inside, each attempt looked brand new: no error, no evidence the earlier one had landed. So it kept going until someone noticed.

This is the failure class nobody warns you about when you hand an agent a mailbox. The industry calls it duplicate completion: the original succeeds, the response is lost, the retry re-sends. It’s not a model problem and it’s not a prompt problem. Telling an agent “don’t send twice” in its instructions is not enforceable. Agents re-plan, they retry, they lose context across restarts. Every scheduled job, every cron, every “oops, run it again” is another roll of the dice.

And Gmail gives you no help. Stripe, Resend, and the other transactional APIs all have idempotency keys: send the same key twice, get one charge, one email. Gmail’s API has no such thing. The guarantee has to live on your side, in code, at the tool boundary — somewhere the agent cannot reason its way around.

What I built

send-once is one Python file, no dependencies beyond the standard library. Every scheduled or agent-driven send routes through it, and it enforces at most once with three gates:

  1. An operation ledger. A local sqlite database keyed by a deterministic operation id, like loop-morning-brief-2026-09-17. If this operation already recorded a send, the wrapper refuses. Same intent, same key, and a retry becomes a no-op instead of a duplicate.

  2. A Sent-folder check before every send. It searches Sent for the same recipient and subject in the last 24 hours. If a match exists, it refuses. Sent is the source of truth, so this gate holds even if the ledger is lost, the run moved machines, or the send happened outside this tool entirely.

  3. No blind retries, ever. If the send result is ambiguous — timeout, empty output, lost response — the wrapper does not retry. It re-checks Sent. If the send landed, it records that and reports honestly. If it can’t be confirmed, it stops and hands it to a human. An inconclusive pre-check is also a refusal: when the tool can’t verify what already happened, the safe move is to stop, not to guess.

The exit codes are the interface: 0 means sent (or already sent), 2 means refused as a duplicate, 3 means a human needs to verify. Prose instructions get skipped or misread by workers. The wrapper doesn’t.

Here’s the shape of it — the ledger is the whole trick:

import sqlite3, sys, hashlib

DB = "send_once.db"

def op_id(kind, recipient, subject, date):
    raw = f"{kind}|{recipient}|{subject}|{date}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]

def already_sent(op):
    con = sqlite3.connect(DB)
    row = con.execute(
        "SELECT 1 FROM ledger WHERE op_id = ?", (op,)
    ).fetchone()
    con.close()
    return bool(row)

def record(op, message_id):
    con = sqlite3.connect(DB)
    con.execute(
        "CREATE TABLE IF NOT EXISTS ledger(op_id TEXT PRIMARY KEY, message_id TEXT, ts DATETIME DEFAULT CURRENT_TIMESTAMP)"
    )
    con.execute(
        "INSERT OR IGNORE INTO ledger(op_id, message_id) VALUES (?, ?)",
        (op, message_id),
    )
    con.commit()
    con.close()

# Gate 1: the ledger. Same operation id -> refuse, don't resend.
op = op_id("morning-brief", "will@example.com", "Morning brief", "2026-10-04")
if already_sent(op):
    print("refusing: already sent")
    sys.exit(2)  # 2 = duplicate refused

# Gate 2: check the Sent folder via the Gmail API before sending.
# Gate 3: only record truthfully after the send resolves.
# If the result is ambiguous, re-check Sent — never blind-retry.
record(op, message_id)  # message_id from the confirmed send

Take it, make it better

This solved my problem, not everyone’s. It’s MIT licensed, it’s one file, and the mailer backend is a documented protocol so any Gmail CLI can slot in.

Take it, make it better. If you build something better, come back. We’ll be customer number one, and we’ll pay you for it.

Repo: https://github.com/tygart-media/send-once

Track the AI tools you actually use
Live, vendor-neutral prices & limits for ChatGPT, Claude, Gemini, Perplexity and more — and we’ll email you the moment your tools change price or limits. Free, no hype.
See the live AI tracker →or set up your alerts

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *