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": "digest-daily",
"name": "Finance \u2014 Daily digest email",
"nodes": [
{
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
},
"position": [
240,
300
],
"notes": "Polls every 15 min. Sends one digest per poll for each missed day until caught up. 5 days offline = 5 digests over ~75 minutes."
},
{
"name": "Check if daily digest is due",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT\n (last_fetched_at AT TIME ZONE 'Asia/Kolkata')::date::text AS period_start,\n ((last_fetched_at AT TIME ZONE 'Asia/Kolkata')::date + 1)::text AS period_end\nFROM sync_state\nWHERE source = 'digest_daily'\n AND NOW() >= (((last_fetched_at AT TIME ZONE 'Asia/Kolkata')::date + 1)::timestamp + INTERVAL '9 hours') AT TIME ZONE 'Asia/Kolkata'"
},
"position": [
460,
300
],
"notes": "last_fetched_at = start of next undigested day (midnight IST). Fires when we're past 9am IST of the day AFTER that \u2014 meaning the period day is fully complete. Returns 0 rows if nothing is due."
},
{
"name": "Query period stats",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT\n (SELECT count(*) FROM raw_events\n WHERE ingested_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n AND ingested_at < $2::date AT TIME ZONE 'Asia/Kolkata') AS raw_ingested,\n (SELECT count(*) FROM parsed_transactions\n WHERE parsed_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n AND parsed_at < $2::date AT TIME ZONE 'Asia/Kolkata'\n AND ebk_status = 'written') AS written_to_ebk,\n (SELECT count(*) FROM parsed_transactions\n WHERE parsed_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n AND parsed_at < $2::date AT TIME ZONE 'Asia/Kolkata'\n AND parse_method = 'groq') AS groq_calls,\n (SELECT count(*) FROM failed_events\n WHERE failed_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n AND failed_at < $2::date AT TIME ZONE 'Asia/Kolkata'\n AND resolved = FALSE) AS open_failures,\n (SELECT count(*) FROM dedup_log\n WHERE first_seen_at >= $1::date AT TIME ZONE 'Asia/Kolkata'\n AND first_seen_at < $2::date AT TIME ZONE 'Asia/Kolkata') AS deduplicated,\n (SELECT COALESCE(SUM(amount),0) FROM parsed_transactions\n WHERE direction = 'debit'\n AND transaction_time >= date_trunc('month', NOW() AT TIME ZONE 'Asia/Kolkata') AT TIME ZONE 'Asia/Kolkata') AS month_spend",
"additionalFields": {
"queryParams": "={{ [$json.period_start, $json.period_end] }}"
}
},
"position": [
680,
200
]
},
{
"name": "Query open failures",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT fe.id, fe.stage, fe.error_message, re.subject, re.sender, fe.failed_at FROM failed_events fe LEFT JOIN raw_events re ON fe.raw_event_id = re.id WHERE fe.resolved = FALSE ORDER BY fe.failed_at DESC LIMIT 10"
},
"position": [
680,
400
]
},
{
"name": "Query unreviewed GroqCloud",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "SELECT id, input_fragment, parsed_fields, called_at FROM groq_parse_log WHERE promoted = FALSE ORDER BY called_at DESC LIMIT 5"
},
"position": [
680,
600
]
},
{
"name": "Build email body",
"type": "n8n-nodes-base.code",
"parameters": {
"language": "javaScript",
"jsCode": "const period = $('Check if daily digest is due').first().json;\nconst stats = $('Query period stats').first().json;\nconst failures = $('Query open failures').all().map(i => i.json);\nconst groqItems = $('Query unreviewed GroqCloud').all().map(i => i.json);\n\nconst periodDate = new Date(period.period_start + 'T00:00:00+05:30')\n .toLocaleDateString('en-IN', { dateStyle: 'full' });\n\nlet failureRows = failures.length === 0\n ? '<tr><td colspan=\"4\" style=\"color:green\">None \u2014 all clear</td></tr>'\n : failures.map(f =>\n `<tr>\n <td>${f.id}</td>\n <td>${f.stage}</td>\n <td>${f.subject || '\u2014'}</td>\n <td>${f.error_message?.substring(0, 80) || '\u2014'}</td>\n </tr>`\n ).join('');\n\nlet groqRows = groqItems.length === 0\n ? '<tr><td colspan=\"3\" style=\"color:green\">None pending</td></tr>'\n : groqItems.map(g =>\n `<tr>\n <td>${g.id}</td>\n <td>${g.input_fragment?.substring(0, 80) || '\u2014'}</td>\n <td>${JSON.stringify(g.parsed_fields)?.substring(0, 100) || '\u2014'}</td>\n </tr>`\n ).join('');\n\nconst html = `\n<html><body style=\"font-family: sans-serif; color: #222; max-width: 700px; margin: auto\">\n<h2>Finance pipeline \u2014 daily digest</h2>\n<p style=\"color: #666\">${periodDate}</p>\n\n<h3>Activity for ${periodDate}</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse; width:100%\">\n <tr style=\"background:#f0f0f0\"><th>Metric</th><th>Value</th></tr>\n <tr><td>Emails ingested</td><td>${stats.raw_ingested}</td></tr>\n <tr><td>Transactions written to ezbookkeeping</td><td>${stats.written_to_ebk}</td></tr>\n <tr><td>Deduplicated (dropped)</td><td>${stats.deduplicated}</td></tr>\n <tr><td>GroqCloud fallback calls</td><td>${stats.groq_calls}</td></tr>\n <tr><td>Open failures</td><td style=\"color:${stats.open_failures > 0 ? 'red' : 'green'}\">${stats.open_failures}</td></tr>\n <tr><td>Month-to-date spend</td><td>${Number(stats.month_spend).toLocaleString('en-IN', { style: 'currency', currency: 'INR' })}</td></tr>\n</table>\n\n<h3>Open failures (manual review needed)</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse; width:100%\">\n <tr style=\"background:#f0f0f0\"><th>ID</th><th>Stage</th><th>Email subject</th><th>Error</th></tr>\n ${failureRows}\n</table>\n\n<h3>GroqCloud parses not yet promoted to regex</h3>\n<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\" style=\"border-collapse:collapse; width:100%\">\n <tr style=\"background:#f0f0f0\"><th>ID</th><th>Input fragment</th><th>Parsed fields</th></tr>\n ${groqRows}\n</table>\n\n<p style=\"font-size:12px;color:#999\">To mark a failure resolved: <code>UPDATE failed_events SET resolved=TRUE WHERE id=<id>;</code><br>\nTo promote a Groq pattern to regex: see RUNBOOK.md</p>\n</body></html>\n`;\n\nreturn [{ json: { subject: `Finance digest \u2014 ${periodDate}`, html, period_end: period.period_end } }];\n"
},
"position": [
900,
400
]
},
{
"name": "Send email",
"type": "n8n-nodes-base.gmail",
"parameters": {
"operation": "send",
"sendTo": "={{ $credentials.digestRecipient }}",
"subject": "={{ $json.subject }}",
"message": "={{ $json.html }}",
"options": {
"bodyContentType": "html"
}
},
"position": [
1120,
400
]
},
{
"name": "Advance digest cursor",
"type": "n8n-nodes-base.postgres",
"parameters": {
"operation": "executeQuery",
"query": "UPDATE sync_state SET last_fetched_at = $1::date AT TIME ZONE 'Asia/Kolkata', updated_at = NOW() WHERE source = 'digest_daily'",
"additionalFields": {
"queryParams": "={{ [$('Build email body').first().json.period_end] }}"
}
},
"position": [
1340,
400
],
"notes": "Advances last_fetched_at by exactly 1 day (to period_end). Does NOT jump to NOW(). Next poll will check if another day is pending."
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Check if daily digest is due",
"type": "main",
"index": 0
}
]
]
},
"Check if daily digest is due": {
"main": [
[
{
"node": "Query period stats",
"type": "main",
"index": 0
},
{
"node": "Query open failures",
"type": "main",
"index": 0
},
{
"node": "Query unreviewed GroqCloud",
"type": "main",
"index": 0
}
]
]
},
"Query period stats": {
"main": [
[
{
"node": "Build email body",
"type": "main",
"index": 0
}
]
]
},
"Query open failures": {
"main": [
[
{
"node": "Build email body",
"type": "main",
"index": 0
}
]
]
},
"Query unreviewed GroqCloud": {
"main": [
[
{
"node": "Build email body",
"type": "main",
"index": 0
}
]
]
},
"Build email body": {
"main": [
[
{
"node": "Send email",
"type": "main",
"index": 0
}
]
]
},
"Send email": {
"main": [
[
{
"node": "Advance digest cursor",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Finance — Daily digest email. Uses postgres, gmail. Scheduled trigger; 8 nodes.
Source: https://github.com/nh2seven/rupio/blob/4a1135ba72c16edc2df6b93a5d81c12bfe9830cc/workflows/digest/daily.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.
This workflow automates the complete end-to-end processing of daily revenue transactions for finance and accounting teams. It systematically retrieves, validates, and standardizes transaction data fro
Monthly Energy Generation Report (Postgres → PDF → Email). Uses httpRequest, gmail, postgres. Scheduled trigger; 7 nodes.
YOUR_ID 4. Uses gmail, googleDrive, googleSheets, httpRequest. Scheduled trigger; 53 nodes.
Suspicious_login_detection. Uses postgres, httpRequest, noOp, html. Webhook trigger; 43 nodes.
This n8n workflow is designed for security monitoring and incident response when suspicious login events are detected. It can be initiated either manually from within the n8n UI for testing or automat