I’ve been running this on my own mailbox for a while and cleaned it up for sharing.
The problem it solves: inquiries arrive faster than anyone answers them, so the first reply goes out
late (or never), and the lead is gone. Most “AI replies to your email” setups I tried fail for two
reasons: the model output can’t be routed reliably, and the reply breaks the mail thread.
The pipeline:
IMAP trigger → normalise → AI classify (strict JSON) → AI draft → HUMAN APPROVAL
→ SMTP reply in the same thread → log to Sheet
Design decisions that mattered most, in case they save someone the same debugging:
Strict JSON from the model, not free text.
I ask for {category, urgency, reference_no, needs_human} and validate it. Routing then becomes a
Switch node instead of another LLM call — cheaper and auditable. Anything that fails validation
goes to the human queue rather than being guessed at.
Preserve the thread on send.
Set In-Reply-To / References from the incoming message and keep the Re: subject, otherwise your
“automated” reply starts a new thread in the customer’s mailbox and nobody can follow the history.
Idempotency before side effects.
Key every message on its Message-ID and store it before doing anything irreversible. Without this,
an n8n retry double-sends. (Same pattern saved me from duplicate webhook deliveries.)
Low confidence → human, always.
A false “urgent” costs one click. A false “not urgent” costs a customer. I also deliberately keep
the send behind a human click for anything customer-facing.
Reconcile, don’t trust green runs.
A successful n8n execution only means no node threw — not that the output was right. I compare
processed IDs/counts against the mailbox on a schedule and alert on mismatch.
Honest boundaries so nobody over-buys this:
· not tested on image-only PDFs / scanned documents (no OCR in this workflow)
· text bodies only; attachments are logged, not parsed
· no telephony, no multi-tenant, no CRM write-back beyond a sheet
· WhatsApp is intentionally out of scope — unofficial libraries get numbers banned, and the official
Cloud API needs a verified business account plus pre-approved templates
Update: I have submitted this workflow to the n8n template library as well, so it should be importable straight from the templates gallery once it clears review. Until then the JSON in the repo is the current version:
Two things I learned while cleaning it up, in case anyone is building something similar:
keep the model output to a strict JSON schema - routing then becomes a Switch node instead of a second LLM call, and every decision is auditable;
key each message on its Message-ID before any side effect, otherwise an n8n retry double-sends.
Good write-up, and the honest boundaries section is rare. Two questions on the approval step, since you offered. First: if the reviewer edits the draft before approving, how do you make sure what SMTP sends is exactly what they approved? Second: you store the Message-ID before the send. How do you tell “attempted” from “sent” when the SMTP node times out? That’s the case where an idempotency key alone stops helping.
Really enjoyed this write-up, @ck_lu , especially the reconciliation step. Most people stop at “the run went green.” And @BorkoB two questions are the right ones to push on.
I’d argue they’re actually the same question. Once a human sits in the middle, the reply stops being one workflow run and becomes a long-running job, so its status needs to live on a record of its own instead of inside whichever execution happens to be holding it.
For an inbound email, something like:
Drafted → Pending Review → Approved → Sending → Sent
with two side states: Send Unknown and Needs Review.
Here’s one way to work through BorkoB’s questions with that:
Edited drafts: approval freezes a specific version. The approved text (and a hash of it) is saved on the record along with who approved it and when, and the send step only ever uses that saved copy. Any edit after that sends it back to Pending Review. You never get “approved one thing, sent another.”
Attempted vs sent: the record moves to Sendingbefore the send. If the send confirms, it moves to Sent. If it times out, it doesn’t retry. It moves to Send Unknown. Since you already store the Message-ID, a follow-up workflow can search the Sent folder or the thread for it. Found means Sent. Not found goes to a person, who approves a resend, and that decision is logged with their name on it.
Nothing waits forever: if a reply sits in Pending Review or Send Unknown longer than you’re comfortable with, that triggers an escalation instead of building up a silent backlog.
“Why did the customer get this?” gets answered by the record’s history (drafted, edited by X, approved by Y at 10:42, sent), not by digging through old executions.
n8n still does all the orchestration. It just moves the record from state to state instead of carrying the whole job inside one run.
ck_lu, where does the reviewer actually edit: in the same place they approve, or in a separate draft (Gmail, Slack) that has to be synced back?
One caution on the Sent-folder check: don’t assume a raw SMTP send automatically saves a Sent copy. That depends on the provider and how the message is submitted. If no copy is saved, “not found” tells you very little.
I’d store the outbound Message-ID separately from the inbound one, plus the approved draft version. An SMTP timeout should stay in Send Unknown until you have provider evidence or a human reviews it. Even a successful SMTP response means accepted for delivery, not necessarily delivered to the recipient.
A useful test is to simulate a lost response after the server accepts the message, then run recovery twice. Neither recovery should automatically send a second email.
Both of these match what I ended up building, so disclosure: I build Guardian, an approval and audit gate for n8n.
@productive_space the record outside the execution is the core of it. Each action becomes its own record with a status. An edit before approval is checked against the rules again, and the original and the approved version each get a hash. The send step can claim the record only once, so a second run is skipped instead of sending again. I tested that yesterday.
@operantsolo your Send Unknown point is the gap I haven’t closed. Guardian records that the send was claimed, not that the provider accepted it. Your test (lost response, recover twice, no second email) is the right acceptance test. What would you store as provider evidence: the outbound Message-ID alone, or something from the provider’s API?
Nice to see classification, approval and sending treated as separate stages. I’m building a small open-weights decision model. Would you be open to comparing the category and urgency decisions on synthetic messages while keeping drafting, approval and Message-ID protections intact? I can contribute an adapter and publish both successes and errors.
@BorkoB I’d store both, but the outbound Message-ID is a lookup key, not proof of acceptance.
Before sending, save the Guardian attempt ID, sending account, intended recipients, approved revision and attempt time. If the sender supports a custom Message-ID or correlation header, save that mapping too. The stock n8n Send Email node doesn’t expose custom headers, so don’t assume you can preassign the ID there.
For SMTP, inspect the send result for response, messageId, accepted and rejected. Store those against the attempt, plus any provider queue ID returned. Check every intended recipient: one accepted address doesn’t mean all addresses were accepted. The final success response after the message body means the server accepted responsibility, not inbox delivery (RFC 5321).
For a provider API, keep its successful send response and provider message ID, if returned, separate from the email’s Message-ID. Use that provider’s documented acceptance status.
The difficult case is losing the response, or crashing before saving it. Before relying on recovery, test whether provider logs or lookup can match a send using your pre-recorded correlation data. A matching conversation isn’t enough. If the provider can’t establish the result, keep Send Unknown for human review. Recovery must not automatically resend; neither an expired claim nor reuse of a Message-ID makes that safe.
Which sender are you connecting first? That’s the piece that decides what Guardian can actually verify.
@Marchik thank you, this is the clearest version of the send-side record I’ve seen. I’m keeping your list as it is: attempt ID, sending account, intended recipients, approved revision and attempt time before the send; response, messageId, accepted and rejected per recipient after it.
Gmail first, through the n8n Gmail node. It’s what my test workflows use, and what the template authors I’ve been working with use. As far as I understand, the Gmail API returns its own message ID and thread ID, and Gmail search can find a message by its Message-ID, so a lookup after a lost response might be possible. I haven’t tested that yet, so I won’t claim it.
Agreed on recovery: Send Unknown goes to a person, never an automatic resend. That’s already how the claim works in Guardian. A second attempt on a claimed send is skipped, not repeated.
@BorkoB One adjustment for Gmail: accepted / rejected are SMTP sender results. Save Gmail’s successful response and id / threadId instead; those aren’t recipient delivery receipts.
Google’s rfc822msgid: lookup searches the email’s Message-ID header, not Gmail’s API id. For this approach, save a unique outgoing Message-ID against the attempt before sending. The incoming email’s ID won’t identify your reply. The documented n8n Gmail Send/Reply options don’t expose a custom Message-ID field, so that prerequisite still needs checking. Without a reliable identifier saved beforehand, keep Send Unknown; matching subject, recipients and body isn’t enough.
Once you have that identifier, test the exact operation your template uses in a test account: let Gmail accept the send, then stop before persisting its response. Recover using only the saved pre-send data. Include an older, similar reply in the same thread as a false-match check. Fetch the candidate with Gmail Get (Simplify off for the full message) and verify its outgoing Message-ID equals the saved one, then check the sending account, recipients and approved content. Confirm the attempt only if those checks identify that exact send; this still doesn’t prove inbox delivery.
Run recovery twice without another send. Missing, ambiguous or conflicting evidence stays Send Unknown.
For Get Many, select Unread and read emails. Also check search permissions: Google doesn’t allow q searches with gmail.metadata.
Which operation are you using—Send, Reply, or Draft Send—and what can you save before it runs?
@Marchik thanks, you’re right on both. I mixed up the SMTP fields with Gmail.
I use Send. Before it runs I have the Guardian intent ID, the recipients, the subject and the time. What I don’t have is my own Message-ID, the Gmail node doesn’t let me set one. So for now a lost response stays Send Unknown.
If I try your recovery test, I’d send through the Gmail API directly so I can set the Message-ID first. I’ll post how it goes, also if it doesn’t work.
Both of these are the right questions, and I would rather be precise about what the template does than sound more finished than it is.
What ships in it: intake → normalise → strict-JSON classify → draft → everything goes to a review queue (autoSend is hard-coded false) → the queue row is written and the approver gets a notification. The approval callback webhook is a stub — nothing is wired to it yet.
Edited drafts → “approved != sent”
So in the template as published there is no editable approval surface: the notification carries the draft text, and if the reviewer edits there, nothing reads it back. That is exactly the failure mode you are pointing at, and I left the loop open on purpose rather than shipping a half-true one. What I would wire for a real deployment: the draft text + status live on the record, the reviewer edits that record (not a notification), the approve action re-reads the stored text, hashes it together with approver + timestamp, and the send step only sends that hash-matched string. Any edit after approval puts the record back to pending.
attempted vs sent on SMTP timeout
Also not in the template. The shape I would build: write status=sending + the outbound Message-ID to the record *before* the SMTP call; success → sent; timeout → unknown, and no automatic retry — a human resolves it by looking for the Message-ID in the Sent store, and a resend is a separate, logged approval.
Honest limitation, agreeing with @operantsolo and @Marchik: SMTP 250 means accepted, not delivered; and on providers where a raw SMTP send is not saved to Sent, “not found” is weak evidence. A provider API gives better evidence but costs you the preassigned Message-ID as the correlation key.
@BorkoB — that is why I think Guardian’s claim-once + one-record-per-action shape is right; the hard part is exactly the provider-evidence side.
@productive_space — agreed on the record-and-status model, and in the template I did not get that far: the notification just carries the draft text, there is no separate draft store to sync, and nothing reads edits back yet. For a real deployment I would put text + status on the record and have the reviewer edit there, precisely to avoid two sources of truth.
@mems — yes, interested. Scope as you describe it (classification/urgency only; drafting and approval untouched) works for me.
I can generate ~100 synthetic inquiries with a noisy label set (10 categories, 3 urgency levels, some deliberately ambiguous, a few “should go to a human”). Each side returns category + urgency; we publish the confusion matrix plus disagreements with reasoning — successes and errors both.
If your adapter emits strict JSON ({category, urgency}) we can compare offline without touching any workflow. Send me the expected input format and I will prepare the set — happy to make it public.
Thanks, that’s a clear answer, and leaving the loop open beats shipping a half-true one.
Your point is close to how I ended up doing it: the reviewer edits the stored request, the edit is checked against the rules again before the approval is accepted, and the send step reads the approved version from Guardian, not from the notification. Original and approved versions each keep their own fingerprint
What I’ve learned in the last weeks is that building this is the easier half. Getting someone to put one real workflow through it is the harder one.
That last line is the one that matters, and it is exactly where I am: the workflow is built, the loop is honest, and I still need one agency to put a real inbox through it.
If you are up for it, here is a swap that costs us both nothing: I run one real mailbox through Guardian (one workflow, a couple of weeks, real enquiries) and write down what breaks; you point my triage template at a real inbox and tell me where the approval step actually fails. Between us that is one end-to-end case each, and neither of us has to convince a stranger to be first.
If that is too much for now, even just the shape of the record you expect back from the send step would be useful — I would rather build that once, against your rules, than invent a second dialect.