{
  "name": "R1 lead capture: sheet row + Gmail draft (never a send)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "back-office-lead",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "0f1e2d3c-0001-4a00-8000-000000000001",
      "name": "New lead arrives",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -400,
        300
      ],
      "webhookId": "0f1e2d3c-0001-4a00-8000-000000000001"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// R1 - the Code node of lead_capture.n8n.json, as one readable file.\n// This file is n8n_normalize_code.js, read-date 2026-11-10.\n//\n// What this is: the normaliser step of recipe R1. n8n stores a Code node's\n// script inside the workflow file; this is that same script in a form you can\n// read, edit and diff. The workflow holds these exact lines, and\n// build_workflow_json.py in this folder is what puts them in, so the two\n// copies cannot drift apart. If you change it here, re-run:\n//\n//     python3 build_workflow_json.py\n//\n// What it does: takes whatever your form posted, finds the lead's name,\n// email, phone, source and message however those fields happen to be named,\n// and passes on only the leads that are new and usable. A lead it rejects\n// stops here, so no sheet row is added and no draft is made for it.\n//\n// Nothing here sends anything. The only mail the workflow touches is the\n// DRAFT the Gmail node creates at the end of the chain.\n//\n// Read-date: 2026-11-10.\n\n// Set this to true while you are setting the recipe up, and the node will\n// pass on every lead it was given, each one marked ok or not-ok with the\n// reason. Open the execution in n8n and you can read, item by item, exactly\n// what arrived and what you decided about it. Set it back to false when the\n// recipe is behaving: with false, a rejected lead goes no further.\nvar SHOW_REJECTED = false;\n\n// Which incoming field names mean which output field. Names are \"folded\"\n// first (lower-case, everything but letters and digits removed), so\n// \"Email Address\", \"email_address\" and \"E-MAIL!\" all match \"emailaddress\".\nvar ALIAS = {\n  name: ['name', 'fullname', 'yourname', 'contactname', 'leadname', 'buyername'],\n  email: ['email', 'emailaddress', 'leademail', 'contactemail', 'buyeremail'],\n  phone: ['phone', 'phonenumber', 'phoneno', 'mobile', 'cell', 'telephone'],\n  source: ['source', 'leadsource', 'utmsource', 'channel', 'medium', 'campaign'],\n  message: ['message', 'yourmessage', 'messages', 'comment', 'comments',\n            'notes', 'note', 'inquiry', 'enquiry', 'details', 'description', 'brief'],\n  leadDate: ['date', 'datetime', 'received', 'receivedat', 'submitted',\n             'submittedat', 'timestamp', 'createdat']\n};\n\nvar EMAIL = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]{2,}$/;\n\nfunction fold(key) {\n  return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');\n}\n\nfunction text(value) {\n  return value === null || value === undefined ? '' : String(value).trim();\n}\n\n// A Webhook node wraps what was posted inside \"body\", so look there first.\nfunction unwrap(json) {\n  return json && json.body && typeof json.body === 'object' ? json.body : json;\n}\n\n// A seen-list that survives between runs of the workflow. n8n resets static\n// data when a workflow is re-published; that is acceptable here because the\n// sheet node below is the durable record and it keys on the email address, so\n// the worst a reset can cost is one extra draft nobody has opened - never a\n// second row, and never a second message, because nothing in this workflow\n// can send.\nvar stored = $getWorkflowStaticData('global');\nif (!Array.isArray(stored.seenEmails)) {\n  stored.seenEmails = [];\n}\nif (stored.seenEmails.length > 2000) {\n  stored.seenEmails = stored.seenEmails.slice(-1000);\n}\n\nvar out = [];\n\nfor (var item of $input.all()) {\n  var raw = unwrap(item.json) || {};\n  var flat = {};\n\n  for (var entry of Object.entries(raw)) {\n    var folded = fold(entry[0]);\n    var value = text(entry[1]);\n    if (!value) {\n      continue;\n    }\n    for (var pair of Object.entries(ALIAS)) {\n      var canon = pair[0];\n      var names = pair[1];\n      if (!flat[canon] && (folded === canon || names.indexOf(folded) !== -1)) {\n        flat[canon] = value;\n      }\n    }\n  }\n\n  var email = (flat.email || '').toLowerCase();\n  var ok = 'yes';\n  var reason = '';\n\n  if (!email) {\n    ok = 'no';\n    reason = 'no email in what was posted';\n  } else if (!EMAIL.test(email)) {\n    ok = 'no';\n    reason = 'the email value is not an address: ' + email;\n  } else if (stored.seenEmails.indexOf(email) !== -1) {\n    ok = 'no';\n    reason = 'already handled since this workflow was last published';\n  }\n\n  if (ok === 'yes') {\n    stored.seenEmails.push(email);\n  }\n\n  var lead = {\n    ok: ok,\n    reason: reason,\n    email: email,\n    name: flat.name || '',\n    phone: flat.phone || '',\n    source: flat.source || '',\n    message: flat.message || '',\n    leadDate: flat.leadDate || '',\n    receivedAt: $now.toISO()\n  };\n\n  if (ok === 'yes' || SHOW_REJECTED) {\n    out.push({ json: lead });\n  }\n}\n\nreturn out;\n"
      },
      "id": "0f1e2d3c-0002-4a00-8000-000000000002",
      "name": "Normalize and check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -160,
        300
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultName": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultName": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "receivedAt": "={{ $json.receivedAt }}",
            "leadDate": "={{ $json.leadDate }}",
            "name": "={{ $json.name }}",
            "email": "={{ $json.email }}",
            "phone": "={{ $json.phone }}",
            "source": "={{ $json.source }}",
            "message": "={{ $json.message }}"
          },
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "",
          "name": "Google Sheets account"
        }
      },
      "id": "0f1e2d3c-0003-4a00-8000-000000000003",
      "name": "Add the lead row",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        80,
        300
      ]
    },
    {
      "parameters": {
        "resource": "draft",
        "operation": "create",
        "emailType": "to",
        "sendTo": "={{ $json.email }}",
        "subject": "=Re: your enquiry",
        "message": "=Hi {{ $json.name ? $json.name.split(' ')[0] : 'there' }},\n\nThanks for getting in touch{{ $json.source ? ' about ' + $json.source : '' }}. Your details are recorded and I will reply to this address.\n\nThis is what arrived:\n{{ $json.message ? $json.message : '(nothing was written in the form)' }}",
        "options": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "id": "",
          "name": "Gmail account"
        }
      },
      "id": "0f1e2d3c-0004-4a00-8000-000000000004",
      "name": "Create the reply draft",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        320,
        300
      ]
    }
  ],
  "connections": {
    "New lead arrives": {
      "main": [
        [
          {
            "node": "Normalize and check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize and check": {
      "main": [
        [
          {
            "node": "Add the lead row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add the lead row": {
      "main": [
        [
          {
            "node": "Create the reply draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "readDate": "2026-11-10",
    "recipe": "R1 of the Back-Office Autopilot pack",
    "note": "The last node creates a Gmail DRAFT. Nothing in this workflow can send a message: a draft waits in the Drafts folder until a human opens it and presses send. Four values are yours to set - the two credentials, the spreadsheet and the tab. See SETUP.md."
  }
}
