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 →
{
"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
}
]
]
}
}
}
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
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
Toastmasters Email Auto-Responder. Uses gmailTrigger, httpRequest, gmail, googleCalendar. Event-driven trigger; 11 nodes.
email. Uses gmailTrigger, gmail, ftp, mattermost. Event-driven trigger; 81 nodes.
Payment Recovery. Uses stripeTrigger, highLevel, dataTable, googleCalendar. Event-driven trigger; 40 nodes.
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