Skip to content

In n8n, copy this file and paste it onto the canvas, or use Import from File.

{
  "name": "19 - Real Estate Lead Nurture",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "re/lead",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m19-lead-api",
      "name": "New Lead API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ],
      "webhookId": "course-19-lead"
    },
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "from:(zillow.com OR realtor.com OR homes.com OR redfin.com) newer_than:1d",
          "readStatus": "unread"
        },
        "options": {}
      },
      "id": "m19-gmail",
      "name": "Portal Lead Emails",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        180
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 1
            }
          ]
        }
      },
      "id": "m19-drip-schedule",
      "name": "Drip Every Hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        420
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "re/drip/run",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m19-drip-api",
      "name": "Drip Run API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        600
      ],
      "webhookId": "course-19-drip"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "re/lead/stop",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m19-stop-api",
      "name": "Stop Lead API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        780
      ],
      "webhookId": "course-19-stop"
    },
    {
      "parameters": {
        "path": "re/lead/status",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m19-status-api",
      "name": "Lead Status API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        960
      ],
      "webhookId": "course-19-status"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "brokerage",
              "value": "Hill Country Realty",
              "type": "string"
            },
            {
              "id": "s2",
              "name": "booking_link",
              "value": "https://cal.com/hill-country-realty/showing",
              "type": "string"
            },
            {
              "id": "s3",
              "name": "agents",
              "value": "={{ [ { name: 'Maria Lopez', email: 'maria@hillcountry.example', phone: '(512) 555-0101' }, { name: 'James Carter', email: 'james@hillcountry.example', phone: '(512) 555-0102' } ] }}",
              "type": "array"
            },
            {
              "id": "s4",
              "name": "sms_from",
              "value": "+15125550100",
              "type": "string"
            },
            {
              "id": "s5",
              "name": "sms_enabled",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "s6",
              "name": "dedupe_days",
              "value": 30,
              "type": "number"
            },
            {
              "id": "s7",
              "name": "drip",
              "value": "={{ [ { step: 'checkin', day: 1 }, { step: 'listings', day: 3 }, { step: 'last_chance', day: 7 } ] }}",
              "type": "array"
            },
            {
              "id": "s8",
              "name": "listings",
              "value": "={{ [\n  { id: 'HC-1001', address: '214 Live Oak St', city: 'Austin', price: 465000, beds: 3, baths: 2, type: 'sale', status: 'active', listed_at: '2026-10-01', url: 'https://example.com/listings/HC-1001' },\n  { id: 'HC-1002', address: '88 Barton Hills Dr', city: 'Austin', price: 520000, beds: 3, baths: 2.5, type: 'sale', status: 'active', listed_at: '2026-10-05', url: 'https://example.com/listings/HC-1002' },\n  { id: 'HC-1003', address: '1910 Cedar Bend Ln', city: 'Austin', price: 430000, beds: 3, baths: 2, type: 'sale', status: 'active', listed_at: '2026-09-28', url: 'https://example.com/listings/HC-1003' },\n  { id: 'HC-1004', address: '5 Riverside Ct', city: 'Austin', price: 899000, beds: 5, baths: 4, type: 'sale', status: 'active', listed_at: '2026-10-08', url: 'https://example.com/listings/HC-1004' },\n  { id: 'HC-1005', address: '742 Maple Way', city: 'Round Rock', price: 410000, beds: 4, baths: 3, type: 'sale', status: 'active', listed_at: '2026-10-03', url: 'https://example.com/listings/HC-1005' },\n  { id: 'HC-1006', address: '300 Lamar Blvd Unit 12', city: 'Austin', price: 2400, beds: 2, baths: 2, type: 'rent', status: 'active', listed_at: '2026-10-09', url: 'https://example.com/listings/HC-1006' },\n  { id: 'HC-1007', address: '61 Pecan Grove Rd', city: 'Austin', price: 2150, beds: 2, baths: 1, type: 'rent', status: 'active', listed_at: '2026-10-02', url: 'https://example.com/listings/HC-1007' },\n  { id: 'HC-1008', address: '17 Bluebonnet Dr', city: 'Austin', price: 489000, beds: 4, baths: 3, type: 'sale', status: 'pending', listed_at: '2026-09-20', url: 'https://example.com/listings/HC-1008' }\n] }}",
              "type": "array"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m19-settings",
      "name": "Settings (edit me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        480
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r1",
                    "leftValue": "={{ $('New Lead API').isExecuted || $('Portal Lead Emails').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "new lead"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r2",
                    "leftValue": "={{ $('Drip Every Hour').isExecuted || $('Drip Run API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "drip"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r3",
                    "leftValue": "={{ $('Stop Lead API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "stop"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r4",
                    "leftValue": "={{ $('Lead Status API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "status"
            }
          ]
        },
        "options": {}
      },
      "id": "m19-route",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        460,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "// Turn any inquiry into one clean lead: structured JSON, a pasted portal email, or the Gmail trigger.\nconst viaApi = $('New Lead API').isExecuted;\nconst src = viaApi ? $('New Lead API').first().json : $('Portal Lead Emails').first().json;\nconst b = viaApi ? src.body ?? {} : {};\n\n// Raw email text (API \"raw_email\" or Gmail trigger)\nconst from = viaApi ? String(b.from ?? '') : String(src.from?.value?.[0]?.address ?? src.from ?? '');\nconst subject = viaApi ? String(b.subject ?? '') : String(src.subject ?? '');\nconst raw = viaApi ? String(b.raw_email ?? '') : String(src.text ?? src.snippet ?? '');\nconst all = `${subject}\\n${raw}`;\nconst field = (label) => (raw.match(new RegExp(`^\\\\s*${label}\\\\s*[:\\\\-]\\\\s*(.+)$`, 'im'))?.[1] ?? '').trim();\n\nconst name = String(b.name ?? '').trim() || field('Name') || field('From');\nconst email = (String(b.email ?? '').trim() || field('Email') || (raw.match(/[\\w.+-]+@[\\w-]+\\.[\\w.-]+/)?.[0] ?? '')).toLowerCase();\nlet phone = (String(b.phone ?? '') || field('Phone') || (raw.match(/\\(?\\d{3}\\)?[\\s.-]\\d{3}[\\s.-]\\d{4}/)?.[0] ?? '')).replace(/[^\\d+]/g, '');\nif (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\nif (/^1\\d{10}$/.test(phone)) phone = '+' + phone;\nconst message = String(b.message ?? '').trim() || field('Message') || field('Comments');\nconst property = String(b.property ?? '').trim() || field('Property') || field('Listing') || (all.match(/\\d{2,6} [A-Z][\\w ]+? (?:St|Street|Ave|Avenue|Rd|Road|Dr|Drive|Ln|Lane|Blvd|Ct|Way)\\b[^\\n]*/)?.[0] ?? '').trim();\nconst listingId = String(b.listing_id ?? '').trim() || field('Listing ID') || (all.match(/\\b(?:MLS|Listing)\\s*#?\\s*([A-Z0-9-]{4,})/i)?.[1] ?? '');\n\nconst text = `${message} ${subject} ${raw}`.toLowerCase();\nconst sourceHay = `${b.source ?? ''} ${from} ${subject}`.toLowerCase();\nconst source = ['zillow', 'realtor.com', 'homes.com', 'redfin', 'trulia'].find((s) => sourceHay.includes(s)) ?? (String(b.source ?? '').toLowerCase() || 'website');\nconst intent = /\\b(rent|lease|rental)\\b/.test(text) ? 'renter' : /(sell my|home value|what.?s my home worth|list my|selling)/.test(text) ? 'seller' : 'buyer';\nconst timeframe = (text.match(/\\b(asap|this week|this month|next month|30 days|60 days|90 days|3 months|6 months|this year|just looking|browsing)\\b/)?.[1]) ?? null;\n\nreturn [{\n  json: {\n    name, first_name: name.split(' ')[0] || 'there', email, phone, message, property, listing_id: listingId,\n    source, intent, timeframe, pre_approved: /pre-?approved|preapproved/.test(text),\n    valid: Boolean(email || phone),\n    dry_run: viaApi && src.query?.dry_run === 'true',\n    via_api: viaApi,\n    received_at: DateTime.now().toISO(),\n  },\n}];\n"
      },
      "id": "m19-parse",
      "name": "Parse Lead",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        80
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "v1",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m19-valid",
      "name": "Has Contact?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        80
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { error: 'need an email or phone: send {name, email, phone, message, property} or {from, subject, raw_email}' } }}",
        "options": {
          "responseCode": 400
        }
      },
      "id": "m19-400",
      "name": "Respond No Contact",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1140,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "// Find the listing they asked about, then up to 3 similar ones (same type and city, price within ±20%).\nconst s = $('Settings (edit me)').first().json;\nconst lead = $input.first().json;\nconst L = s.listings;\nconst norm = (x) => String(x ?? '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\\s+/g, ' ').trim();\n\nconst asked = L.find((l) => lead.listing_id && l.id.toLowerCase() === lead.listing_id.toLowerCase())\n  ?? L.find((l) => lead.property && norm(lead.property).includes(norm(l.address).split(' ').slice(0, 3).join(' ')));\nconst wantType = lead.intent === 'renter' ? 'rent' : 'sale';\nconst ref = asked ?? { city: (L.find((l) => norm(lead.property).includes(norm(l.city)))?.city) ?? null, price: null, type: wantType };\n\nconst similar = L\n  .filter((l) => l.status === 'active' && l.id !== asked?.id && l.type === (asked?.type ?? wantType))\n  .filter((l) => !ref.city || l.city === ref.city)\n  .filter((l) => !ref.price || Math.abs(l.price - ref.price) / ref.price <= 0.2)\n  .sort((a, b) => (ref.price ? Math.abs(a.price - ref.price) - Math.abs(b.price - ref.price) : b.listed_at.localeCompare(a.listed_at)))\n  .slice(0, 3);\n\nreturn [{ json: { ...lead, asked_listing: asked ?? null, matches: lead.intent === 'seller' ? [] : similar } }];\n"
      },
      "id": "m19-match",
      "name": "Match Listings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1140,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Dedupe, assign an agent round-robin, and schedule the follow-up drip. State lives in workflow static data.\n// A dry run computes everything but saves nothing.\nconst s = $('Settings (edit me)').first().json;\nconst lead = $input.first().json;\nconst db = $getWorkflowStaticData('global');\ndb.leads = db.leads ?? {};\ndb.rr = db.rr ?? 0;\n\nconst key = lead.email || lead.phone;\nconst prev = db.leads[key];\nconst now = DateTime.fromISO(lead.received_at);\nconst isDuplicate = Boolean(prev && prev.status !== 'stopped' && now.diff(DateTime.fromISO(prev.created_at), 'days').days < s.dedupe_days);\n\nlet agent;\nif (isDuplicate) {\n  agent = s.agents.find((a) => a.email === prev.agent_email) ?? s.agents[0];\n} else {\n  agent = s.agents[db.rr % s.agents.length];\n}\n\nconst drip = isDuplicate ? prev.drip : s.drip.map((d) => ({ step: d.step, day: d.day, due_at: now.plus({ days: d.day }).toISO(), sent_at: null, last_error: null }));\nconst record = {\n  key, name: lead.name, first_name: lead.first_name, email: lead.email, phone: lead.phone, intent: lead.intent,\n  property: lead.asked_listing?.address ?? lead.property, match_ids: lead.matches.map((m) => m.id),\n  agent_email: agent.email, agent_name: agent.name, status: 'active',\n  created_at: isDuplicate ? prev.created_at : lead.received_at,\n  inquiries: (prev?.inquiries ?? 0) + 1, drip,\n};\n\nif (!lead.dry_run) {\n  if (!isDuplicate) db.rr += 1;\n  db.leads[key] = record;\n}\n\nreturn [{ json: { ...lead, is_duplicate: isDuplicate, agent, drip } }];\n"
      },
      "id": "m19-register",
      "name": "Register Lead",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Instant replies: a short SMS and a fuller email, written from the assigned agent.\nconst s = $('Settings (edit me)').first().json;\nconst l = $input.first().json;\nconst money = (n) => '$' + Number(n).toLocaleString('en-US');\nconst home = (m) => `${m.address}, ${m.city}: ${money(m.price)}${m.type === 'rent' ? '/mo' : ''}, ${m.beds} bd / ${m.baths} ba`;\nconst agentFirst = l.agent.name.split(' ')[0];\nconst about = l.asked_listing?.address ?? (l.property || 'your home search');\nconst availability = l.asked_listing ? (l.asked_listing.status === 'active' ? \"It's still available\" : \"That one just went under contract, but I have similar homes\") : 'Happy to help';\n\nlet sms;\nlet subject;\nlet body;\nif (l.intent === 'seller') {\n  sms = `Hi ${l.first_name}, it's ${agentFirst} with ${s.brokerage}. Thanks for reaching out about selling! I can put together a free home value report this week. When's a good time for a quick call? ${s.booking_link}`;\n  subject = `Your home value report, ${l.first_name}`;\n  body = `Hi ${l.first_name},\\n\\nThanks for reaching out about selling your home. I'll prepare a free, no-obligation market analysis based on recent sales nearby.\\n\\nPick a time that suits you for a 15-minute call: ${s.booking_link}\\n\\nBest,\\n${l.agent.name}\\n${s.brokerage} · ${l.agent.phone}`;\n} else if (l.is_duplicate) {\n  sms = `Hi ${l.first_name}, ${agentFirst} again from ${s.brokerage}. Got your note about ${about}. Want to see it? Reply with a day that works or book here: ${s.booking_link}`;\n  subject = `Re: ${about}`;\n  body = `Hi ${l.first_name},\\n\\nThanks for reaching out again about ${about}. I'd love to show it to you. Book a time here: ${s.booking_link}\\n\\nBest,\\n${l.agent.name}\\n${s.brokerage} · ${l.agent.phone}`;\n} else {\n  sms = `Hi ${l.first_name}, it's ${agentFirst} with ${s.brokerage}. Thanks for asking about ${about}! ${availability}. Want to see it this week? Reply with a day/time or book: ${s.booking_link}`;\n  subject = `${about}: details + ${l.matches.length} similar home${l.matches.length === 1 ? '' : 's'}`;\n  body = `Hi ${l.first_name},\\n\\nThanks for your interest in ${about}. ${availability}.\\n` +\n    (l.matches.length ? `\\nYou might also like:\\n${l.matches.map((m) => `• ${home(m)}\\n  ${m.url}`).join('\\n')}\\n` : '') +\n    `\\nWant to see ${l.asked_listing ? 'it' : 'some homes'} in person? Book a showing here: ${s.booking_link}` +\n    (l.pre_approved ? '' : `\\n\\nIf you're buying, getting pre-approved first makes your offer stronger. Happy to introduce a lender.`) +\n    `\\n\\nBest,\\n${l.agent.name}\\n${s.brokerage} · ${l.agent.phone}`;\n}\nsms = (sms + ' Reply STOP to opt out.').slice(0, 320);\n\nreturn [{ json: { ...l, messages: { sms, email: { to: l.email, subject, body } } } }];\n"
      },
      "id": "m19-compose",
      "name": "Compose Replies",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1580,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "d1",
              "leftValue": "={{ $json.dry_run }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m19-dry",
      "name": "Dry Run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1800,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { dry_run: true, lead: { name: $json.name, email: $json.email, phone: $json.phone, source: $json.source, intent: $json.intent, timeframe: $json.timeframe, pre_approved: $json.pre_approved }, is_duplicate: $json.is_duplicate, agent: $json.agent, asked_listing: $json.asked_listing, matches: $json.matches, messages: $json.messages, drip: $json.drip } }}",
        "options": {}
      },
      "id": "m19-respond-dry",
      "name": "Respond Plan",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2020,
        -120
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "p1",
              "leftValue": "={{ Boolean($json.phone) && $('Settings (edit me)').first().json.sms_enabled }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m19-hasphone",
      "name": "Text Them?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2020,
        80
      ]
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "={{ $('Settings (edit me)').first().json.sms_from }}",
        "to": "={{ $json.phone }}",
        "message": "={{ $json.messages.sms }}",
        "options": {}
      },
      "id": "m19-sms",
      "name": "Send SMS",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [
        2240,
        0
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "sendTo": "={{ $('Compose Replies').first().json.messages.email.to }}",
        "subject": "={{ $('Compose Replies').first().json.messages.email.subject }}",
        "emailType": "text",
        "message": "={{ $('Compose Replies').first().json.messages.email.body }}",
        "options": {
          "appendAttribution": false,
          "senderName": "={{ $('Compose Replies').first().json.agent.name }}"
        }
      },
      "id": "m19-email",
      "name": "Send Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2460,
        80
      ],
      "webhookId": "course-19-gmail-send",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// What the lead received. SMS and email both run with \"On Error: Continue\"; success requires an id.\nconst l = $('Compose Replies').first().json;\nconst smsRan = $('Send SMS').isExecuted;\nconst sms = smsRan ? $('Send SMS').first().json : null;\nconst mail = $('Send Email').first().json;\nconst err = (j) => (typeof j?.error === 'string' ? j.error : j?.error?.message ?? null);\nconst smsOk = Boolean(sms && !sms.error && sms.sid);\nconst mailOk = Boolean(mail && !mail.error && mail.id);\nreturn [{\n  json: {\n    lead: { name: l.name, email: l.email, phone: l.phone, source: l.source, intent: l.intent, timeframe: l.timeframe, pre_approved: l.pre_approved },\n    is_duplicate: l.is_duplicate,\n    agent: l.agent,\n    asked_listing: l.asked_listing,\n    matches: l.matches,\n    messages: l.messages,\n    drip: l.drip,\n    sent: {\n      sms: smsOk, sms_error: smsRan ? (smsOk ? null : err(sms) ?? 'no message sid returned') : 'skipped (no phone or SMS disabled)',\n      email: mailOk, email_error: mailOk ? null : err(mail) ?? 'no message id returned',\n    },\n  },\n}];\n"
      },
      "id": "m19-report",
      "name": "Lead Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2680,
        80
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "a1",
              "leftValue": "={{ $('New Lead API').isExecuted }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m19-viaapi",
      "name": "From API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2900,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m19-respond",
      "name": "Respond Report",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        3120,
        0
      ]
    },
    {
      "parameters": {},
      "id": "m19-alert",
      "name": "Alert Agent (Exercise 1)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2900,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "// Find drip messages that are due now, for leads still active. One item per message.\n// \"now\" can be injected via the API ({\"now\": \"...\"}) so the schedule is testable.\nconst s = $('Settings (edit me)').first().json;\nconst viaApi = $('Drip Run API').isExecuted;\nconst body = viaApi ? $('Drip Run API').first().json.body ?? {} : {};\nconst now = body.now ? DateTime.fromISO(body.now) : DateTime.now();\nconst db = $getWorkflowStaticData('global');\nconst leads = Object.values(db.leads ?? {});\nconst byId = Object.fromEntries(s.listings.map((l) => [l.id, l]));\nconst money = (n) => '$' + Number(n).toLocaleString('en-US');\n\nconst TEMPLATES = {\n  checkin: (l) => ({\n    subject: `Any questions about ${l.property || 'the homes I sent'}?`,\n    body: `Hi ${l.first_name},\\n\\nJust checking in: do you have any questions about ${l.property || 'the homes I sent'}? I'm happy to set up a showing or send more photos and details.\\n\\nBook a time: ${s.booking_link}\\n\\n${l.agent_name}\\n${s.brokerage}`,\n  }),\n  listings: (l) => {\n    const homes = l.match_ids.map((id) => byId[id]).filter(Boolean);\n    return {\n      subject: homes.length ? `${homes.length} homes you might like` : 'Homes that just came on the market',\n      body: `Hi ${l.first_name},\\n\\n` + (homes.length\n        ? `Based on what you were looking at, these could be a fit:\\n${homes.map((h) => `• ${h.address}, ${h.city}: ${money(h.price)}, ${h.beds} bd / ${h.baths} ba\\n  ${h.url}`).join('\\n')}\\n\\n`\n        : `New listings come up every week. Tell me your budget and must-haves and I'll send the best matches.\\n\\n`) +\n        `Want to tour any of them? ${s.booking_link}\\n\\n${l.agent_name}\\n${s.brokerage}`,\n    };\n  },\n  last_chance: (l) => ({\n    subject: 'Should I keep you on my list?',\n    body: `Hi ${l.first_name},\\n\\nI haven't heard back, so I don't want to fill your inbox. Are you still looking, or should I close your file for now? A one-word reply is perfect.\\n\\n${l.agent_name}\\n${s.brokerage}`,\n  }),\n};\n\nconst due = [];\nfor (const l of leads) {\n  if (l.status !== 'active' || !l.email) continue;\n  for (const d of l.drip) {\n    if (!d.sent_at && DateTime.fromISO(d.due_at) <= now) {\n      due.push({ key: l.key, step: d.step, to: l.email, agent: l.agent_name, ...TEMPLATES[d.step](l), dry_run: body.dry_run === true });\n    }\n  }\n}\nreturn due.map((json) => ({ json }));\n"
      },
      "id": "m19-due",
      "name": "Find Due Messages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        560
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "n1",
              "leftValue": "={{ Boolean($json.key) && !$json.dry_run }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m19-anydue",
      "name": "Send Now?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        560
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.to }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {
          "appendAttribution": false,
          "senderName": "={{ $json.agent }}"
        }
      },
      "id": "m19-drip-email",
      "name": "Send Drip Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1140,
        480
      ],
      "webhookId": "course-19-gmail-drip",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Mark each drip email as sent (or record the error so the next run retries it).\nconst db = $getWorkflowStaticData('global');\nconst dueItems = $('Find Due Messages').all().map((i) => i.json).filter((d) => d.key); // \"Always Output Data\" adds an empty item when nothing is due\nconst sendRan = $('Send Drip Email').isExecuted;\nconst results = sendRan ? $('Send Drip Email').all().map((i) => i.json) : [];\n\nconst out = dueItems.map((d, i) => {\n  const r = results[i];\n  const ok = Boolean(r && !r.error && r.id);\n  const error = d.dry_run ? null : ok ? null : (typeof r?.error === 'string' ? r.error : r?.error?.message) ?? 'no message id returned';\n  if (!d.dry_run) {\n    const step = db.leads[d.key]?.drip.find((x) => x.step === d.step);\n    if (step) { if (ok) step.sent_at = DateTime.now().toISO(); else step.last_error = error; }\n  }\n  return { key: d.key, step: d.step, to: d.to, subject: d.subject, sent: ok, error, dry_run: d.dry_run };\n});\nreturn [{ json: { due: out.length, sent: out.filter((o) => o.sent).length, messages: out } }];\n"
      },
      "id": "m19-record",
      "name": "Record Drip Results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        560
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "b1",
              "leftValue": "={{ $('Drip Run API').isExecuted }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m19-dripapi",
      "name": "Drip via API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1580,
        560
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m19-respond-drip",
      "name": "Respond Drip",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1800,
        520
      ]
    },
    {
      "parameters": {
        "jsCode": "// Stop the drip when a lead replies, books, or opts out.\nconst b = $('Stop Lead API').first().json.body ?? {};\nconst db = $getWorkflowStaticData('global');\ndb.leads = db.leads ?? {};\nlet phone = String(b.phone ?? '').replace(/[^\\d+]/g, '');\nif (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\nconst key = String(b.email ?? '').trim().toLowerCase() || phone;\nconst lead = db.leads[key];\nif (!lead) return [{ json: { found: false, key } }];\nlead.status = 'stopped';\nlead.stop_reason = String(b.reason ?? 'replied');\nlead.stopped_at = DateTime.now().toISO();\nreturn [{ json: { found: true, key, status: lead.status, stop_reason: lead.stop_reason, pending_steps_cancelled: lead.drip.filter((d) => !d.sent_at).map((d) => d.step) } }];\n"
      },
      "id": "m19-stop",
      "name": "Stop Drip",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        780
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ $json.found ? 200 : 404 }}"
        }
      },
      "id": "m19-respond-stop",
      "name": "Respond Stop",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        780
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read-only view of one lead's record (for checking the drip and debugging).\nconst q = $('Lead Status API').first().json.query ?? {};\nlet phone = String(q.phone ?? '').replace(/[^\\d+]/g, '');\nif (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\nconst key = String(q.email ?? '').trim().toLowerCase() || phone;\nconst lead = ($getWorkflowStaticData('global').leads ?? {})[key];\nreturn [{ json: lead ? { found: true, ...lead } : { found: false, key } }];\n"
      },
      "id": "m19-status",
      "name": "Lead Status",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        960
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ $json.found ? 200 : 404 }}"
        }
      },
      "id": "m19-respond-status",
      "name": "Respond Status",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        960
      ]
    }
  ],
  "connections": {
    "New Lead API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Portal Lead Emails": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drip Every Hour": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drip Run API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stop Lead API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Status API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (edit me)": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Parse Lead",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Find Due Messages",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop Drip",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lead Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Lead": {
      "main": [
        [
          {
            "node": "Has Contact?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Contact?": {
      "main": [
        [
          {
            "node": "Match Listings",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond No Contact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match Listings": {
      "main": [
        [
          {
            "node": "Register Lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Register Lead": {
      "main": [
        [
          {
            "node": "Compose Replies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compose Replies": {
      "main": [
        [
          {
            "node": "Dry Run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry Run?": {
      "main": [
        [
          {
            "node": "Respond Plan",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Text Them?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text Them?": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send SMS": {
      "main": [
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Email": {
      "main": [
        [
          {
            "node": "Lead Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Report": {
      "main": [
        [
          {
            "node": "From API?",
            "type": "main",
            "index": 0
          },
          {
            "node": "Alert Agent (Exercise 1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "From API?": {
      "main": [
        [
          {
            "node": "Respond Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Due Messages": {
      "main": [
        [
          {
            "node": "Send Now?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Now?": {
      "main": [
        [
          {
            "node": "Send Drip Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Record Drip Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Drip Email": {
      "main": [
        [
          {
            "node": "Record Drip Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Drip Results": {
      "main": [
        [
          {
            "node": "Drip via API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drip via API?": {
      "main": [
        [
          {
            "node": "Respond Drip",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stop Drip": {
      "main": [
        [
          {
            "node": "Respond Stop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lead Status": {
      "main": [
        [
          {
            "node": "Respond Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  }
}