This workflow follows the Gmail → Postgres 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": "",
"name": "05b - Digest Email",
"active": true,
"nodes": [
{
"parameters": {},
"id": "trigger-manual",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-896,
0
]
},
{
"id": "trigger-webhook",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-896,
416
],
"parameters": {
"httpMethod": "POST",
"path": "job-digest-run",
"responseMode": "lastNode"
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT COUNT(*) FILTER (WHERE date_seen >= CURRENT_DATE - INTERVAL '8 days') AS total_seen, COUNT(*) FILTER (WHERE date_seen >= CURRENT_DATE - INTERVAL '8 days' AND LENGTH(TRIM(description)) >= 200) AS with_body, COUNT(*) FILTER (WHERE date_seen >= CURRENT_DATE - INTERVAL '8 days' AND quarantine_reason IS NOT NULL) AS quarantined, COUNT(*) FILTER (WHERE date_seen >= CURRENT_DATE - INTERVAL '8 days' AND scored_at IS NOT NULL) AS scored FROM listings;",
"options": {}
},
"id": "pg-stats-coverage",
"name": "Postgres: Get Coverage Stats",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
-752,
208
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT id, source, url, title, company, priority_bonus, score, rationale FROM listings WHERE score >= 6 AND digested_at IS NULL AND date_seen >= CURRENT_DATE - INTERVAL '8 days' ORDER BY priority_bonus DESC, score DESC;",
"options": {}
},
"id": "pg-select-digest",
"name": "Postgres: Select Digest Candidates",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
-608,
208
],
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Patch 3a: filter Postgres v2 zero-row phantom ({success:true}) out of the digest.\n// Real listings always have a numeric id; phantoms never do.\nconst rows = $input.all().map(i => i.json).filter(r => r && r.id);\n\n// Patch 4: pull coverage stats from upstream node for the email health banner.\n// Window matches the digest SELECT (date_seen >= today - 8 days). Ints are returned\n// as strings by node-postgres for COUNT/bigint, so coerce defensively.\nconst statsRaw = $('Postgres: Get Coverage Stats').first().json || {};\nconst stats = {\n total_seen: Number(statsRaw.total_seen || 0),\n with_body: Number(statsRaw.with_body || 0),\n quarantined: Number(statsRaw.quarantined || 0),\n scored: Number(statsRaw.scored || 0)\n};\n\nconst escapeHtml = (s) => String(s || '')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n\nconst seen = new Map();\nfor (const r of rows) {\n const key = `${(r.title || '').toLowerCase().trim()}|${(r.company || '').toLowerCase().trim()}`;\n const existing = seen.get(key);\n if (!existing) {\n seen.set(key, r);\n } else if (existing.source === 'linkedin' && r.source !== 'linkedin') {\n seen.set(key, r);\n }\n}\nconst deduped = Array.from(seen.values());\n\ndeduped.sort((a, b) => {\n if (a.priority_bonus !== b.priority_bonus) {\n return a.priority_bonus ? -1 : 1;\n }\n return (b.score || 0) - (a.score || 0);\n});\n\nconst today = new Date().toLocaleDateString('en-GB', {\n timeZone: 'Europe/Berlin',\n year: 'numeric', month: 'long', day: 'numeric'\n});\n\nconst subject = `Job Radar: ${deduped.length} new ${deduped.length === 1 ? 'match' : 'matches'} (${today})`;\n\nconst cardHtml = (r) => {\n const priorityBadge = r.priority_bonus\n ? `<span style=\"background:#e11d48;color:#fff;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:6px;\">PRIORITY</span>`\n : '';\n const scoreBadge = `<span style=\"background:#0a66c2;color:#fff;padding:3px 10px;border-radius:4px;font-weight:bold;font-size:14px;\">${r.score}/10</span>`;\n const sourceTag = `<span style=\"color:#666;font-size:11px;margin-left:8px;text-transform:uppercase;\">${escapeHtml(r.source)}</span>`;\n return `\n <div style=\"border:1px solid #ddd;border-radius:8px;padding:14px;margin-bottom:12px;font-family:Arial,sans-serif;\">\n <div style=\"margin-bottom:6px;\">${priorityBadge}${scoreBadge}${sourceTag}</div>\n <div style=\"font-size:16px;font-weight:bold;margin-bottom:4px;\">\n <a href=\"${escapeHtml(r.url)}\" style=\"color:#0a66c2;text-decoration:none;\">${escapeHtml(r.title || 'Untitled role')}</a>\n </div>\n <div style=\"font-size:13px;color:#333;margin-bottom:8px;\">${escapeHtml(r.company || 'Unknown')}</div>\n <div style=\"font-size:13px;color:#555;line-height:1.4;\">${escapeHtml(r.rationale || '')}</div>\n </div>`;\n};\n\n// Coverage banner: surfaces the body-coverage health of this scoring window.\n// Rendered only when the digest sends (count > 0); zero-match runs skip the email.\nconst bodyPct = stats.total_seen > 0\n ? Math.round((stats.with_body / stats.total_seen) * 100)\n : 0;\nconst coverageBanner = `\n <div style=\"background:#f0f6ff;border-left:3px solid #0a66c2;padding:10px 12px;margin-bottom:16px;font-size:12px;color:#444;line-height:1.5;\">\n <strong style=\"color:#0a66c2;\">Coverage</strong> ·\n ${stats.total_seen} seen this week ·\n ${stats.with_body} with body (${bodyPct}%) ·\n ${stats.scored} scored ·\n ${stats.quarantined} quarantined (no body)\n </div>`;\n\nconst html = `<!DOCTYPE html><html><body style=\"margin:0;padding:16px;background:#f6f6f6;font-family:Arial,sans-serif;\">\n <div style=\"max-width:640px;margin:0 auto;\">\n <h2 style=\"margin:0 0 16px 0;font-size:18px;\">Job Radar — ${today}</h2>\n <p style=\"margin:0 0 12px 0;font-size:13px;color:#666;\">\n ${deduped.length} listing${deduped.length === 1 ? '' : 's'} scored 6 or higher.\n ${deduped.some(r => r.priority_bonus) ? 'Priority listings pinned to top.' : ''}\n </p>\n ${coverageBanner}\n ${deduped.map(cardHtml).join('')}\n <p style=\"margin-top:24px;font-size:11px;color:#999;\">Sent by 05b - Digest Email</p>\n </div>\n</body></html>`;\n\nreturn [{ json: { subject, html, count: deduped.length, included_ids: deduped.map(r => r.id), stats } }];"
},
"id": "code-assemble-digest",
"name": "Code: Assemble Digest",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-304,
208
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "has-rows",
"leftValue": "={{ $json.count }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-has-matches",
"name": "IF: Has Matches",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
0,
208
]
},
{
"parameters": {
"resource": "message",
"operation": "send",
"sendTo": "your-email@example.com",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html }}",
"options": {
"appendAttribution": false
}
},
"id": "gmail-send",
"name": "Gmail: Send Digest",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
304,
160
],
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
}
},
{
"id": "pg-mark-digested",
"name": "Postgres: Mark Digested",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2,
"position": [
608,
160
],
"parameters": {
"operation": "executeQuery",
"query": "=UPDATE listings SET digested_at = NOW() WHERE id = ANY(ARRAY[{{ ($('Code: Assemble Digest').first().json.included_ids || [0]).join(',') }}]::bigint[]);",
"options": {}
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Postgres: Get Coverage Stats",
"type": "main",
"index": 0
}
]
]
},
"Webhook Trigger": {
"main": [
[
{
"node": "Postgres: Get Coverage Stats",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Get Coverage Stats": {
"main": [
[
{
"node": "Postgres: Select Digest Candidates",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Select Digest Candidates": {
"main": [
[
{
"node": "Code: Assemble Digest",
"type": "main",
"index": 0
}
]
]
},
"Code: Assemble Digest": {
"main": [
[
{
"node": "IF: Has Matches",
"type": "main",
"index": 0
}
]
]
},
"IF: Has Matches": {
"main": [
[
{
"node": "Gmail: Send Digest",
"type": "main",
"index": 0
}
]
]
},
"Gmail: Send Digest": {
"main": [
[
{
"node": "Postgres: Mark Digested",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"timezone": "Europe/Berlin",
"callerPolicy": "workflowsFromSameOwner",
"saveDataErrorExecution": "all",
"saveDataSuccessExecution": "all",
"saveExecutionProgress": true,
"saveManualExecutions": true
}
}
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.
gmailOAuth2postgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
05b - Digest Email. Uses postgres, gmail. Event-driven trigger; 8 nodes.
Source: https://github.com/ozlar34/job-match-radar/blob/main/workflows/05b-digest-email/digest-email.json — 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.
Loxone MCP Client - Integration Hub. Uses start, googleCalendar, slack, mcp. Event-driven trigger; 20 nodes.
ch3. Uses postgres, formTrigger, gmail, form. Event-driven trigger; 19 nodes.
Stage B. Uses executeWorkflowTrigger, postgres, dataTable, gmail. Event-driven trigger; 8 nodes.
chart. Uses httpRequest, postgres, executeWorkflowTrigger, gmail. Event-driven trigger; 7 nodes.
Stage A. Uses postgres, gmail, executeWorkflowTrigger. Event-driven trigger; 6 nodes.