{
  "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, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;');\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 &nbsp;\u00b7&nbsp; ${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
          }
        ]
      ]
    }
  }
}