This workflow corresponds to n8n.io template #18107 — we link there as the canonical source.
This workflow follows the Gmail → Google Calendar 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 →
{
"id": "CkQWEYOIUqxLTC1U",
"name": "Enterprise Deal Knowledge Graph Builder",
"tags": [
{
"id": "67R2t9aCg8ADLQ6p",
"name": "salesforce",
"createdAt": "2026-08-12T09:28:19.344Z",
"updatedAt": "2026-08-12T09:28:19.344Z"
},
{
"id": "NpHF6iO2OksZi27E",
"name": "production-style",
"createdAt": "2026-08-12T09:28:19.253Z",
"updatedAt": "2026-08-12T09:28:19.253Z"
},
{
"id": "R0XmWuqG0xRbeXjc",
"name": "relationship-mapping",
"createdAt": "2026-08-12T09:28:19.323Z",
"updatedAt": "2026-08-12T09:28:19.323Z"
},
{
"id": "mJyw4UnalTyhDgv2",
"name": "gmail",
"createdAt": "2026-08-12T09:28:19.280Z",
"updatedAt": "2026-08-12T09:28:19.280Z"
},
{
"id": "tQ1k4Blu026PBZtV",
"name": "google-calendar",
"createdAt": "2026-08-12T09:28:19.363Z",
"updatedAt": "2026-08-12T09:28:19.363Z"
}
],
"nodes": [
{
"id": "773e3c6b-1bdb-45e3-9d6c-d14c6ab137ff",
"name": "Set Workflow Config",
"type": "n8n-nodes-base.set",
"position": [
592,
832
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "6160a7d6-7536-40fe-a1a4-9b7c454ecb7e",
"name": "gmail_query",
"type": "string",
"value": "newer_than:1d"
},
{
"id": "549574c3-b415-48ca-85b3-573095793fe5",
"name": "calendar_id",
"type": "string",
"value": "primary"
},
{
"id": "94a3e74c-4faf-45b8-92a6-c12d68bb52dd",
"name": "lookback_start",
"type": "string",
"value": "={{$now.minus({ days: 30 }).toISO()}}"
},
{
"id": "0ec9b3ca-711b-4478-a080-660b66c509c9",
"name": "lookahead_end",
"type": "string",
"value": "={{$now.plus({ days: 30 }).toISO()}}"
},
{
"id": "051d1705-c725-4001-88d1-88003b3fb45d",
"name": "max_gmail_messages",
"type": "number",
"value": 200
},
{
"id": "78822c9b-e371-482f-9cce-b4793fea9524",
"name": "max_calendar_events",
"type": "number",
"value": 200
}
]
}
},
"typeVersion": 3.4
},
{
"id": "629da10a-f591-4bc4-b4c9-afaa566e274e",
"name": "Get Salesforce Contacts",
"type": "n8n-nodes-base.salesforce",
"position": [
848,
672
],
"parameters": {
"options": {
"fields": "Id,FirstName, LastName, Email, AccountId"
},
"resource": "contact",
"operation": "getAll"
},
"typeVersion": 1
},
{
"id": "0c4df6de-465a-4739-ac18-91cfaaa8818f",
"name": "Get Salesforce Accounts",
"type": "n8n-nodes-base.salesforce",
"position": [
1456,
736
],
"parameters": {
"options": {},
"resource": "account",
"operation": "getAll",
"returnAll": true
},
"typeVersion": 1
},
{
"id": "797bba10-619e-48cd-8a93-3d5fdcb20c1d",
"name": "Get Salesforce Opportunities",
"type": "n8n-nodes-base.salesforce",
"position": [
1456,
928
],
"parameters": {
"options": {},
"resource": "opportunity",
"operation": "getAll",
"returnAll": true
},
"typeVersion": 1
},
{
"id": "85226146-8054-4f3c-a325-56016cd3ce2d",
"name": "Get Gmail Messages",
"type": "n8n-nodes-base.gmail",
"position": [
1472,
528
],
"parameters": {
"limit": "={{$json.max_gmail_messages}}",
"simple": false,
"filters": {
"q": "={{$node[\"Build Gmail Query From Contacts\"].json[\"gmail_query_dynamic\"]}}"
},
"options": {},
"operation": "getAll"
},
"typeVersion": 2.2
},
{
"id": "60b45c2d-3f3b-4878-acaf-b46aa6dcfbb9",
"name": "Get Calendar Events",
"type": "n8n-nodes-base.googleCalendar",
"position": [
1456,
1120
],
"parameters": {
"limit": "={{$json.max_calendar_events}}",
"options": {},
"timeMax": "={{$json.lookahead_end}}",
"timeMin": "={{$json.lookback_start}}",
"calendar": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultName": ""
},
"operation": "getAll"
},
"typeVersion": 1.3
},
{
"id": "f6c6f51a-d7b3-4c6b-8dd4-961eb71a17fd",
"name": "Enterprise Data Aggregation Scheduler",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
416,
832
],
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 6
}
]
}
},
"typeVersion": 1.3
},
{
"id": "aba55826-b0a3-4639-a85d-dd21224a60a8",
"name": "Normalize Contacts",
"type": "n8n-nodes-base.set",
"position": [
1040,
624
],
"parameters": {
"mode": "raw",
"options": {},
"jsonOutput": "={\n \"record_type\": \"contact\",\n \"contact_id\": \"{{ $json.Id }}\",\n \"contact_name\": \"{{ [$json.FirstName, $json.LastName].filter(Boolean).join(' ').trim() }}\",\n \"contact_email\": \"{{ ($json.Email || '').toLowerCase().trim() }}\",\n \"account_id\": \"{{ $json.AccountId || '' }}\"\n}"
},
"typeVersion": 3.4
},
{
"id": "3c3bdedb-4a8a-4f62-8927-cb6226682fca",
"name": "Normalize Accounts",
"type": "n8n-nodes-base.set",
"position": [
1888,
752
],
"parameters": {
"mode": "raw",
"options": {},
"jsonOutput": "={\n \"record_type\": \"account\",\n \"account_id\": \"{{ $json.Id }}\",\n \"account_name\": \"{{ $json.Name || '' }}\",\n \"account_type\": \"{{ $json.Type || '' }}\"\n}"
},
"typeVersion": 3.4
},
{
"id": "18360703-8845-4eb9-aec5-19360e7c7e14",
"name": "Normalize Opportunities",
"type": "n8n-nodes-base.set",
"position": [
1888,
944
],
"parameters": {
"mode": "raw",
"options": {},
"jsonOutput": "={\n \"record_type\": \"opportunity\",\n \"opportunity_id\": \"{{ $json.Id }}\",\n \"account_id\": \"{{ $json.AccountId || '' }}\",\n \"amount\": \"{{ Number($json.Amount || 0) }}\",\n \"probability\": \"{{ Number($json.Probability || 0) }}\",\n \"opportunity_type\": \"{{ $json.Type || '' }}\"\n}"
},
"typeVersion": 3.4
},
{
"id": "c8e1812a-ed40-49f9-92e1-234377606715",
"name": "Normalize Emails",
"type": "n8n-nodes-base.set",
"position": [
1888,
544
],
"parameters": {
"mode": "raw",
"options": {},
"jsonOutput": "={\n \"record_type\": \"email\",\n \"message_id\": \"{{ $json.id || '' }}\",\n \"thread_id\": \"{{ $json.threadId || '' }}\",\n \"subject\": \"{{ ($json.headers.subject || '').replace(/^Subject:\\\\s*/i, '').replace(/\\\"/g, '').trim() }}\",\n \"from_email\": \"{{ (($json.headers.from || '').replace(/^From:\\\\s*/i, '').match(/<([^>]+)>/)?.[1] || ($json.headers.from || '').replace(/^From:\\\\s*/i, '')).replace(/\\\"/g, '').toLowerCase().trim() }}\",\n \"to_raw\": \"{{ ($json.headers.to || '').replace(/^To:\\\\s*/i, '').replace(/\\\"/g, '').trim() }}\",\n \"cc_raw\": \"{{ ($json.headers.cc || '').replace(/^Cc:\\\\s*/i, '').replace(/\\\"/g, '').trim() }}\",\n \"timestamp\": \"{{ ($json.headers.date || '').replace(/^Date:\\\\s*/i, '').replace(/\\\"/g, '').trim() }}\",\n \"snippet\": \"{{ ($json.text || '').replace(/\\\"/g, '') }}\"\n}"
},
"typeVersion": 3.4
},
{
"id": "3a7cbf73-02fd-4b3f-b31c-bfc96c1f7c4d",
"name": "Normalize Meetings",
"type": "n8n-nodes-base.code",
"position": [
1888,
1136
],
"parameters": {
"jsCode": "return $input.all().map(item => {\n const e = item.json;\n const startObj = e.start || {};\n const endObj = e.end || {};\n\n return {\n json: {\n record_type: 'meeting',\n event_id: e.id || '',\n meeting_title: e.summary || '',\n organizer_email: (e.organizer?.email || '').toLowerCase().trim(),\n start_time: startObj['dateTime'] ?? startObj['date'] ?? '',\n end_time: endObj['dateTime'] ?? endObj['date'] ?? '',\n location: e.location || '',\n status: e.status || '',\n attendees: e.attendees || []\n }\n };\n});"
},
"typeVersion": 2
},
{
"id": "b9310169-19b0-4228-91df-25a070a9f9a7",
"name": "Build Gmail Query From Contacts",
"type": "n8n-nodes-base.code",
"position": [
1264,
528
],
"parameters": {
"jsCode": "const contacts = $input.all().map(i => i.json);\n\nconst emails = contacts\n .map(c => (c.contact_email || '').trim().toLowerCase())\n .filter(Boolean);\n\nconst uniqueEmails = [...new Set(emails)];\n\n// keep query size reasonable\nconst limitedEmails = uniqueEmails.slice(0, 20);\n\nconst emailQueryPart = limitedEmails.join(' OR ');\n\nreturn [\n {\n json: {\n gmail_query_dynamic: emailQueryPart\n ? `(${emailQueryPart}) newer_than:30d`\n : 'newer_than:30d'\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "6486e5f8-527a-4867-a183-6eebfda8f5fb",
"name": "Build Contact Relationship Profiles",
"type": "n8n-nodes-base.code",
"position": [
2272,
752
],
"parameters": {
"jsCode": "const contacts = $('Normalize Contacts').all().map(i => i.json);\nconst accounts = $('Normalize Accounts').all().map(i => i.json);\nconst opportunities = $('Normalize Opportunities').all().map(i => i.json);\nconst emails = $('Normalize Emails').all().map(i => i.json);\nconst meetings = $('Normalize Meetings').all().map(i => i.json);\nfunction splitEmails(input) {\n if (!input) return [];\n\n return [...new Set(\n String(input)\n .split(',')\n .map(v => v.trim())\n .filter(Boolean)\n .map(v => v.replace(/^(to|from|cc|bcc):\\s*/i, '').trim()) \n .map(v => {\n const match = v.match(/<([^>]+)>/);\n return (match ? match[1] : v).toLowerCase().trim();\n })\n )];\n}\n\nfunction normalizeAttendees(attendees) {\n if (!Array.isArray(attendees)) return [];\n return [...new Set(\n attendees\n .map(a => typeof a === 'string' ? a : a?.email)\n .filter(Boolean)\n .map(v => String(v).toLowerCase().trim())\n )];\n}\n\nfunction extractDomainFromAccountName(name = '') {\n return String(name || '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, ' ')\n .trim();\n}\n\nfunction parseDateSafe(v) {\n if (!v) return null;\n const d = new Date(v);\n return Number.isNaN(d.getTime()) ? null : d.toISOString();\n}\n\nconst accountById = new Map(\n accounts.map(a => [\n a.account_id,\n {\n account_id: a.account_id,\n account_name: a.account_name,\n account_type: a.account_type || '',\n account_key: extractDomainFromAccountName(a.account_name || '')\n }\n ])\n);\n\nconst oppsByAccount = new Map();\nfor (const opp of opportunities) {\n const key = opp.account_id || '';\n if (!oppsByAccount.has(key)) oppsByAccount.set(key, []);\n oppsByAccount.get(key).push({\n opportunity_id: opp.opportunity_id,\n amount: Number(opp.amount || 0),\n probability: Number(opp.probability || 0),\n opportunity_type: opp.opportunity_type || ''\n });\n}\n\nconst emailsByPerson = new Map();\nfor (const email of emails) {\n const fromEmail = (email.from_email || '').toLowerCase().trim();\n const toEmails = splitEmails(email.to_raw);\n const ccEmails = splitEmails(email.cc_raw);\n const participants = [...new Set([fromEmail, ...toEmails, ...ccEmails].filter(Boolean))];\n const subjectLower = (email.subject || '').toLowerCase();\n const fromLower = fromEmail;\n const snippetLower = (email.snippet || '').toLowerCase();\n\n const isSystemEmail =\n fromLower.includes('mailer-daemon') ||\n fromLower.includes('postmaster') ||\n subjectLower.includes('delivery status notification') ||\n subjectLower.includes('security alert') ||\n subjectLower.includes('verify your identity') ||\n snippetLower.includes('address not found') ||\n snippetLower.includes('recipient address rejected') ||\n snippetLower.includes('verification code');\n\n if (isSystemEmail) {\n continue;\n }\n\n const normalized = {\n message_id: email.message_id || '',\n thread_id: email.thread_id || '',\n subject: email.subject || '',\n from_email: fromEmail,\n to_emails: toEmails,\n cc_emails: ccEmails,\n timestamp: parseDateSafe(email.timestamp),\n snippet: email.snippet || ''\n };\n\n for (const participant of participants) {\n if ((!emailsByPerson.has(participant))) {\n emailsByPerson.set(participant, []); \n }\n emailsByPerson.get(participant).push(normalized);\n }\n\n\n}\n\nconst meetingsByPerson = new Map();\nfor (const meeting of meetings) {\n const attendeeEmails = normalizeAttendees(meeting.attendees);\n\n const normalized = {\n event_id: meeting.event_id || '',\n meeting_title: meeting.meeting_title || '',\n organizer_email: (meeting.organizer_email || '').toLowerCase().trim(),\n attendee_emails: attendeeEmails,\n start_time: parseDateSafe(meeting.start_time),\n end_time: parseDateSafe(meeting.end_time),\n location: meeting.location || '',\n status: meeting.status || ''\n };\n\n for (const attendee of attendeeEmails) {\n if (!meetingsByPerson.has(attendee)) meetingsByPerson.set(attendee, []);\n meetingsByPerson.get(attendee).push(normalized);\n }\n}\n\nfunction sortDesc(arr, field) {\n return [...arr].sort((a, b) => {\n const av = a[field] ? new Date(a[field]).getTime() : 0;\n const bv = b[field] ? new Date(b[field]).getTime() : 0;\n return bv - av;\n });\n}\n\nfunction latestInteraction(emails, meetings) {\n const allDates = [\n ...emails.map(e => e.timestamp),\n ...meetings.map(m => m.start_time)\n ]\n .filter(Boolean)\n .map(v => new Date(v).getTime())\n .filter(v => !Number.isNaN(v));\n\n if (!allDates.length) return null;\n return new Date(Math.max(...allDates)).toISOString();\n}\n\nfunction stakeholderRole(emailCount, meetingCount, oppCount) {\n const score = emailCount * 3 + meetingCount * 5 + oppCount * 4;\n if (score >= 35) return 'high_influence';\n if (score >= 18) return 'active_stakeholder';\n if (score >= 8) return 'engaged_contact';\n return 'light_touch';\n}\n\nconst output = [];\n\nfor (const contact of contacts) {\n const emailKey = (contact.contact_email || '').toLowerCase().trim();\n if (!emailKey) continue;\n\n// let contactEmails = [];\n\n// Array.from(emailsByPerson, (data) => { \n// console.log(data[0] === emailKey)\n// if (data[0] === emailKey) {\n// contactEmails.push(data);\n// }\n// });\n const account = accountById.get(contact.account_id) || null;\n const contactOpps = oppsByAccount.get(contact.account_id) || [];\n const contactEmails = sortDesc(emailsByPerson.get(emailKey) || [], 'timestamp');\n const contactMeetings = sortDesc(meetingsByPerson.get(emailKey) || [], 'start_time');\n\n const emailCount = contactEmails.length;\n const meetingCount = contactMeetings.length;\n const opportunityCount = contactOpps.length;\n const openPipelineAmount = contactOpps.reduce((sum, o) => sum + Number(o.amount || 0), 0);\n const engagementScore = Math.min(100, emailCount * 6 + meetingCount * 10 + opportunityCount * 8);\n\n output.push({\n json: {\n contact_id: contact.contact_id || '',\n contact_name: contact.contact_name || '',\n contact_email: emailKey,\n account,\n opportunities: contactOpps,\n emails: contactEmails,\n meetings: contactMeetings,\n metrics: {\n email_count: emailCount,\n meeting_count: meetingCount,\n opportunity_count: opportunityCount,\n open_pipeline_amount: openPipelineAmount,\n latest_interaction_at: latestInteraction(contactEmails, contactMeetings),\n engagement_score: engagementScore,\n stakeholder_role_hint: stakeholderRole(emailCount, meetingCount, opportunityCount)\n }\n }\n });\n}\n\nreturn output;"
},
"typeVersion": 2
},
{
"id": "920bef65-ffd2-4811-8525-baf5738e77fa",
"name": "Build Graph Payload",
"type": "n8n-nodes-base.code",
"position": [
2608,
752
],
"parameters": {
"jsCode": "const profiles = $input.all().map(i => i.json);\n\nconst nodeMap = new Map();\nconst edgeMap = new Map();\n\nfunction addNode(id, type, data) {\n if (!id) return;\n const existing = nodeMap.get(id) || { id, type };\n nodeMap.set(id, {\n ...existing,\n ...data,\n id,\n type,\n });\n}\n\nfunction addEdge(from, to, relation, data = {}) {\n if (!from || !to || !relation) return;\n const key = `${from}__${relation}__${to}`;\n const existing = edgeMap.get(key) || { from, to, relation };\n edgeMap.set(key, {\n ...existing,\n ...data,\n from,\n to,\n relation,\n });\n}\n\nfunction safeNumber(value, fallback = 0) {\n const n = Number(value);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction safeString(value, fallback = '') {\n return value == null ? fallback : String(value);\n}\n\nfunction uniq(values) {\n return [...new Set(values.filter(Boolean))];\n}\n\nfunction normalizeOpportunityType(value) {\n const v = safeString(value).trim();\n return v || 'Unknown Opportunity Type';\n}\n\nfunction normalizeAccountType(value) {\n const v = safeString(value).trim();\n return v || 'Unknown';\n}\n\nfunction buildOpportunityDisplayName(opp) {\n const type = normalizeOpportunityType(opp.opportunity_type);\n const amount = safeNumber(opp.amount, 0);\n const probability = safeNumber(opp.probability, 0);\n return `${type} \u2022 ${amount} \u2022 ${probability}%`;\n}\n\nconst accountStats = new Map();\n\nfor (const p of profiles) {\n const accountId = p.account?.account_id;\n if (!accountId) continue;\n\n if (!accountStats.has(accountId)) {\n accountStats.set(accountId, {\n contactIds: new Set(),\n opportunityMap: new Map(),\n total_email_count: 0,\n total_meeting_count: 0,\n latest_interaction_at: null,\n });\n }\n\n const stats = accountStats.get(accountId);\n\n if (p.contact_id) stats.contactIds.add(p.contact_id);\n\n stats.total_email_count += safeNumber(p.metrics?.email_count, 0);\n stats.total_meeting_count += safeNumber(p.metrics?.meeting_count, 0);\n\n for (const opp of p.opportunities || []) {\n const oppId = safeString(opp.opportunity_id).trim();\n if (!oppId) continue;\n\n if (!stats.opportunityMap.has(oppId)) {\n stats.opportunityMap.set(oppId, {\n amount: safeNumber(opp.amount, 0),\n probability: safeNumber(opp.probability, 0),\n opportunity_type: normalizeOpportunityType(opp.opportunity_type),\n });\n }\n }\n\n const latest = p.metrics?.latest_interaction_at;\n if (latest && (!stats.latest_interaction_at || new Date(latest) > new Date(stats.latest_interaction_at))) {\n stats.latest_interaction_at = latest;\n }\n}\n\nfor (const p of profiles) {\n const contactId = `contact:${p.contact_id || p.contact_email}`;\n const accountIdRaw = p.account?.account_id || '';\n const accountId = accountIdRaw ? `account:${accountIdRaw}` : '';\n\n const metrics = p.metrics || {};\n const accountSummary = accountStats.get(accountIdRaw);\n\n let accountTotalOpenPipeline = 0;\n let accountTotalWeightedPipeline = 0;\n let accountTotalOpportunities = 0;\n let accountTotalContacts = 0;\n let accountTotalEmailCount = 0;\n let accountTotalMeetingCount = 0;\n\n if (accountSummary) {\n accountTotalContacts = accountSummary.contactIds.size;\n accountTotalOpportunities = accountSummary.opportunityMap.size;\n accountTotalEmailCount = accountSummary.total_email_count;\n accountTotalMeetingCount = accountSummary.total_meeting_count;\n\n for (const oppData of accountSummary.opportunityMap.values()) {\n const amount = safeNumber(oppData.amount, 0);\n const probability = safeNumber(oppData.probability, 0);\n accountTotalOpenPipeline += amount;\n accountTotalWeightedPipeline += Number(((amount * probability) / 100).toFixed(2));\n }\n }\n\n addNode(contactId, 'contact', {\n name: safeString(p.contact_name),\n email: safeString(p.contact_email).toLowerCase().trim(),\n engagement_score: safeNumber(metrics.engagement_score, 0),\n stakeholder_role_hint: safeString(metrics.stakeholder_role_hint || 'light_touch'),\n latest_interaction_at: metrics.latest_interaction_at || null,\n open_pipeline_amount: safeNumber(metrics.open_pipeline_amount, 0),\n email_count: safeNumber(metrics.email_count, 0),\n meeting_count: safeNumber(metrics.meeting_count, 0),\n opportunity_count: safeNumber(metrics.opportunity_count, 0),\n display_name: safeString(p.contact_name || p.contact_email || contactId),\n });\n\n if (accountId) {\n addNode(accountId, 'account', {\n name: safeString(p.account?.account_name),\n account_type: normalizeAccountType(p.account?.account_type),\n account_key: safeString(p.account?.account_key),\n total_contacts: accountTotalContacts,\n total_opportunities: accountTotalOpportunities,\n total_open_pipeline: accountTotalOpenPipeline,\n total_weighted_pipeline: accountTotalWeightedPipeline,\n total_email_count: accountTotalEmailCount,\n total_meeting_count: accountTotalMeetingCount,\n latest_interaction_at: accountSummary?.latest_interaction_at || null,\n display_name: safeString(p.account?.account_name || accountId),\n });\n\n addEdge(contactId, accountId, 'BELONGS_TO');\n }\n\n for (const opp of p.opportunities || []) {\n const oppId = opp.opportunity_id ? `opportunity:${opp.opportunity_id}` : '';\n if (!oppId) continue;\n\n const amount = safeNumber(opp.amount, 0);\n const probability = safeNumber(opp.probability, 0);\n const weightedAmount = Number(((amount * probability) / 100).toFixed(2));\n const oppType = normalizeOpportunityType(opp.opportunity_type);\n const oppDisplayName = buildOpportunityDisplayName(opp);\n\n addNode(oppId, 'opportunity', {\n name: oppDisplayName,\n display_name: oppDisplayName,\n amount,\n probability,\n weighted_amount: weightedAmount,\n opportunity_type: oppType,\n });\n\n addEdge(contactId, oppId, 'INVOLVED_IN', {\n amount,\n probability,\n weighted_amount: weightedAmount,\n opportunity_type: oppType,\n });\n\n if (accountId) {\n addEdge(accountId, oppId, 'OWNS', {\n amount,\n probability,\n weighted_amount: weightedAmount,\n opportunity_type: oppType,\n });\n }\n }\n\n for (const email of p.emails || []) {\n const emailId = email.message_id ? `email:${email.message_id}` : '';\n if (!emailId) continue;\n\n const subjectClean = safeString(email.subject).replace(/^Subject:\\s*/i, '').trim() || 'No Subject';\n const toEmails = uniq((email.to_emails || []).map(v => safeString(v).toLowerCase().trim()));\n const ccEmails = uniq((email.cc_emails || []).map(v => safeString(v).toLowerCase().trim()));\n\n addNode(emailId, 'email', {\n subject: subjectClean,\n timestamp: email.timestamp || null,\n from_email: safeString(email.from_email).toLowerCase().trim(),\n to_emails: toEmails,\n cc_emails: ccEmails,\n snippet: safeString(email.snippet),\n display_name: subjectClean,\n });\n\n addEdge(contactId, emailId, 'SENT_OR_RECEIVED', {\n timestamp: email.timestamp || null,\n subject: subjectClean,\n });\n }\n\n for (const meeting of p.meetings || []) {\n const meetingId = meeting.event_id ? `meeting:${meeting.event_id}` : '';\n if (!meetingId) continue;\n\n const title = safeString(meeting.meeting_title).trim() || 'Untitled Meeting';\n\n addNode(meetingId, 'meeting', {\n title,\n start_time: meeting.start_time || null,\n end_time: meeting.end_time || null,\n organizer_email: safeString(meeting.organizer_email).toLowerCase().trim(),\n attendee_emails: uniq((meeting.attendee_emails || []).map(v => safeString(v).toLowerCase().trim())),\n status: safeString(meeting.status),\n location: safeString(meeting.location),\n display_name: title,\n });\n\n addEdge(contactId, meetingId, 'ATTENDED', {\n start_time: meeting.start_time || null,\n end_time: meeting.end_time || null,\n status: safeString(meeting.status),\n });\n }\n}\n\nreturn [\n {\n json: {\n generated_at: new Date().toISOString(),\n profile_count: profiles.length,\n node_count: nodeMap.size,\n edge_count: edgeMap.size,\n nodes: Array.from(nodeMap.values()),\n edges: Array.from(edgeMap.values()),\n },\n },\n];"
},
"typeVersion": 2
},
{
"id": "d3c70531-9f56-43aa-bb35-7e5f7ad64fb5",
"name": "Aggregate All Data Sources",
"type": "n8n-nodes-base.merge",
"position": [
2112,
704
],
"parameters": {
"numberInputs": 5
},
"typeVersion": 3.2
},
{
"id": "e8290f70-04ba-4a50-b75f-de7899c9077d",
"name": "Write Nodes To Neo4j",
"type": "n8n-nodes-base.httpRequest",
"position": [
2832,
752
],
"parameters": {
"url": "https://YourUsername.databases.neo4j.io/db/YourUsername/query/v2",
"method": "POST",
"options": {},
"sendBody": true,
"sendHeaders": true,
"authentication": "genericCredentialType",
"bodyParameters": {
"parameters": [
{
"name": "statement",
"value": "=UNWIND $nodes AS node\nCALL {\n WITH node\n\n FOREACH (_ IN CASE WHEN node.type = 'contact' THEN [1] ELSE [] END |\n MERGE (n:Contact {id: node.id})\n SET n.name = coalesce(node.name, ''),\n n.email = coalesce(node.email, ''),\n n.display_name = coalesce(node.display_name, coalesce(node.name, '')),\n n.engagement_score = coalesce(node.engagement_score, 0),\n n.stakeholder_role_hint = coalesce(node.stakeholder_role_hint, ''),\n n.latest_interaction_at = node.latest_interaction_at,\n n.open_pipeline_amount = coalesce(node.open_pipeline_amount, 0),\n n.email_count = coalesce(node.email_count, 0),\n n.meeting_count = coalesce(node.meeting_count, 0),\n n.opportunity_count = coalesce(node.opportunity_count, 0)\n )\n\n FOREACH (_ IN CASE WHEN node.type = 'account' THEN [1] ELSE [] END |\n MERGE (n:Account {id: node.id})\n SET n.name = coalesce(node.name, ''),\n n.display_name = coalesce(node.display_name, coalesce(node.name, '')),\n n.account_type = coalesce(node.account_type, 'Unknown'),\n n.account_key = coalesce(node.account_key, ''),\n n.total_contacts = coalesce(node.total_contacts, 0),\n n.total_opportunities = coalesce(node.total_opportunities, 0),\n n.total_open_pipeline = coalesce(node.total_open_pipeline, 0),\n n.total_weighted_pipeline = coalesce(node.total_weighted_pipeline, 0),\n n.total_email_count = coalesce(node.total_email_count, 0),\n n.total_meeting_count = coalesce(node.total_meeting_count, 0),\n n.latest_interaction_at = node.latest_interaction_at\n )\n\n FOREACH (_ IN CASE WHEN node.type = 'opportunity' THEN [1] ELSE [] END |\n MERGE (n:Opportunity {id: node.id})\n SET n.name = coalesce(node.name, ''),\n n.display_name = coalesce(node.display_name, coalesce(node.name, '')),\n n.amount = coalesce(node.amount, 0),\n n.probability = coalesce(node.probability, 0),\n n.weighted_amount = coalesce(node.weighted_amount, 0),\n n.opportunity_type = coalesce(node.opportunity_type, 'Unknown Opportunity Type')\n )\n\n FOREACH (_ IN CASE WHEN node.type = 'email' THEN [1] ELSE [] END |\n MERGE (n:Email {id: node.id})\n SET n.subject = coalesce(node.subject, 'No Subject'),\n n.display_name = coalesce(node.display_name, coalesce(node.subject, 'No Subject')),\n n.timestamp = node.timestamp,\n n.from_email = coalesce(node.from_email, ''),\n n.to_emails = coalesce(node.to_emails, []),\n n.cc_emails = coalesce(node.cc_emails, []),\n n.snippet = coalesce(node.snippet, '')\n )\n\n FOREACH (_ IN CASE WHEN node.type = 'meeting' THEN [1] ELSE [] END |\n MERGE (n:Meeting {id: node.id})\n SET n.title = coalesce(node.title, 'Untitled Meeting'),\n n.display_name = coalesce(node.display_name, coalesce(node.title, 'Untitled Meeting')),\n n.start_time = node.start_time,\n n.end_time = node.end_time,\n n.organizer_email = coalesce(node.organizer_email, ''),\n n.attendee_emails = coalesce(node.attendee_emails, []),\n n.status = coalesce(node.status, ''),\n n.location = coalesce(node.location, '')\n )\n\n RETURN 1 AS done\n}\nRETURN size($nodes) AS nodes_processed"
},
{
"name": "parameters",
"value": "={{ { nodes: $('Build Graph Payload').first().json.nodes } }}"
}
]
},
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"typeVersion": 4.4
},
{
"id": "0481bbbe-7795-40aa-98fd-34569779ed89",
"name": "Write Relationships To Neo4j",
"type": "n8n-nodes-base.httpRequest",
"position": [
3056,
752
],
"parameters": {
"url": "https://YourUsername.databases.neo4j.io/db/YourUsername/query/v2",
"method": "POST",
"options": {},
"sendBody": true,
"sendHeaders": true,
"authentication": "genericCredentialType",
"bodyParameters": {
"parameters": [
{
"name": "statement",
"value": "=UNWIND $edges AS edge\nMATCH (from {id: edge.from})\nMATCH (to {id: edge.to})\n\nFOREACH (_ IN CASE WHEN edge.relation = 'BELONGS_TO' THEN [1] ELSE [] END |\n MERGE (from)-[r:BELONGS_TO]->(to)\n)\n\nFOREACH (_ IN CASE WHEN edge.relation = 'INVOLVED_IN' THEN [1] ELSE [] END |\n MERGE (from)-[r:INVOLVED_IN]->(to)\n SET r.amount = coalesce(edge.amount, 0),\n r.probability = coalesce(edge.probability, 0),\n r.weighted_amount = coalesce(edge.weighted_amount, 0),\n r.opportunity_type = coalesce(edge.opportunity_type, 'Unknown Opportunity Type')\n)\n\nFOREACH (_ IN CASE WHEN edge.relation = 'OWNS' THEN [1] ELSE [] END |\n MERGE (from)-[r:OWNS]->(to)\n SET r.amount = coalesce(edge.amount, 0),\n r.probability = coalesce(edge.probability, 0),\n r.weighted_amount = coalesce(edge.weighted_amount, 0),\n r.opportunity_type = coalesce(edge.opportunity_type, 'Unknown Opportunity Type')\n)\n\nFOREACH (_ IN CASE WHEN edge.relation = 'SENT_OR_RECEIVED' THEN [1] ELSE [] END |\n MERGE (from)-[r:SENT_OR_RECEIVED]->(to)\n SET r.timestamp = edge.timestamp,\n r.subject = coalesce(edge.subject, 'No Subject')\n)\n\nFOREACH (_ IN CASE WHEN edge.relation = 'ATTENDED' THEN [1] ELSE [] END |\n MERGE (from)-[r:ATTENDED]->(to)\n SET r.start_time = edge.start_time,\n r.end_time = edge.end_time,\n r.status = coalesce(edge.status, '')\n)\n\nRETURN size($edges) AS edges_processed"
},
{
"name": "parameters",
"value": "={{ { edges: $('Build Graph Payload').first().json.edges } }}"
}
]
},
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"typeVersion": 4.4
},
{
"id": "7145182b-3898-42a3-93e5-a243a39375a6",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-432,
192
],
"parameters": {
"width": 752,
"height": 1104,
"content": "**Title: Enterprise Deal Knowledge Graph Builder \u2014 Overview**\n\nThis workflow aggregates enterprise data from Salesforce, Gmail, and Google Calendar to build a unified relationship intelligence graph in Neo4j. It runs on a scheduled trigger and begins by fetching contacts, accounts, opportunities, emails, and meeting data. Each dataset is normalized into a consistent structure, ensuring clean and usable records.\n\nNext, the workflow combines all data sources and builds contact-centric relationship profiles. These profiles include engagement metrics such as email activity, meeting frequency, opportunity involvement, and calculated engagement scores. Based on these enriched profiles, a graph payload is constructed with nodes (Contact, Account, Opportunity, Email, Meeting) and relationships between them.\n\nFinally, the workflow writes structured nodes and relationships into Neo4j, enabling advanced querying and visualization of deal pipelines and stakeholder interactions.\n\n**Setup Steps:**\n\n1. Configure credentials for Salesforce, Gmail, Google Calendar, and Neo4j\n2. Update values in **Set Workflow Config**\n3. Test each data node individually\n4. Run the full workflow once\n5. Activate **Enterprise Data Aggregation Scheduler**\n\n"
},
"typeVersion": 1
},
{
"id": "c6aee9cf-efd1-4710-8f8c-b60ba78c9957",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
368,
320
],
"parameters": {
"color": 7,
"width": 1264,
"height": 976,
"content": "## Section: Data Sources ##\n\nFetches raw data from:\n\n* Get Salesforce Contacts\n* Get Salesforce Accounts\n* Get Salesforce Opportunities\n* Get Gmail Messages\n* Get Calendar Events\n\nBuild Gmail Query From Contacts dynamically filters Gmail data.\n"
},
"typeVersion": 1
},
{
"id": "05bb2b92-8ff6-4a94-97d0-69b121c6b0d9",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
1712,
176
],
"parameters": {
"color": 7,
"width": 768,
"height": 1120,
"content": "\n\n## Section: Normalization & Profiling\n\nNormalizes all datasets using:\n\n* Normalize Contacts / Accounts / Opportunities\n* Normalize Emails / Meetings\n\nAggregate All Data Sources merges inputs.\nBuild Contact Relationship Profiles creates engagement metrics and unified profiles.\n"
},
"typeVersion": 1
},
{
"id": "8606e3e5-e7e6-417a-8708-f2e35ceeee1b",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
2560,
352
],
"parameters": {
"color": 7,
"width": 704,
"height": 640,
"content": "## Section: Graph Builder & Storage\n\nBuilds and stores the knowledge graph:\n\n* Build Graph Payload creates nodes and edges\n* Write Nodes To Neo4j stores entities\n* Write Relationships To Neo4j connects relationships\n\nCreates a fully connected Neo4j graph for analysis.\n"
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "e3bed6b6-3851-45ad-8f9f-31ab2f70ff2b",
"nodeGroups": [],
"connections": {
"Normalize Emails": {
"main": [
[
{
"node": "Aggregate All Data Sources",
"type": "main",
"index": 3
}
]
]
},
"Get Gmail Messages": {
"main": [
[
{
"node": "Normalize Emails",
"type": "main",
"index": 0
}
]
]
},
"Normalize Accounts": {
"main": [
[
{
"node": "Aggregate All Data Sources",
"type": "main",
"index": 1
}
]
]
},
"Normalize Contacts": {
"main": [
[
{
"node": "Aggregate All Data Sources",
"type": "main",
"index": 0
},
{
"node": "Build Gmail Query From Contacts",
"type": "main",
"index": 0
}
]
]
},
"Normalize Meetings": {
"main": [
[
{
"node": "Aggregate All Data Sources",
"type": "main",
"index": 4
}
]
]
},
"Build Graph Payload": {
"main": [
[
{
"node": "Write Nodes To Neo4j",
"type": "main",
"index": 0
}
]
]
},
"Get Calendar Events": {
"main": [
[
{
"node": "Normalize Meetings",
"type": "main",
"index": 0
}
]
]
},
"Set Workflow Config": {
"main": [
[
{
"node": "Get Salesforce Contacts",
"type": "main",
"index": 0
},
{
"node": "Get Salesforce Accounts",
"type": "main",
"index": 0
},
{
"node": "Get Salesforce Opportunities",
"type": "main",
"index": 0
},
{
"node": "Get Calendar Events",
"type": "main",
"index": 0
}
]
]
},
"Write Nodes To Neo4j": {
"main": [
[
{
"node": "Write Relationships To Neo4j",
"type": "main",
"index": 0
}
]
]
},
"Get Salesforce Accounts": {
"main": [
[
{
"node": "Normalize Accounts",
"type": "main",
"index": 0
}
]
]
},
"Get Salesforce Contacts": {
"main": [
[
{
"node": "Normalize Contacts",
"type": "main",
"index": 0
}
]
]
},
"Normalize Opportunities": {
"main": [
[
{
"node": "Aggregate All Data Sources",
"type": "main",
"index": 2
}
]
]
},
"Aggregate All Data Sources": {
"main": [
[
{
"node": "Build Contact Relationship Profiles",
"type": "main",
"index": 0
}
]
]
},
"Get Salesforce Opportunities": {
"main": [
[
{
"node": "Normalize Opportunities",
"type": "main",
"index": 0
}
]
]
},
"Build Gmail Query From Contacts": {
"main": [
[
{
"node": "Get Gmail Messages",
"type": "main",
"index": 0
}
]
]
},
"Build Contact Relationship Profiles": {
"main": [
[
{
"node": "Build Graph Payload",
"type": "main",
"index": 0
}
]
]
},
"Enterprise Data Aggregation Scheduler": {
"main": [
[
{
"node": "Set Workflow Config",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs every 6 hours to pull Salesforce contacts, accounts, and opportunities, then fetches related Gmail messages and Google Calendar events, calculates per-contact engagement metrics, and writes a Neo4j knowledge graph with entities and relationships for deal and…
Source: https://n8n.io/workflows/18107/ — 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.
HVACFlow - Smart HVAC Service Management System copy. Uses mongoDb, gmail, twilio, googleCalendar. Scheduled trigger; 42 nodes.
Splitout Googlecalendar. Uses httpRequest, scheduleTrigger, googleCalendar, splitOut. Scheduled trigger; 19 nodes.
It’s very important to come prepared to Sales calls. This often means a lot of manual research about the person you’re calling with. This workflow delivers the latest social media activity (LinkedIn +
Httprequest Workflow. Uses httpRequest, googleCalendar, clearbit, gmail. Scheduled trigger; 19 nodes.
This workflow is designed for sales teams and business development managers who use Pipedrive to track leads and Google Calendar to schedule discovery calls. It automates the sending of commercial bri