Nodes:
name: Update last contacted time
type: n8n-nodes-base.googleSheets
parameters: columns: value: row_number: ={{ $('To email?').item.json.row_number }}
first_emailed: ={{ $now.format('yyyy-MM-dd') }}
schema: type: string
display: yes
removed: yes
required: no
displayName: email
defaultMatch: no
canBeUsedToMatch: yes
type: string
display: yes
removed: no
required: no
displayName: first_emailed
defaultMatch: no
canBeUsedToMatch: yes
type: string
display: yes
removed: yes
required: no
displayName: name
defaultMatch: no
canBeUsedToMatch: yes
type: string
display: yes
removed: yes
required: no
displayName: company
defaultMatch: no
canBeUsedToMatch: yes
type: string
display: yes
removed: no
readOnly: yes
required: no
displayName: row_number
defaultMatch: no
canBeUsedToMatch: yes
mappingMode: defineBelow
matchingColumns: row_number
operation: update
sheetName: __rl: yes
mode: url
value: ={{ $('Settings').item.json.sheet_url }}
documentId: __rl: yes
mode: url
value: ={{ $('Settings').item.json.sheet_url }}
name: Set message template
type: n8n-nodes-base.set
disabled: yes
parameters: fields: values: name: message_template
stringValue: ={{ $('Email sequence').first().json.emails[0].message }}
name: email
stringValue: david@thedavid.co.uk
name: name
stringValue: Daffyd
name: company
stringValue: Davey Enterprises
name: subject
stringValue: ={{ $('Settings').item.json.subject }}
name: sender_name
stringValue: ={{ $('Settings').item.json.sender_name }}
name: mail_id
stringValue: ={{ $('Settings').item.json.mail_id }}
name: mail_seq
stringValue: 0
include: none
name: Email sequence
type: n8n-nodes-base.set
parameters: mode: raw
jsonOutput: {
  "emails": [
    {
      "message": "Hi {name}, hope you're well!<br />\n<br />\nYou're doing some great things at {company}, and I wanted to touch base to see if you wanted to chat.<br />\n<br />\nWould it make sense to jump on a quick call?<br />\n<br />\nRegards,<br />\n<br />\nNathan<br />\n",
      "send_on_day": 0
    },
    {
      "message": "Hi {name},<br />\n<br />\nJust wanted to follow up on this, since it would be great to talk.<br />\n<br />\nRegards,<br />\n<br />\nNathan<br />\n",
      "send_on_day": 3
    },
    {
      "message": "Just thought I'd give this one last try :)<br />\n<br />\nNathan\n",
      "send_on_day": 8
    }
  ]
}

name: Fill message placeholders
type: n8n-nodes-base.code
parameters: mode: runOnceForEachItem
jsCode: function extractPlaceholders(str) {
    // Regular expression to match placeholders
    // It matches any alphanumeric character including dashes and underscores between {}
    const regex = /\{([a-zA-Z0-9_-]+)\}/g;
    
    // Set to store unique placeholders
    const uniquePlaceholders = new Set();

    // Extract and store unique placeholders
    let match;
    while ((match = regex.exec(str)) !== null) {
        uniquePlaceholders.add(match[1]);
    }

    // Convert the Set to an array and return
    return Array.from(uniquePlaceholders);
}

let placeholders = Object.keys(item.json.placeholders)
console.log(placeholders)

// Substitute all the placeholders in the message
  item.json.message = item.json.message_template
  for (let key of extractPlaceholders(item.json.message_template)) {
    if(!placeholders.includes(key)) throw new Error(`Missing data for placeholder '{${key}}'`)
    const regex = new RegExp(`\\{${key}\\}`, 'g'); // Create a regex to match the exact word surrounded by {}
    item.json.message = item.json.message.replaceAll(regex, item.json.placeholders[key]);
  }

return item
name: Compose message
type: n8n-nodes-base.set
parameters: fields: values: name: message
stringValue: =<span data-cam='{{ $json.mail_id }}' data-seq='{{ $json.mail_seq }}' data-ph='{{ JSON.stringify($json.placeholders) }}'></span>{{ $json.message }}
name: Get previous message threads
type: n8n-nodes-base.gmail
parameters: filters: q: =subject:{{ $json.subject }} after:{{ $now.minus({'days': $json.emails.last().send_on_day+1}).toSQL().substr(0, 10) }}
resource: thread
returnAll: yes
name: Get thread details
type: n8n-nodes-base.gmail
parameters: simple: no
resource: thread
threadId: ={{ $json.id }}
operation: get
name: Classify threads
type: n8n-nodes-base.code
parameters: jsCode: // Because emails are sent slightly after the schedule trigger runs, we'll end up waiting an extra day to send unless we take into account the execution time of the workflow
let buffer_mins = 20

let templates = $('Email sequence').first().json.emails

function next_sequence_number(messages, mail_id, sender_name) {
  for (let i = 0; i < messages.length; i++) {
    if(!('html' in messages[i])) return -1;
    let in_campaign = messages[i].html.includes("data-cam='"+mail_id+"'")
    let valid_seq = messages[i].html.includes("data-seq='"+i+"'")
    let from_us = messages[i].From.includes(sender_name)
    console.log(in_campaign + ", " + valid_seq + ", " + from_us)
    if(!(from_us && in_campaign && valid_seq)) {
      return -1;
    }
  }
  return messages.length;
}


for (const item of $input.all()) {
  item.json.first_message_at = DateTime.fromMillis($('Get thread details').item.json.messages[0].internalDate*1)
  item.json.days_since_first_message = DateTime.now().diff(item.json.first_message_at, 'days').days
  item.json.next_sequence_number = next_sequence_number(
    item.json.messages,
    $('Settings').first().json.mail_id,
    $('Settings').first().json.sender_name
  );
  item.json.next_message_due = (
    item.json.next_sequence_number > 0
    && item.json.next_sequence_number < templates.length
    && templates[item.json.next_sequence_number].send_on_day <= item.json.days_since_first_message + (buffer_mins/60/24)
  )

  // Retrieve the placeholder values from the snippet, for use in future messages
  const ph_matches = item.json.messages[0].snippet.match(/data-ph='([^']*)'/)
  if(ph_matches?.length > 1) {
    const placeholders = JSON.parse(ph_matches[1])
    for(key of placeholders.keys()) {
      item.json[key] = placeholders[key]
    }
  }

}

return $input.all();
name: Next message due?
type: n8n-nodes-base.filter
parameters: conditions: options: caseSensitive: yes
typeValidation: strict
combinator: and
conditions: operator: type: boolean
operation: true
singleValue: yes
leftValue: ={{ $json.next_message_due }}
name: Sticky Note2
type: n8n-nodes-base.stickyNote
parameters: color: 7
width: 181.66573318934627
height: 344.96230939963334
content: Follow-up is due if:
- All the messages in the thread are automated (no-one has replied yet)
- Enough time has passed for the next message to be sent
name: Execute Workflow Trigger
type: n8n-nodes-base.executeWorkflowTrigger
name: Replying?
type: n8n-nodes-base.if
parameters: conditions: options: caseSensitive: yes
typeValidation: strict
combinator: and
conditions: operator: type: string
operation: exists
singleValue: yes
leftValue: ={{ $json.reply_message_id }}
name: Send new message
type: n8n-nodes-base.gmail
parameters: sendTo: ={{ $json.to_email }}
message: ={{ $json.message }}
options: senderName: ={{ $json.sender_name }}
appendAttribution: no
subject: ={{ $json.subject }}
name: Call message send sub-workflow
type: n8n-nodes-base.executeWorkflow
parameters: workflowId: ={{ $workflow.id }}
name: Prepare reply params
type: n8n-nodes-base.set
parameters: fields: values: name: message_template
stringValue: ={{ $('Email sequence').first().json.emails[$json.next_sequence_number].message }}
name: reply_message_id
stringValue: ={{ $json.messages.last().id }}
name: sender_name
stringValue: ={{ $('Settings').item.json.sender_name }}
name: mail_id
stringValue: ={{ $('Settings').item.json.mail_id }}
name: mail_seq
stringValue: ={{ $json.next_sequence_number }}
name: to
stringValue: ={{ $json.messages[0].To }}
include: none
name: Prepare first message params
type: n8n-nodes-base.set
parameters: fields: values: name: message_template
stringValue: ={{ $('Email sequence').first().json.emails[0].message }}
name: to_email
stringValue: ={{ $('Get emails').item.json[$('Settings').item.json.email_column_name] }}
name: subject
stringValue: ={{ $('Settings').item.json.subject }}
name: sender_name
stringValue: ={{ $('Settings').item.json.sender_name }}
name: mail_id
stringValue: ={{ $('Settings').item.json.mail_id }}
name: mail_seq
stringValue: 0
name: Sticky Note4
type: n8n-nodes-base.stickyNote
parameters: color: 7
width: 181.66573318934627
height: 410.4105111871959
content: Columns the sheet needs
- email
- first_emailed (leave blank - will be filled in automatically)
- Other columns matching placeholders in email sequence
name: Call message send sub-workflow1
type: n8n-nodes-base.executeWorkflow
parameters: mode: each
workflowId: ={{ $workflow.id }}
name: To email?
type: n8n-nodes-base.filter
parameters: conditions: options: caseSensitive: yes
typeValidation: strict
combinator: and
conditions: operator: type: boolean
operation: true
singleValue: yes
leftValue: ={{ $json.first_emailed.isEmpty() }}
name: Decode messages
type: n8n-nodes-base.code
parameters: jsCode: // TODO: Some messages have an empty payload and a parts field instead (containing an array)

for (const item of $input.all()) {
  for (const message of item.json.messages) {
    console.log('message', message.payload.body.data || "")
    let buffer = Buffer.from(message.payload.body.data || "", "base64");
    message.html = buffer.toString("utf8")
  }
}

return $input.all();
name: Decode placeholder values
type: n8n-nodes-base.code
parameters: mode: runOnceForEachItem
jsCode: const html = $('Decode messages').item.json.messages[0].html
const matches = html.match(/data-ph='([^']*)'/)
let placeholders = {}
if(matches?.length > 0) {
  ph = JSON.parse(matches[1])
  for(k of Object.keys(ph)) {
    placeholders[k] = ph[k]
  }
}
item.json.placeholders = placeholders

return item;
name: Package placeholder values
type: n8n-nodes-base.code
parameters: mode: runOnceForEachItem
jsCode: function extractPlaceholders(str) {
    // Regular expression to match placeholders
    // It matches any alphanumeric character including dashes and underscores between {}
    const regex = /\{([a-zA-Z0-9_-]+)\}/g;
    
    // Set to store unique placeholders
    const uniquePlaceholders = new Set();

    // Extract and store unique placeholders
    let match;
    while ((match = regex.exec(str)) !== null) {
        uniquePlaceholders.add(match[1]);
    }

    // Convert the Set to an array and return
    return Array.from(uniquePlaceholders);
}


// Gather all the placeholder values for passing on
const all_ph_raw = $('Email sequence').first().json.emails.flatMap(e => extractPlaceholders(e.message))
const all_ph = [...new Set(all_ph_raw)];
let placeholders = {}
for(ph of all_ph) {
  if($('Get emails').item.json[ph] == undefined) throw new Error(`Message placeholder '{${ph}}' requires a column called '${ph}' in the Google Sheet`)
  placeholders[ph] = $('Get emails').item.json[ph]
}

item.json.placeholders = placeholders

return item;
name: Settings
type: n8n-nodes-base.set
parameters: assignments: assignments: name: sheet_url
type: string
value: https://docs.google.com/spreadsheets/d/12dsbRpvtVFDdPmyZ7-39vuHuFpM1eMyfOqGGGdsnrtc/edit#gid=0
name: subject
type: string
value: My amazing campaign
name: sender_name
type: string
value: Nathan Automat
name: email_column_name
type: string
value: email
name: mail_id
type: string
value: 123456
name: Reply to message
type: n8n-nodes-base.gmail
parameters: message: ={{ $json.message }}
options: senderName: David Roberts
messageId: ={{ $json.reply_message_id }}
operation: reply
name: Get emails
type: n8n-nodes-base.googleSheets
parameters: sheetName: __rl: yes
mode: url
value: ={{ $('Settings').item.json.sheet_url }}
documentId: __rl: yes
mode: url
value: ={{ $('Settings').item.json.sheet_url }}
name: Sticky Note5
type: n8n-nodes-base.stickyNote
parameters: color: 7
width: 1790.5345476157208
height: 515.1374038700677
content: ## Send initial emails
name: Sticky Note6
type: n8n-nodes-base.stickyNote
parameters: color: 7
width: 1797.4980261229769
height: 515.1374038700677
content: ## Send follow-up emails if appropriate
name: Sticky Note7
type: n8n-nodes-base.stickyNote
parameters: color: 7
width: 1797.4980261229769
height: 515.1374038700677
content: ## Sub-workflow for sending the emails
This is called by the sections above - you shouldn't need to touch it
name: Every hour
type: n8n-nodes-base.scheduleTrigger
parameters: rule: interval: field: hours
triggerAtMinute: 12
name: Sticky Note3
type: n8n-nodes-base.stickyNote
parameters: color: 7
width: 181.66573318934627
height: 306.5470605243249
content: If reply_message_id is set, will reply to that message.

Otherwise, will send a new message to the user in the 'email' field
name: Don't email on weekends
type: n8n-nodes-base.filter
parameters: conditions: options: caseSensitive: yes
typeValidation: strict
combinator: and
conditions: operator: type: boolean
operation: false
singleValue: yes
leftValue: ={{ $now.isWeekend() }}
name: Sticky Note8
type: n8n-nodes-base.stickyNote
parameters: width: 404.04123613412946
height: 418.96364526464185
content: # Try me out
1. Clone [this Google worksheet](https://docs.google.com/spreadsheets/d/12dsbRpvtVFDdPmyZ7-39vuHuFpM1eMyfOqGGGdsnrtc/edit#gid=0) and update the 'Settings' node with its URL (plus change any other settings there you need to)
2. Adjust the sequence of emails you want to send in the 'Email Sequence' node

Make sure you set how many days after the previous email to wait before sending, and note that the emails are in HTML format.

Connections:
Settings: main: node: Email sequence
type: main
Replying?: main: node: Reply to message
type: main
node: Send new message
type: main
To email?: main: node: Prepare first message params
type: main
Every hour: main: node: Don't email on weekends
type: main
Get emails: main: node: To email?
type: main
Email sequence: main: node: Get previous message threads
type: main
node: Get emails
type: main
Compose message: main: node: Replying?
type: main
Decode messages: main: node: Classify threads
type: main
Classify threads: main: node: Next message due?
type: main
Next message due?: main: node: Prepare reply params
type: main
Get thread details: main: node: Decode messages
type: main
Prepare reply params: main: node: Decode placeholder values
type: main
Set message template: main: node: Fill message placeholders
type: main
Don't email on weekends: main: node: Settings
type: main
Execute Workflow Trigger: main: node: Set message template
type: main
Decode placeholder values: main: node: Call message send sub-workflow
type: main
Fill message placeholders: main: node: Compose message
type: main
Package placeholder values: main: node: Call message send sub-workflow1
type: main
Get previous message threads: main: node: Get thread details
type: main
Prepare first message params: main: node: Package placeholder values
type: main
Call message send sub-workflow1: main: node: Update last contacted time
type: main