This workflow corresponds to n8n.io template #17672 — we link there as the canonical source.
This workflow follows the Gmail → HTTP Request 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": "emailDeliverability27",
"name": "Audit email deliverability with SPF, DKIM, DMARC and real Gmail and Outlook placement using Claude AI",
"tags": [],
"nodes": [
{
"id": "n1",
"name": "Run manually",
"type": "n8n-nodes-base.manualTrigger",
"position": [
-1088,
48
],
"parameters": {},
"typeVersion": 1
},
{
"id": "n2",
"name": "Set config: deliverability",
"type": "n8n-nodes-base.code",
"position": [
-896,
48
],
"parameters": {
"jsCode": "// Email deliverability checker - tune here.\nconst LOOKBACK_DAYS = 30;\nreturn [{ json: {\n // Cutoff for the Microsoft Graph receivedAfter filter, derived from LOOKBACK_DAYS.\n SINCE_ISO: new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString(),\n // The domain you send FROM (not your website domain, if they differ).\n DOMAIN: 'nocode.expert',\n\n // DKIM selectors to probe. Each ESP publishes its key under its own selector,\n // so probing this list also tells you which ESP a domain is set up for.\n // Unresolved selectors are not errors, they just mean \"not that provider\".\n DKIM_SELECTORS: [\n 'resend', // Resend\n 'google', // Google Workspace\n 'k1', 'k2', // Klaviyo, SendGrid\n 's1', 's2', // Amazon SES, Zoho\n 'selector1', 'selector2', // Microsoft 365\n 'mail', 'dkim', 'default', // common self-hosted\n 'mandrill', 'pm', 'sm', // Mailchimp Transactional, Postmark, SparkPost\n ],\n\n // Real-placement branches (optional): the address your CAMPAIGNS ARE SENT\n // FROM. Not your inbound or contact address. The connected mailboxes must be\n // subscribed to that mail directly. Forwarded mail bypasses part of the\n // provider's filtering and will read cleaner than reality.\n SENDER_ADDRESS: 'user@example.com',\n LOOKBACK_DAYS: LOOKBACK_DAYS,\n MAX_MESSAGES: 25,\n\n // SPF has a hard 10 DNS-lookup limit. Warn before it is hit.\n SPF_LOOKUP_WARN: 8,\n} }];"
},
"typeVersion": 2
},
{
"id": "a1",
"name": "Build DNS queries",
"type": "n8n-nodes-base.code",
"position": [
-144,
-176
],
"parameters": {
"jsCode": "// Build one DNS question per check. No API calls here, string building only.\nconst cfg = $('Set config: deliverability').first().json;\nconst d = cfg.DOMAIN.trim().toLowerCase().replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n\nconst q = [];\nq.push({ check: 'spf', label: 'SPF', name: d, type: 'TXT' });\nq.push({ check: 'dmarc', label: 'DMARC', name: '_dmarc.' + d, type: 'TXT' });\nq.push({ check: 'mx', label: 'MX', name: d, type: 'MX' });\n(cfg.DKIM_SELECTORS || []).forEach((sel) => {\n q.push({ check: 'dkim', label: 'DKIM ' + sel, selector: sel, name: sel + '._domainkey.' + d, type: 'TXT' });\n});\n\nreturn q.map((x) => ({ json: Object.assign({ domain: d }, x) }));"
},
"typeVersion": 2
},
{
"id": "a2",
"name": "Look up DNS over HTTPS",
"type": "n8n-nodes-base.httpRequest",
"position": [
80,
-176
],
"parameters": {
"url": "https://dns.google/resolve",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "name",
"value": "={{ $json.name }}"
},
{
"name": "type",
"value": "={{ $json.type }}"
}
]
}
},
"typeVersion": 4.2
},
{
"id": "a3",
"name": "Attach question to answer",
"type": "n8n-nodes-base.code",
"position": [
304,
-176
],
"parameters": {
"jsCode": "// Pair each DoH answer back with the question that produced it. The HTTP node\n// preserves input order, so index alignment is safe here.\nconst questions = $('Build DNS queries').all().map((i) => i.json);\nreturn $input.all().map((item, idx) => {\n const q = questions[idx] || {};\n return { json: Object.assign({}, item.json, { _q: q, _q_name: q.name, domain: q.domain }) };\n});"
},
"typeVersion": 2
},
{
"id": "a4",
"name": "Assess authentication",
"type": "n8n-nodes-base.code",
"position": [
528,
-176
],
"parameters": {
"jsCode": "// Aggregate every DNS answer into a graded checklist. Pure transforms.\nconst cfg = $('Set config: deliverability').first().json;\nconst rows = $input.all().map((i) => i.json);\nconst domain = (rows[0] && rows[0].domain) || cfg.DOMAIN;\n\n// Each item carries the DoH JSON plus the question it answered. Google's\n// resolver returns { Status, Answer: [{ name, type, data }] }. Status 0 = ok,\n// 3 = NXDOMAIN. Quoted TXT chunks are concatenated for long keys.\nconst answersFor = (row) => {\n const a = (row.Answer || []).map((x) => String(x.data || ''));\n return a.map((s) => s.replace(/^\"|\"$/g, '').replace(/\" \"/g, ''));\n};\n\nconst byCheck = { spf: [], dmarc: [], mx: [], dkim: [] };\nrows.forEach((r) => {\n const q = $('Build DNS queries').all().find((i) => i.json.name === r._q_name);\n const meta = r._q || {};\n if (byCheck[meta.check]) byCheck[meta.check].push({ meta: meta, answers: answersFor(r), status: r.Status });\n});\n\nconst checks = [];\nconst add = (id, label, status, detail, move) => checks.push({ id: id, label: label, status: status, detail: detail, move: move || '' });\n\n// ---- SPF ------------------------------------------------------------------\nconst spfRow = byCheck.spf[0];\nconst spfRecords = spfRow ? spfRow.answers.filter((t) => /^v=spf1/i.test(t)) : [];\nif (!spfRecords.length) {\n add('SPF', 'SPF record', 'critical', 'No SPF record found on ' + domain + '.',\n 'Publish a TXT record starting v=spf1 that lists every service allowed to send as this domain, ending in ~all.');\n} else if (spfRecords.length > 1) {\n add('SPF', 'SPF record', 'critical', spfRecords.length + ' SPF records found. More than one is a permanent error and receivers will fail the check outright.',\n 'Merge them into a single TXT record.');\n} else {\n const spf = spfRecords[0];\n // Mechanisms that each cost a DNS lookup against the hard limit of 10.\n const lookups = (spf.match(/(^|\\s)(include:|a[:\\s]|mx[:\\s]|ptr|exists:|redirect=)/gi) || []).length;\n const allMech = (spf.match(/[~\\-+?]all/i) || [''])[0].toLowerCase();\n if (allMech === '+all') {\n add('SPF', 'SPF policy', 'critical', 'SPF ends in +all, which authorises the entire internet to send as you.', 'Change +all to ~all.');\n } else if (!allMech) {\n add('SPF', 'SPF policy', 'flag', 'SPF has no all mechanism, so receivers apply a neutral result.', 'End the record with ~all.');\n } else {\n add('SPF', 'SPF policy', 'pass', 'Ends in ' + allMech + '.', '');\n }\n if (lookups > 10) {\n add('SPF', 'SPF lookup count', 'critical', lookups + ' top-level DNS lookups, over the hard limit of 10. SPF returns permerror and every check fails.',\n 'Flatten or remove includes. Note nested includes add more lookups on top of this count.');\n } else if (lookups >= cfg.SPF_LOOKUP_WARN) {\n add('SPF', 'SPF lookup count', 'flag', lookups + ' top-level DNS lookups against a limit of 10, and nested includes add more.',\n 'Audit the includes before adding another sending service.');\n } else {\n add('SPF', 'SPF lookup count', 'pass', lookups + ' top-level lookups, within the limit of 10.', '');\n }\n}\n\n// ---- DMARC ----------------------------------------------------------------\nconst dmarcRow = byCheck.dmarc[0];\nconst dmarcRec = dmarcRow ? dmarcRow.answers.find((t) => /^v=DMARC1/i.test(t)) : null;\nif (!dmarcRec) {\n add('DMARC', 'DMARC record', 'critical', 'No DMARC record on _dmarc.' + domain + '.',\n 'Publish v=DMARC1; p=none; rua=mailto:you@' + domain + ' to start collecting reports, then tighten to quarantine.');\n} else {\n const p = ((dmarcRec.match(/[;\\s]p=([a-z]+)/i) || [])[1] || 'none').toLowerCase();\n const rua = /rua=/i.test(dmarcRec);\n const pct = (dmarcRec.match(/[;\\s]pct=(\\d+)/i) || [])[1];\n if (p === 'none') {\n add('DMARC', 'DMARC policy', 'flag', 'Policy is p=none, so DMARC is only monitoring. Nothing is actually protected and spoofed mail is still delivered.',\n 'Move to p=quarantine once your reports are clean. Gmail and Yahoo require DMARC for bulk senders.');\n } else {\n add('DMARC', 'DMARC policy', 'pass', 'Policy is p=' + p + (pct ? ' at pct=' + pct : '') + '.', '');\n }\n add('DMARC', 'DMARC reporting', rua ? 'pass' : 'flag',\n rua ? 'Aggregate reports are being collected (rua present).' : 'No rua address, so you receive no aggregate reports and are blind to failures.',\n rua ? '' : 'Add rua=mailto:dmarc@' + domain + ' to the record.');\n}\n\n// ---- DKIM -----------------------------------------------------------------\nconst found = byCheck.dkim.filter((r) => r.answers.some((t) => /v=DKIM1/i.test(t) || /(^|;)\\s*p=[A-Za-z0-9+/]/.test(t)));\nconst foundSelectors = found.map((r) => r.meta.selector);\nif (!found.length) {\n add('DKIM', 'DKIM key', 'critical', 'No DKIM key found on any of the ' + byCheck.dkim.length + ' selectors probed.',\n 'Your ESP publishes the selector to use. Unsigned mail fails DMARC alignment and gets filtered.');\n} else {\n const empty = found.filter((r) => r.answers.some((t) => /(^|;)\\s*p=\\s*(;|$)/.test(t)));\n if (empty.length) {\n add('DKIM', 'DKIM key', 'critical', 'Selector ' + empty[0].meta.selector + ' exists but the public key is empty, which is how a revoked key looks.',\n 'Re-publish the key from your ESP.');\n } else {\n add('DKIM', 'DKIM key', 'pass', 'Key found on: ' + foundSelectors.join(', ') + '.', '');\n }\n}\n\n// ---- MX -------------------------------------------------------------------\nconst mxRow = byCheck.mx[0];\nconst mxCount = mxRow ? mxRow.answers.length : 0;\nadd('MX', 'MX records', mxCount ? 'pass' : 'flag',\n mxCount ? mxCount + ' MX record(s) present.' : 'No MX records. You cannot receive replies, bounces, or DMARC reports at this domain.',\n mxCount ? '' : 'Add MX records, even if you only send. Receivers treat a send-only domain with no MX as suspicious.');\n\nconst criticals = checks.filter((c) => c.status === 'critical');\nconst flags = checks.filter((c) => c.status === 'flag');\nconst passes = checks.filter((c) => c.status === 'pass');\nconst verdict = criticals.length ? 'failing' : flags.length ? 'at risk' : 'clean';\n\nreturn [{ json: {\n domain: domain,\n verdict: verdict,\n counts: { passed: passes.length, flags: flags.length, critical: criticals.length },\n checks: checks,\n dkim_selectors_found: foundSelectors,\n dkim_selectors_probed: byCheck.dkim.length,\n spf_record: spfRecords[0] || null,\n dmarc_record: dmarcRec || null,\n} }];"
},
"typeVersion": 2
},
{
"id": "b1",
"name": "Read Gmail",
"type": "n8n-nodes-base.gmail",
"onError": "continueRegularOutput",
"position": [
-192,
272
],
"parameters": {
"limit": "={{ $('Set config: deliverability').first().json.MAX_MESSAGES }}",
"simple": false,
"filters": {
"q": "={{ $('Set config: deliverability').first().json.SENDER_ADDRESS ? 'from:' + $('Set config: deliverability').first().json.SENDER_ADDRESS + ' newer_than:' + $('Set config: deliverability').first().json.LOOKBACK_DAYS + 'd' : 'newer_than:1d larger:100M' }}",
"includeSpamTrash": true
},
"options": {},
"operation": "getAll"
},
"typeVersion": 2.1,
"alwaysOutputData": true
},
{
"id": "b2",
"name": "Read Gmail placement",
"type": "n8n-nodes-base.code",
"position": [
384,
256
],
"parameters": {
"jsCode": "// Branch B: real placement from Gmail's own system labels.\n// Gmail exposes its tabs as labels, so CATEGORY_PROMOTIONS is the Promotions\n// tab and SPAM is the spam folder. No seed network and no third party needed.\nconst msgs = $input.all().map((i) => i.json).filter((m) => m && m.id);\n\nconst tabOf = (L) => {\n L = L || [];\n if (L.indexOf('SPAM') !== -1) return 'Spam';\n if (L.indexOf('TRASH') !== -1) return 'Trash';\n if (L.indexOf('CATEGORY_PROMOTIONS') !== -1) return 'Promotions';\n if (L.indexOf('CATEGORY_UPDATES') !== -1) return 'Updates';\n if (L.indexOf('CATEGORY_SOCIAL') !== -1) return 'Social';\n if (L.indexOf('CATEGORY_FORUMS') !== -1) return 'Forums';\n if (L.indexOf('INBOX') !== -1) return 'Primary';\n return 'Unknown';\n};\n\nconst tally = {};\nconst samples = [];\nmsgs.forEach((m) => {\n const tab = tabOf(m.labelIds);\n tally[tab] = (tally[tab] || 0) + 1;\n if (samples.length < 5) {\n const h = ((m.payload && m.payload.headers) || []).find((x) => x.name === 'Subject');\n samples.push({ tab: tab, subject: h ? h.value : '(no subject)' });\n }\n});\n\nconst n = msgs.length;\nconst pctOf = (k) => (n ? Math.round((k / n) * 100) : null);\nconst inboxed = tally.Primary || 0;\nconst filtered = (tally.Promotions || 0) + (tally.Updates || 0) + (tally.Social || 0) + (tally.Forums || 0);\nconst spam = (tally.Spam || 0) + (tally.Trash || 0);\n\nreturn [{ json: { _branch: 'gmail', placement: {\n provider: 'Gmail',\n inbox_label: 'Primary', filtered_label: 'Promotions/Updates',\n checked: n, tally: tally,\n inbox_pct: pctOf(inboxed), filtered_pct: pctOf(filtered), spam_pct: pctOf(spam),\n samples: samples, skipped: n === 0,\n} } }];"
},
"typeVersion": 2
},
{
"id": "ol_inbox",
"name": "Read Outlook inbox",
"type": "n8n-nodes-base.microsoftOutlook",
"onError": "continueRegularOutput",
"position": [
-336,
608
],
"parameters": {
"limit": "={{ $('Set config: deliverability').first().json.MAX_MESSAGES }}",
"output": "raw",
"options": {},
"filtersUI": {
"values": {
"filters": {
"sender": "={{ $('Set config: deliverability').first().json.SENDER_ADDRESS }}",
"receivedAfter": "={{ $('Set config: deliverability').first().json.SINCE_ISO }}",
"foldersToInclude": [
"inbox"
]
}
}
},
"operation": "getAll"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "ol_junkemail",
"name": "Read Outlook junk",
"type": "n8n-nodes-base.microsoftOutlook",
"onError": "continueRegularOutput",
"position": [
0,
608
],
"parameters": {
"limit": "={{ $('Set config: deliverability').first().json.MAX_MESSAGES }}",
"output": "raw",
"options": {},
"filtersUI": {
"values": {
"filters": {
"sender": "={{ $('Set config: deliverability').first().json.SENDER_ADDRESS }}",
"receivedAfter": "={{ $('Set config: deliverability').first().json.SINCE_ISO }}",
"foldersToInclude": [
"junkemail"
]
}
}
},
"operation": "getAll"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "c3",
"name": "Read Outlook placement",
"type": "n8n-nodes-base.code",
"position": [
480,
592
],
"parameters": {
"jsCode": "// Branch C: real placement from Microsoft Graph.\n// Two reads rather than one, because a Graph message carries parentFolderId but\n// not the folder NAME. Querying the well-known folders separately means the\n// folder is known from which node returned the message, with no ID lookup.\n// Within the inbox, inferenceClassification is Outlook's Focused/Other split,\n// which is the closest equivalent to Gmail's tabs.\nconst grab = (node) => {\n try { return $(node).all().map((i) => i.json).filter((m) => m && m.id); }\n catch (e) { return []; }\n};\nconst inbox = grab('Read Outlook inbox');\nconst junk = grab('Read Outlook junk');\n\nconst tally = {};\nconst samples = [];\nconst bump = (k) => { tally[k] = (tally[k] || 0) + 1; };\n\ninbox.forEach((m) => {\n const cls = (m.inferenceClassification || 'focused').toLowerCase();\n const tab = cls === 'other' ? 'Other' : 'Focused';\n bump(tab);\n if (samples.length < 5) samples.push({ tab: tab, subject: m.subject || '(no subject)' });\n});\njunk.forEach((m) => {\n bump('Junk');\n if (samples.length < 5) samples.push({ tab: 'Junk', subject: m.subject || '(no subject)' });\n});\n\nconst n = inbox.length + junk.length;\nconst pctOf = (k) => (n ? Math.round((k / n) * 100) : null);\n\nreturn [{ json: { _branch: 'outlook', placement: {\n provider: 'Outlook',\n inbox_label: 'Focused', filtered_label: 'Other',\n checked: n, tally: tally,\n inbox_pct: pctOf(tally.Focused || 0), filtered_pct: pctOf(tally.Other || 0), spam_pct: pctOf(tally.Junk || 0),\n samples: samples, skipped: n === 0,\n} } }];"
},
"typeVersion": 2
},
{
"id": "m1",
"name": "Merge branches",
"type": "n8n-nodes-base.merge",
"position": [
768,
48
],
"parameters": {
"numberInputs": 3
},
"typeVersion": 3.2
},
{
"id": "r1",
"name": "Combine signals",
"type": "n8n-nodes-base.code",
"position": [
992,
48
],
"parameters": {
"jsCode": "// Gather the three branches into one object. The Merge node only exists to\n// synchronise them, so each branch is read back by name rather than by position.\nconst cfg = $('Set config: deliverability').first().json;\nconst auth = $('Assess authentication').first().json;\n\nconst grab = (node) => {\n try {\n const j = $(node).first().json;\n return (j && j.placement) ? j.placement : null;\n } catch (e) { return null; }\n};\n\nconst providers = [grab('Read Gmail placement'), grab('Read Outlook placement')].filter(Boolean);\nconst live = providers.filter((p) => !p.skipped);\n\n// Pooled view across whichever mailboxes actually returned mail.\nlet pooled = null;\nif (live.length) {\n const checked = live.reduce((s, p) => s + p.checked, 0);\n const w = (key) => Math.round(live.reduce((s, p) => s + ((p[key] || 0) * p.checked), 0) / checked);\n pooled = { mailboxes: live.length, checked: checked, inbox_pct: w('inbox_pct'), filtered_pct: w('filtered_pct'), spam_pct: w('spam_pct') };\n}\n\nreturn [{ json: Object.assign({}, auth, {\n sender_address: cfg.SENDER_ADDRESS || '',\n providers: providers,\n providers_live: live.length,\n pooled: pooled,\n}) }];"
},
"typeVersion": 2
},
{
"id": "r2",
"name": "Build AI prompt",
"type": "n8n-nodes-base.code",
"position": [
1200,
48
],
"parameters": {
"jsCode": "// Build the triage prompt. No API call here.\nconst d = $json;\nconst notable = d.checks.filter((c) => c.status !== 'pass');\n\nconst system = 'You are a deliverability consultant writing for a marketer who sends real campaigns, not a sysadmin. Plain, direct, no jargon without a one-line translation. Never write an em dash, use a comma or a full stop. Only state facts present in the input, never invent a record value or a percentage. Return ONLY valid JSON matching the requested shape, no prose outside it.';\n\nconst placementLines = (d.providers || []).map((p) => p.skipped\n ? '- ' + p.provider + ': no mailbox connected, no data.'\n : '- ' + p.provider + ' (' + p.checked + ' messages): ' + p.inbox_pct + '% ' + p.inbox_label +\n ', ' + p.filtered_pct + '% ' + p.filtered_label + ', ' + p.spam_pct + '% spam.').join('\\n');\n\nconst user = [\n 'Domain: ' + d.domain + '. Sending address under test: ' + (d.sender_address || 'not set') + '.',\n 'Authentication verdict: ' + d.verdict + '. ' + d.counts.critical + ' critical, ' + d.counts.flags + ' flags, ' + d.counts.passed + ' passed.',\n '',\n 'AUTHENTICATION findings that are not passing:',\n notable.length ? notable.map((c) => '- [' + c.status.toUpperCase() + '] ' + c.label + ': ' + c.detail + (c.move ? ' Fix: ' + c.move : '')).join('\\n') : '- none, all authentication checks passed.',\n '',\n 'DKIM selectors that resolved: ' + (d.dkim_selectors_found.length ? d.dkim_selectors_found.join(', ') : 'none') + ' out of ' + d.dkim_selectors_probed + ' probed.',\n 'IMPORTANT: the probe list is a fixed list of selectors used by common ESPs. A selector that did not resolve simply means the domain does not use that provider. It is NOT a broken record, there is nothing in DNS to remove, and it must never be reported as a problem. Only the selectors that resolved exist.',\n '',\n 'REAL PLACEMENT, per provider:',\n placementLines || '- no mailboxes connected.',\n d.providers_live ? '' : 'No provider returned data, so say nothing about where mail is landing.',\n '',\n 'Write the read. Rank fixes by what actually moves inbox placement, not by how alarming the label sounds. Where providers disagree, say what that implies: a domain-level authentication fault tends to hurt every provider at once, so one provider filtering while another does not usually points at content, sending history, or that provider list rather than at DNS. Say plainly when a placement problem is a content or engagement issue rather than an authentication one.',\n '',\n 'Return JSON: {\"headline\":\"one sentence\",\"fixes\":[{\"rank\":1,\"what\":\"\",\"why_it_matters\":\"\",\"how\":\"\"}],\"esp_guess\":\"which ESP the selectors suggest, or empty\",\"provider_read\":\"one sentence on what the per-provider split implies, or empty if no data\",\"note\":\"one honest caveat\"}',\n].join('\\n');\n\nconst body = { model: 'claude-haiku-4-5', max_tokens: 1500, system: system, messages: [{ role: 'user', content: user }] };\nreturn [{ json: { body: body, _ctx: d } }];"
},
"typeVersion": 2
},
{
"id": "r3",
"name": "Explain and rank with Claude AI",
"type": "n8n-nodes-base.httpRequest",
"maxTries": 3,
"position": [
1424,
48
],
"parameters": {
"url": "https://api.anthropic.com/v1/messages",
"method": "POST",
"options": {},
"jsonBody": "={{ $json.body }}",
"sendBody": true,
"sendHeaders": true,
"specifyBody": "json",
"headerParameters": {
"parameters": [
{
"name": "x-api-key",
"value": "={{ $env.ANTHROPIC_API_KEY }}"
},
{
"name": "anthropic-version",
"value": "2023-06-01"
},
{
"name": "content-type",
"value": "application/json"
}
]
}
},
"retryOnFail": true,
"typeVersion": 4.2,
"waitBetweenTries": 2000
},
{
"id": "s0",
"name": "Sticky Note - Overview",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2272,
-224
],
"parameters": {
"width": 940,
"height": 604,
"content": "## Email Deliverability Checker\n\nTwo independent branches. One audits the authentication on your sending domain. The other reads where your real campaigns actually landed, per mailbox provider. Most tools in this space need a paid seed network. This one reads each provider's own classification instead.\n\n### How it works\n- Technical branch resolves SPF, DKIM, DMARC and MX over DNS-over-HTTPS, so no DNS node and no credentials are needed.\n- Gmail branch reads system labels, which is how Primary, Promotions and Spam are known exactly rather than guessed.\n- Outlook branch reads the Inbox and Junk folders separately and uses inferenceClassification for the Focused and Other split.\n- Both placement branches are optional. Any provider with no credential attached is reported as not connected and the run still completes.\n- Claude ranks the fixes and reads the disagreement between providers, which is the signal that separates a domain fault from a content problem.\n\n### Setup\n1. Set DOMAIN in the Config node. That alone runs the technical branch, with no credentials.\n2. Set SENDER_ADDRESS to the address your campaigns are sent FROM, then attach a Gmail or Outlook credential to whichever mailbox receives them.\n3. Add ANTHROPIC_API_KEY to your environment.\n\nBuilt by **nocode.expert** - done-for-you automation & tracking. https://nocode.expert"
},
"typeVersion": 1
},
{
"id": "s1",
"name": "Sticky Note - Section 1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-176,
-528
],
"parameters": {
"color": 7,
"width": 900,
"height": 276,
"content": "### Branch A, technical. No credentials required\n\nOne HTTP Request node answers every DNS question, including one per DKIM selector, so the list can grow without adding nodes. The check worth knowing about is the SPF lookup count: SPF allows 10 DNS lookups and going over does not degrade gracefully, the record returns permerror and every check fails. DMARC at p=none is reported as a flag rather than a pass, because it looks configured but protects nothing."
},
"typeVersion": 1
},
{
"id": "s3",
"name": "Sticky Note - Section 3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-320,
816
],
"parameters": {
"color": 7,
"width": 900,
"height": 400,
"content": "### Branch C, Outlook. And why Yahoo and Apple are not here\n\nA Graph message carries parentFolderId but not the folder name, so the Inbox and Junk folders are queried separately and the folder is known from which node returned the message. No ID lookup and no tenant-specific values. Within the inbox, inferenceClassification gives the Focused and Other split.\n\nYahoo and iCloud are deliberately absent. Neither exposes a usable mail API for this, and the only route is IMAP, where n8n's IMAP node is a trigger and cannot fetch mid-workflow. Adding a fake branch for them would report no data forever. If you need them, run a separate IMAP-trigger workflow that logs placement to a sheet.\n\nBe honest about what this measures. It is mail delivered to the mailboxes you connected, each with its own engagement history, so it is a strong signal about authentication and content, not a prediction for your whole list."
},
"typeVersion": 1
},
{
"id": "r4",
"name": "compile deliverability report",
"type": "n8n-nodes-base.code",
"position": [
1648,
48
],
"parameters": {
"jsCode": "// Assemble the report. Returns it so it shows in the node output and can be\n// chained into Slack, Gmail, or a Sheets row.\nconst ctx = $('Build AI prompt').first().json._ctx;\nlet ai = {};\ntry {\n const txt = ($json.content && $json.content[0] && $json.content[0].text) || '{}';\n ai = JSON.parse(txt.replace(/^[^{]*/, '').replace(/[^}]*$/, ''));\n} catch (e) { ai = { headline: '', fixes: [], esp_guess: '', provider_read: '', note: '' }; }\n\nconst pad = (s, n) => (String(s) + ' ').slice(0, n);\nconst L = [];\nL.push('EMAIL DELIVERABILITY CHECK ' + ctx.domain);\nL.push('=======================================================================');\nL.push('');\nL.push('TECHNICAL ' + ctx.verdict.toUpperCase() + ' (' + ctx.counts.passed + ' passed, ' + ctx.counts.flags + ' flags, ' + ctx.counts.critical + ' critical)');\nctx.checks.forEach((c) => L.push(' [' + c.status + '] ' + c.label + ': ' + c.detail));\nL.push(' DKIM selectors found: ' + (ctx.dkim_selectors_found.length ? ctx.dkim_selectors_found.join(', ') : 'none') + ' of ' + ctx.dkim_selectors_probed + ' probed');\nif (ai.esp_guess) L.push(' Looks like: ' + ai.esp_guess);\nL.push('');\nL.push('REAL PLACEMENT sender: ' + (ctx.sender_address || 'not set'));\nL.push(' ' + pad('PROVIDER', 12) + pad('INBOX', 22) + pad('FILTERED', 22) + pad('SPAM', 8) + 'SAMPLE');\n(ctx.providers || []).forEach((p) => {\n if (p.skipped) {\n L.push(' ' + pad(p.provider, 12) + 'no mailbox connected');\n } else {\n L.push(' ' + pad(p.provider, 12)\n + pad(p.inbox_pct + '% ' + p.inbox_label, 22)\n + pad(p.filtered_pct + '% ' + p.filtered_label, 22)\n + pad(p.spam_pct + '%', 8)\n + p.checked + ' msgs');\n }\n});\nif (ctx.pooled) {\n L.push(' ' + pad('POOLED', 12) + pad(ctx.pooled.inbox_pct + '% inbox', 22) + pad(ctx.pooled.filtered_pct + '% filtered', 22) + pad(ctx.pooled.spam_pct + '%', 8) + ctx.pooled.checked + ' msgs across ' + ctx.pooled.mailboxes);\n}\nif (ai.provider_read) { L.push(''); L.push(' ' + ai.provider_read); }\nL.push('');\nif (ai.headline) { L.push(ai.headline); L.push(''); }\nL.push('FIX IN THIS ORDER');\nif ((ai.fixes || []).length) {\n ai.fixes.forEach((f) => {\n L.push(' ' + f.rank + '. ' + f.what);\n if (f.why_it_matters) L.push(' Why: ' + f.why_it_matters);\n if (f.how) L.push(' How: ' + f.how);\n });\n} else { L.push(' Nothing outstanding.'); }\n\n(ctx.providers || []).filter((p) => !p.skipped && (p.samples || []).length).forEach((p) => {\n L.push('');\n L.push('RECENT IN ' + p.provider.toUpperCase());\n p.samples.forEach((s) => L.push(' [' + s.tab + '] ' + s.subject));\n});\nif (ai.note) { L.push(''); L.push('Note: ' + ai.note); }\n\nreturn [{ json: { report: L.join('\\n'), domain: ctx.domain, verdict: ctx.verdict, counts: ctx.counts, checks: ctx.checks, providers: ctx.providers, pooled: ctx.pooled, ai: ai } }];"
},
"typeVersion": 2
},
{
"id": "f67bbdc1-65ac-4867-b921-00a8a096b77e",
"name": "Send a message",
"type": "n8n-nodes-base.gmail",
"position": [
1856,
48
],
"parameters": {
"message": "={{ $json.report }}",
"options": {}
},
"typeVersion": 2.2
},
{
"id": "s2",
"name": "Sticky Note - Section 2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-208,
64
],
"parameters": {
"color": 7,
"width": 452,
"height": 164,
"content": "### Branch B, Gmail\n\nLabels are the placement. CATEGORY_PROMOTIONS is the Promotions tab, SPAM is the spam folder."
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "f8c8c44d-b2a4-4ac9-ba9e-b41642161998",
"nodeGroups": [],
"connections": {
"Read Gmail": {
"main": [
[
{
"node": "Read Gmail placement",
"type": "main",
"index": 0
}
]
]
},
"Run manually": {
"main": [
[
{
"node": "Set config: deliverability",
"type": "main",
"index": 0
}
]
]
},
"Merge branches": {
"main": [
[
{
"node": "Combine signals",
"type": "main",
"index": 0
}
]
]
},
"Build AI prompt": {
"main": [
[
{
"node": "Explain and rank with Claude AI",
"type": "main",
"index": 0
}
]
]
},
"Combine signals": {
"main": [
[
{
"node": "Build AI prompt",
"type": "main",
"index": 0
}
]
]
},
"Build DNS queries": {
"main": [
[
{
"node": "Look up DNS over HTTPS",
"type": "main",
"index": 0
}
]
]
},
"Read Outlook junk": {
"main": [
[
{
"node": "Read Outlook placement",
"type": "main",
"index": 0
}
]
]
},
"Read Outlook inbox": {
"main": [
[
{
"node": "Read Outlook junk",
"type": "main",
"index": 0
}
]
]
},
"Read Gmail placement": {
"main": [
[
{
"node": "Merge branches",
"type": "main",
"index": 1
}
]
]
},
"Assess authentication": {
"main": [
[
{
"node": "Merge branches",
"type": "main",
"index": 0
}
]
]
},
"Look up DNS over HTTPS": {
"main": [
[
{
"node": "Attach question to answer",
"type": "main",
"index": 0
}
]
]
},
"Read Outlook placement": {
"main": [
[
{
"node": "Merge branches",
"type": "main",
"index": 2
}
]
]
},
"Attach question to answer": {
"main": [
[
{
"node": "Assess authentication",
"type": "main",
"index": 0
}
]
]
},
"Set config: deliverability": {
"main": [
[
{
"node": "Build DNS queries",
"type": "main",
"index": 0
},
{
"node": "Read Gmail",
"type": "main",
"index": 0
},
{
"node": "Read Outlook inbox",
"type": "main",
"index": 0
}
]
]
},
"compile deliverability report": {
"main": [
[
{
"node": "Send a message",
"type": "main",
"index": 0
}
]
]
},
"Explain and rank with Claude AI": {
"main": [
[
{
"node": "compile deliverability report",
"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 audits a sending domain’s SPF, DKIM, DMARC, and MX records via Google DNS-over-HTTPS, checks real message placement in connected Gmail and Outlook mailboxes, and sends a ranked deliverability fix report generated by Anthropic Claude to a Gmail message. Runs when…
Source: https://n8n.io/workflows/17672/ — 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.
Fetches all open sprint tickets daily from your Jira project Analyzes each ticket for overdue days and blocked status Routes to the right escalation level: assignee email → team Google Chat alert → ma
This workflow collects event reservations via an n8n form, checks capacity against n8n Data Tables, generates a ticket and QR code, emails an e-ticket with Gmail, and posts updates to Discord, includi
This workflow accepts a suspected scam URL via an n8n form, enriches it with RDAP, certificate transparency, DNS/IP hosting data, urlscan.io results, and HTML fingerprints, then correlates findings ag
This workflow automatically handles every resolved Jira bug by verifying the fix, notifying the customer, updating HubSpot, commenting on the Jira issue, alerting the team on Slack, and logging everyt
This template is built to be customized for your specific needs. This template has the core logic and n8n node specific references sorted to work with dynamic file names throughout the workflow. Store