This workflow corresponds to n8n.io template #16010 — we link there as the canonical source.
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": "Phn985OUwvEM6Yy0",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Automated Remote Job Tracker: RSS Feeds to HTML Email Daily",
"tags": [],
"nodes": [
{
"id": "9406c078-cc6d-4072-bd07-e157e579a7a0",
"name": "Every Day at 8 AM",
"type": "n8n-nodes-base.scheduleTrigger",
"notes": "Fires once daily at 8:00 AM in your n8n server's timezone.\nTo change the time, open this node and adjust the hour slider.\nFor weekdays only: switch to 'Custom (Cron)' and enter: 0 8 * * 1-5",
"position": [
-2464,
816
],
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 8
}
]
}
},
"typeVersion": 1.1
},
{
"id": "8b1d3c94-6ca5-420b-9ce5-4e3cdba6d949",
"name": "Config",
"type": "n8n-nodes-base.set",
"position": [
-2272,
816
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "cfg-keywords",
"name": "keywords",
"type": "array",
"value": "=[\"react\",\"node\",\"typescript\",\"remote\",\"frontend\",\"fullstack\",\"full-stack\"]"
},
{
"id": "cfg-maxage",
"name": "maxAgeHours",
"type": "number",
"value": 24
},
{
"id": "cfg-boards",
"name": "jobBoards",
"type": "array",
"value": "=[\"https://remoteok.com/remote-dev-jobs.rss\",\"https://weworkremotely.com/remote-jobs.rss\",\"https://himalayas.app/jobs/rss\"]"
},
{
"id": "cfg-recipient",
"name": "recipientEmail",
"type": "string",
"value": "your@email.com"
},
{
"id": "cfg-sender",
"name": "senderEmail",
"type": "string",
"value": "user@example.com"
}
]
}
},
"typeVersion": 3.3
},
{
"id": "9ac660a3-27d2-4ef4-8aa0-91838cacc20a",
"name": "Expand Board URLs",
"type": "n8n-nodes-base.code",
"position": [
-2096,
816
],
"parameters": {
"jsCode": "// \u2500\u2500\u2500 Expand Board URLs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Converts the jobBoards array from Config into individual n8n items\n// so the RSS Reader can process each feed one at a time.\n//\n// Input: { jobBoards: [\"url1\", \"url2\", \"url3\"] }\n// Output: three separate items, one per URL\n\nconst config = $input.first().json;\nconst jobBoards = Array.isArray(config.jobBoards) ? config.jobBoards : [];\n\nif (jobBoards.length === 0) {\n throw new Error('No job board URLs found in Config. Add at least one RSS URL to the jobBoards array.');\n}\n\nreturn jobBoards.map(url => ({\n json: { jobBoards: url.trim() }\n}));"
},
"typeVersion": 2
},
{
"id": "e21c8373-cdc5-484d-96cb-dfe8407cb17d",
"name": "Split Job Boards",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-1888,
816
],
"parameters": {
"options": {
"reset": false
}
},
"typeVersion": 3
},
{
"id": "b65912d3-411d-487b-b333-4b01f58ceaa4",
"name": "RSS Read",
"type": "n8n-nodes-base.rssFeedRead",
"onError": "continueErrorOutput",
"position": [
-1664,
832
],
"parameters": {
"url": "={{ $json.jobBoards }}",
"options": {
"customFields": "company"
}
},
"typeVersion": 1.2
},
{
"id": "581f404f-6c9c-43d5-8c22-2c991539420e",
"name": "Log Fetch Error",
"type": "n8n-nodes-base.code",
"position": [
-1488,
1008
],
"parameters": {
"jsCode": "// \u2500\u2500\u2500 Log RSS Fetch Error \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Catches failed RSS feed requests without stopping the loop.\n// Errors are logged to n8n execution logs; the loop continues normally.\n\nconst item = $input.first();\n\n// Identify which feed failed\nconst feedUrl = item.json.jobBoards\n ?? item.json.url\n ?? item.json.link\n ?? 'unknown feed';\n\nconst errorMsg = item.json.error?.message\n ?? item.json.error\n ?? (typeof item.json.statusCode === 'number' ? `HTTP ${item.json.statusCode}` : null)\n ?? 'connection failed or feed unreachable';\n\nconsole.error(`[Job Digest] RSS feed failed \u2192 ${feedUrl} \u2014 ${errorMsg}`);\n\n// Returning [] means no items flow forward from this branch.\n// The connection back to Split Job Boards continues the loop.\nreturn [];"
},
"typeVersion": 2
},
{
"id": "76746990-3356-4b10-9ba5-57b06d1d2d9d",
"name": "Normalize Jobs",
"type": "n8n-nodes-base.code",
"position": [
-1408,
816
],
"parameters": {
"jsCode": "// \u2500\u2500\u2500 Normalize Jobs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Standardises raw RSS fields into a consistent schema.\n// Different feeds use different field names for the same data \u2014 this node\n// unifies them so downstream nodes can work with a single predictable shape.\n//\n// Output schema per item:\n// { title, company, url, datePosted, categories, content, source, scrapedAt }\n\nconst sourceURL = $('Split Job Boards').first().json.jobBoards ?? '';\nconst regex = /(?:https?:\\/\\/)?(?:www\\.)?([^\\/\\s\\.]+)/;\nconst regexMatch = sourceURL.match(regex);\nconst detectedSource = regexMatch ? regexMatch[1] : 'unknown';\n\nfunction normalizeDate(raw) {\n if (!raw) return '';\n raw = String(raw).trim();\n // Already ISO 8601\n if (/^\\d{4}-\\d{2}-\\d{2}/.test(raw)) return raw;\n // Unix timestamp (10 or 13 digits)\n if (/^\\d{10,13}$/.test(raw)) {\n const ms = raw.length === 10 ? parseInt(raw, 10) * 1000 : parseInt(raw, 10);\n return new Date(ms).toISOString();\n }\n // Human-readable / RFC 2822 (common in RSS feeds)\n const parsed = Date.parse(raw);\n if (!isNaN(parsed)) return new Date(parsed).toISOString();\n return raw; // Return as-is if unparseable; recency filter will skip it\n}\n\nconst output = [];\nconst scrapedAt = new Date().toISOString();\n\nfor (const item of $input.all()) {\n const raw = item.json;\n\n const title = (raw.title ?? '').trim().replace(/\\s+/g, ' ');\n const content = (raw.contentSnippet ?? raw.content ?? '').trim().replace(/\\s+/g, ' ');\n const categories = Array.isArray(raw.categories)\n ? raw.categories.join(' , ')\n : (raw.categories ?? '');\n const company = (raw.company ?? '').trim().replace(/\\s+/g, ' ');\n\n // Try multiple URL fields in order of preference\n const rawUrl = raw.url ?? raw.applyUrl ?? raw.link ?? raw.guid ?? '';\n const url = rawUrl.trim();\n\n const date = raw.isoDate\n ?? normalizeDate(raw.datePosted ?? raw.date ?? raw.pubDate ?? '');\n\n // Skip entries with no title or no URL \u2014 they're not usable\n if (!title || !url) continue;\n\n output.push({\n json: {\n title,\n company: company || 'Unknown Company',\n url,\n datePosted: date,\n categories,\n content,\n source: raw.sourceName ?? detectedSource,\n scrapedAt,\n }\n });\n}\n\nreturn output;"
},
"typeVersion": 2
},
{
"id": "31fc96bc-48bd-4250-a0b0-c22dc29d92ca",
"name": "Filter by Keyword & Recency",
"type": "n8n-nodes-base.code",
"position": [
-1184,
816
],
"parameters": {
"jsCode": "// \u2500\u2500\u2500 Filter by Keyword & Recency \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Reads keywords and maxAgeHours from the Config node.\n//\n// Keyword matching: case-insensitive, checks title + company + content + categories\n// Recency: drops jobs older than maxAgeHours (default 24h)\n//\n// \u26a0\ufe0f alwaysOutputData is ON \u2014 this ensures the Split/Loop continues\n// even when zero items match. Do not disable it.\n\nconst config = $('Config').first().json;\nconst keywords = (config.keywords ?? []).map(k => k.toLowerCase().trim()).filter(Boolean);\nconst maxAgeHours = config.maxAgeHours ?? 24;\nconst maxAgeMs = maxAgeHours * 60 * 60 * 1000;\nconst now = Date.now();\n\nconst output = [];\n\nfor (const item of $input.all()) {\n const { title, company, datePosted, content, categories } = item.json;\n\n // \u2500\u2500 Keyword filter (skip if keywords list is empty = accept all) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (keywords.length > 0) {\n const haystack = `${title} ${company} ${content} ${categories}`.toLowerCase();\n const matches = keywords.some(kw => haystack.includes(kw));\n if (!matches) continue;\n }\n\n // \u2500\u2500 Recency filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (datePosted) {\n const postedMs = new Date(datePosted).getTime();\n // Only filter if the date was parseable; otherwise let it through\n if (!isNaN(postedMs) && now - postedMs > maxAgeMs) continue;\n }\n\n output.push(item);\n}\n\nreturn output;"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "55ab56b5-a3fa-48ee-a25b-09fc3cf6c52a",
"name": "Deduplicate Jobs",
"type": "n8n-nodes-base.code",
"position": [
-944,
816
],
"parameters": {
"jsCode": "// \u2500\u2500\u2500 Deduplicate Jobs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Uses $getWorkflowStaticData('global') to persist seen job URLs across\n// workflow executions in the n8n database.\n//\n// First run: all jobs are new \u2192 all pass through\n// Later runs: only jobs with URLs not seen before pass through\n//\n// Auto-maintenance:\n// - Entries older than 30 days are purged on every run\n// - Maximum 2,000 entries; oldest are dropped if exceeded\n//\n// \u26a0\ufe0f alwaysOutputData is ON \u2014 ensures the loop continues even when\n// all incoming jobs are duplicates. Do not disable it.\n\nconst MAX_SEEN_ENTRIES = 2000;\nconst MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.seenJobs) staticData.seenJobs = {};\n\n// \u2500\u2500 Purge entries older than 30 days \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst now = Date.now();\nfor (const [url, ts] of Object.entries(staticData.seenJobs)) {\n if (now - new Date(ts).getTime() > MAX_AGE_MS) {\n delete staticData.seenJobs[url];\n }\n}\n\n// \u2500\u2500 Enforce size cap: drop oldest entries first \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet entries = Object.entries(staticData.seenJobs);\nif (entries.length > MAX_SEEN_ENTRIES) {\n entries.sort((a, b) => new Date(a[1]) - new Date(b[1]));\n const toDrop = entries.slice(0, entries.length - MAX_SEEN_ENTRIES);\n for (const [url] of toDrop) delete staticData.seenJobs[url];\n}\n\n// \u2500\u2500 Identify jobs not yet seen \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst newJobs = [];\nfor (const item of $input.all()) {\n const url = item.json.url;\n if (!url) continue;\n if (staticData.seenJobs[url]) continue; // already sent\n newJobs.push(item);\n}\n\n// \u2500\u2500 Mark new jobs as seen (written to DB at end of execution) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst seenAt = new Date().toISOString();\nfor (const item of newJobs) {\n staticData.seenJobs[item.json.url] = seenAt;\n}\n\n// Returning empty array is intentional when all jobs are duplicates.\n// alwaysOutputData: true ensures the loop still continues.\nreturn newJobs.length > 0 ? newJobs : [];"
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "6636098d-31ff-4751-94a4-d1b589fc7900",
"name": "Collect All New Jobs",
"type": "n8n-nodes-base.aggregate",
"position": [
-944,
656
],
"parameters": {
"options": {},
"aggregate": "aggregateAllItemData"
},
"typeVersion": 1
},
{
"id": "bcbf44db-cd1d-4b3b-ac61-491d1f55c733",
"name": "Any New Jobs?",
"type": "n8n-nodes-base.if",
"position": [
-736,
656
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond-count",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.data.length }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2
},
{
"id": "afe9c56a-201b-4ef3-b2cd-748b1f17e438",
"name": "Build Email HTML",
"type": "n8n-nodes-base.code",
"position": [
-400,
528
],
"parameters": {
"jsCode": "// \u2500\u2500\u2500 Build Email HTML \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Generates the full digest email from the aggregated jobs array.\n// Outputs: { subject, html, jobCount }\n\nconst allItems = $input.all();\n\n// Aggregate node wraps all items into data[]; handle both paths\nlet jobs = [];\nif (allItems.length === 1 && Array.isArray(allItems[0].json.data)) {\n jobs = allItems[0].json.data;\n} else {\n jobs = allItems.map(i => i.json);\n}\n\n// Drop any ghost empty objects (e.g. from alwaysOutputData passthrough)\njobs = jobs.filter(job => {\n if (!job || typeof job !== 'object') return false;\n return Object.values(job).some(v =>\n v !== null && v !== undefined && !(typeof v === 'string' && v.trim() === '')\n );\n});\n\nconst jobCount = jobs.length;\nconst todayStr = new Date().toLocaleDateString('en-US', {\n weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'\n});\nconst runAt = new Date().toUTCString();\n\n// \u2500\u2500 Empty-state guard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (jobCount === 0) {\n return [{\n json: {\n subject: `Remote Job Digest \u2014 No New Jobs Today (${todayStr})`,\n html: `<html><body style=\"font-family:Arial,sans-serif;padding:32px;color:#555;\">\n <p>No new remote jobs matched your filters today. Check back tomorrow!</p>\n </body></html>`,\n jobCount: 0,\n }\n }];\n}\n\nfunction formatDate(iso) {\n if (!iso) return '\u2014';\n const d = new Date(iso);\n if (isNaN(d)) return iso;\n return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });\n}\n\nfunction escapeHtml(str) {\n return String(str ?? '')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\nconst rows = jobs.map((job, i) => {\n const bg = i % 2 === 0 ? '#ffffff' : '#f9f9f9';\n return `\n <tr style=\"background:${bg};\">\n <td style=\"padding:12px 16px;border-bottom:1px solid #eee;vertical-align:top;\">\n <a href=\"${escapeHtml(job.url)}\"\n style=\"color:#1a73e8;font-weight:600;text-decoration:none;font-size:14px;\">\n ${escapeHtml(job.title)}\n </a>\n <div style=\"font-size:12px;color:#888;margin-top:3px;\">\n ${escapeHtml(job.source)}\n </div>\n </td>\n <td style=\"padding:12px 16px;border-bottom:1px solid #eee;vertical-align:top;\n font-size:13px;color:#666;white-space:nowrap;\">\n ${formatDate(job.datePosted)}\n </td>\n </tr>`;\n}).join('');\n\nconst html = `<!DOCTYPE html>\n<html lang=\"en\">\n<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f6f8;font-family:Arial,Helvetica,sans-serif;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#f4f6f8;padding:32px 0;\">\n <tr><td align=\"center\">\n <table width=\"640\" cellpadding=\"0\" cellspacing=\"0\"\n style=\"background:#fff;border-radius:8px;overflow:hidden;\n box-shadow:0 2px 8px rgba(0,0,0,.08);max-width:100%;\">\n <tr>\n <td style=\"background:#1a73e8;padding:24px 32px;\">\n <h1 style=\"margin:0;color:#fff;font-size:22px;font-weight:700;\">Remote Job Digest</h1>\n <p style=\"margin:4px 0 0;color:#d0e4ff;font-size:13px;\">${todayStr}</p>\n </td>\n </tr>\n <tr>\n <td style=\"padding:12px 32px;background:#eef4ff;border-bottom:1px solid #d0e4ff;\">\n <p style=\"margin:0;font-size:13px;color:#1a73e8;\">\n <strong>${jobCount} new job${jobCount === 1 ? '' : 's'}</strong> matched your filters.\n </p>\n </td>\n </tr>\n <tr>\n <td style=\"padding:0 16px;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\">\n <thead>\n <tr style=\"background:#f0f4ff;\">\n <th style=\"padding:10px 16px;text-align:left;font-size:11px;color:#555;\n text-transform:uppercase;border-bottom:2px solid #dde8ff;\">Position</th>\n <th style=\"padding:10px 16px;text-align:left;font-size:11px;color:#555;\n text-transform:uppercase;border-bottom:2px solid #dde8ff;\n white-space:nowrap;\">Posted</th>\n </tr>\n </thead>\n <tbody>${rows}</tbody>\n </table>\n </td>\n </tr>\n <tr>\n <td style=\"padding:20px 32px;border-top:1px solid #eee;background:#fafafa;text-align:center;\">\n <p style=\"margin:0;font-size:11px;color:#aaa;\">\n Generated by n8n Remote Job Digest \u00b7 ${runAt}\n </p>\n <p style=\"margin:6px 0 0;font-size:11px;color:#aaa;\">\n Duplicate jobs are automatically filtered across runs.\n </p>\n </td>\n </tr>\n </table>\n </td></tr>\n </table>\n</body></html>`;\n\nreturn [{\n json: {\n subject: `Remote Job Digest \u2014 ${jobCount} New Job${jobCount === 1 ? '' : 's'} (${todayStr})`,\n html,\n jobCount\n }\n}];"
},
"typeVersion": 2
},
{
"id": "0411e683-3e0a-4b64-9108-4208798c3ab6",
"name": "Send Digest Email",
"type": "n8n-nodes-base.emailSend",
"position": [
-64,
640
],
"parameters": {
"html": "={{ $json.html }}",
"options": {},
"subject": "={{ $json.subject }}",
"toEmail": "={{ $('Config').first().json.recipientEmail }}",
"fromEmail": "={{ $('Config').first().json.senderEmail }}"
},
"credentials": {
"smtp": {
"name": "<your credential>"
}
},
"typeVersion": 2.1
},
{
"id": "2de62470-8cfd-47d2-9023-f047cd0012a3",
"name": "No New Jobs \u2014 Skip",
"type": "n8n-nodes-base.set",
"position": [
-384,
784
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "no-jobs-msg",
"name": "message",
"type": "string",
"value": "No new jobs matched filters today. Email skipped."
},
{
"id": "no-jobs-ts",
"name": "checkedAt",
"type": "string",
"value": "={{ $now.toISO() }}"
}
]
}
},
"typeVersion": 3.3
},
{
"id": "a974db30-a2ef-49d7-a6ab-ee67dfcce6f6",
"name": "Sticky Note \u2014 Welcome",
"type": "n8n-nodes-base.stickyNote",
"position": [
-3280,
208
],
"parameters": {
"color": 5,
"width": 496,
"height": 536,
"content": "## \ud83d\udc4b Welcome \u2014 Quick Setup\n\nThis workflow scrapes **3 remote job RSS feeds** every morning, removes jobs you've already seen, and emails you a clean digest.\n\n**\u26a1 5-Minute Setup:**\n\n**Step 1** \u2014 Open the **Config** node:\n- `keywords` \u2014 your stack (e.g. `react`, `python`)\n- `recipientEmail` \u2014 your inbox address\n- `senderEmail` \u2014 the From address\n\n**Step 2** \u2014 Create an **SMTP credential**\n\u2192 See the \ud83d\udce7 SMTP Setup sticky note\n\n**Step 3** \u2014 Click **Test Workflow** to run manually and check your inbox\n\n**Step 4** \u2014 Toggle the workflow **Active** to start the daily schedule\n\n---\n\u23f0 **Schedule:** 8 AM in your n8n server's local timezone.\nEdit the **Every Day at 8 AM** trigger to change the time.\n\n\ud83d\udd01 **Deduplication:** Jobs you've already received will never be sent again \u2014 even across days."
},
"typeVersion": 1
},
{
"id": "a64bb118-5ee7-4b09-b217-a3fcd9501006",
"name": "Sticky Note \u2014 Keywords",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2496,
192
],
"parameters": {
"color": 4,
"width": 520,
"height": 568,
"content": "## \ud83d\udd11 Configuring Keywords\n\nOpen the **Config** node and update the `keywords` array.\n\n**Tech stack examples:**\n```\n[\"react\", \"vue\", \"frontend\", \"typescript\"]\n[\"python\", \"django\", \"fastapi\", \"backend\"]\n[\"devops\", \"kubernetes\", \"terraform\", \"aws\"]\n[\"ios\", \"swift\", \"android\", \"flutter\"]\n```\n\n**How matching works:**\nEach keyword is checked (case-insensitive) across:\n- Job title\n- Company name\n- Job description snippet\n- RSS categories/tags\n\nA job passes if **any one keyword** matches.\n\n\ud83d\udca1 Set `keywords` to `[]` (empty array) to receive **all jobs** from all boards regardless of title.\n\n**Recency:** Change `maxAgeHours` to widen or narrow the posting window. Use `72` for your first test run."
},
"typeVersion": 1
},
{
"id": "b7eec894-00f7-44c8-909e-93fbdf0ff8b2",
"name": "Sticky Note \u2014 RSS Boards",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2496,
1008
],
"parameters": {
"color": 4,
"width": 524,
"height": 624,
"content": "## \ud83c\udf10 RSS Job Board Sources\n\nThis workflow uses **RSS feeds** \u2014 structured XML data provided directly by each job board. This is more reliable than HTML scraping because:\n- No CSS selectors to maintain\n- Clean, structured data\n- Rarely blocked by servers\n- Faster to parse\n\n**Pre-configured feeds:**\n```\nRemoteOK:\nhttps://remoteok.com/remote-dev-jobs.rss\n\nWeWorkRemotely:\nhttps://weworkremotely.com/remote-jobs.rss\n\nHimalayas:\nhttps://himalayas.app/jobs/rss\n```\n\n**Adding a new feed:**\n1. Find the board's RSS URL (look for an RSS icon, or try appending `/feed` or `/rss`)\n2. Add the URL to `jobBoards` in the **Config** node\n3. Test the feed URL in a browser \u2014 you should see XML\n\n**Why not LinkedIn/Indeed/Glassdoor?**\nThose require login or block automated access. The boards above provide public RSS."
},
"typeVersion": 1
},
{
"id": "89c073d3-10f6-44ee-99fe-ca6f00e73c56",
"name": "Sticky Note \u2014 Loop Explained",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1920,
192
],
"parameters": {
"color": 4,
"width": 528,
"height": 572,
"content": "## \ud83d\udd04 How the Loop Works\n\nThe **Split Job Boards** node processes one RSS feed at a time using a loop:\n\n```\nExpand Board URLs\n \u2193 (3 separate items)\nSplit Job Boards \u2190\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 \u2502\n \u251c\u2500 output[1] each feed \u2500\u2500\u2192 RSS Read\u2502\n \u2502 Normalize\u2502\n \u2502 Filter \u2502\n \u2502 Dedup \u2500\u2500\u2500\u2518\n \u2502\n \u2514\u2500 output[0] when all done\n \u2193\n Collect All New Jobs\n```\n\n**Critical settings that make the loop work:**\n- `Filter by Keyword & Recency` \u2192 `alwaysOutputData: ON`\n- `Deduplicate Jobs` \u2192 `alwaysOutputData: ON`\n\nThese ensure the loop continues even when a feed returns zero matching jobs. **Do not disable them.**\n\nIf RSS Read fails, `Log Fetch Error` catches the error and also returns the loop \u2014 so one broken feed never stops the others."
},
"typeVersion": 1
},
{
"id": "58e7c90e-628d-4016-8676-4f2ad8a56076",
"name": "Sticky Note \u2014 Deduplication",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1200,
1024
],
"parameters": {
"color": 4,
"width": 584,
"height": 612,
"content": "## \ud83d\udd01 How Deduplication Works\n\nThe **Deduplicate Jobs** node uses `$getWorkflowStaticData('global')` \u2014 a persistent key-value store built into n8n, stored in its database.\n\n**What is stored:**\n```json\n{\n \"seenJobs\": {\n \"https://remoteok.com/jobs/123\": \"2026-05-27T08:01:00Z\",\n \"https://himalayas.app/jobs/456\": \"2026-05-26T08:00:00Z\"\n }\n}\n```\n\n**Automatic cleanup:**\n- Entries older than **30 days** are purged every run\n- Maximum **2,000 entries** \u2014 oldest are dropped if exceeded\n\n**Survives:** n8n restarts, server reboots, upgrades\n\n**Lost if:** workflow is deleted and re-imported\n\n**To reset history** (useful during testing):\n1. Open **Deduplicate Jobs**\n2. Add `staticData.seenJobs = {};` before the `return` line\n3. Run once \u2192 remove that line \u2192 save"
},
"typeVersion": 1
},
{
"id": "8f9f46c0-ef30-454b-9301-eb827a1a8e90",
"name": "Sticky Note \u2014 SMTP",
"type": "n8n-nodes-base.stickyNote",
"position": [
-176,
832
],
"parameters": {
"color": 4,
"width": 540,
"height": 556,
"content": "## \ud83d\udce7 SMTP Credential Setup\n\nThis workflow uses standard SMTP \u2014 **no OAuth or API keys required**.\n\n**Recommended free providers:**\n\n| Provider | Free limit | SMTP Host |\n|---|---|---|\n| Brevo | 300/day | smtp-relay.brevo.com |\n| Mailgun | 100/day | smtp.mailgun.org |\n| Gmail | ~500/day | smtp.gmail.com |\n\n**Gmail requires an App Password:**\n1. Enable 2FA on your Google account\n2. Go to: Google Account \u2192 Security \u2192 App Passwords\n3. Create one for Mail \u2192 copy the 16-char password\n4. Use that (not your login password) in the credential\n\n**To add the SMTP credential in n8n:**\n1. Go to **Settings \u2192 Credentials \u2192 Add Credential**\n2. Search for **SMTP**\n3. Enter: Host \u00b7 Port (587) \u00b7 Username \u00b7 Password\n4. Click Save\n5. Select it in the **Send Digest Email** node\n\n\u26a0\ufe0f Use Port `587` with TLS, or Port `465` with SSL.\n\u26a0\ufe0f Never hardcode passwords inside Set/Code nodes."
},
"typeVersion": 1
},
{
"id": "a5a39074-dc0f-4ce7-a71e-d4642c80cf5f",
"name": "Sticky Note \u2014 Troubleshooting",
"type": "n8n-nodes-base.stickyNote",
"position": [
-3280,
976
],
"parameters": {
"color": 2,
"width": 492,
"height": 648,
"content": "## \ud83d\udee0 Troubleshooting\n\n**No jobs in the email:**\n- Set `keywords` to `[]` in Config for a test run\n- Increase `maxAgeHours` to `72` temporarily\n- Check if the RSS feed URL returns data in your browser\n- Verify the RSS Read node output in a manual test run\n\n**Email not arriving:**\n- Double-check SMTP host, port, username, and password\n- Check your spam / junk folder\n- Try sending a test email via your SMTP provider's dashboard\n- Gmail: confirm you're using an App Password, not your login password\n\n**Duplicate jobs still appearing:**\n- The workflow must be **saved** after a test for static data to persist\n- Reset dedup history (see Deduplication sticky note)\n- Confirm job URLs are stable (not session-based or timestamped)\n\n**RSS feed returning errors:**\n- Paste the feed URL into a browser to verify it's accessible\n- Some feeds temporarily go down \u2014 `Log Fetch Error` handles this gracefully\n- If a feed is consistently broken, remove it from the `jobBoards` array\n\n**Loop seems stuck:**\n- Check that `alwaysOutputData: ON` is set on Filter and Dedup nodes\n- Verify the Dedup \u2192 Split connection exists"
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "256ecbe9-d580-480a-a054-6c65ec0fb02b",
"connections": {
"Config": {
"main": [
[
{
"node": "Expand Board URLs",
"type": "main",
"index": 0
}
]
]
},
"RSS Read": {
"main": [
[
{
"node": "Normalize Jobs",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Fetch Error",
"type": "main",
"index": 0
}
]
]
},
"Any New Jobs?": {
"main": [
[
{
"node": "Build Email HTML",
"type": "main",
"index": 0
}
],
[
{
"node": "No New Jobs \u2014 Skip",
"type": "main",
"index": 0
}
]
]
},
"Normalize Jobs": {
"main": [
[
{
"node": "Filter by Keyword & Recency",
"type": "main",
"index": 0
}
]
]
},
"Log Fetch Error": {
"main": [
[
{
"node": "Split Job Boards",
"type": "main",
"index": 0
}
]
]
},
"Build Email HTML": {
"main": [
[
{
"node": "Send Digest Email",
"type": "main",
"index": 0
}
]
]
},
"Deduplicate Jobs": {
"main": [
[
{
"node": "Split Job Boards",
"type": "main",
"index": 0
}
]
]
},
"Split Job Boards": {
"main": [
[
{
"node": "Collect All New Jobs",
"type": "main",
"index": 0
}
],
[
{
"node": "RSS Read",
"type": "main",
"index": 0
}
]
]
},
"Every Day at 8 AM": {
"main": [
[
{
"node": "Config",
"type": "main",
"index": 0
}
]
]
},
"Expand Board URLs": {
"main": [
[
{
"node": "Split Job Boards",
"type": "main",
"index": 0
}
]
]
},
"Collect All New Jobs": {
"main": [
[
{
"node": "Any New Jobs?",
"type": "main",
"index": 0
}
]
]
},
"Filter by Keyword & Recency": {
"main": [
[
{
"node": "Deduplicate Jobs",
"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.
smtp
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow runs daily at 8 AM, reads multiple remote job RSS feeds (RemoteOK, WeWorkRemotely, and Himalayas), filters postings by keyword and age, deduplicates jobs across runs, and sends an HTML digest email via SMTP. Runs every day at 8:00 AM based on your n8n server time…
Source: https://n8n.io/workflows/16010/ — 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 is an improvement of this workflow by Greg Brzezinka.
N8N-Self-Updater. Uses ssh, emailSend, httpRequest. Scheduled trigger; 27 nodes.
> An automated n8n workflow originally built for DigitalOcean-based n8n deployments, but fully compatible with any VPS or cloud hosting (e.g., AWS, Google Cloud, Hetzner, Linode, etc.) where n8n ru
What if you could spot a major sales problem—or a winning campaign—the very next morning, instead of weeks later? Imagine receiving a beautiful, data-rich alert directly in your inbox the moment your
Track Changes Of Product Prices. Uses htmlExtract, functionItem, httpRequest, writeBinaryFile. Scheduled trigger; 25 nodes.