This workflow corresponds to n8n.io template #13566 — we link there as the canonical source.
This workflow follows the Gmail → Google Sheets 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": "VO0V8MuXMu0wPsMp",
"name": "My workflow",
"tags": [],
"nodes": [
{
"id": "950eaf09-ac30-4530-99f0-c37384e2339f",
"name": "When clicking \u2018Execute workflow\u2019",
"type": "n8n-nodes-base.manualTrigger",
"position": [
-464,
144
],
"parameters": {},
"typeVersion": 1
},
{
"id": "67fbbead-8ebd-48f6-9925-a907d7cd638f",
"name": "Get many messages",
"type": "n8n-nodes-base.gmail",
"position": [
-224,
144
],
"parameters": {
"simple": false,
"filters": {
"sender": "user@example.com",
"readStatus": "unread",
"includeSpamTrash": true
},
"options": {},
"operation": "getAll"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 2.2
},
{
"id": "51141783-e4e7-4420-8813-d134818a0867",
"name": "Extract Content Font Family from HTML",
"type": "n8n-nodes-base.code",
"position": [
304,
240
],
"parameters": {
"jsCode": "/**\n * n8n Function node (ES5-friendly)\n * Extracts font-family for each requested content string from Gmail HTML.\n *\n * Input:\n * - $json.html : full Gmail HTML body (string) \u2014 escaped or real\n *\n * Output (array of items):\n * [{ json: { \n * Content, \n * \"Actual font family\", \n * \"Tag name\", \n * \"Tag snippet (escaped)\", \n * \"Anchor href\" \n * } \n * }, ...]\n */\n\n// -------------------- Utilities --------------------\nfunction unescapeHtmlEntities(s) {\n if (!s) return \"\";\n return s\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&amp;/g, \"&\")\n .replace(/&quot;/g, \"\\\"\")\n .replace(/&#39;/g, \"'\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/&/g, \"&\")\n .replace(/"/g, \"\\\"\")\n .replace(/'/g, \"'\");\n}\n\nfunction escapeHtml(s) {\n if (!s) return \"\";\n return s.replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\nfunction innerText(htmlFrag) {\n if (!htmlFrag) return \"\";\n var noTags = htmlFrag.replace(/<[^>]*>/g, \" \");\n return unescapeHtmlEntities(noTags)\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nfunction parseAttributes(openingTag) {\n var attrs = {};\n var attrRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\\s*=\\s*(\"([^\"]*)\"|'([^']*)')/g;\n var m;\n while ((m = attrRegex.exec(openingTag)) !== null) {\n attrs[m[1]] = (m[3] || m[4] || \"\").trim();\n }\n return attrs;\n}\n\nfunction normalizeFontFamily(ff) {\n if (!ff) return null;\n return ff.toLowerCase()\n .replace(/[\"']/g, \"\")\n .replace(/\\s*,\\s*/g, \",\")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nfunction extractFontFamilyFromStyle(style) {\n if (!style || typeof style !== \"string\") return null;\n var low = style.toLowerCase();\n var idx = low.indexOf(\"font-family\");\n if (idx === -1) return null;\n var s = style.slice(idx);\n var colonIdx = s.indexOf(\":\");\n if (colonIdx === -1) return null;\n s = s.slice(colonIdx + 1);\n var semiIdx = s.indexOf(\";\");\n if (semiIdx !== -1) s = s.slice(0, semiIdx);\n return normalizeFontFamily(s);\n}\n\n// -------------------- Core logic (unchanged) --------------------\n// ... [functions: escRe, makeFlexibleRegex, findTextIndex, findEnclosingTag, \n// findAncestorWithFont, scanTagsForText, getTagAndFont, getFontForMultiline] ...\n\n// -------------------- Read inputs --------------------\nvar htmlRaw = ($json && typeof $json.html === \"string\") ? $json.html : \"\";\nvar htmlReal = unescapeHtmlEntities(htmlRaw);\n\n// -------------------- Build content list --------------------\nvar contentList = [];\n\nif ($json && Array.isArray($json.searchItems) && $json.searchItems.length) {\n for (var si = 0; si < $json.searchItems.length; si++) {\n var v = $json.searchItems[si];\n if (typeof v === \"string\" && v.trim().length) contentList.push(v.trim());\n }\n} else if ($json && typeof $json.Content === \"string\" && $json.Content.trim().length) {\n var parts = $json.Content.split(/\\r?\\n/);\n for (var p = 0; p < parts.length; p++) {\n var s = parts[p].trim();\n if (s) contentList.push(s);\n }\n} else {\n // Default: generic placeholders for validation\n contentList = [\n \"Sample Heading Text\",\n \"Sample body copy paragraph with multiple lines.\\n\\nWhy attend?\\n\\nLearn how to validate fonts in email content\\nUnderstand how font-family impacts branding\\nDiscover effective QA strategies\\nExplore accessibility considerations\\nGet answers in a live Q&A\",\n \"For immediate support, please contact us\",\n \"Let's Connect\",\n \"Privacy Policy\",\n \"You received this email because you are subscribed to updates.\",\n \"Company Name | Address Line | City, State ZIP\",\n \"Unsubscribe\"\n ];\n}\n\n// -------------------- Produce outputs --------------------\nvar items = [];\n\nfor (var ci = 0; ci < contentList.length; ci++) {\n var content = String(contentList[ci]);\n\n var result;\n if (/\\\\n|\\r?\\n/.test(content)) {\n result = getFontForMultiline(htmlReal, content);\n } else {\n var variants = [content];\n if (/\\s\\|\\s/.test(content)) variants.push(content.replace(/\\s*\\|\\s*/g, \" \"));\n result = null;\n for (var vi = 0; vi < variants.length; vi++) {\n var res = getTagAndFont(htmlReal, variants[vi]);\n if (res && (res.fontFamily || res.tagStructureReal)) { result = res; break; }\n }\n if (!result) result = { tagName: null, fontFamily: \"\", attrs: {}, tagStructureReal: null, anchorHref: null };\n }\n\n var tagStructureEsc = result.tagStructureReal ? escapeHtml(result.tagStructureReal) : \"Not Found\";\n\n items.push({\n json: {\n Content: content,\n \"Actual font family\": result.fontFamily || \"\",\n \"Tag name\": result.tagName || null,\n \"Tag snippet (escaped)\": tagStructureEsc,\n \"Anchor href\": result.anchorHref || null\n }\n });\n}\n\nreturn items;"
},
"typeVersion": 2,
"alwaysOutputData": false
},
{
"id": "38c84d7c-a939-4723-aebd-c2d77a1d1c53",
"name": "Combine Font Inputs",
"type": "n8n-nodes-base.merge",
"position": [
560,
160
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "ff78c01e-fea3-4033-92fd-cd2af602e652",
"name": "Extract Actual Font Family and Results",
"type": "n8n-nodes-base.code",
"position": [
832,
160
],
"parameters": {
"jsCode": "/**\n * n8n Function node (ES5-friendly)\n * Validates Excel \"Expected font family\" against Actual font family parsed from Gmail HTML.\n * Falls back to parser-derived \"Actual font family\" when HTML parsing doesn't find a styled tag.\n *\n * Input: merged items (Excel rows + parser rows + Gmail HTML item)\n * Output: array of items in Excel template shape:\n * [{ json: { Content, \"Expected font family\", \"Actual font family\", Result } }, ...]\n */\n\n// -------------------- Collect items --------------------\nvar allItems = $items();\n\n// -------------------- Partition items; capture HTML --------------------\nvar excelRows = [];\nvar parserRows = [];\nvar htmlRaw = \"\";\n\nfor (var i = 0; i < allItems.length; i++) {\n var j = allItems[i].json || {};\n var keys = Object.keys(j);\n\n if (keys.indexOf(\"Content\") !== -1 && keys.indexOf(\"Expected font family\") !== -1) {\n excelRows.push(j);\n }\n\n if (keys.indexOf(\"Content\") !== -1 && keys.indexOf(\"Actual font family\") !== -1) {\n parserRows.push(j);\n }\n\n if (!htmlRaw && typeof j.html === \"string\" && j.html.indexOf(\"<html\") !== -1) {\n htmlRaw = j.html;\n }\n}\n\nif (!htmlRaw) {\n var mergedString = JSON.stringify(allItems.map(function (it) { return it.json || {}; }));\n var s = mergedString.indexOf(\"<html\");\n var e = mergedString.lastIndexOf(\"</html>\");\n if (s !== -1 && e !== -1) {\n htmlRaw = mergedString.slice(s, e + \"</html>\".length);\n }\n}\n\n// -------------------- Utilities --------------------\nfunction unescapeHtmlEntities(s) {\n if (!s) return \"\";\n return s\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&amp;/g, \"&\")\n .replace(/&quot;/g, \"\\\"\")\n .replace(/&#39;/g, \"'\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/&/g, \"&\")\n .replace(/"/g, \"\\\"\")\n .replace(/'/g, \"'\");\n}\nvar html = unescapeHtmlEntities(htmlRaw || \"\");\n\nfunction normalizeFontFamily(ff) {\n if (!ff) return null;\n return ff.toLowerCase()\n .replace(/[\"']/g, \"\")\n .replace(/\\s*,\\s*/g, \",\")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nfunction compareFamilies(expectedRaw, actualRaw) {\n var expected = normalizeFontFamily(expectedRaw);\n var actual = normalizeFontFamily(actualRaw);\n if (!expected || !actual) return false;\n return actual.indexOf(expected) !== -1 || expected.indexOf(actual) !== -1;\n}\n\nfunction parseAttributes(openingTag) {\n var attrs = {};\n var attrRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\\s*=\\s*(\"([^\"]*)\"|'([^']*)')/g;\n var m;\n while ((m = attrRegex.exec(openingTag)) !== null) {\n attrs[m[1]] = (m[3] || m[4] || \"\").trim();\n }\n return attrs;\n}\n\nfunction extractFontFamilyFromStyle(style) {\n if (!style || typeof style !== \"string\") return null;\n var low = style.toLowerCase();\n var idx = low.indexOf(\"font-family\");\n if (idx === -1) return null;\n var s = style.slice(idx);\n var colonIdx = s.indexOf(\":\");\n if (colonIdx === -1) return null;\n s = s.slice(colonIdx + 1);\n var semiIdx = s.indexOf(\";\");\n if (semiIdx !== -1) s = s.slice(0, semiIdx);\n return normalizeFontFamily(s);\n}\n\nfunction innerText(htmlFrag) {\n if (!htmlFrag) return \"\";\n var noTags = htmlFrag.replace(/<[^>]*>/g, \" \");\n return unescapeHtmlEntities(noTags).replace(/\\s+/g, \" \").trim();\n}\n\nfunction escRe(s) { return s.replace(/[-/\\\\^$*+?.()|[\\]{}]/g, \"\\\\$&\"); }\n\nfunction makeFlexibleRegex(text) {\n var t = String(text).replace(/&/g, \"&\").replace(/\\s+/g, \" \").trim();\n t = escRe(t);\n t = t.replace(/\\s+/g, \"\\\\s+\");\n t = t.replace(/\\\\\\|/g, \"(?:\\\\s*\\\\|\\\\s*|\\\\s+)\");\n return t;\n}\n\nfunction findTextIndex(htmlStr, text) {\n var pattern = makeFlexibleRegex(text);\n var re = new RegExp(pattern, \"i\");\n var m = re.exec(htmlStr);\n return m ? m.index : -1;\n}\n\nfunction findEnclosingTag(htmlStr, idx) {\n var openAngleIdx = htmlStr.lastIndexOf(\"<\", idx);\n if (openAngleIdx === -1) return null;\n var closeAngleIdx = htmlStr.indexOf(\">\", openAngleIdx);\n if (closeAngleIdx === -1) return null;\n\n var openingTag = htmlStr.slice(openAngleIdx, closeAngleIdx + 1);\n var tagMatch = openingTag.match(/^<\\s*([a-zA-Z0-9]+)\\b/);\n var tagName = tagMatch ? tagMatch[1].toLowerCase() : null;\n var attrs = parseAttributes(openingTag);\n\n var closingTag = tagName ? (\"</\" + tagName + \">\") : null;\n var endIdx = closingTag ? htmlStr.indexOf(closingTag, idx) : -1;\n var snippet = (endIdx !== -1) ? htmlStr.slice(openAngleIdx, endIdx + closingTag.length) : openingTag;\n\n return { tagName: tagName, attrs: attrs, tagStructureReal: snippet };\n}\n\nfunction findAncestorWithFont(htmlStr, idx) {\n var preferred = [\"h1\",\"h2\",\"h3\",\"p\",\"li\",\"td\",\"div\"];\n for (var k = 0; k < preferred.length; k++) {\n var t = preferred[k];\n var openIdx = htmlStr.lastIndexOf(\"<\" + t, idx);\n if (openIdx === -1) continue;\n var closeOpen = htmlStr.indexOf(\">\", openIdx);\n if (closeOpen === -1) continue;\n\n var closingTag = \"</\" + t + \">\";\n var endIdx = htmlStr.indexOf(closingTag, idx);\n if (endIdx === -1) continue;\n\n if (closeOpen < idx && idx < endIdx) {\n var openingTag = htmlStr.slice(openIdx, closeOpen + 1);\n var attrs = parseAttributes(openingTag);\n var ff = extractFontFamilyFromStyle(attrs.style || \"\");\n if (ff) return ff;\n }\n }\n return null;\n}\n\nfunction scanTagsForText(htmlStr, targetText, tagNames) {\n for (var ti = 0; ti < tagNames.length; ti++) {\n var t = tagNames[ti];\n var re = new RegExp(\"<\" + t + \"[^>]*>([\\\\s\\\\S]*?)</\" + t + \">\", \"gi\");\n var m;\n while ((m = re.exec(htmlStr)) !== null) {\n var snippet = m[0];\n var open = snippet.match(new RegExp(\"^<\" + t + \"[^>]*>\", \"i\"));\n var openingTag = open ? open[0] : \"\";\n var attrs = parseAttributes(openingTag);\n var ff = extractFontFamilyFromStyle(attrs.style || \"\");\n var inner = innerText(snippet);\n\n var pattern = new RegExp(makeFlexibleRegex(targetText), \"i\");\n if (pattern.test(inner)) {\n return ff || \"\";\n }\n }\n }\n return \"\";\n}\n\nfunction getActualFontFromHtml(htmlStr, content) {\n var isMultiLine = /\\\\n|\\r?\\n/.test(content);\n if (isMultiLine) {\n var raw = String(content).replace(/\\\\n/g, \"\\n\");\n var lines = raw.split(/\\r?\\n/).map(function(s){return s.trim();}).filter(function(s){return !!s;});\n for (var j = 0; j < lines.length; j++) {\n var ff1 = getActualFontFromHtml(htmlStr, lines[j]);\n if (ff1) return ff1;\n }\n return \"\";\n }\n\n var variants = [content];\n if (/\\s\\|\\s/.test(content)) variants.push(content.replace(/\\s*\\|\\s*/g, \" \"));\n\n for (var vi = 0; vi < variants.length; vi++) {\n var t = variants[vi];\n var idx = findTextIndex(htmlStr"
},
"typeVersion": 2,
"alwaysOutputData": false
},
{
"id": "ac67d32f-489d-49e9-ab9f-e0e1fd877d32",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1392,
-64
],
"parameters": {
"width": 832,
"height": 624,
"content": "## Main Overview \u2013 Font Family Check Workflow\n\nThis workflow automates the font\u2011family validation process for email QA by comparing the expected font values provided in Google Sheets with the actual font extracted from the email HTML. It replaces manual inspection of email content and style attributes, ensuring consistency with brand guidelines and reducing QA time. The workflow evaluates multiple content sections\u2014such as headings, subheadings, CTA labels, paragraphs, disclaimers, and footer text\u2014and verifies that each one uses the correct font family as specified by the design or branding document.\n\n**How it works**\n1. Load Expected Content + Expected Font Family from Google Sheets.\n2. Parse the email HTML to locate the matching content block.\n3. Use a JavaScript/HTML extraction node to read the actual font-family applied to that content.\n4. Merge expected and actual values.\n5. Compare Expected Font vs Actual Font to produce a Result (Match / Mismatch).\n6. Log Actual Font + Result back into Google Sheets as part of the QA output.\n\n**Setup steps**\n* Prepare a Google Sheet with columns: Label, Content, Expected Font Family.\n* Ensure the HTML parsing logic correctly targets selectors related to your email structure.\n* Map HTML elements to the correct content labels for accurate comparisons.\n* Connect Google Sheets + Gmail/HTML Source nodes."
},
"typeVersion": 1
},
{
"id": "84bec468-7928-4334-b460-7471bdb34762",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
0
],
"parameters": {
"color": 7,
"width": 1344,
"height": 400,
"content": "## Font Family Validation\nReads expected content + font family from Google Sheets, extracts actual font family from HTML using JS, merges inputs, compares expected vs actual, and logs results back into Google Sheets."
},
"typeVersion": 1
},
{
"id": "605c61d4-dff4-4e9e-a667-857cabbd6da7",
"name": "Load Expected Content and Font Family",
"type": "n8n-nodes-base.googleSheets",
"position": [
176,
112
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "id",
"value": "=// Parameters provided by user in node config\nconst sheetId = this.getNodeParameter(\"sheetId\", 0) || \"YOUR_SPREADSHEET_ID\";\nconst sheetName = this.getNodeParameter(\"sheetName\", 0) || \"Sheet1\";\n\nconst { google } = require(\"googleapis\");\nconst sheets = google.sheets({ version: \"v4\", auth });\n\nconst response = await sheets.spreadsheets.values.get({\n spreadsheetId: sheetId,\n range: sheetName,\n});\n\nreturn response.data.values.map(row => ({ json: { row } }));"
},
"documentId": {
"__rl": true,
"mode": "url",
"value": "=https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.7
},
{
"id": "771fae7e-0349-4bc3-91fe-c3b3cc84b460",
"name": "Log Font Checks to Excel",
"type": "n8n-nodes-base.googleSheets",
"position": [
1088,
160
],
"parameters": {
"columns": {
"value": {
"Result": "={{ $json.Result }}",
"Content": "={{ $json.Content }}",
"Actual font family": "={{ $json['Actual font family'] }}",
"Expected font family": "={{ $json['Expected font family'] }}"
},
"schema": [
{
"id": "Content",
"type": "string",
"display": true,
"required": false,
"displayName": "Content",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Expected font family",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "Expected font family",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Actual font family",
"type": "string",
"display": true,
"required": false,
"displayName": "Actual font family",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Result",
"type": "string",
"display": true,
"required": false,
"displayName": "Result",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [
"Expected font family"
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "appendOrUpdate",
"sheetName": {
"__rl": true,
"mode": "id",
"value": "=// Parameters provided by user in node config\nconst sheetId = this.getNodeParameter(\"sheetId\", 0) || \"YOUR_SPREADSHEET_ID\";\nconst sheetName = this.getNodeParameter(\"sheetName\", 0) || \"Sheet1\";\n\nconst { google } = require(\"googleapis\");\nconst sheets = google.sheets({ version: \"v4\", auth });\n\nconst response = await sheets.spreadsheets.values.get({\n spreadsheetId: sheetId,\n range: sheetName,\n});\n\nreturn response.data.values.map(row => ({ json: { row } }));"
},
"documentId": {
"__rl": true,
"mode": "url",
"value": "=https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.7
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "410a53b2-21c1-4105-bd80-213d300fed14",
"connections": {
"Get many messages": {
"main": [
[
{
"node": "Extract Content Font Family from HTML",
"type": "main",
"index": 0
},
{
"node": "Load Expected Content and Font Family",
"type": "main",
"index": 0
}
]
]
},
"Combine Font Inputs": {
"main": [
[
{
"node": "Extract Actual Font Family and Results",
"type": "main",
"index": 0
}
]
]
},
"When clicking \u2018Execute workflow\u2019": {
"main": [
[
{
"node": "Get many messages",
"type": "main",
"index": 0
}
]
]
},
"Extract Content Font Family from HTML": {
"main": [
[
{
"node": "Combine Font Inputs",
"type": "main",
"index": 1
}
]
]
},
"Load Expected Content and Font Family": {
"main": [
[
{
"node": "Combine Font Inputs",
"type": "main",
"index": 0
}
]
]
},
"Extract Actual Font Family and Results": {
"main": [
[
{
"node": "Log Font Checks to Excel",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2googleSheetsOAuth2Api
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Load Expected Content + Expected Font Family from Google Sheets. Parse the email HTML to locate the matching content block. Use a JavaScript/HTML extraction node to read the actual font-family applied to that content. Merge expected and actual values. Compare Expected Font vs…
Source: https://n8n.io/workflows/13566/ — 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.
Stop losing valuable leads to missed meetings with this No-Show Follow-Up & Rescheduling automation! Whenever a lead is marked as a “no-show” in your CRM or calendar, this workflow automatically sends
This workflow automatically detects bounced or invalid email addresses from your Gmail inbox and updates their status in Google Sheets. It fetches bounce notifications, extracts failed email addresses
The goal is to reduce inbox noise and automatically organize repetitive types of emails so that imprtant messages remain visible while unsolicited or promotional emails are handled automatically. When
This workflow streamlines accounts receivable management by automatically monitoring invoices in Google Sheets and sending scheduled payment reminders. It is designed for businesses using Gmail and Go
Never miss a qualified LinkedIn Sales Navigator message again. This automation monitors your Gmail for LinkedIn notifications, cross-references senders with your lead database in Google Sheets, and in