AutomationFlowsEmail & Gmail › New Submission

New Submission

ByFelix @easybits on n8n.io

New Submission

Event trigger★★★★☆ complexity27 nodesGmail Trigger@Easybits/N8N Nodes ExtractorGoogle CalendarGmail
Email & Gmail Trigger: Event Nodes: 27 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #15986 — we link there as the canonical source.

This workflow follows the Gmail → Gmail Trigger recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "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
          }
        ]
      ]
    }
  }
}
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

New Submission

Source: https://n8n.io/workflows/15986/ — original creator credit. Request a take-down →

More Email & Gmail workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Email & Gmail

Reference Check Parser – Turn Reference Emails into a Comparable Candidate Sheet (powered by easybits). Uses gmailTrigger, @easybits/n8n-nodes-extractor, googleSheets, gmail. Event-driven trigger; 21

Gmail Trigger, @Easybits/N8N Nodes Extractor, Google Sheets +1
Email & Gmail

Toastmasters Email Auto-Responder. Uses gmailTrigger, httpRequest, gmail, googleCalendar. Event-driven trigger; 11 nodes.

Gmail Trigger, HTTP Request, Gmail +2
Email & Gmail

email. Uses gmailTrigger, gmail, ftp, mattermost. Event-driven trigger; 81 nodes.

Gmail Trigger, Gmail, Ftp +1
Email & Gmail

Payment Recovery. Uses stripeTrigger, highLevel, dataTable, googleCalendar. Event-driven trigger; 40 nodes.

Stripe Trigger, High Level, Data Table +3
Email & Gmail

This workflow automates the processing of credit card statement emails from multiple banks. It extracts important payment details, stores them in Google Sheets, and creates calendar reminders in Googl

Google Sheets, Gmail Trigger, Google Calendar