B
Briefed

Build an AI newsletter with n8n

8 min read · Last updated July 8, 2026

n8n is one of the best tools for a self-built AI newsletter because it gives you a real scheduler, real error handling, and full control over the LLM call - without writing a whole application. The workflow in this guide is five nodes: a Schedule Trigger, one or more RSS Read nodes, a Code node that merges and dedupes, an OpenAI node that writes the digest, and a Send Email node that delivers it over SMTP. Expect two to four hours to get it running end to end, most of which goes into prompt tuning and email formatting.

Step 1: Get n8n running (Cloud vs self-hosted)

You have two realistic options. n8n Cloud is the fastest path: sign up, and you have an editor with nothing to maintain. The trade-off is a monthly subscription (the starter tier is roughly 20-24 EUR/month depending on billing) and execution limits on the cheaper plans. Self-hosting is nearly free at this scale - a workflow that runs once a day is trivial load, so the smallest VPS you can rent (around $5/month at Hetzner, DigitalOcean, or similar) is plenty. Self-hosting means you own updates, backups, and TLS.

If you self-host, docker compose is the standard setup. Create a directory on your server, save this as docker-compose.yml, and run docker compose up -d.

docker-compose.yml
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: always
    ports:
      - "5678:5678"
    environment:
      - N8N_HOST=n8n.yourdomain.com
      - N8N_PORT=5678
      - N8N_PROTOCOL=https
      - WEBHOOK_URL=https://n8n.yourdomain.com/
      - GENERIC_TIMEZONE=Europe/Vienna
      - TZ=Europe/Vienna
      - N8N_ENCRYPTION_KEY=replace-with-a-long-random-string
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  n8n_data:

Set N8N_ENCRYPTION_KEY once and never change it - n8n uses it to encrypt stored credentials, and losing it means re-entering every credential. Put a reverse proxy (Caddy or nginx) with TLS in front of port 5678 before exposing it to the internet.

Step 2: Schedule Trigger

Create a new workflow and add a Schedule Trigger node. For a weekday-morning newsletter, use a cron expression instead of the interval mode so daylight-saving changes behave predictably.

  • Trigger Rules: add rule, Trigger Interval = Custom (Cron)
  • Cron Expression: 0 7 * * 1-5 (07:00, Monday to Friday)
  • Make sure GENERIC_TIMEZONE matches your timezone, otherwise cron fires in UTC

Step 3: Fetch articles (RSS Read or a news API)

Add one RSS Read node per feed. Each node has a single URL field - paste the feed URL and you are done. Connect the Schedule Trigger to every RSS Read node in parallel, then connect all RSS nodes into the Code node from the next step. n8n merges the incoming branches automatically, with each article arriving as one item.

  • RSS Read node, URL: https://hnrss.org/frontpage (example - Hacker News front page)
  • RSS Read node, URL: https://techcrunch.com/feed/
  • RSS Read node, URL: any blog or publisher feed you follow

If a source has no feed, use an HTTP Request node against a news API instead. For NewsAPI: Method GET, URL https://newsapi.org/v2/everything, query parameters q (your topic), from (yesterday's date), sortBy=publishedAt, and an apiKey parameter - or better, put the key in a Header Auth credential so it is not stored in plain text on the node. NewsAPI's free tier is for development only (no commercial use, delayed articles), so treat it as a prototyping option.

Step 4: Merge and dedupe in a Code node

Add a Code node, set Mode to Run Once for All Items, Language JavaScript, and paste this. It normalizes fields across feeds, drops duplicates by link, keeps only the last 24 hours, sorts newest first, and caps the list at 25 items so the LLM prompt stays a reasonable size.

Code node - merge, dedupe, filter
var seen = {};
var out = [];
var cutoff = Date.now() - 24 * 60 * 60 * 1000;

for (var i = 0; i < items.length; i++) {
  var a = items[i].json;
  var link = a.link || a.url || "";
  var title = a.title || "";
  if (!link || !title) continue;
  if (seen[link]) continue;
  seen[link] = true;

  var published = a.isoDate || a.pubDate || a.publishedAt || null;
  var ts = published ? new Date(published).getTime() : Date.now();
  if (isNaN(ts) || ts < cutoff) continue;

  var summary = a.contentSnippet || a.description || a.content || "";
  summary = summary.replace(/<[^>]*>/g, "").trim();
  if (summary.length > 400) summary = summary.slice(0, 400) + "...";

  out.push({
    json: {
      title: title.trim(),
      link: link,
      source: a.creator || a.author || (a.source && a.source.name) || "",
      published: new Date(ts).toISOString(),
      summary: summary,
    },
  });
}

out.sort(function (x, y) {
  return y.json.published.localeCompare(x.json.published);
});
out = out.slice(0, 25);

var digestInput = out
  .map(function (it, idx) {
    return (
      (idx + 1) + ". " + it.json.title +
      " (" + it.json.link + ")\n" + it.json.summary
    );
  })
  .join("\n\n");

return [{ json: { count: out.length, digestInput: digestInput } }];

This 24-hour window is stateless dedupe - it only prevents duplicates within one run. If your feeds re-publish old items, add an n8n static-data check (workflow staticData) or a small database to remember links across runs.

Step 5: Write the digest with the OpenAI node

Add the OpenAI node (operation: Message a Model). Create an OpenAI credential first: in n8n go to Credentials, New, OpenAI, and paste an API key from platform.openai.com. Configure the node like this.

  • Resource: Text, Operation: Message a Model
  • Model: gpt-4o-mini (cheap and fine for digests; upgrade to gpt-4o if summaries feel flat)
  • System message: paste the prompt below
  • User message (expression): ={{ $json.digestInput }}
  • Options: Temperature 0.4, Max Tokens 1500
System prompt
You are the editor of a short daily email newsletter. You receive a numbered list of articles, each with a title, URL, and snippet.

Write an HTML email digest:
- Start with a 2-sentence overview of the day's main theme.
- Group the articles into 2-4 topical sections, each with an <h3> heading.
- For each article: an <a> link on the title, then one sentence (max 25 words) saying what happened and why it matters. Use only facts present in the snippet - do not invent details, numbers, or outcomes.
- Skip articles that are duplicates of each other or pure marketing.
- End with a one-line "Worth a click" pick.

Output rules: valid HTML fragment only (no <html> or <body> tags), no markdown, no preamble, no code fences.

Step 6: Send Email node (SMTP)

Add a Send Email node. Create an SMTP credential with your provider's settings - a transactional service like Postmark, Brevo, or Amazon SES is more reliable than a personal Gmail account, and Gmail SMTP now requires an app password with 2FA anyway. Typical credential values: Host smtp.your-provider.com, Port 587, SSL/TLS off with STARTTLS on (or port 465 with TLS), plus your username and key.

  • From Email: newsletter@yourdomain.com (a domain you control - see the cost section on SPF/DKIM)
  • To Email: your address, or a comma-separated list for a handful of readers
  • Subject (expression): ={{ 'Daily digest - ' + $now.format('dd LLL yyyy') }}
  • Email Format: HTML
  • HTML (expression): ={{ $json.message.content }} - check the OpenAI node's output structure in the execution view; on some node versions the text lands under a different key, so map whichever field holds the digest

Step 7: Test with pinned data

Do not tune your prompt by hammering the RSS feeds and the OpenAI API on every run. Execute the workflow once, then open each RSS Read node's output and click the pin icon (Pin Data). n8n will now reuse that captured data on every manual execution, so you can edit the Code node and the prompt and re-run instantly and deterministically. You can also pin the OpenAI node's output while you fiddle with only the email formatting, which saves API spend. Pins apply only to manual executions - production (scheduled) runs always fetch live data.

Error handling and retries

A scheduled workflow fails silently unless you tell it not to. Two things fix that.

  1. Per-node retries: on the RSS Read, HTTP Request, and OpenAI nodes open Settings and enable Retry On Fail (3 tries, 5000 ms wait). Feed timeouts and 429s from OpenAI are the two most common transient failures.
  2. On the RSS nodes also set On Error to Continue (using error output) or Continue - one dead feed should not kill the whole issue. The Code node above already tolerates missing fields.
  3. Error workflow: create a second workflow whose trigger is the Error Trigger node, connected to a notification you will actually see (Telegram, Slack, or a plain Send Email to yourself with {{ $json.workflow.name }} and {{ $json.execution.url }} in the body). Then in your newsletter workflow open Settings and set this as the Error Workflow. Now any failed production run pings you instead of disappearing.

Also check Settings, Save Data on Error is enabled (it is by default) so you can open the failed execution and see exactly which node broke and with what input.

What this really costs

FactorRealistic answer
Setup time2-4 hours for the workflow itself; add 1-2 hours for VPS + docker + TLS if self-hosting, and another 1-2 hours for email domain authentication
Monthly costn8n Cloud starter tier (roughly 20-24 EUR/mo) or a ~$5/mo VPS; OpenAI usage for one daily gpt-4o-mini digest is typically well under $1/mo; transactional email free tiers cover a personal list
Email deliverabilitySending from your own domain requires SPF, DKIM, and DMARC DNS records or you land in spam; personal Gmail SMTP caps out around 500 recipients/day and looks increasingly untrustworthy to filters
Ongoing maintenanceFeeds die or change format, OpenAI models get deprecated, n8n updates occasionally break nodes, and prompt output drifts - budget 30-60 min/month of tinkering, more when something breaks the morning you rely on it

The honest summary: the money is negligible, the time is not. The build is a fun afternoon, but you are signing up to be the operator of a small production system. The failure mode that actually bites is silence - a feed 404s or your OpenAI key hits a quota, and the newsletter just stops arriving until you notice. The error workflow above mitigates this, but only if you set it up and keep the notification channel alive.

The second cost that sneaks up is quality drift. The prompt that produced sharp summaries in week one slowly gets stale as your interests shift and as sources change their snippet formats, and nothing in the system tells you - you just notice the digest getting less useful. Re-reading the output critically once a month and adjusting the prompt is part of the deal.

Don't want to maintain this?

Briefed does the same thing - a personalized, AI-written newsletter on your schedule - free, in about 30 seconds, with deliverability and refinement handled for you. See the full DIY vs Briefed comparison for honest numbers.

No credit card. No app to install. Unsubscribe with one click, anytime.

Questions

Should I use n8n Cloud or self-host for a newsletter workflow?

For one daily workflow, self-hosting on a ~$5/mo VPS is the cheapest option and the load is trivial. Choose n8n Cloud if you do not want to manage a server, TLS, and updates - the starter tier covers this use case comfortably.

Can I use the n8n AI Agent node instead of the plain OpenAI node?

You can, but you do not need to. A digest is a single deterministic LLM call - the plain OpenAI (Message a Model) node is simpler, cheaper to debug, and easier to pin during testing. Agent nodes earn their keep when the task needs tools or multi-step reasoning.

How do I stop the newsletter from repeating yesterday's articles?

The Code node in this guide filters to the last 24 hours, which handles most of it. For feeds that re-publish old items, persist seen links across runs using workflow static data (getWorkflowStaticData in a Code node) or a small table in Postgres or Google Sheets, and skip links you have already sent.

Why does my scheduled run fire at the wrong hour?

Cron expressions in n8n evaluate in the instance timezone. Self-hosted, set GENERIC_TIMEZONE (and TZ) in your docker compose environment; on n8n Cloud, set the timezone in workflow settings. Otherwise the default is UTC.

More guides