{
  "name": "Newsletter Digest Generator",
  "description": "Aggregate articles from multiple RSS feeds, deduplicate and normalize across feed variations, draft a curated weekly digest using Claude, back up the draft to Google Sheets, then push a Mailchimp draft campaign ready for editorial review. Includes empty-feed guard, AI output contract enforcement and safe fallback handling across all nodes.",
  "version": "2.0",
  "type": "starter_template",
  "kairox_workflow": "newsletter-digest-generator",
  "stack": {
    "automation": "n8n",
    "content_sources": "RSS feeds (one n8n RSS node per feed — feeds are merged before filtering)",
    "ai": "Claude API (claude-3-5-sonnet recommended for editorial quality)",
    "draft_storage": "Google Sheets (Drafts tab)",
    "email_platform": "Mailchimp"
  },
  "required_credentials": [
    {
      "name": "Anthropic API Key",
      "type": "api_key",
      "where_to_set": "n8n > Settings > Credentials > New > Header Auth",
      "note": "Claude 3.5 Sonnet is recommended — strong editorial judgment and consistent formatting. Replace with OpenAI by swapping the HTTP Request node for an OpenAI node and updating the response parsing in node 5b."
    },
    {
      "name": "Mailchimp API Key",
      "type": "api_key",
      "where_to_set": "n8n > Settings > Credentials > New > Mailchimp API",
      "note": "Find your API key in Mailchimp: Account > Extras > API Keys. Also note your Audience List ID: Audience > Settings > Audience Name and Defaults."
    },
    {
      "name": "Google Sheets OAuth2",
      "type": "oauth2",
      "where_to_set": "n8n > Settings > Credentials > New > Google Sheets OAuth2",
      "note": "Used to back up the generated digest draft before Mailchimp. If Mailchimp fails, the draft is preserved here for manual recovery. Create a Google Sheet with tabs: Queue (for future use) and Drafts (columns: date, subject_line, digest_text, parse_success)."
    }
  ],
  "nodes": [
    {
      "id": "1",
      "name": "Schedule — Weekly Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "description": "Triggers the digest workflow once per week. Default: Friday morning, giving you the weekend to review and schedule Monday delivery.",
      "config": {
        "rule": "0 8 * * 5",
        "note": "Default: 8:00 AM UTC every Friday. n8n Cloud users can set timezone directly in Workflow Settings — use local time there instead. Self-hosted: adjust the cron hour for your UTC offset.",
        "timezone_offsets": {
          "US_Eastern": "UTC-5/UTC-4 — 8 AM local: use hour 13 (EST) or 12 (EDT)",
          "US_Pacific": "UTC-8/UTC-7 — 8 AM local: use hour 16 (PST) or 15 (PDT)",
          "UK": "UTC+0/UTC+1 — 8 AM local: use hour 8 (GMT) or 7 (BST)",
          "Central_Europe": "UTC+1/UTC+2 — 8 AM local: use hour 7 (CET) or 6 (CEST)",
          "India": "UTC+5:30 — 8 AM local: use hour 2 minute 30",
          "Singapore": "UTC+8 — 8 AM local: use hour 0 (previous UTC day)"
        }
      }
    },
    {
      "id": "2",
      "name": "RSS — Source Feed 1",
      "type": "n8n-nodes-base.rssFeedRead",
      "description": "Fetches articles from your first RSS source. Each feed requires its own node — the n8n RSS node accepts one URL per node. Outputs are merged in node 2d.",
      "config": {
        "url": "RSS_FEED_URL_1",
        "note": "Replace RSS_FEED_URL_1 with your first feed URL. Example: https://feeds.feedburner.com/example or https://example.com/feed.xml. To add more feeds, duplicate this node pattern and connect to node 2d."
      }
    },
    {
      "id": "2b",
      "name": "RSS — Source Feed 2",
      "type": "n8n-nodes-base.rssFeedRead",
      "description": "Second RSS source. Add or remove RSS nodes freely — each connects to node 2d (Merge).",
      "config": {
        "url": "RSS_FEED_URL_2",
        "note": "Replace with your second feed URL. If you only have one feed, remove this node and its connection to node 2d."
      }
    },
    {
      "id": "2c",
      "name": "RSS — Source Feed 3",
      "type": "n8n-nodes-base.rssFeedRead",
      "description": "Third RSS source.",
      "config": {
        "url": "RSS_FEED_URL_3",
        "note": "Replace with your third feed URL. Remove if unused."
      }
    },
    {
      "id": "2d",
      "name": "Merge — Combine Feed Articles",
      "type": "n8n-nodes-base.merge",
      "description": "Combines the article outputs from all RSS feed nodes into a single list for filtering. Connect every RSS node (2, 2b, 2c...) as an input here.",
      "config": {
        "mode": "append",
        "note": "All inputs are appended into one flat list. Add feeds by creating additional RSS nodes and connecting them here. Remove feeds by disconnecting their nodes."
      }
    },
    {
      "id": "3",
      "name": "Filter — Articles from Last 7 Days",
      "type": "n8n-nodes-base.filter",
      "description": "Keeps only articles published within the configured lookback window. Discards older content.",
      "config": {
        "condition": "{{ new Date($json.pubDate || $json.isoDate || $json.date || 0).getTime() > Date.now() - (7 * 24 * 60 * 60 * 1000) }}",
        "note": "The condition handles multiple date field names (pubDate, isoDate, date) used by different RSS feeds. Adjust 7 days to a longer window if your feeds publish infrequently."
      }
    },
    {
      "id": "3b",
      "name": "Deduplicate + Normalize Articles",
      "type": "n8n-nodes-base.code",
      "description": "Removes duplicate articles by URL (stripping query parameters), normalizes content fields across feed variations, and stops the workflow cleanly if no articles remain after deduplication.",
      "config": {
        "language": "javascript",
        "code": "const items = $input.all();\nconst MAX_ARTICLES = 25; // cap to control AI token usage\nconst seenUrls = new Set();\nconst result = [];\n\nfor (const item of items) {\n  const a = item.json;\n  \n  // Normalise URL — strip query params for deduplication\n  const rawUrl = (a.link || a.url || '').trim();\n  const url = rawUrl.split('?')[0];\n  if (!url || seenUrls.has(url)) continue;\n  seenUrls.add(url);\n  \n  // Safe field fallbacks — RSS feeds use different field names for article body\n  const snippet = (\n    a.contentSnippet ||\n    a.description ||\n    a.summary ||\n    a['content:encoded'] ||\n    a.content ||\n    ''\n  ).replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, 400);\n  \n  result.push({\n    json: {\n      title:   (a.title   || 'Untitled').trim().slice(0, 200),\n      link:    rawUrl,\n      source:  (a.creator || a.author || a['dc:creator'] || 'unknown').toString().trim().slice(0, 100),\n      pubDate: a.pubDate || a.isoDate || a.date || '',\n      snippet: snippet || 'No article preview available.'\n    }\n  });\n  \n  if (result.length >= MAX_ARTICLES) break;\n}\n\n// Empty guard — stop cleanly if no articles to process\nif (result.length === 0) {\n  console.log('Newsletter digest: no articles found this week after filtering and deduplication. Workflow stopped cleanly.');\n  return []; // Empty return stops execution — no AI call, no blank Mailchimp draft\n}\n\nreturn result;",
        "note": "Returning an empty array stops execution here. The workflow does not continue to the AI or Mailchimp nodes. Adjust MAX_ARTICLES to balance content breadth against AI token cost."
      }
    },
    {
      "id": "4",
      "name": "Aggregate — Format Article List",
      "type": "n8n-nodes-base.code",
      "description": "Combines all deduplicated articles into a single structured text block for the AI. Returns one item with the full formatted list.",
      "config": {
        "language": "javascript",
        "code": "const items = $input.all();\n\nconst formattedList = items.map((item, i) => {\n  const a = item.json;\n  return `[${i + 1}] ${a.title}\\nSource: ${a.source}\\nURL: ${a.link}\\nDate: ${a.pubDate}\\nPreview: ${a.snippet}`;\n}).join('\\n\\n---\\n\\n');\n\nreturn [{\n  json: {\n    articles: formattedList,\n    article_count: items.length\n  }\n}];",
        "note": "All articles are passed as a single structured text block. Article count is included for the AI context."
      }
    },
    {
      "id": "5",
      "name": "Claude — Curate and Draft Digest",
      "type": "n8n-nodes-base.httpRequest",
      "description": "Sends the formatted article list to Claude with an embedded curation prompt. Returns a structured JSON object with subject_line and digest_html — enforced by explicit output format instructions in the system prompt.",
      "config": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "headers": {
          "x-api-key": "{{ $env.ANTHROPIC_API_KEY }}",
          "anthropic-version": "2023-06-01",
          "Content-Type": "application/json"
        },
        "body": {
          "model": "claude-3-5-sonnet-20241022",
          "max_tokens": 3000,
          "system": "You are a newsletter curation editor. Your job is to select the most relevant articles from a provided list and write a polished weekly digest for your specific audience.\n\n─── NEWSLETTER CONTEXT — CONFIGURE THIS SECTION BEFORE ACTIVATING ───\nTOPIC: [Your newsletter topic — e.g. 'practical AI tools for operators and founders']\nAUDIENCE: [Who reads this — e.g. 'early-stage founders and operators who want practical AI leverage, not hype']\nTONE: [Voice and register — e.g. 'direct and practical, no buzzwords, always specific']\nSECTIONS: [2-3 recurring section themes — e.g. 'This Week in AI · Tools Worth Testing · One Takeaway']\n─── END NEWSLETTER CONTEXT ───\n\nFrom the article list provided:\n1. Select 4-7 of the most relevant and interesting items for your audience\n2. Write a 1-3 sentence editorial summary for each selected article explaining why it matters\n3. Organise them into your defined sections with short section introductions\n4. Write a brief opening paragraph setting the tone for this week\n5. Close with a short 'one takeaway' or editorial thought\n\nCRITICAL OUTPUT REQUIREMENT:\nYou MUST return ONLY a valid JSON object with exactly these two keys — nothing before or after:\n{\n  \"subject_line\": \"your email subject line here\",\n  \"digest_html\": \"<full formatted HTML content here>\"\n}\n\nRules for subject_line:\n- Under 60 characters\n- Specific to this week's content — not generic\n- No clickbait\n\nRules for digest_html:\n- Valid HTML suitable for email clients\n- Use <h2> for section headers, <p> for body text, <a href='...'>article title</a> for links\n- Include the article URL as a link on the article title\n- No inline styles or complex CSS — keep it clean\n\nDo NOT include markdown code fences. Do NOT include prose outside the JSON object.",
          "messages": [
            {
              "role": "user",
              "content": "Here are {{ $json.article_count }} articles from this week's sources:\n\n{{ $json.articles }}"
            }
          ]
        },
        "note": "The system prompt enforces a strict JSON output contract. Node 5b parses this output safely. Configure the NEWSLETTER CONTEXT section with your topic, audience, tone and section names before activating."
      }
    },
    {
      "id": "5b",
      "name": "Parse AI Output",
      "type": "n8n-nodes-base.code",
      "description": "Parses the Claude JSON response and extracts subject_line and digest_html. Provides fallback values if the parse fails so the workflow continues gracefully. Exposes a parse_success flag for monitoring.",
      "config": {
        "language": "javascript",
        "code": "// Claude returns the message content as content[0].text\nconst raw = $input.item.json.content?.[0]?.text ||\n            $input.item.json.choices?.[0]?.message?.content ||\n            '{}';\n\nlet parsed;\ntry {\n  // Strip any accidental markdown fences Claude may add\n  const clean = raw\n    .replace(/^```json\\s*/i, '')\n    .replace(/^```\\s*/, '')\n    .replace(/\\s*```$/, '')\n    .trim();\n  parsed = JSON.parse(clean);\n} catch {\n  parsed = {};\n}\n\n// Validate and extract fields with safe fallbacks\nconst subject_line = typeof parsed.subject_line === 'string' && parsed.subject_line.trim().length > 0\n  ? parsed.subject_line.trim().slice(0, 100)\n  : 'This Week: Your Newsletter Digest';\n\nconst digest_html = typeof parsed.digest_html === 'string' && parsed.digest_html.trim().length > 50\n  ? parsed.digest_html.trim()\n  : '<p><strong>Digest generation encountered an issue — the raw AI response has been saved to your Drafts sheet for manual recovery.</strong></p>';\n\n// Plain text version for the Drafts backup sheet\nconst digest_text = digest_html\n  .replace(/<[^>]+>/g, ' ')\n  .replace(/&amp;/g, '&')\n  .replace(/&lt;/g, '<')\n  .replace(/&gt;/g, '>')\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nconst parse_success = typeof parsed.subject_line === 'string' && typeof parsed.digest_html === 'string';\n\nreturn [{\n  json: {\n    subject_line,\n    digest_html,\n    digest_text,\n    parse_success,\n    generated_at: new Date().toISOString()\n  }\n}];",
        "note": "If parse_success is false in the Drafts sheet, check that the AI system prompt in node 5 has not been accidentally modified — the output contract depends on the exact format instruction."
      }
    },
    {
      "id": "5c",
      "name": "Google Sheets — Backup Draft",
      "type": "n8n-nodes-base.googleSheets",
      "description": "Writes the generated digest to a Drafts log in Google Sheets before the Mailchimp step. If Mailchimp fails, the draft is preserved here for manual recovery. Also provides a searchable history of all generated digests.",
      "config": {
        "operation": "append",
        "spreadsheet_id": "YOUR_GOOGLE_SHEET_ID",
        "sheet_name": "Drafts",
        "columns": {
          "Date": "{{ $json.generated_at }}",
          "Subject": "{{ $json.subject_line }}",
          "Digest Text": "{{ $json.digest_text }}",
          "Parse Success": "{{ $json.parse_success }}"
        },
        "note": "Create a Google Sheet with a tab named 'Drafts' and columns: Date, Subject, Digest Text, Parse Success. Replace YOUR_GOOGLE_SHEET_ID with the ID from the Sheet URL. This write happens before Mailchimp — if the campaign creation fails, you have a fallback copy here."
      }
    },
    {
      "id": "6",
      "name": "Mailchimp — Create Draft Campaign",
      "type": "n8n-nodes-base.mailchimp",
      "description": "Creates a draft email campaign in Mailchimp using the parsed subject_line and digest_html. Status is set to 'draft' — human review and approval before sending is required.",
      "config": {
        "operation": "createCampaign",
        "type": "regular",
        "subject": "{{ $json.subject_line }}",
        "body_html": "{{ $json.digest_html }}",
        "list_id": "YOUR_MAILCHIMP_LIST_ID",
        "status": "draft",
        "note": "Replace YOUR_MAILCHIMP_LIST_ID with your Audience list ID (Mailchimp > Audience > Settings > Audience Name and Defaults > Audience ID). The campaign is created as a draft — you must review and schedule it manually in Mailchimp before it is sent. For Beehiiv or other platforms, replace this node with an HTTP Request node using your platform's API."
      }
    }
  ],
  "connections": [
    { "from": "1", "to": "2",  "label": "weekly trigger" },
    { "from": "1", "to": "2b", "label": "weekly trigger" },
    { "from": "1", "to": "2c", "label": "weekly trigger" },
    { "from": "2",  "to": "2d", "label": "feed 1 articles" },
    { "from": "2b", "to": "2d", "label": "feed 2 articles" },
    { "from": "2c", "to": "2d", "label": "feed 3 articles" },
    { "from": "2d", "to": "3",  "label": "all articles combined" },
    { "from": "3",  "to": "3b", "label": "recent articles" },
    { "from": "3b", "to": "4",  "label": "deduplicated articles (or empty — stops here)" },
    { "from": "4",  "to": "5",  "label": "formatted article list" },
    { "from": "5",  "to": "5b", "label": "Claude response" },
    { "from": "5b", "to": "5c", "label": "parsed digest — back up before Mailchimp" },
    { "from": "5c", "to": "6",  "label": "draft saved — create Mailchimp campaign" }
  ],
  "setup_checklist": [
    "1. Add Anthropic API credentials in n8n > Settings > Credentials > New > Header Auth (key: x-api-key, value: your API key)",
    "2. Add Mailchimp API credentials in n8n > Settings > Credentials > New > Mailchimp API",
    "3. Add Google Sheets OAuth2 credentials in n8n > Settings > Credentials > New > Google Sheets OAuth2",
    "4. Create a Google Sheet with a tab named 'Drafts' and four column headers: Date, Subject, Digest Text, Parse Success",
    "5. Replace YOUR_GOOGLE_SHEET_ID in node 5c with the ID from your sheet URL",
    "6. Replace RSS_FEED_URL_1, RSS_FEED_URL_2 and RSS_FEED_URL_3 in nodes 2, 2b and 2c with your actual RSS feed URLs. Remove unused feed nodes.",
    "7. Configure the NEWSLETTER CONTEXT section in node 5 (Claude system prompt) — fill in your topic, audience description, tone and recurring section names",
    "8. Replace YOUR_MAILCHIMP_LIST_ID in node 6 with your Mailchimp Audience ID",
    "9. Adjust the cron expression in node 1 for your timezone and preferred publish day using the timezone_offsets table",
    "10. Run the workflow manually once. Verify: the Drafts sheet receives a row, a Mailchimp draft is created, and the subject_line and digest_html look correct",
    "11. Review the generated draft in Mailchimp for editorial quality. If content is off, refine the NEWSLETTER CONTEXT section in node 5.",
    "12. Activate the workflow and review the first scheduled run before relying on it"
  ],
  "production_notes": [
    "Empty feed guard (node 3b): if no articles pass the date filter or survive deduplication, node 3b returns an empty array and the workflow stops cleanly. No AI call is made, no blank Mailchimp draft is created. The execution simply ends — check n8n's execution history to confirm the run completed with 0 items.",
    "AI output contract (nodes 5 + 5b): the system prompt in node 5 explicitly requires a JSON response with subject_line and digest_html. Node 5b enforces this by parsing defensively and providing fallback values. Check parse_success = false rows in the Drafts sheet if digest quality is degraded.",
    "RSS feed architecture: the n8n RSS node accepts one URL per node. Nodes 2, 2b and 2c are three separate feed sources merged at node 2d. To add a fourth feed, duplicate any RSS node and connect it to node 2d.",
    "Deduplication (node 3b): articles are deduplicated by URL with query parameters stripped. The same article from different feeds or slightly different link variations will not appear twice in the digest.",
    "RSS field normalization (node 3b): the snippet fallback chain handles contentSnippet, description, summary, content:encoded and content — covering the most common RSS field naming variations across feed types.",
    "Draft backup (node 5c): the digest is written to Google Sheets before the Mailchimp step. If Mailchimp fails, open the Drafts sheet, copy the 'Digest Text' cell content, and create the campaign manually.",
    "Human review is required: the Mailchimp campaign is always created as a draft. Never configure the status as 'sent' — always review the content before scheduling."
  ]
}
