{
  "name": "Lumeor | AI newsletter draft (Notion inbox to Mailchimp)",
  "nodes": [
    {
      "parameters": {
        "content": "## AI newsletter generation\nTeammates save links to a Notion \"Newsletter inbox\" during the week. On issue day the workflow fetches each article, has Claude pick the best items per section and summarize each one from the fetched text, builds the email HTML, creates a **draft** Mailchimp campaign, sends a test to the editor and posts the link in Slack. A person reviews and sends. Used items are marked so they are not reused.\n\n**Notion database properties:** Name (title), URL (url), Note (text), Section (select), Status (select: New, Used).\n\n**Setup**\n1. Share the Notion database with your integration.\n2. Credentials: Notion API, Anthropic, Mailchimp API key, Slack.\n3. Replace REPLACE_WITH_NOTION_DATABASE_ID, REPLACE_WITH_MAILCHIMP_DC (the part after the dash in your API key, for example us21), REPLACE_WITH_AUDIENCE_ID, REPLACE_WITH_FROM_NAME, REPLACE_WITH_REPLY_TO_EMAIL, REPLACE_WITH_EDITOR_EMAIL, REPLACE_WITH_EDITOR_CHANNEL and REPLACE_WITH_ALERTS_CHANNEL.\n4. Edit the newsletter format (purpose, sections, items per section) in the Claude system prompt.\n5. Replace the simple HTML in \"Validate and build email\" with your own template markup if you have one.\n\nSummaries are written only from fetched article text. Links that cannot be fetched are listed in Slack for a manual summary.\n\nModel: claude-opus-5-5. For simple, high-volume classification steps, claude-haiku-5-5 is a cheaper option.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-newsletter-generation/",
        "height": 860,
        "width": 580,
        "color": 7
      },
      "id": "4988bc65-b4e0-4850-8103-129f88b39022",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -460
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * 4"
            }
          ]
        }
      },
      "id": "53bddaad-1f21-4a85-962f-add3e40efb49",
      "name": "Thursdays at 9am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.notion.com/v1/databases/REPLACE_WITH_NOTION_DATABASE_ID/query",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "notionApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Notion-Version",
              "value": "2022-06-28"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ filter: { property: 'Status', select: { equals: 'New' } }, sorts: [ { timestamp: 'created_time', direction: 'descending' } ], page_size: 100 }) }}",
        "options": {}
      },
      "id": "9615e5ee-7be6-4254-8fb7-7e4407b34df6",
      "name": "Get unused inbox items",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Flattens Notion pages into one item per saved link (newest 30).\nconst text = (p) => (p && (p.rich_text || p.title) ? (p.rich_text || p.title).map((t) => t.plain_text).join('') : '');\nconst pages = $input.first().json.results || [];\nreturn pages\n  .map((pg) => ({\n    page_id: pg.id,\n    url: pg.properties.URL ? pg.properties.URL.url : '',\n    title_hint: text(pg.properties.Name),\n    note: text(pg.properties.Note),\n    section_hint: pg.properties.Section && pg.properties.Section.select ? pg.properties.Section.select.name : '',\n  }))\n  .filter((c) => /^https?:\\/\\//.test(c.url || ''))\n  .slice(0, 30)\n  .map((c) => ({ json: c }));"
      },
      "id": "9bd1102f-8fb6-4b80-81de-693d922fd663",
      "name": "Prepare candidates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.url }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "text"
            }
          },
          "timeout": 15000
        }
      },
      "id": "1c0e5486-bb07-417c-8ade-327dc953fa9d",
      "name": "Fetch article",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Strips each page to readable text and records title, publisher and date.\n// Pages that fail to load or have little text are listed for a manual summary.\nconst decode = (s) => s.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&quot;/g, '\"').replace(/&#39;|&apos;/g, \"'\").replace(/&lt;/g, '<').replace(/&gt;/g, '>');\nconst meta = (html, key) => {\n  const m = html.match(new RegExp(`<meta[^>]+(?:property|name)=[\"']${key}[\"'][^>]+content=[\"']([^\"']+)`, 'i'));\n  return m ? decode(m[1]) : '';\n};\nconst candidates = [];\nconst failed = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json;\n  const c = $('Prepare candidates').itemMatching(i).json;\n  const html = typeof res.data === 'string' ? res.data : '';\n  const ok = !res.error && res.statusCode >= 200 && res.statusCode < 300;\n  const body = decode(\n    html\n      .replace(/<(script|style|nav|header|footer|aside|noscript)[\\s\\S]*?<\\/\\1>/gi, ' ')\n      .replace(/<[^>]+>/g, ' '),\n  ).replace(/\\s+/g, ' ').trim();\n  if (!ok || body.length < 400) {\n    failed.push({ page_id: c.page_id, url: c.url, reason: res.error ? 'fetch error' : `status ${res.statusCode || 'n/a'}, ${body.length} chars` });\n    continue;\n  }\n  const titleTag = (html.match(/<title[^>]*>([\\s\\S]*?)<\\/title>/i) || [])[1] || '';\n  candidates.push({\n    candidate_id: candidates.length + 1,\n    page_id: c.page_id,\n    url: c.url,\n    title: meta(html, 'og:title') || decode(titleTag).trim() || c.title_hint,\n    publisher: meta(html, 'og:site_name') || new URL(c.url).hostname.replace(/^www\\./, ''),\n    published: meta(html, 'article:published_time').slice(0, 10),\n    saver_note: c.note,\n    section_hint: c.section_hint,\n    text: body.slice(0, 6000),\n  });\n}\nreturn [{ json: { candidates, failed } }];"
      },
      "id": "b62832e5-965f-46ea-96dd-a54a63ffcb23",
      "name": "Extract article text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'claude-opus-5-5', max_tokens: 4000, system: \"You assemble a weekly email newsletter from candidate articles. Each candidate includes text fetched from the page.\\nNEWSLETTER FORMAT (replace with your own):\\n- Purpose: practical ideas on operations and automation for owners of small and mid-sized companies.\\n- Sections, in order: \\\"From our blog\\\" (our own posts), \\\"Worth reading\\\" (external articles), \\\"Product updates\\\". Skip a section if nothing fits.\\n- Up to 3 items per section, 6 items total.\\nRules:\\n- Choose items that fit the purpose; prefer specific and practical over general news. Use the saver note and section hint as signals.\\n- Write each summary (2 to 4 sentences) only from that candidate's text. Do not add facts, numbers or quotes that are not in it.\\n- why_it_matters is one sentence for our readers.\\n- Refer to items only by candidate_id. List every candidate you did not use in skipped with a short reason.\\n- Offer 3 subject lines under 60 characters, a preview text under 90 characters, and a 2 to 3 sentence intro that names the issue's themes.\", output_config: { \"format\": { \"type\": \"json_schema\", \"schema\": { \"type\": \"object\", \"properties\": { \"subject_options\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }, \"preview_text\": { \"type\": \"string\" }, \"intro\": { \"type\": \"string\" }, \"sections\": { \"type\": \"array\", \"items\": { \"type\": \"object\", \"properties\": { \"name\": { \"type\": \"string\" }, \"items\": { \"type\": \"array\", \"items\": { \"type\": \"object\", \"properties\": { \"candidate_id\": { \"type\": \"integer\" }, \"headline\": { \"type\": \"string\" }, \"summary\": { \"type\": \"string\" }, \"why_it_matters\": { \"type\": \"string\" } }, \"required\": [ \"candidate_id\", \"headline\", \"summary\", \"why_it_matters\" ], \"additionalProperties\": false } } }, \"required\": [ \"name\", \"items\" ], \"additionalProperties\": false } }, \"skipped\": { \"type\": \"array\", \"items\": { \"type\": \"object\", \"properties\": { \"candidate_id\": { \"type\": \"integer\" }, \"reason\": { \"type\": \"string\" } }, \"required\": [ \"candidate_id\", \"reason\" ], \"additionalProperties\": false } } }, \"required\": [ \"subject_options\", \"preview_text\", \"intro\", \"sections\", \"skipped\" ], \"additionalProperties\": false } } }, messages: [ { role: 'user', content: 'Candidates (JSON):\\n' + JSON.stringify($json.candidates.map(c => ({ candidate_id: c.candidate_id, title: c.title, publisher: c.publisher, published: c.published, saver_note: c.saver_note, section_hint: c.section_hint, text: c.text }))) } ] }) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "a8525ce6-93b3-4699-9438-ed3248040dc7",
      "name": "Pick and summarize with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Maps Claude's picks back to real URLs by candidate_id, checks them, and renders simple HTML.\nconst { candidates, failed } = $('Extract article text').first().json;\nconst res = $input.first().json;\nconst fail = (reason) => [{ json: { error: reason, details: { candidates: candidates.length, failed: failed.length } } }];\nif (!candidates.length) return fail('No inbox items could be fetched this week');\nif (res.stop_reason === 'refusal' || res.stop_reason === 'max_tokens') return fail(`Claude stopped with stop_reason=${res.stop_reason}`);\n// Opus 5.5 may return a thinking block first, so find the text block explicitly.\nconst block = (res.content || []).find((b) => b.type === 'text');\nif (!block) return fail('Claude response had no text block');\nlet d;\ntry { d = JSON.parse(block.text); } catch (e) { return fail('Claude returned invalid JSON'); }\n\nconst byId = Object.fromEntries(candidates.map((c) => [c.candidate_id, c]));\nconst problems = [];\nconst used = [];\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst sectionsHtml = (d.sections || [])\n  .filter((s) => (s.items || []).length)\n  .map((s) => {\n    const itemsHtml = s.items\n      .map((it) => {\n        const c = byId[it.candidate_id];\n        if (!c) { problems.push(`unknown candidate_id ${it.candidate_id}`); return ''; }\n        if (used.includes(c.page_id)) { problems.push(`candidate ${it.candidate_id} used twice`); return ''; }\n        if (!String(it.summary || '').trim()) problems.push(`empty summary for ${c.url}`);\n        used.push(c.page_id);\n        return `<h3 style=\"margin:16px 0 4px\"><a href=\"${esc(c.url)}\">${esc(it.headline)}</a></h3>` +\n          `<p style=\"margin:0 0 4px;color:#666;font-size:13px\">${esc(c.publisher)}${c.published ? ', ' + esc(c.published) : ''}</p>` +\n          `<p style=\"margin:0 0 4px\">${esc(it.summary)}</p><p style=\"margin:0 0 12px\"><em>Why it matters:</em> ${esc(it.why_it_matters)}</p>`;\n      })\n      .join('');\n    return `<h2 style=\"margin:24px 0 8px\">${esc(s.name)}</h2>${itemsHtml}`;\n  })\n  .join('');\nif (!used.length) problems.push('no items selected');\nif (!d.subject_options || !d.subject_options.length) problems.push('no subject lines');\nif (problems.length) return fail(`Newsletter draft failed checks: ${problems.join('; ')}`);\n\nconst html = `<div style=\"font-family:Arial,sans-serif;max-width:600px;margin:0 auto;line-height:1.5\">` +\n  `<p>${esc(d.intro)}</p>${sectionsHtml}` +\n  `<p style=\"margin-top:32px;font-size:12px;color:#888\">You are receiving this because you subscribed. *|UNSUB|* *|LIST:ADDRESSLINE|*</p></div>`;\nreturn [{\n  json: {\n    subject: d.subject_options[0].slice(0, 150),\n    subject_options: d.subject_options,\n    preview_text: String(d.preview_text || '').slice(0, 150),\n    html,\n    used_page_ids: used,\n    skipped: (d.skipped || []).map((s) => `${(byId[s.candidate_id] || {}).url || s.candidate_id}: ${s.reason}`),\n    failed: failed.map((f) => `${f.url} (${f.reason})`),\n  },\n}];"
      },
      "id": "ff233130-3178-4366-9635-120fe513b431",
      "name": "Validate and build email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        300
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_MAILCHIMP_DC.api.mailchimp.com/3.0/campaigns",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "mailchimpApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ type: 'regular', recipients: { list_id: 'REPLACE_WITH_AUDIENCE_ID' }, settings: { subject_line: $json.subject, preview_text: $json.preview_text, title: 'Newsletter draft ' + $now.toFormat('yyyy-MM-dd'), from_name: 'REPLACE_WITH_FROM_NAME', reply_to: 'REPLACE_WITH_REPLY_TO_EMAIL' } }) }}",
        "options": {}
      },
      "id": "62ea0cd1-db4d-43fa-87fe-5f02f0800de0",
      "name": "Create draft campaign",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1540,
        280
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "PUT",
        "url": "=https://REPLACE_WITH_MAILCHIMP_DC.api.mailchimp.com/3.0/campaigns/{{ $json.id }}/content",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "mailchimpApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ html: $('Validate and build email').first().json.html }) }}",
        "options": {}
      },
      "id": "6221a91c-d96f-471a-b9b9-f32d2d7bf60e",
      "name": "Set campaign content",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://REPLACE_WITH_MAILCHIMP_DC.api.mailchimp.com/3.0/campaigns/{{ $('Create draft campaign').first().json.id }}/actions/test",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "mailchimpApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ test_emails: ['REPLACE_WITH_EDITOR_EMAIL'], send_type: 'html' }) }}",
        "options": {}
      },
      "id": "2c39040a-ea5c-47fe-acb0-5b46498251c6",
      "name": "Send test to editor",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1980,
        280
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_EDITOR_CHANNEL",
          "mode": "name"
        },
        "text": "=:newspaper: *Newsletter draft is ready for review* ({{ $('Validate and build email').first().json.used_page_ids.length }} items). A test email is on its way.\nEdit and send: https://REPLACE_WITH_MAILCHIMP_DC.admin.mailchimp.com/campaigns/edit?id={{ $('Create draft campaign').first().json.web_id }}\n*Subject options:* {{ $('Validate and build email').first().json.subject_options.join(' | ') }}\n{{ $('Validate and build email').first().json.failed.length ? '*Could not fetch (summarize by hand):*\\n' + $('Validate and build email').first().json.failed.join('\\n') : '' }}\n{{ $('Validate and build email').first().json.skipped.length ? '_Skipped: ' + $('Validate and build email').first().json.skipped.join('; ') + '_' : '' }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "ac2f0e4b-e10f-43d4-9299-213b1fe7f8e6",
      "name": "Tell editor the draft is ready",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        2200,
        280
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// One item per Notion page that made it into this issue.\nreturn $('Validate and build email').first().json.used_page_ids.map((id) => ({ json: { page_id: id } }));"
      },
      "id": "daa0f862-55f7-476a-8efd-7317edac1c63",
      "name": "List used items",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2420,
        280
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "=https://api.notion.com/v1/pages/{{ $json.page_id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "notionApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Notion-Version",
              "value": "2022-06-28"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ properties: { Status: { select: { name: 'Used' } } } }) }}",
        "options": {}
      },
      "id": "b3a1a263-8068-437b-9c05-6b63415571a3",
      "name": "Mark items used in Notion",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2640,
        280
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ALERTS_CHANNEL",
          "mode": "name"
        },
        "text": "=:warning: *Newsletter draft needs attention*\n*Reason:* {{ typeof $json.error === 'string' ? $json.error : (($json.error && $json.error.message) || 'Unknown error') }}\n*Details:* {{ JSON.stringify($json.details || $json.error || {}).slice(0, 600) }}\n*Execution:* {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "2c4c88c2-4072-42cc-99ef-720ad379876e",
      "name": "Alert: newsletter needs attention",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1540,
        520
      ]
    }
  ],
  "connections": {
    "Thursdays at 9am": {
      "main": [
        [
          {
            "node": "Get unused inbox items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get unused inbox items": {
      "main": [
        [
          {
            "node": "Prepare candidates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare candidates": {
      "main": [
        [
          {
            "node": "Fetch article",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch article": {
      "main": [
        [
          {
            "node": "Extract article text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract article text": {
      "main": [
        [
          {
            "node": "Pick and summarize with Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick and summarize with Claude": {
      "main": [
        [
          {
            "node": "Validate and build email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: newsletter needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate and build email": {
      "main": [
        [
          {
            "node": "Create draft campaign",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: newsletter needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create draft campaign": {
      "main": [
        [
          {
            "node": "Set campaign content",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Alert: newsletter needs attention",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set campaign content": {
      "main": [
        [
          {
            "node": "Send test to editor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send test to editor": {
      "main": [
        [
          {
            "node": "Tell editor the draft is ready",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tell editor the draft is ready": {
      "main": [
        [
          {
            "node": "List used items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List used items": {
      "main": [
        [
          {
            "node": "Mark items used in Notion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
