n8n Lead Generation Template Pack — 5 B2B Outbound Workflows (CAN-SPAM and GDPR Fields Built In)
Five ready-to-import n8n workflows that carry your own lead list from a Google Sheet to a sent 3-step outbound sequence, with a physical postal address and a one-click unsubscribe link in every message and a written send log behind it. Opt-outs and hard bounces land on your do-not-contact tab the moment they arrive and the sequence re-reads that tab before every single run — CAN-SPAM allows 10 business days to honour an opt-out (15 U.S.C. § 7704(a)(4)(A)), this pack does it on the next run.
Free workflow — copy & import
{
"name": "1 - Lead list import, do-not-contact scrub and GDPR field stamping",
"nodes": [
{
"parameters": {},
"id": "b896059c-64f1-6f98-b896-059c64f16f98",
"name": "Run the check on your list",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
300
]
},
{
"parameters": {
"operation": "read",
"options": {}
},
"id": "b896059c-64f1-6f99-b896-059c64f16f99",
"name": "Read your do-not-contact tab",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
220,
300
]
},
{
"parameters": {
"jsCode": "const rows = $input.all().map(i => i.json);\nconst emails = [];\nconst domains = [];\nfor (const r of rows) {\n const e = String(r.email || r.Email || '').trim().toLowerCase();\n if (e) emails.push(e);\n const d = String(r.domain || r.Domain || '').trim().toLowerCase().replace(/^www\\./, '');\n if (d) domains.push(d);\n}\nreturn [{ json: { dnc_emails: emails, dnc_domains: domains, dnc_rows_read: rows.length } }];"
},
"id": "b896059c-64f1-6f9a-b896-059c64f16f9a",
"name": "Build the suppression set",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
440,
300
]
},
{
"parameters": {
"operation": "read",
"options": {}
},
"id": "b896059c-64f1-6f9b-b896-059c64f16f9b",
"name": "Read your raw lead list",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
660,
300
]
},
{
"parameters": {
"jsCode": "const dnc = $('Build the suppression set').first().json;\nconst blockedEmails = new Set(dnc.dnc_emails);\nconst blockedDomains = new Set(dnc.dnc_domains);\nconst roleAccounts = ['info', 'sales', 'support', 'admin', 'contact', 'hello', 'office', 'billing', 'noreply', 'no-reply', 'postmaster', 'abuse', 'privacy', 'jobs', 'careers'];\nconst freeMail = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'me.com', 'gmx.de', 'gmx.net', 'web.de', 'proton.me', 'protonmail.com', 'mail.ru', 'yandex.ru'];\nconst pattern = /^[^@\\s]+@[^@\\s.]+\\.[^@\\s]{2,}$/;\nconst seen = new Set();\nconst now = new Date();\nconst day = d => d.toISOString().slice(0, 10);\nconst out = [];\nfor (const item of $input.all()) {\n const r = item.json;\n const email = String(r.email || r.Email || '').trim().toLowerCase();\n const local = email.split('@')[0] || '';\n const domain = (email.split('@')[1] || '').replace(/^www\\./, '');\n let status = 'ready';\n let reason = '';\n if (!pattern.test(email)) { status = 'rejected'; reason = 'email_not_valid'; }\n else if (seen.has(email)) { status = 'rejected'; reason = 'duplicate_in_file'; }\n else if (blockedEmails.has(email)) { status = 'rejected'; reason = 'on_do_not_contact_list'; }\n else if (blockedDomains.has(domain)) { status = 'rejected'; reason = 'domain_on_do_not_contact_list'; }\n else if (freeMail.includes(domain)) { status = 'rejected'; reason = 'not_a_business_domain'; }\n else if (roleAccounts.includes(local)) { status = 'rejected'; reason = 'role_account'; }\n if (email) seen.add(email);\n let collected = String(r.collected_at || '').trim();\n let collectedDate = collected ? new Date(collected) : now;\n if (isNaN(collectedDate)) collectedDate = now;\n if (!collected) collected = day(now);\n const noticeDue = new Date(collectedDate);\n noticeDue.setMonth(noticeDue.getMonth() + 1);\n out.push({ json: {\n email: email,\n first_name: String(r.first_name || '').trim(),\n last_name: String(r.last_name || '').trim(),\n company: String(r.company || '').trim(),\n role: String(r.role || r.title || '').trim(),\n domain: domain,\n country: String(r.country || '').trim().toUpperCase(),\n status: status,\n reason: reason,\n lawful_basis: status === 'ready' ? 'GDPR Art. 6(1)(f) legitimate interest' : '',\n source_url: String(r.source_url || '').trim(),\n collected_at: collected,\n art14_notice_due: day(noticeDue),\n art14_notice_sent: String(r.art14_notice_sent || '').trim(),\n step: Number(r.step || 0),\n last_sent_at: String(r.last_sent_at || '').trim(),\n replied: String(r.replied || '').trim(),\n bounced: String(r.bounced || '').trim(),\n unsubscribed: String(r.unsubscribed || '').trim(),\n paused: String(r.paused || '').trim(),\n checked_at: now.toISOString()\n } });\n}\nreturn out;"
},
"id": "b896059c-64f1-6f9c-b896-059c64f16f9c",
"name": "Clean, dedupe, suppress and stamp the GDPR fields",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
]
},
{
"parameters": {
"operation": "append",
"options": {}
},
"id": "b896059c-64f1-6f9d-b896-059c64f16f9d",
"name": "Write every address back with a status and a reason",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4.5,
"position": [
1100,
300
]
},
{
"parameters": {
"content": "SETUP (about five minutes)\n\n1. In your Google Sheet, create three tabs, spelled exactly like this:\n - Leads - Raw columns: email, first_name, last_name, company, role, country, source_url, collected_at\n - Do Not Contact columns: email, domain, reason, added_at\n - Leads - Checked leave it empty; this workflow writes the header row and the results\n country should be a two-letter code (US, DE, FR). collected_at should be a date like 2026-08-19. Both may be left blank; if collected_at is blank, today's date is used.\n\n2. In both Google Sheets nodes, choose your OWN Google Sheets OAuth2 credential. No credential ships with this file. Then paste your Sheet ID into the Document field of all three Sheets nodes. The Sheet ID is the long part of the sheet URL between /d/ and /edit.\n\n3. Press Execute Workflow. Every row from Leads - Raw comes back in Leads - Checked with status = ready or rejected, and a reason on the rejected ones.\n\nWHAT THE REASONS MEAN\n email_not_valid the address does not match the syntax pattern\n duplicate_in_file the same address appeared earlier in the same run\n on_do_not_contact_list the address is on your Do Not Contact tab\n domain_on_do_not_contact_list the whole domain is on your Do Not Contact tab\n not_a_business_domain a free-mail domain such as gmail.com\n role_account a shared inbox such as info@ or sales@\n\nTUNING\nOpen the Code node 'Clean, dedupe, suppress and stamp the GDPR fields'. The lists roleAccounts and freeMail are plain arrays at the top - add or remove entries to match how you sell. If you sell to sole traders who really do use gmail.com, delete that entry from freeMail.\n\nWHAT GETS STAMPED AND WHY\nRows that pass are stamped with lawful_basis = GDPR Art. 6(1)(f) legitimate interest, with the source_url you recorded, and with art14_notice_due set one month after collected_at. One month is the outer limit in GDPR Art. 14(3)(a) for telling a person you hold data you did not get from them. Nothing is deleted from your sheet - rejected rows are written back with the reason, so you can fix them and run again.",
"height": 220,
"width": 320
},
"id": "b896059c-64f1-6f9e-b896-059c64f16f9e",
"name": "Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1320,
100
]
}
],
"connections": {
"Run the check on your list": {
"main": [
[
{
"node": "Read your do-not-contact tab",
"type": "main",
"index": 0
}
]
]
},
"Read your do-not-contact tab": {
"main": [
[
{
"node": "Build the suppression set",
"type": "main",
"index": 0
}
]
]
},
"Build the suppression set": {
"main": [
[
{
"node": "Read your raw lead list",
"type": "main",
"index": 0
}
]
]
},
"Read your raw lead list": {
"main": [
[
{
"node": "Clean, dedupe, suppress and stamp the GDPR fields",
"type": "main",
"index": 0
}
]
]
},
"Clean, dedupe, suppress and stamp the GDPR fields": {
"main": [
[
{
"node": "Write every address back with a status and a reason",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {},
"meta": {
"templateId": "n8n-lead-gen-b2b-outbound-pack"
}
}