Write and Send Email Sequences with AI

Lifecycle & Email
TripleDart
A build kit for an n8n workflow where AI writes each email from the contact's details, code checks the draft, a person approves it, and Gmail sends it.
Source
TripleDart
Tools
n8n, an AI model, Gmail
Runs
Every weekday
Cost
Free kit, model fees per email
Last updated
October 16, 2026

Key Takeaways

  • The AI writes one email at a time from a row in your sheet, so every email can name the reason you're writing.
  • Code checks every draft before review, and blocks leftover placeholders, missing opt-out lines and banned phrases.
  • For the first two weeks, drafts wait in Gmail until a person sends them.
  • Before every follow-up, the workflow checks the thread and stops the sequence if it finds a reply or a bounce.
  • Use the pre-written templates instead when the same words already work for everyone.

What it is.

Pre-written templates send the same words to everyone. This workflow writes each email fresh from a row in your contact sheet, using the person's name, their company and the reason you're writing to them, which you add as a signal.

An AI step drafts the email under a set list of rules. A code step then checks the draft against thirteen of them, and blocks it if a placeholder is left in, the opt-out line is missing, a banned phrase slipped through or the body runs over the word limit.

Approved drafts send from a person's Gmail inbox in plain text, and follow-ups reply in the same thread. The gate also works out when the next email is due, so a cold sequence runs on days 1, 4, 8 and 14.

Before every follow-up, a second code step stops the sequence if the contact replied, or if a bounce notice turned up in the thread or the inbox. In review mode, a third step notices when someone has sent a draft and dates the next email from that day.

The kit is the parts and the instructions: a prompt, three Code nodes, a contact sheet and a node-by-node guide. You assemble the workflow in your own n8n, and a small test workflow lets you check the code before you connect any accounts.

What you get.

  • A drafting prompt that writes one email at a time from your contact fields, with the n8n expression for each field.
  • Gate code that checks every draft against thirteen rules, fills in your footer and sets the next send date.
  • Stop-check code that ends a sequence on a reply, or on a bounce notice in the thread or the inbox.
  • Review-check code that spots a sent draft and dates the next email from the day it went out.
  • A test workflow that runs all three Code nodes inside n8n on sample data, with no logins needed.
  • A contact sheet and a node-by-node setup guide, from the schedule trigger to the row update.
HOW TO USE IT

How to set it up.

1

Import the test workflow into n8n and run it, to confirm the three Code nodes work in your instance before you connect anything.

2

Import the contact sheet into Google Sheets, add ten contacts with a specific signal each, and set their status to ready and their first send date.

3

Build the workflow from the guide: read the sheet, route rows by status, and run the stop check before every follow-up.

4

Add an AI chat node with the drafting prompt, then an Edit Fields node that puts the sheet row back beside the model's text.

5

Paste the gate code into a Code node, edit its four settings, and send failing drafts to Slack with their reasons.

6

Send passing drafts to Gmail as drafts for the first two weeks; the review check picks up each one after someone sends it.

7

Read every draft from the first ten contacts, adjust the prompt where they miss, then switch trusted sequences to sending directly.

Use cases

Cold outreach from a signal

You add a contact because they posted a job ad or launched a product. The workflow writes a first email built on that signal, with the opt-out line in place, and holds it as a Gmail draft for the rep.

Sales follow-up after a call

You paste two lines of call notes into the signal column. The workflow drafts the recap and follow-ups from those notes, and once the buyer replies, no further follow-up goes out.

Welcome email that names the account

A new signup lands in the sheet with their use case. Each welcome email refers to that use case, and the gate blocks any draft missing the unsubscribe footer.

Nurture email after a download

Someone downloads a guide. The workflow writes each nurture email around the guide's topic, under the 180-word limit, with one ask per email.

Best for

Teams with good data and no time to write

You know why you're writing to each contact, and nobody has time to write fifty emails a week by hand.

Reps who want a first draft, not a robot

Every email still passes through a person for the first weeks, so the rep keeps control of what goes out under their name.

Ops people who already run n8n

The build is about twenty nodes and three pasted scripts. Anyone who has connected Gmail and Google Sheets in n8n can follow the guide.

The drafting prompt

Paste this into the AI step. Fill the square brackets once, and switch each curly-brace field to its n8n expression, such as {{ $json.first_name }}.

Prompt
You write one email in a {sequence} email sequence for [Company], which helps [type of customer] [get the outcome].

Write email number {email_number} of {email_total} to {first_name} at {company}.

What we know about them: {signal}
Proof you may use, and nothing else: [one customer result you can back up, with the customer's permission]
Sender: [Your name], [Your role], [Company]

Rules:
1. Use only the facts above. If a detail is missing, leave it out. Never invent a number, a customer or a quote.
2. Keep the body to 120 words or fewer for cold and sales email, and 180 or fewer for nurture and welcome email.
3. Ask for one thing only, and make it the last sentence before the sign-off.
4. Write in plain text. No images, no bold, and no links in the first cold email.
5. Do not use these phrases: "I hope this email finds you well", "just checking in", "touching base", "quick question", "circle back".
6. If the sequence is cold, end with this line exactly: If this isn't relevant, reply "no" and I won't email again.
7. If the sequence is nurture or welcome, end with %%UNSUBSCRIBE_FOOTER%% on its own line.
8. For email 2 onwards, write it as a follow-up in the same thread. Add something new, and don't repeat the first email.

Return exactly this, with nothing before or after:
SUBJECT: 
BODY:

The gate code

Paste this into an n8n Code node after the AI step, set to run once for all items, and edit the four settings at the top. Each item comes out with a pass flag and its reasons, plus the body to send and its next send date.

JavaScript
// gate.js: paste into an n8n Code node right after "Collect draft".
// Mode: Run Once for All Items. Each item needs json.output (the model's text),
// json.sequence, json.email_number and json.email_total.
// Each item comes out with pass, reasons, subject, body, body_to_send (footer filled in),
// gap_days (days to wait after this email is sent), next_send_date and is_last.
// Edit the four settings below before the first run.

// 1. Your footer for nurture and welcome email: postal address and unsubscribe link.
const FOOTER = "[Company], [postal address]\nUnsubscribe: [unsubscribe link]";
// 2. Word limits: the body may have this many words or fewer.
//    If you change these, change rule 2 in the drafting prompt to match.
const LIMITS = { cold: 120, sales: 120, nurture: 180, welcome: 180 };
const SUBJECT_MAX_WORDS = 8;
// 3. Days to wait after each email before the next one. One number per gap,
//    so a sequence has one more email than it has gaps.
//    Matches the templates: cold days 1, 4, 8, 14; sales 0, 2, 5, 9;
//    nurture 0, 3, 7, 14, 21; welcome 0, 1, 3, 7, 12.
const GAPS = { cold: [3, 4, 6], sales: [2, 3, 4], nurture: [3, 4, 7, 7], welcome: [1, 2, 4, 5] };
// 4. Phrases no draft may use.
const BANNED = ["i hope this email finds you well", "just checking in", "touching base", "quick question", "circle back"];

const FOOTER_TOKEN = "%%UNSUBSCRIBE_FOOTER%%";
const COLD_OPT_OUT = 'reply "no" and i won\'t email again';

function normalize(s) {
  return String(s || "")
    .replace(/\r\n?/g, "\n")
    .replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'")
    .replace(/\*\*(SUBJECT|BODY):\*\*/gi, "$1:")
    .trim();
}

function addDays(iso, n) {
  const d = new Date(iso + "T00:00:00Z");
  d.setUTCDate(d.getUTCDate() + n);
  return d.toISOString().slice(0, 10);
}

function countWords(s) { return String(s).split(/\s+/).filter(Boolean).length; }

function check(raw, sequence, emailNumber, emailTotal, today) {
  const reasons = [];
  const seq = String(sequence || "").trim().toLowerCase();
  const num = parseInt(emailNumber, 10) || 1;
  const gaps = GAPS[seq] || [];
  const planned = gaps.length + 1;
  const total = parseInt(emailTotal, 10) || planned;
  const empty = { subject: "", body_to_send: "", gap_days: "", next_send_date: "", is_last: false };

  if (!LIMITS[seq]) reasons.push(`Unknown sequence "${sequence}"`);
  else if (total !== planned) reasons.push(`email_total is ${total} but GAPS in gate.js plans ${planned} ${seq} emails: make them match`);
  if (num > total) reasons.push(`email_number ${num} is past email_total ${total}: the sequence should already be done`);

  const text = normalize(raw);
  const m = text.match(/^SUBJECT:[ \t]*(.+?)\n+BODY:[ \t]*\n?([\s\S]+)$/i);
  if (!m) return { pass: false, reasons: ["Output is not in the SUBJECT / BODY format", ...reasons], body: text, ...empty };
  const subject = m[1].trim();
  const body = m[2].trim();
  const lower = body.toLowerCase();

  if (/\[[^\]]+\]/.test(subject + "\n" + body)) reasons.push("A [square bracket] placeholder is still in the draft");
  if (/\{\{?[^}]*\}?\}/.test(subject + "\n" + body)) reasons.push("A {field} was not filled in");
  const words = countWords(body.replace(FOOTER_TOKEN, ""));
  if (LIMITS[seq] && words > LIMITS[seq]) reasons.push(`Body is ${words} words, limit is ${LIMITS[seq]} or fewer`);
  if (countWords(subject) > SUBJECT_MAX_WORDS) reasons.push(`Subject is ${countWords(subject)} words, limit is ${SUBJECT_MAX_WORDS} or fewer`);
  for (const p of BANNED) if ((subject + " " + lower).toLowerCase().includes(p)) reasons.push(`Banned phrase: "${p}"`);
  if (seq === "cold" && !lower.includes(COLD_OPT_OUT)) reasons.push("Cold email is missing the opt-out line");
  if (seq === "cold" && num === 1 && /https?:\/\/|www\./i.test(body)) reasons.push("First cold email contains a link");
  const marketing = seq === "nurture" || seq === "welcome";
  if (marketing && !body.includes(FOOTER_TOKEN)) reasons.push("Marketing email is missing the footer token");
  if (marketing && /\[[^\]]+\]/.test(FOOTER)) reasons.push("FOOTER in gate.js still has [square brackets]: add your address and unsubscribe link");

  const isLast = num >= total;
  // No date when the sequence is unknown or the gaps run out: the reasons above say why.
  const gap = isLast || typeof gaps[num - 1] !== "number" ? "" : gaps[num - 1];
  const base = today || new Date().toISOString().slice(0, 10);
  return {
    pass: reasons.length === 0, reasons, subject, body,
    body_to_send: body.replace(FOOTER_TOKEN, marketing ? FOOTER : "").trim(),
    gap_days: gap,
    next_send_date: gap === "" ? "" : addDays(base, gap),
    is_last: isLast,
  };
}

if (typeof module !== "undefined" && module.exports) module.exports = { check, normalize, addDays };
if (typeof $input !== "undefined") {
  return $input.all().map(item => ({
    json: { ...item.json, ...check(item.json.output, item.json.sequence, item.json.email_number, item.json.email_total) },
  }));
}

The stop-check code

Paste this into a Code node before each follow-up, set to run once for each item. Give it the thread's messages and the inbox's recent bounce notices, and it returns stop and the reason.

JavaScript
// stop-check.js: paste into an n8n Code node before drafting a follow-up.
// Mode: Run Once for Each Item. The item needs:
//   json.email     the contact's address
//   json.messages  the messages in the contact's thread (Gmail "Get a thread")
//   json.bounces   recent bounce notices from the inbox (Gmail "Get many messages"
//                  with the search: from:(mailer-daemon OR postmaster) newer_than:30d)
// Bounces are checked in both places, because a bounce notice does not always
// land in the original thread.
// Output: stop (true/false) and why ("replied", "bounced" or "").

function fromOf(msg) {
  if (msg.From) return String(msg.From);
  if (msg.from) return typeof msg.from === "string" ? msg.from : JSON.stringify(msg.from);
  const h = (msg.payload && msg.payload.headers) || msg.headers || [];
  const f = h.find(x => String(x.name).toLowerCase() === "from");
  return f ? String(f.value) : "";
}

function textOf(msg) {
  return [msg.snippet, msg.text, msg.textPlain, msg.html, msg.textAsHtml, msg.Subject, msg.subject]
    .filter(Boolean).map(String).join("\n");
}

const BOUNCE_SENDER = /mailer-daemon|postmaster/i;

function shouldStop(contactEmail, messages, bounces) {
  const who = String(contactEmail || "").trim().toLowerCase();
  for (const m of messages || []) {
    const from = fromOf(m).toLowerCase();
    if (BOUNCE_SENDER.test(from)) return { stop: true, why: "bounced" };
    if (who && from.includes(who)) return { stop: true, why: "replied" };
  }
  // A bounce notice names the failed address in its text, so match the contact's address.
  for (const b of bounces || []) {
    if (!BOUNCE_SENDER.test(fromOf(b))) continue;
    if (who && textOf(b).toLowerCase().includes(who)) return { stop: true, why: "bounced" };
  }
  return { stop: false, why: "" };
}

if (typeof module !== "undefined" && module.exports) module.exports = { shouldStop };
if (typeof $json !== "undefined") {
  return { json: { ...$json, ...shouldStop($json.email, $json.messages, $json.bounces) } };
}

The review-check code

Paste this into a Code node for rows whose status is drafted. It returns sent, the send date and the next send date counted from that day.

JavaScript
// review-check.js: paste into an n8n Code node for rows whose status is "drafted".
// Mode: Run Once for Each Item. The item needs json.messages (the draft's thread,
// from Gmail "Get a thread" with Simplify off, so each message keeps labelIds and
// internalDate), json.gap_days and json.is_last from the sheet row.
// When someone has sent the draft, it returns sent: true, the date it went out,
// and the next send date counted from that day. Update the row with these.

function addDays(iso, n) {
  const d = new Date(iso + "T00:00:00Z");
  d.setUTCDate(d.getUTCDate() + n);
  return d.toISOString().slice(0, 10);
}

function reviewCheck(messages, gapDays, isLast) {
  const sent = (messages || []).filter(m => {
    const labels = m.labelIds || m.labels || [];
    return labels.includes("SENT") && !labels.includes("DRAFT");
  });
  if (!sent.length) return { sent: false, sent_date: "", next_send_date: "" };
  const last = sent.reduce((a, b) => (Number(b.internalDate) > Number(a.internalDate) ? b : a));
  const sentDate = new Date(Number(last.internalDate)).toISOString().slice(0, 10);
  const done = isLast === true || String(isLast).toLowerCase() === "true";
  const gap = parseInt(gapDays, 10);
  return {
    sent: true,
    sent_date: sentDate,
    next_send_date: done || isNaN(gap) ? "" : addDays(sentDate, gap),
    new_status: done ? "done" : "sent",
  };
}

if (typeof module !== "undefined" && module.exports) module.exports = { reviewCheck };
if (typeof $json !== "undefined") {
  return { json: { ...$json, ...reviewCheck($json.messages, $json.gap_days, $json.is_last) } };
}

Related on the Exchange

These pages cover the pieces this workflow builds on:

Open the original.

Hosted on TripleDart, free to open.
Open the Tool

Questions about Write and Send Email Sequences with AI

Does the AI send emails by itself?
Is this an importable n8n workflow?
What does the gate check?
Which AI model should I use?
Can I use this without n8n?
How is this different from the email sequence templates?

Questions about Lifecycle & Email

Do I need an unsubscribe link in a cold email?
What does Gmail require from bulk senders?
How long should a cold email sequence run?
Strengths
  • The gate is code, so a draft with a leftover placeholder or no opt-out line cannot reach the send step.
  • The three Code nodes ran inside n8n with the expected results, and the test workflow lets you repeat that in your own instance.
  • Drafts land in Gmail before anything sends, and the next date counts from the day a person sends them.
  • Bounce notices are matched in the thread and in the inbox, since a bounce doesn't always join the original thread.
Limitations
  • It's ours. TripleDart runs the Exchange and wrote this workflow.
  • It's a build kit, not an importable workflow, so you assemble about twenty nodes yourself.
  • The Gmail, Google Sheets and AI steps haven't been run end to end, since they need your accounts. Run one contact in review mode first.
  • The AI writes from the signal column. A vague signal gives a vague email, and the gate cannot catch that.
  • The gate checks the rules. A sentence can pass every one of them and still be wrong about the contact.
  • Any message from the contact's address stops the sequence, including an out-of-office auto-reply. A reply from a different address, such as a colleague's, isn't noticed.
  • Gmail's n8n node can't add the one-click unsubscribe header Gmail asks bulk senders for, so nurture and welcome email at volume belongs in an email service provider.
Skip this if
  • Skip it if you want a workflow you import and run today. Rahul Joshi's n8n template 10827 imports ready-made and sends automatically, without a review step or a rule check.
  • Skip it if your sequences already book meetings with the same words for everyone. The pre-written templates are faster to run.
  • Skip it if you have no reason to write to each contact. The AI needs a signal to work from, and a list alone isn't one.

Want this running without building it yourself?

TripleDart has scaled 300+ tech companies with expert operators and AI workflows behind every play.