What happens after Approve? My n8n email approval workflow (screenshot + test checklist)

An Approve button leaves an important question: what exactly did the person approve?

I’m building an owner-operated, self-hosted n8n system with AI assistance. Research and drafting feed a review queue; customer emails require my approval. This is my own project, not a paid-client case study.

The flow in the screenshot

The canvas shows a pending-draft lookup, duplicate-contact checks, a Gmail approval request, and an explicit approval branch. Before sending, it re-reads the approved row and verifies the approved snapshot. Follow-ups have a fresh thread check, and the sending path records a receipt. Declined/expired approvals and dry runs have separate branches.

Those are implementation steps, not proof that every failure case is solved. In particular, a send receipt is not delivery evidence.

Six scenarios I would include in an approval-workflow test plan

Scenario Expected behavior to verify
Recipient changes after approval Stop and request fresh approval
Message changes after approval Stop and request fresh approval
Approval link is clicked twice Do not create a second send
Approval expires Do not send
Execution stops around the send step Reconcile the outcome before retrying; do not blindly resend
Recipient replies before a follow-up Block that queued follow-up

Use synthetic contacts or a mailbox you control for these tests. For each case, record the input, expected result, actual result and execution evidence. This is a proposed checklist, not a claim that all six tests have passed in my project.

The part I’m especially interested in

A local “Sending” status cannot by itself prove what happened at the email provider if the execution stops at the wrong moment.

How do you handle that uncertain outcome in your n8n email workflows—especially when the provider has no send-idempotency key? What evidence do you require before allowing a retry?

Screenshot above: my workflow canvas, with the approval branch enlarged. It illustrates the structure; it is not an execution trace.

For that uncertain-send case, I’d leave the attempt unresolved. A timeout or an old “Sending” row isn’t evidence that another send is safe.

If your sending path supports a custom MIME Message-ID, generate it before sending and save it with the approved snapshot and attempt ID. Gmail’s messages.list API⁠ supports rfc822msgid: searches. First test in a mailbox you control that your sending path preserves the header; the n8n node’s capabilities matter here.

On recovery, search the sending account’s Sent mail for that ID, fetch the match, and compare its recipients and content with the approved snapshot. A confirmed match lets you record Gmail’s message ID and close the attempt without resending. That’s evidence of the sent message, not inbox delivery. No match leaves the outcome unknown.

I’d allow an automatic retry only with reliable evidence that the attempt failed before submission, or that the provider explicitly rejected it without accepting the message. Otherwise, hold it for review. If an operator chooses to resend despite uncertainty, record that decision and the duplicate risk rather than quietly treating it as a normal retry.

The custom Message-ID helps correlate evidence; it doesn’t make sending idempotent. Keep that unresolved attempt blocked from another worker or approval click.

Your crash test should cover Gmail accepting the message but the workflow losing the response. Recovery should find the existing message or stop for review.

Are you sending through the Gmail node, SMTP, or a raw Gmail API request?

2 curtidas

This is a good example of why recording a successful workflow execution isn’t enough to guarantee reliable delivery.

I’d separate the send operation into three states:

PENDING: The email is approved but sending hasn’t started.

SENDING: A worker has claimed the send attempt.

SENT: The provider has acknowledged the request and the result has been recorded.

The difficult case is when the provider accepts the email but the workflow crashes before updating the database.

I wouldn’t automatically retry that attempt. I’d mark the outcome as uncertain and reconcile it using whatever evidence the email provider exposes.

If the provider supports an idempotency key, use it. Otherwise, retain a unique internal attempt ID, recipient, message fingerprint, timestamp, and provider response when available.

If reconciliation can’t establish the outcome, manual review is safer than potentially sending twice.

One question: Are you sending through Gmail directly or through a transactional email API? The available recovery mechanisms could be quite different.

Thanks @Marchik and @Mukarram_Ali1. I’m sending through n8n’s Gmail node using Gmail OAuth, rather than SMTP.

The current path reserves the attempt before sending and records Gmail’s message/thread IDs afterward. I haven’t verified that this path preserves a custom MIME Message-ID, so I can’t claim reliable recovery when Gmail accepts a message but the workflow loses the response.

For that unresolved case, I agree the attempt should remain blocked from automatic resend. A useful next test is to send only to a mailbox I control, interrupt around the receipt step, and check what evidence can be recovered.

My latest synthetic tests cover model fallback and holding insufficiently supported drafts for review. They don’t prove uncertain-send reconciliation.

Have either of you verified custom Message-ID preservation with the native Gmail node, or did you use a raw Gmail API request?

I like the idea of checking whether the email has changed after approval. Sometimes, even a small change to the recipient or message could cause problems. I think having a preview of the exact email before approving it would also be useful, so the user knows exactly what will be sent. The challenge is handling situations where the workflow stops during sending and you’re unsure whether the email actually went through.

I checked the native node’s MIME construction, but haven’t sent a live Gmail test. An approved client email can still be hard to recover safely if the send response disappears and you can’t identify the original message.

At this pinned n8n commit, the encoder copies a fixed set of fields into MailComposer. It doesn’t forward messageId or arbitrary headers, and the Send operation doesn’t expose that input. Adding an attempt ID to the incoming JSON therefore won’t set the outgoing MIME Message-ID in that implementation.

I reproduced that mapping locally with Nodemailer 8.0.7: two builds generated different Message-IDs despite the same supplied ID. Passing messageId directly to MailComposer preserved it locally. No mail was sent; this isn’t a test of your installed n8n or Gmail’s stored headers.

The raw Gmail API lets you construct the MIME. I’d use one synthetic email in your own mailbox for two separate checks: first fetch the sent message with users.messages.get(format=raw) and compare the stored header; then ignore the returned Gmail ID and try finding it with messages.list using rfc822msgid:<your-reserved-id>. Check the match against the approved recipients, subject, body and attachments before treating it as the original send. That second check is what a lost-response recovery path actually needs.

Even a preserved header doesn’t make sending twice safe. If either check fails, keep the attempt unresolved rather than resend.

Share your n8n and Gmail node versions and I can check which encoder your workflow uses before you change the sending path.

Thanks, both points help. @jeevethaa_18, agreed: the approver should see the exact recipient, subject, body and attachments, and the workflow should re-read the approved snapshot before sending.

@Marchik, I checked my live self-hosted instance: n8n 2.36.9; the active workflow export uses Gmail node 2.20. I have not verified whether this installed node preserves a caller-supplied MIME Message-ID, or tested recovery after Gmail accepts a message but the workflow loses the response. For now, an ambiguous attempt remains blocked from automatic retry.

The safe next test is synthetic and mailbox-controlled: inspect the stored raw message, then search by rfc822msgid: and compare recipients/content. If that does not uniquely identify the original send, leave the outcome unresolved rather than resend. I’ll report the result only after testing.

I checked the stock n8n 2.36.9 release you’re using. Its Gmail Send path builds a fixed set of email fields, and the MIME encoder doesn’t forward a caller-supplied messageId or arbitrary headers to MailComposer. Adding your attempt ID to the incoming JSON therefore won’t set the outgoing MIME Message-ID through that Send operation.

That’s the email header, separate from the Gmail API message id returned after sending. Also, if 2.20 is the numeric typeVersion in JSON, it’s the same value as 2.2, which this release supports.

So the version check supports using caller-constructed MIME through the raw Gmail API for your controlled header test. It doesn’t establish Gmail’s preservation or recovery behavior. Your planned mailbox test still needs to answer those two questions; the source check alone can’t close an uncertain send.