{
  "name": "Chase Unanswered Quotes Automatically and Stop the Moment Someone Replies",
  "nodes": [
    {
      "parameters": {
        "content": "# Chase quotes until someone answers\n\nQuotes go out, then get forgotten. This checks the register every morning, follows up on the ones that have gone quiet, and escalates anything still unanswered after the last attempt.\n\n**Sequence**\n1. Runs once each morning\n2. Reads your quote register in Google Sheets\n3. Works out which quotes are due a nudge, and which have run out of attempts\n4. Sends the follow-up, worded differently each time\n5. Escalates the rest to Slack and marks them cold\n6. Writes every action back to the sheet\n\n**It stops on reply.** Set a quote's status to `replied` or `won` (a Gmail filter or two clicks) and it is never chased again.\n\n**You need**\n- Google Sheets\n- Gmail\n- Slack\n\nBuilt by Better Automations (Melbourne, Australia).\nhttps://www.betterautomations.com.au/services/n8n-workflow-automation",
        "height": 640,
        "width": 460,
        "color": 4
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000001",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-660, -140]
    },
    {
      "parameters": {
        "content": "## Your quote register\n\nOne row per quote. The sheet needs these column headers, spelled exactly:\n\n`quote_id` · `client_name` · `client_email` · `amount` · `sent_date` · `status` · `attempts` · `last_contacted` · `owner_email`\n\n`status` starts as `sent`. Set it to `replied`, `won` or `lost` and the quote drops out of the chase immediately.\n\nLeave `attempts` and `last_contacted` blank on a new row. The workflow fills them.",
        "height": 400,
        "width": 360,
        "color": 7
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000002",
      "name": "Sticky Note - Sheet",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-160, -560]
    },
    {
      "parameters": {
        "content": "## Timing and tone\n\nDefault schedule is day 3, day 7, then day 14, and each message is worded differently. Three identical emails read as a robot; three that acknowledge time passing read as someone following up.\n\nChange `follow_up_days` in **Configuration** to suit your sales cycle. A trade quote might be 2, 5, 10. A six-figure proposal might be 7, 21, 45.",
        "height": 340,
        "width": 360,
        "color": 7
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000003",
      "name": "Sticky Note - Timing",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [700, -560]
    },
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 9, "triggerAtMinute": 0 }] }
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000010",
      "name": "Every Morning at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [-160, 0]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            { "id": "cfg-days", "name": "follow_up_days", "value": "3,7,14", "type": "string" },
            { "id": "cfg-max", "name": "max_attempts", "value": 3, "type": "number" },
            { "id": "cfg-chan", "name": "escalation_channel", "value": "#sales", "type": "string" },
            { "id": "cfg-sender", "name": "sender_name", "value": "Better Automations", "type": "string" },
            { "id": "cfg-open", "name": "open_statuses", "value": "sent,following_up", "type": "string" }
          ]
        },
        "options": {}
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000011",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [60, 0]
    },
    {
      "parameters": {
        "documentId": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Quote Register" },
        "sheetName": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Quotes" },
        "options": {}
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000012",
      "name": "Read Quote Register",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [280, 0]
    },
    {
      "parameters": {
        "jsCode": "// Decides, for every open quote, whether today is a follow-up day.\n// All the date arithmetic lives here so the rest of the workflow is just sending.\nconst cfg = $('Configuration').first().json;\nconst schedule = String(cfg.follow_up_days).split(',').map((d) => parseInt(d.trim(), 10));\nconst openStatuses = String(cfg.open_statuses).split(',').map((s) => s.trim());\n\nconst DAY = 24 * 60 * 60 * 1000;\nconst today = new Date();\ntoday.setHours(0, 0, 0, 0);\n\nconst daysBetween = (from) => {\n  const d = new Date(from);\n  if (Number.isNaN(d.getTime())) return null;\n  d.setHours(0, 0, 0, 0);\n  return Math.floor((today - d) / DAY);\n};\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const q = item.json;\n\n  // A quote leaves the chase the moment its status changes. This is the whole safety mechanism.\n  if (!openStatuses.includes(String(q.status ?? '').trim())) continue;\n  if (!q.client_email) continue;\n\n  const age = daysBetween(q.sent_date);\n  if (age === null) continue;\n\n  const attempts = parseInt(q.attempts, 10) || 0;\n\n  // Never twice in one day, whatever else is true.\n  if (q.last_contacted && daysBetween(q.last_contacted) === 0) continue;\n\n  if (attempts >= cfg.max_attempts) {\n    out.push({ json: { ...q, action: 'escalate', age_days: age, attempt_number: attempts,\n      reason: `${attempts} attempts over ${age} days with no reply` } });\n    continue;\n  }\n\n  // Due when the quote has reached the next scheduled day for this attempt number.\n  const dueAt = schedule[attempts];\n  if (dueAt === undefined || age < dueAt) continue;\n\n  const attemptNumber = attempts + 1;\n  const tone = [\n    { subject: `Following up on your quote`, body: `just checking this landed and whether you had any questions about it.` },\n    { subject: `Still thinking it over?`, body: `no rush at all, but I wanted to check whether anything in the quote needed changing. Happy to adjust the scope if the number is the sticking point.` },\n    { subject: `Should I close this one off?`, body: `I have not heard back, which is completely fine. Let me know either way and I will either keep it open or tidy it out of the system.` },\n  ][Math.min(attemptNumber - 1, 2)];\n\n  out.push({ json: { ...q, action: 'follow_up', age_days: age, attempt_number: attemptNumber,\n    email_subject: tone.subject, email_body: tone.body } });\n}\n\nreturn out;"
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000013",
      "name": "Decide Who Is Due",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [500, 0]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
                "conditions": [{ "id": "r-fu", "leftValue": "={{ $json.action }}", "rightValue": "follow_up", "operator": { "type": "string", "operation": "equals" } }],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "follow up"
            },
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
                "conditions": [{ "id": "r-esc", "leftValue": "={{ $json.action }}", "rightValue": "escalate", "operator": { "type": "string", "operation": "equals" } }],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "escalate"
            }
          ]
        },
        "options": {}
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000014",
      "name": "Follow Up or Escalate",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [760, 0]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.client_email }}",
        "subject": "={{ $json.email_subject }} ({{ $json.quote_id }})",
        "emailType": "text",
        "message": "=Hi {{ $json.client_name.split(' ')[0] }},\n\nI sent through a quote {{ $json.age_days }} days ago for ${{ $json.amount }}, and {{ $json.email_body }}\n\nIf it is easier to talk it through, just reply with a couple of times that suit.\n\n{{ $('Configuration').first().json.sender_name }}",
        "options": { "replyTo": "={{ $json.owner_email }}" }
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000015",
      "name": "Send the Follow-up",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [1040, -120],
      "webhookId": "c3d4e5f6-0003-4000-8000-00000000f001"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Quote Register" },
        "sheetName": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Quotes" },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "quote_id": "={{ $json.quote_id }}",
            "status": "following_up",
            "attempts": "={{ $json.attempt_number }}",
            "last_contacted": "={{ $now.format('yyyy-MM-dd') }}"
          },
          "matchingColumns": ["quote_id"]
        },
        "options": {}
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000016",
      "name": "Record the Attempt",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [1300, -120]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "={{ $('Configuration').first().json.escalation_channel }}", "mode": "name" },
        "text": "=:hourglass: *Quote has gone cold*\n\n*{{ $json.client_name }}* · {{ $json.quote_id }} · ${{ $json.amount }}\nSent {{ $json.age_days }} days ago · {{ $json.reason }}\nOwner: {{ $json.owner_email }}\n\nMarked cold and taken out of the chase. Worth a phone call if it matters.",
        "otherOptions": { "includeLinkToWorkflow": false }
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000017",
      "name": "Escalate to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1040, 140],
      "webhookId": "c3d4e5f6-0003-4000-8000-00000000f002"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Quote Register" },
        "sheetName": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Quotes" },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "quote_id": "={{ $json.quote_id }}",
            "status": "cold",
            "last_contacted": "={{ $now.format('yyyy-MM-dd') }}"
          },
          "matchingColumns": ["quote_id"]
        },
        "options": {}
      },
      "id": "c3d4e5f6-0003-4000-8000-000000000018",
      "name": "Mark as Cold",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [1300, 140]
    }
  ],
  "connections": {
    "Every Morning at 9am": { "main": [[{ "node": "Configuration", "type": "main", "index": 0 }]] },
    "Configuration": { "main": [[{ "node": "Read Quote Register", "type": "main", "index": 0 }]] },
    "Read Quote Register": { "main": [[{ "node": "Decide Who Is Due", "type": "main", "index": 0 }]] },
    "Decide Who Is Due": { "main": [[{ "node": "Follow Up or Escalate", "type": "main", "index": 0 }]] },
    "Follow Up or Escalate": {
      "main": [
        [{ "node": "Send the Follow-up", "type": "main", "index": 0 }],
        [{ "node": "Escalate to Slack", "type": "main", "index": 0 }]
      ]
    },
    "Send the Follow-up": { "main": [[{ "node": "Record the Attempt", "type": "main", "index": 0 }]] },
    "Escalate to Slack": { "main": [[{ "node": "Mark as Cold", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1" },
  "pinData": {},
  "meta": {},
  "tags": []
}
