Build an n8n Email Draft Assistant—with Recipient Locks, Policy Evidence, and Human Review
An AI email workflow should earn the right to create a draft before anyone considers automatic sending. Incoming email is untrusted external content: it may contain a wrong address, a sensitive request, misleading instructions, or text designed to manipulate the model. The safer first deployment lets AI propose a reply, validates the exact draft fields outside the model, and leaves send_authorized set to false until a person reviews the Gmail draft.
This guide builds that draft-only boundary in n8n. It uses the Gmail Trigger, structured model output, a deterministic validation gate, and Gmail’s Draft Create operation. A companion Python gate and nine tests demonstrate the recipient, thread, policy, link, and action checks without claiming that a prompt alone makes email automation safe.
✅ Target behavior: a supported FAQ question may produce a Gmail draft addressed only to the original sender. Nothing in the workflow can send that draft automatically.
Decide what the workflow is allowed to do
The old four-step pattern—trigger, ask a model, branch on a word, send—places too much authority in model output. A response such as simple is not authorization to contact someone outside the organization.
Start with a smaller capability:
| Capability | Pilot decision |
|---|---|
| Read selected incoming messages | allowed through a filtered Gmail Trigger |
| Classify a message | allowed, but classification is untrusted |
| Propose reply text | allowed from approved policy entries |
| Choose another recipient | denied |
| Add CC or BCC | denied |
| Add a new link | denied unless policy-approved |
| Create a Gmail draft | allowed after deterministic validation |
| Send or reply | absent from the pilot workflow |
This is a control boundary, not a wording preference. Renaming a Send node to “Safe Reply” does not reduce its permissions. The Gmail node operation itself must be Draft → Create, and there should be no Send or Reply node reachable from the pilot path.
Map the trust boundaries before adding AI
The workflow crosses several boundaries:
| Asset or surface | Failure to consider | Control in this design |
|---|---|---|
| Incoming body | indirect prompt injection | treat as data; never grant it tool authority |
| Sender and thread IDs | model substitutes a target | copy from trigger and compare exactly |
| Policy facts | model invents an answer | require known policy IDs and human review |
| URLs in reply | phishing or exfiltration link | allow only URLs attached to selected policy entries |
| Mailbox credential | excessive access or leakage | use n8n credentials and the narrowest workable scope |
| Draft action | accidental external communication | create draft only; no send path |
| Execution logs | unnecessary personal data retention | log IDs, hashes, flags, and decisions rather than full bodies when possible |
OWASP describes email bodies and attachments as possible indirect prompt-injection channels. Its guidance also warns that keyword screening is not a security boundary: attackers can rephrase or encode instructions, while benign requests may contain words such as “delete” or “system.”
The companion gate flags a few obvious phrases for a reviewer, but safety does not depend on finding them. Even an undetected injection cannot change the recipient, add CC/BCC, introduce an unapproved URL, select send, or bypass human review.
Use the Gmail Trigger as a narrow intake queue
n8n’s current Gmail Trigger supports Message Received, a polling schedule, a maximum number of messages per poll, and filters for labels, Gmail search, read status, spam/trash, and sender.
For a pilot inbox:
- create a Gmail label such as
AI_DRAFT_CANDIDATE; - trigger only on that label;
- exclude spam and trash;
- begin with a small maximum per poll;
- keep messages unread until the draft record exists; and
- use a separate processed label keyed to the source message ID.
An unread filter alone is not an idempotency strategy. A failure between creating a draft and marking the message can cause another poll to process the same message. Store the Gmail message ID and resulting draft ID in a durable table or data store, and make the create step reject a second successful record for the same source ID.
The simplified trigger output contains message IDs, labels, and headers such as From, To, CC, BCC, and Subject. Fetch only the body required for the supported task. Do not send an entire mailbox thread, unrelated attachments, signatures, or hidden quoted history to the model by default.
Preserve the source fields outside the model
Immediately after the trigger, create a trusted envelope with an Edit Fields node:
{
"message_id": "<copied from Gmail Trigger>",
"thread_id": "<copied from Gmail Trigger>",
"from": "<copied from Gmail Trigger>",
"subject": "<copied from Gmail Trigger>",
"body": "<bounded message text>"
}
Do not ask the model to reconstruct message_id, thread_id, or the recipient from prose. Keep those values on a separate branch and merge them back after the model proposes content.
Limit body length before inference. Decide how to handle quoted history, HTML, images, attachments, and non-text content. A plain-text pilot can route unsupported content to manual review instead of silently discarding information the reply may depend on.
📌 The sender header is not proof of identity or authorization. The pilot only preserves the mailbox target; it does not establish that the requester may change an account, payment destination, contract, or password.
Give the model a proposal schema, not an action tool
The model should return a proposal with explicit fields:
{
"action": "create_draft",
"message_id": "msg-001",
"thread_id": "thread-001",
"to": "customer@example.com",
"cc": [],
"bcc": [],
"subject": "Re: Return window",
"body": "Proposed reply text",
"policy_ids": ["returns-001"]
}
n8n’s Structured Output Parser can require fields based on a JSON Schema. Use Define using JSON Schema when you need tighter control than generating a schema from an example. The documentation notes that generated example schemas treat every field as mandatory and that $ref is not supported.
Structured output is useful, but it only answers whether the model produced the expected shape. It does not prove that:
- the recipient is authorized;
- the policy ID exists;
- the answer follows the policy;
- a URL is safe;
- the source thread matches; or
- the requested action is permitted.
Those checks belong in deterministic code after parsing.
Keep policy facts small, versioned, and reviewable
For the pilot, each supported answer should have an ID, approved text, and approved URLs:
{
"version": "2026-11-01",
"entries": {
"returns-001": {
"answer": "Returns may be requested within 30 days, subject to the published conditions.",
"approved_urls": ["https://example.com/returns"]
}
}
}
Store policy outside the prompt when possible. Record the version used to create each draft. If a policy changes, a reviewer must be able to tell whether an older draft is now stale.
Start with questions that have low consequence and stable answers. Route these topics directly to manual handling:
- passwords, login changes, account recovery, or identity verification;
- invoices, refunds, payment instructions, bank details, or credit-card issues;
- contracts, legal notices, disputes, or compliance requests;
- personal or health information;
- threats, abuse, emergencies, or safety matters;
- attachments that must be interpreted; and
- any request outside the approved policy set.
The model can help label a topic, but the application should also apply deterministic routing rules. Neither layer is perfect; the draft remains pending human review either way.
Validate the proposal at the action boundary
The companion email_draft_gate.py accepts the source envelope, the model proposal, and the policy object. It then enforces the action contract.
The recipient check is exact:
if _email_address(proposal.get("to"), "proposal.to") != message.sender:
raise ValueError("proposal.to must equal the original sender")
CC and BCC are unavailable:
if proposal.get("cc") not in (None, [], "") or proposal.get("bcc") not in (None, [], ""):
raise ValueError("cc and bcc are not allowed in the draft pilot")
The action must remain draft-only:
if proposal.get("action") != "create_draft":
raise ValueError("proposal.action must be create_draft; sending is not authorized")
The gate also requires an exact source message ID and thread ID, preserves the reply subject, limits the body to 2,000 characters, rejects active or remote-loading HTML, verifies every policy ID, and rejects URLs not approved by the selected policy entries.
No semantic validator can prove that arbitrary generated prose is factually correct. The policy references and deterministic checks reduce the review surface; the human reviewer still compares the proposed wording with the cited policy.
Produce a review package, not a send token
A valid proposal becomes this kind of package:
{
"review_status": "pending_human",
"send_authorized": false,
"source": {
"message_id": "msg-001",
"thread_id": "thread-001",
"message_sha256": "08c2a108cfa26738cf46a2ff46971e07fc66cc3e2377894eeb9deb38cfdfb985"
},
"draft": {
"to": "customer@example.com",
"cc": [],
"bcc": [],
"subject": "Re: Return window",
"policy_ids": ["returns-001"],
"policy_version": "2026-11-01"
}
}
The full sample also contains the proposed body and reviewer flags. It intentionally contains no approval token and no send method.
A body hash can help identify which source text was reviewed without copying the entire body into every operational log. It is not anonymization: a hash of predictable content may still be guessable, and the original message remains personal or confidential data. Apply the organization’s retention, access, and incident-response rules.
Create the Gmail draft only after validation
Connect the passing branch to the Gmail node with:
Resource: Draft
Operation: Create
Email Type: Text
To Email: validated draft.to
Subject: validated draft.subject
Message: validated draft.body
Thread ID: validated source.thread_id
CC: empty
BCC: empty
Attachments: none in the pilot
n8n documents Draft Create separately from its Message Send and Reply operations. Google’s Gmail API documentation likewise treats drafts.create and drafts.send as distinct methods. A Gmail draft is an unsent message with the DRAFT system label; sending it creates a sent message and removes the draft.
After the node returns a draft ID, record the mapping:
source message ID → policy version → proposal hash → Gmail draft ID → pending review
Only then add a processed label or mark the source message as handled by this workflow. If draft creation fails, preserve the failure state and allow an operator to retry without creating duplicates.
Review the exact draft in Gmail
The reviewer should see the source message and the final Gmail draft, not only a summary generated by the same model.
Use a short checklist:
- Is the recipient exactly the original sender?
- Does the thread and subject match the incoming message?
- Is the request within the supported policy scope?
- Does every factual sentence match the cited policy entry?
- Are all links approved and correctly displayed?
- Does the message avoid confidential internal information?
- Is manual escalation required?
The reviewer sends from Gmail using the organization’s ordinary process. That action is intentionally outside the pilot workflow.
n8n also offers human-in-the-loop review for AI tool calls. Its documentation specifically identifies external messages, record changes, purchases, and deletions as actions that benefit from approval. If a later phase adds a send tool, connect that exact tool to a human review step and display its actual parameters. A prompt that merely says “ask for approval” does not stop execution.
⚠️ Approval fatigue is a real operational failure mode. Do not present dozens of low-context requests to reviewers until “Approve” becomes a reflex. Keep the supported scope narrow, show the recipient and proposed content, and monitor denial and correction reasons.
Treat prompt-injection screening as an alert
The sample gate flags phrases such as “ignore previous instructions” and “reveal the system prompt.” This produces a visible review signal:
{
"possible_prompt_injection": true,
"sensitive_topics": [],
"send_authorized": false
}
Do not invert this logic and assume an email is safe when no pattern matches. OWASP explains that keyword filters can miss paraphrases, other languages, encodings, hidden characters, and indirect instructions. They also produce false positives for ordinary messages.
The security property comes from the action boundary:
- the model has no send tool;
- the only allowed action string is
create_draft; - the recipient comes from the source envelope;
- CC and BCC are empty;
- links come from approved policy entries;
- sensitive topics are visible to a reviewer; and
- the result is pending human review.
Prompt wording and screening are additional layers, not substitutes for those controls.
Configure credentials and logs deliberately
Use an n8n Google credential rather than embedding tokens in prompts, expressions, Code nodes, or static workflow data. Review the OAuth scopes required by the exact Gmail operations. Google documents gmail.compose as one of the scopes that can create drafts, while other broader scopes can also do so. Choose the narrowest scope supported by the deployment and required operations.
Separate development and production mailboxes. Use synthetic messages and example.com addresses during testing. Do not test prompt injection with real secrets in the system prompt or real recipient lists.
For execution data, prefer metadata needed for support and audit:
- workflow and execution ID;
- source message ID and thread ID;
- input hash, policy version, and proposal hash;
- validation result and reason code;
- draft ID when created;
- reviewer outcome; and
- timestamps.
Avoid retaining full message bodies, model prompts, credentials, tokens, or unnecessary attachments in logs. Confirm n8n execution-data retention settings and the organization’s privacy requirements before activating the workflow.
Add failure handling before activation
Create a separate n8n error workflow beginning with Error Trigger. Its notification should identify the workflow, execution, source message ID, and failed stage without copying the full email body or credential data.
Define responses for these cases:
| Failure | Workflow response |
|---|---|
| Invalid sender or malformed envelope | stop and route to manual queue |
| Structured output failure | stop; do not guess fields |
| Unknown policy ID | stop and notify policy owner |
| Recipient or thread mismatch | security alert; no draft |
| Unapproved URL | security alert; no draft |
| Gmail draft creation failure | retain pending state; bounded retry |
| Duplicate source message ID | return existing draft record or stop |
| Sensitive topic | create no automatic draft, or clearly flag for manual handling |
Retries should be limited to transient service failures. Do not retry validation failures until the underlying message, policy, or proposal changes.
Run the contract tests outside n8n
The companion files use the Python standard library:
py email_draft_gate.py sample-draft.json --output sample-gated-draft.json
py -m unittest -v test_email_draft_gate.py
Nine deterministic tests passed while preparing this guide. They verify that:
- a valid proposal remains pending and unsent;
- a send action is rejected;
- recipient substitution is rejected;
- CC or BCC is rejected;
- an unknown policy ID is rejected;
- an unapproved URL is rejected;
- an obvious prompt-injection phrase is flagged without gaining authority;
- a sensitive financial topic is flagged; and
- a mismatched source message ID is rejected.
These tests cover the included gate, not an imported n8n workflow, Gmail OAuth configuration, a language model, or an organization’s policy accuracy. Port the same checks to a Code node or a controlled internal validation service, then verify the n8n execution data for every branch.
Test the complete workflow with harmless messages
Before activation, use a test mailbox and record the expected result for at least these cases:
- a supported FAQ question;
- an unknown question;
- a sender with a display name;
- a message that tries to change the recipient;
- a proposed CC or BCC;
- an approved policy link;
- an unapproved external link;
- a malformed structured response;
- an invoice or bank-change request;
- an account-recovery request;
- a quoted instruction telling the model to ignore its rules;
- the same Gmail message delivered to the workflow twice;
- Gmail draft creation returning a transient error; and
- the error notification path.
The observable success condition is not “the model replied politely.” It is that only the supported message creates one draft, every rejected case creates no draft, no case sends mail, and each branch leaves an inspectable reason.
Expand authority only after evidence
Track how reviewers change drafts:
- recipient corrections;
- factual corrections;
- policy gaps;
- tone edits;
- deleted links;
- sensitive-topic escalations; and
- duplicate or irrelevant drafts.
Use those corrections to improve the policy and routing rules. Do not use a high approval rate alone to justify automatic sending; reviewers may approve by habit, and low-risk traffic may not represent the messages that arrive later.
A later send-enabled phase needs a separate threat review, action-specific approval for the exact recipient and content, authorization checks outside the model, revocation and incident procedures, and regression tests after prompt, model, tool, or policy changes.
For many teams, draft assistance is already the useful stopping point. It reduces blank-page work while preserving the person who owns the customer relationship as the final sender.
Comments
Post a Comment