This is part of my build-in-public series: I am shipping a small store of n8n workflow templates (AI Automation Lab) and a free open-source lab on GitHub.
Every idempotency article ends the same way: "just add an idempotency key." That advice is fine until you actually send email. The Gmail API's messages.send has no idempotency-key parameter. You POST a raw message and you get a message id back - or your connection dies and you get nothing. Now you have to decide, from the outside, whether the mail went out.
Retry blindly and you can double-send. Don't retry and you can silently drop a real notification. That is the worst kind of fork, because the customer can see both branches.
Here is the trick I use to make Gmail sends safe to retry with zero provider-side idempotency support: make the message carry its own reconciliation handle.
The insight: the Message-ID is a key you already control
Every email has a Message-ID header. RFC 5322 requires it to be globally unique, and Gmail stores it, keeps it searchable, and preserves the SENT label on the copy it saves. So if you set the Message-ID yourself - instead of letting Gmail generate a random one - you have turned the mailbox into a lookup table that can answer "did I already send this?".
Derive the Message-ID deterministically from the job, never from the clock:
// n8n Code node - deterministic Message-ID from a stable job key
const crypto = require('crypto');
const jobKey = $json.customer_id + ':' + $json.event_type + ':' + $json.order_id;
const hash = crypto.createHash('sha256').update(jobKey).digest('hex').slice(0, 32);
const messageId = '<job-' + hash + '@inbound.yourdomain.com>';
const raw = [
'From: "Support" <support@yourdomain.com>',
'To: ' + $json.to,
'Subject: ' + $json.subject,
'Message-ID: ' + messageId,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset="UTF-8"',
'',
$json.body,
].join('\r\n');
return { json: { raw: Buffer.from(raw).toString('base64url'), messageId } };
Two properties matter. The key is derived from the source event (customer id + event type + order id), so the same logical send always produces the same Message-ID. And it contains no $now, no $execution.id, no fresh uuid.
One requirement: you have to send raw MIME yourself - Gmail users.messages.send with a raw field. If you let the Gmail node build the message for you, Gmail assigns its own Message-ID and you lose the handle. In n8n that means an HTTP Request node (or the Gmail node's raw field), not the friendly "Send" input.
The recovery step: ask the mailbox instead of guessing
Now the sweeper can reconcile instead of blindly resending:
GET /gmail/v1/users/me/messages?q=rfc822msgid:<job-abc123@inbound.yourdomain.com>
-
A hit (and the message carries the
SENTlabel): the send happened. Mark the jobdone. Do not resend. - No hit: the send did not happen. Retry with the same deterministic Message-ID.
That is the whole mechanism. No provider support, no extra service, no exactly-once-capable dependency. Just a key you injected and a query you can run.
Three caveats that decide whether this is safe
This is not free lunch. Skip these and you ship a worse bug than the one you fixed.
1. Search indexing is not instant. Gmail's search index lags by seconds to a couple of minutes. A "not found" right after a crash is inconclusive, not proof of absence. So do not treat the first negative as "retry now." Delay the sweeper, re-scan once the index catches up, and only retry if it is still absent after that window. If a channel cannot tolerate the latency, that channel is a review, not an auto-retry.
2. A hit proves existence, not uniqueness. Finding one copy in your own mailbox is evidence that you sent it, not a server-side guarantee that a second copy cannot exist. To assert uniqueness you must count: if the same Message-ID ever landed twice, your query returns two hits. So assert hits == 1, not hits >= 1. A count of two is a duplicate effect and belongs in review - it is exactly the signal you were trying to detect.
3. Log the effect where the effect happens. Count side effects at the provider, or in your relay's acceptance log that records the actual effect row with its Message-ID - never at n8n. The n8n execution count tells you nothing about whether the mail left the building.
Not every channel deserves the same guarantee
Once this works, resist using it everywhere. Ask what a duplicate actually costs per channel:
- Customer email (receipt, status update): a duplicate is mildly annoying, a missed email is worse. At-least-once plus a dedup hint is the right trade-off, and this Message-ID trick makes even that safe.
-
Anything involving money or an irreversible action: force
Unknown->reviewand let a human decide. The mechanism still gives that human a handle to check, but it must not auto-resume.
Write that decision down per provider, because exactly-once is a property of the receiving system - the caller cannot impose it. Your workflow's job is to be honest about which class it is talking to.
How I test it
Same as any crash-boundary test: a relay in front of the provider with three modes.
-
fail-before-forward- Gmail never sees it. Retry must be safe, and the resend must produce exactly one effect (the query finds nothing, we retry, the second attempt is the only one). -
forward-then-drop-response- Gmail accepts it, the relay drops the response. The ambiguous class. The sweeper must find the message by its Message-ID and markdonewithout resending. -
forward-and-respond- happy path. Endsdone, and the sweeper must not touch it.
Assert three things, all of them in the acceptance log, not in n8n: same key retried -> exactly one effect; drop-response -> no duplicate; ambiguous job -> lands in review and is never auto-resent. And report mock results as mock results - a green mock test is not a Gmail guarantee until you have watched a real ambiguous send reconcile.
The takeaway
You do not need the provider to hand you an idempotency key to make a send safe to retry. You need a key you control, a place that remembers it, and the discipline to ask instead of guessing. For Gmail, that place is the mailbox itself, and the key is the Message-ID you set on the way out.
n8n is the worker. The mailbox is the source of truth. Keep them in those roles and the double email stops happening.
If this was useful, the free lab (MIT, ready-to-import JSON) is here: github.com/zhp910318/n8n-ai-automation-lab - and I keep a small set of production-tested n8n templates at AI Automation Lab.
📬 Get the free n8n workflow templates — plus new ones by email. Join the free list and I will send the free templates plus occasional automation tips. No spam, unsubscribe anytime.
Top comments (0)