{
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "name": "Event Calendar Workflow",
  "tags": [],
  "nodes": [
    {
      "id": "6bc21d65-3db3-4361-9670-f042cc89ae64",
      "name": "Gmail Trigger",
      "type": "n8n-nodes-base.gmailTrigger",
      "position": [
        80,
        64
      ],
      "parameters": {
        "simple": false,
        "filters": {
          "labelIds": [
            "YOUR_EVENTS_LABEL_ID"
          ]
        },
        "options": {
          "downloadAttachments": true
        },
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "8876eb2f-b56b-4a56-80b6-78f1bc7b306f",
      "name": "Code: Email Body Preparation",
      "type": "n8n-nodes-base.code",
      "position": [
        416,
        -144
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Turns the incoming email into a minimal PDF that the easybits Extractor can read.\n// Pure JS, no dependencies, no external services. Paste as-is.\n\nconst email = $input.item.json;\n\nconst subject = email.subject || '(no subject)';\nconst from = email.from?.value?.[0]?.address || email.from?.text || 'unknown';\nconst date = email.date || '';\nconst body = email.text || email.snippet || '';\n\n// Build the full text content that goes into the PDF\nconst rawContent = `Subject: ${subject}\nFrom: ${from}\nDate: ${date}\n\n${body}`;\n\n// PDF string escaping\nconst escapePdf = (str) =>\n  str.replace(/\\\\/g, '\\\\\\\\').replace(/\\(/g, '\\\\(').replace(/\\)/g, '\\\\)');\n\n// Wrap long lines so they don't overflow the page width (~90 chars at 10pt)\nconst lines = rawContent.split('\\n').flatMap((line) => {\n  if (line.length <= 90) return [line];\n  const wrapped = [];\n  for (let i = 0; i < line.length; i += 90) {\n    wrapped.push(line.slice(i, i + 90));\n  }\n  return wrapped;\n});\n\n// Paginate: 55 lines per page at 10pt with reasonable margins\nconst LINES_PER_PAGE = 55;\nconst pages = [];\nfor (let i = 0; i < lines.length; i += LINES_PER_PAGE) {\n  pages.push(lines.slice(i, i + LINES_PER_PAGE));\n}\nif (pages.length === 0) pages.push(['(empty email body)']);\n\n// Build PDF content streams (one per page)\nconst buildContentStream = (pageLines) => {\n  const textCommands = pageLines\n    .map((line, idx) => {\n      const y = 780 - idx * 14; // start near top, 14pt line height\n      return `BT /F1 10 Tf 50 ${y} Td (${escapePdf(line)}) Tj ET`;\n    })\n    .join('\\n');\n  return `${textCommands}`;\n};\n\n// Construct PDF objects\nconst pdfObjects = [];\n\n// 1: Catalog\npdfObjects.push(`<< /Type /Catalog /Pages 2 0 R >>`);\n\n// 2: Pages tree (filled in after we know page object IDs)\n// Placeholder \u2014 we'll come back to it\n\n// 3+: Font, then page objects, then content streams\nconst fontObjId = 3;\npdfObjects.push(null); // reserve slot for Pages (object 2)\npdfObjects.push(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`); // object 3\n\n// Page + content stream pairs start at object 4\nconst pageObjectIds = [];\nlet nextObjId = 4;\nfor (const pageLines of pages) {\n  const pageId = nextObjId++;\n  const contentId = nextObjId++;\n  pageObjectIds.push(pageId);\n\n  const stream = buildContentStream(pageLines);\n\n  pdfObjects.push(\n    `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 ${fontObjId} 0 R >> >> /Contents ${contentId} 0 R >>`\n  );\n  pdfObjects.push(\n    `<< /Length ${stream.length} >>\\nstream\\n${stream}\\nendstream`\n  );\n}\n\n// Now build the Pages object (object 2)\nconst kids = pageObjectIds.map((id) => `${id} 0 R`).join(' ');\npdfObjects[1] = `<< /Type /Pages /Kids [${kids}] /Count ${pageObjectIds.length} >>`;\n\n// Assemble the PDF file\nlet pdf = '%PDF-1.4\\n';\nconst offsets = [0]; // object 0 is the free object, offset 0\nfor (let i = 0; i < pdfObjects.length; i++) {\n  offsets.push(pdf.length);\n  pdf += `${i + 1} 0 obj\\n${pdfObjects[i]}\\nendobj\\n`;\n}\n\nconst xrefOffset = pdf.length;\npdf += `xref\\n0 ${pdfObjects.length + 1}\\n`;\npdf += `+1234567890 f \\n`;\nfor (let i = 1; i <= pdfObjects.length; i++) {\n  pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \\n`;\n}\npdf += `trailer\\n<< /Size ${pdfObjects.length + 1} /Root 1 0 R >>\\n`;\npdf += `startxref\\n${xrefOffset}\\n%%EOF`;\n\n// Return as binary\nconst buffer = Buffer.from(pdf, 'binary');\n\nreturn {\n  json: $input.item.json,\n  binary: {\n    bodyPdf: {\n      data: buffer.toString('base64'),\n      mimeType: 'application/pdf',\n      fileName: 'email-body.pdf',\n      fileExtension: 'pdf',\n    },\n  },\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "6888eb23-1172-4015-9c6d-203f6219d06f",
      "name": "Extractor: Email Body",
      "type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
      "position": [
        752,
        -144
      ],
      "parameters": {},
      "typeVersion": 2
    },
    {
      "id": "326fecb7-2531-467c-9cd6-74b9cc6572af",
      "name": "If: Valid Event?",
      "type": "n8n-nodes-base.if",
      "position": [
        1424,
        80
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "f6f29567-daa0-4ab1-96c4-292fe0168f89",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.data.start_datetime }}",
              "rightValue": ""
            },
            {
              "id": "968cdf33-5dc7-49e0-8b4c-95658e128799",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.data.event_type }}",
              "rightValue": ""
            },
            {
              "id": "7adb59fc-7e42-4497-8159-b85041c9228f",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.data.start_datetime }}",
              "rightValue": "null"
            },
            {
              "id": "e23dcdc8-9031-4feb-9530-13ee04b59e40",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.data.event_type }}",
              "rightValue": "null"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "6a26fe56-6906-49a9-80b3-309abf1b3d56",
      "name": "Set: Build Event Description",
      "type": "n8n-nodes-base.set",
      "position": [
        1760,
        -64
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8d66272e-4b24-499a-b0c6-db306fd0fb10",
              "name": "event_description",
              "type": "string",
              "value": "={{\n(() => {\n  const d = $json.data;\n  const lines = [];\n  if (d.vendor) lines.push(`Vendor: ${d.vendor}`);\n  if (d.confirmation_number) lines.push(`Confirmation: ${d.confirmation_number}`);\n  if (d.participants) lines.push(`Participants: ${d.participants}`);\n  if (d.notes) lines.push('', 'Notes:', d.notes);\n  lines.push('', '\u2014 Original Email \u2014', $json.archive_link);\n  lines.push('', `Auto-imported by easybits Extractor \u2022 ${new Date().toISOString().split('T')[0]}`);\n  return lines.join('\\n');\n})()\n}}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "191ad0c5-43b8-4ee7-9b48-5ccf89fa2505",
      "name": "Calendar: Create Event",
      "type": "n8n-nodes-base.googleCalendar",
      "position": [
        2096,
        -64
      ],
      "parameters": {
        "end": "={{ $json.data.end_datetime || $json.data.start_datetime }}",
        "start": "={{ $json.data.start_datetime }}",
        "calendar": {
          "__rl": true,
          "mode": "list",
          "value": "user@example.com"
        },
        "additionalFields": {
          "summary": "={{ $json.data.title }}",
          "location": "={{ $json.data.location }}",
          "description": "={{ $json.event_description }}"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "8401e530-7288-4126-87f5-3fd37bf10785",
      "name": "Gmail: Remove Events Label",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2432,
        -64
      ],
      "parameters": {
        "labelIds": [
          "YOUR_EVENTS_LABEL_ID"
        ],
        "messageId": "={{ $('Gmail Trigger').item.json.id }}",
        "operation": "removeLabels"
      },
      "typeVersion": 2.2
    },
    {
      "id": "77713d25-1659-4e90-a13a-2a3eafd51e48",
      "name": "Code: Explode Attachments",
      "type": "n8n-nodes-base.code",
      "position": [
        416,
        368
      ],
      "parameters": {
        "jsCode": "// Splits Gmail attachments into one item per PDF/image.\n// If there are no usable attachments, returns an empty array \u2014 the\n// downstream Extractor + Merge will naturally produce no attachment\n// extractions, and the body branch carries the workflow alone.\n\nconst item = $input.first();\nconst results = [];\n\nif (!item.binary) return results;\n\nfor (const [key, binary] of Object.entries(item.binary)) {\n  const mime = binary.mimeType || '';\n  // Only process PDFs and images \u2014 easybits-compatible formats\n  if (!mime.startsWith('application/pdf') && !mime.startsWith('image/')) {\n    continue;\n  }\n\n  results.push({\n    json: {\n      attachmentKey: key,\n      attachmentMime: mime,\n      attachmentName: binary.fileName || key,\n    },\n    binary: {\n      attachment: binary,\n    },\n  });\n}\n\nreturn results;"
      },
      "typeVersion": 2
    },
    {
      "id": "f4b7405b-9fd8-4459-aa6f-86e67d16aef7",
      "name": "Extractor: Attachment",
      "type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
      "position": [
        752,
        368
      ],
      "parameters": {},
      "typeVersion": 2
    },
    {
      "id": "fd35302b-2263-4d30-a715-24d337ff9404",
      "name": "Code: Merge Extractions",
      "type": "n8n-nodes-base.code",
      "position": [
        1088,
        80
      ],
      "parameters": {
        "jsCode": "// Merges extractions from the body branch and the attachment branch.\n// Strategy: collect every extraction's `data` object, then for each\n// field take the first non-null value found.\n//\n// Priority: ATTACHMENTS WIN OVER BODY.\n// Attachments are the authoritative source (actual ticket/invoice PDF).\n// The body is fallback context \u2014 used only to fill gaps where the\n// attachment didn't have a value. This prevents email signatures or\n// promotional content from polluting fields the attachment got right.\n\nconst allExtractions = [];\n\n// 1. Attachments first (highest priority)\nfor (const item of $input.all()) {\n  if (item.json?.data) {\n    allExtractions.push(item.json.data);\n  }\n}\n\n// 2. Body extraction last (fallback)\nconst bodyExt = $('Extractor: Email Body').first();\nif (bodyExt?.json?.data) {\n  allExtractions.push(bodyExt.json.data);\n}\n\nconst fields = [\n  'event_type', 'title', 'start_datetime', 'end_datetime',\n  'location', 'confirmation_number', 'vendor', 'notes',\n  'participants',\n];\n\nconst isMissing = (v) =>\n  v === null ||\n  v === undefined ||\n  v === '' ||\n  (typeof v === 'string' && v.trim().toLowerCase() === 'null');\n\nconst merged = {};\nfor (const field of fields) {\n  merged[field] = null;\n  for (const extraction of allExtractions) {\n    if (!isMissing(extraction[field])) {\n      merged[field] = extraction[field];\n      break;\n    }\n  }\n}\n\nreturn [{ json: { data: merged } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "8ecd081d-6759-425b-ab73-297f3ec8dd5f",
      "name": "Gmail: Flag for Review",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1760,
        448
      ],
      "parameters": {
        "labelIds": [
          "YOUR_NEEDS_REVIEW_LABEL_ID"
        ],
        "messageId": "={{ $('Gmail Trigger').item.json.id }}",
        "operation": "addLabels"
      },
      "typeVersion": 2.2
    },
    {
      "id": "b863a953-6df5-4111-8bdb-0e0185728788",
      "name": "Gmail: Remove Events Label (Review Path)",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2096,
        448
      ],
      "parameters": {
        "labelIds": [
          "YOUR_EVENTS_LABEL_ID"
        ],
        "messageId": "={{ $('Gmail Trigger').item.json.id }}",
        "operation": "removeLabels"
      },
      "typeVersion": 2.2
    },
    {
      "id": "d8128e2b-3007-4708-aef1-9cf4d22a876d",
      "name": "Gmail: Send Review Notification",
      "type": "n8n-nodes-base.gmail",
      "position": [
        2432,
        448
      ],
      "parameters": {
        "sendTo": "user@example.com",
        "message": "={{(() => {  const d = $('Code: Merge Extractions').first().json.data;  const orig = $('Gmail Trigger').item.json;  const fields = [    ['Event type', d.event_type],    ['Title', d.title],    ['Start', d.start_datetime],    ['End', d.end_datetime],    ['Location', d.location],    ['Confirmation #', d.confirmation_number],    ['Vendor', d.vendor],    ['Participants', d.participants],    ['Notes', d.notes],  ];  const isMissing = (v) =>    v === null || v === undefined || v === '' ||    (typeof v === 'string' && v.trim().toLowerCase() === 'null');  const missing = fields.filter(([_, v]) => isMissing(v)).map(([k]) => k);  const found = fields.filter(([_, v]) => !isMissing(v));  const rows = found.map(([k, v]) =>    `<tr><td style=\"padding:4px 12px 4px 0;color:#666;\">${k}</td><td style=\"padding:4px 0;\"><strong>${v}</strong></td></tr>`  ).join('');  return `    <div style=\"font-family:-apple-system,sans-serif;max-width:560px;\">      <h2 style=\"color:#c00;margin:0 0 12px;\">\u26a0\ufe0f Needs review</h2>      <p>The calendar workflow couldn't auto-create an event from this email. It's been moved to the <code>Events/Needs-Review</code> label.</p>      <p><strong>Original subject:</strong> ${orig.subject || '(no subject)'}<br>      <strong>From:</strong> ${orig.from?.value?.[0]?.address || 'unknown'}</p>      <h3 style=\"margin:20px 0 8px;font-size:14px;\">Missing fields</h3>      <p style=\"color:#c00;\">${missing.length ? missing.join(', ') : '(none \u2014 but validation gate still failed)'}</p>      <h3 style=\"margin:20px 0 8px;font-size:14px;\">What was extracted</h3>      <table style=\"border-collapse:collapse;font-size:14px;\">${rows || '<tr><td>(nothing)</td></tr>'}</table>      <hr style=\"border:0;border-top:1px solid #eee;margin:24px 0;\">      <p style=\"color:#999;font-size:12px;\">Auto-generated by easybits Extractor \u2022 ${new Date().toISOString().split('T')[0]}</p>    </div>  `.trim();})()}}",
        "options": {},
        "subject": "={{ '\u26a0\ufe0f Calendar workflow: needs review \u2014 ' + $('Gmail Trigger').item.json.subject }}"
      },
      "typeVersion": 2.2
    },
    {
      "id": "4655a803-b6d3-46e0-a164-835bd758625c",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        304,
        -400
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 416,
        "content": "## \ud83d\udcc4 Email Body Preparation\n\nRenders the email's subject, sender, date, and body into a minimal PDF."
      },
      "typeVersion": 1
    },
    {
      "id": "6ce9617a-1c9e-4e65-ab48-4651c1da93bb",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -32,
        -256
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 480,
        "content": "## \ud83d\udcec Watch for Labeled Emails\n\nPolls Gmail every minute for new messages with the `Events` label applied.\n\n**What to label:**\nAny confirmation email you want auto-added to your calendar \u2013 flight bookings, hotel reservations, restaurant bookings, doctor appointments, package deliveries, event tickets."
      },
      "typeVersion": 1
    },
    {
      "id": "5d039db6-69e4-4358-a23f-8b334f459003",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        304,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 496,
        "content": "## \ud83d\udcce Explode Attachments\n\nSplits Gmail attachments into one item per PDF/image so the Extractor can process each one independently.\n\n**Filter:**\nOnly `application/pdf` and `image/*` types pass through. Inline signatures, calendar invites (.ics), and other formats are skipped.\n\n**Output:**\n0+ items, each with the binary under `binary.attachment`"
      },
      "typeVersion": 1
    },
    {
      "id": "5c465cdf-c4ca-4bdf-a37b-699118161d06",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        -400
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 416,
        "content": "## \ud83e\udd16 Extract from Email Body\n\nRuns the easybits Extractor on the email body PDF.\n\nReturns extracted data under `json.data`. Fields not found return `null` \u2013 a confident \"not present\" signal we use downstream."
      },
      "typeVersion": 1
    },
    {
      "id": "7b225ff2-269c-4e35-a367-2d269e5249a3",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 496,
        "content": "## \ud83e\udd16 Extract from Attachment\n\nRuns the easybits Extractor on each attached PDF or image.\n\nSame pipeline as \"Extracotr: Email Body\", different input source. The merge step combines results."
      },
      "typeVersion": 1
    },
    {
      "id": "e3be49ec-e30f-47f3-a2b3-7ea40cce6ea8",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        -288
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 512,
        "content": "## \ud83d\udd00 Merge Extractions\n\nCombines extractions from both branches into a single result.\n\n**Priority: Attachments win over body**\nAttachments are the authoritative source (the actual ticket/invoice PDF). The body is fallback \u2013 used only to fill gaps where the attachment had no value.\n\n**Handles \"null\" strings:**\nSome LLM responses return the literal string `\"null\"` instead of a JSON `null`. The merge logic treats both as missing values."
      },
      "typeVersion": 1
    },
    {
      "id": "7fb96136-d4aa-4eeb-87a9-4c07fa11d009",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        -288
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 512,
        "content": "## \u2705 Validation Gate\n\nChecks if the extraction produced a valid event.\n\n**Required:**\nBoth `start_datetime` AND `event_type` must be non-empty.\n\n**True branch \u2192** Calendar event creation\n**False branch \u2192** Needs-Review flow (no event created, user gets notified)\n\nThis is how non-confirmation emails (newsletters, marketing, random replies) get cleanly rejected without producing broken calendar events."
      },
      "typeVersion": 1
    },
    {
      "id": "30ba5d84-9b31-4be9-ad25-68f1f8506929",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1648,
        -416
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 528,
        "content": "## \u270f\ufe0f Build Event Description\n\nConstructs the calendar event description from extracted fields.\n\n**Included when present:**\n- Vendor name\n- Confirmation number\n- Participants\n- Notes (vendor-specific details)\n- Auto-import footer with date\n\nFields that returned `null` are skipped \u2013 the description stays clean and only shows what was actually extracted."
      },
      "typeVersion": 1
    },
    {
      "id": "79c3cbe7-d959-4c45-9ddf-e0554080cc2d",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1984,
        -416
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 528,
        "content": "## \ud83d\udcc5 Create Calendar Event\n\nCreates the event in your `Auto-imported` Google Calendar.\n\n**Mapped fields:**\n- **Summary** \u2190 `title`\n- **Start** \u2190 `start_datetime` (ISO 8601 with timezone offset)\n- **End** \u2190 `end_datetime`, falls back to `start_datetime` if null\n- **Location** \u2190 `location`\n- **Description** \u2190 built in previous step"
      },
      "typeVersion": 1
    },
    {
      "id": "9084448c-e790-4f62-b47a-e07dc33d7bcf",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2320,
        -416
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 528,
        "content": "## \ud83e\uddf9 Remove Events Label\n\nRemoves the `Events` label from the source email after successful processing.\n\n**Why:**\nThe Gmail Trigger polls based on the label. Without removal, the next poll would re-process the same email and create duplicate calendar events."
      },
      "typeVersion": 1
    },
    {
      "id": "e32da995-16ae-4adf-8fe9-b274ac09ffaa",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1648,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 496,
        "content": "## \ud83c\udff7\ufe0f Flag for Review\n\nAdds the `Events/Needs-Review` label to the email so you can scan all review-needed items in one place in Gmail."
      },
      "typeVersion": 1
    },
    {
      "id": "e09277d4-9532-4fc5-9ace-5c21473d0af0",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1984,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 496,
        "content": "## \ud83e\uddf9 Remove Events Label\n\nRemoves the `Events` trigger label from the source email so the next Gmail Trigger poll doesn't reprocess it.\n\nThe `Events/Needs-Review` label added in the previous step stays applied \u2013 the email remains visible in your `Events/Needs-Review` view in Gmail for manual review."
      },
      "typeVersion": 1
    },
    {
      "id": "725bd031-8709-47e3-8a63-2f6567659ca8",
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2320,
        128
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 496,
        "content": "## \ud83d\udce8 Send Review Notification\n\nSends an HTML email to yourself with:\n- A red \"Needs review\" header\n- The original email's subject and sender\n- A list of which fields are missing\n- A table of what *was* extracted (so you can tell if it was a near-miss or a total whiff)\n\n**Update:** Change the `sendTo` address in this node to your own email."
      },
      "typeVersion": 1
    },
    {
      "id": "94586f0c-791d-4277-a3f1-a555a7dc0e6b",
      "name": "Sticky Note13",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -896,
        -816
      ],
      "parameters": {
        "width": 848,
        "height": 1712,
        "content": "# \ud83d\udcc5 Email-to-Calendar: Auto-Extract Events from Confirmation Emails with easybits\n\n## What This Workflow Does\nForward any confirmation email \u2013 flight bookings, hotel reservations, restaurant bookings, doctor appointments, package deliveries, event tickets \u2013 to a labeled inbox, and it automatically becomes a calendar event with all the details (date, time, location, confirmation number, vendor) attached.\n\nOne universal pipeline handles every vendor format. No per-airline, per-hotel, per-restaurant parsing logic. The **easybits Extractor** reads the email body and any PDF attachments, pulls out the structured event data, and Google Calendar takes care of the rest.\n\n## How It Works\n1. **Gmail Trigger** \u2013 Polls Gmail for messages with your chosen label (e.g. `Events`)\n2. **Dual Extraction** \u2013 Email body is rendered to PDF and extracted; PDF/image attachments are extracted in parallel\n3. **Merge** \u2013 Combines both extractions; attachment data wins over body data (attachments are authoritative)\n4. **Validation Gate** \u2013 Confirms a real event was extracted (date + type present)\n5. **Calendar Event** \u2013 Creates the event in your dedicated `Auto-imported` calendar\n6. **Review Path** \u2013 Non-confirmation emails get a `Needs-Review` label + email notification listing missing fields\n\n---\n\n## Setup Guide\n\n### 1. Create Your easybits Extractor Pipeline\n1. Go to [extractor.easybits.tech](https://extractor.easybits.tech), sign up, and click **\"Create a Pipeline\"** on your dashboard\n2. Fill in the **Pipeline Name** (e.g. \"Event Calendar\") and **Description**\n3. Upload a **sample confirmation email PDF** as your reference (a flight or hotel booking works well)\n4. Click **\"Auto-Mapping\"** \u2013 the Extractor analyzes your sample and suggests fields automatically\n5. Adjust the suggested fields to match the 9 fields used by this workflow (see field list below)\n6. Click **\"Save & Test Pipeline\"** to verify extraction works\n7. Go to **Pipeline Details \u2192 View Pipeline** and copy your **Pipeline ID** and **API Key**\n\n**Required fields** (all string type, all should return `null` when missing):\n- `event_type` \u2013 flight / hotel / restaurant / appointment / delivery / event / other\n- `title` \u2013 short event summary, max 60 chars\n- `start_datetime` \u2013 ISO 8601 with timezone offset\n- `end_datetime` \u2013 ISO 8601 with timezone offset\n- `location` \u2013 full address or venue name\n- `confirmation_number` \u2013 booking reference / PNR / order number\n- `vendor` \u2013 company or service provider\n- `notes` \u2013 vendor-specific details (seat, table size, room type)\n- `participants` \u2013 passenger names, party size, attendees\n\n> \ud83d\udca1 **Tip:** The more specific your field descriptions are, the more accurate your results will be \u2013 treat them like code.\n\n### 2. Install the easybits Extractor Node\n1. The node is **verified** and works on n8n Cloud out of the box \u2013 just search for \"easybits Extractor\" in the node panel\n2. For self-hosted instances, go to **Settings \u2192 Community Nodes \u2192 Install** and enter `@easybits/n8n-nodes-extractor`\n\n### 3. Connect Your Accounts\n1. **Gmail** \u2013 connect via OAuth2 for the Gmail Trigger and the three Gmail action nodes\n2. **Google Calendar** \u2013 connect via OAuth2 for the Calendar: Create Event node\n3. **easybits Extractor** \u2013 paste your Pipeline ID and API Key into both Extractor nodes\n\n### 4. Configure Gmail Labels\n1. In Gmail, create a label named `Events` \u2013 this is what the trigger watches for\n2. Create a nested label `Events/Needs-Review` \u2013 emails that fail validation get moved here\n3. In n8n, select these labels in the Gmail Trigger, Flag for Review, and Remove Events Label nodes\n\n### 5. Set Up the Auto-Imported Calendar\n1. Go to [calendar.google.com](https://calendar.google.com) \u2192 \"Other calendars\" \u2192 **+** \u2192 Create new calendar\n2. Name it `Auto-imported` (or your choice) and save\n3. In the Calendar: Create Event node, select this calendar from the dropdown\n\n### 6. Activate & Test\n1. Click **Active** in the top-right corner of n8n\n2. Apply the `Events` label to any confirmation email in Gmail\n3. Within a minute, check your `Auto-imported` calendar \u2013 the event should appear with full details\n4. The `Events` label is automatically removed after processing to prevent duplicates"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "connections": {
    "Gmail Trigger": {
      "main": [
        [
          {
            "node": "Code: Email Body Preparation",
            "type": "main",
            "index": 0
          },
          {
            "node": "Code: Explode Attachments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If: Valid Event?": {
      "main": [
        [
          {
            "node": "Set: Build Event Description",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Gmail: Flag for Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extractor: Attachment": {
      "main": [
        [
          {
            "node": "Code: Merge Extractions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extractor: Email Body": {
      "main": [
        [
          {
            "node": "Code: Merge Extractions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calendar: Create Event": {
      "main": [
        [
          {
            "node": "Gmail: Remove Events Label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail: Flag for Review": {
      "main": [
        [
          {
            "node": "Gmail: Remove Events Label (Review Path)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Merge Extractions": {
      "main": [
        [
          {
            "node": "If: Valid Event?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Explode Attachments": {
      "main": [
        [
          {
            "node": "Extractor: Attachment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Email Body Preparation": {
      "main": [
        [
          {
            "node": "Extractor: Email Body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set: Build Event Description": {
      "main": [
        [
          {
            "node": "Calendar: Create Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gmail: Remove Events Label (Review Path)": {
      "main": [
        [
          {
            "node": "Gmail: Send Review Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}