B
Briefed

Build a Python news digest: feedparser + LLM + SMTP

5 min read · Last updated July 8, 2026

This is the modular version of a DIY news digest. Instead of one long script, you get five short files with clean seams: swap the LLM provider with one environment variable, test the fetcher without spending API tokens, replace SMTP with an API-based sender later without touching anything else. If you want the single-file version first, read the ChatGPT API tutorial; this one is for people who know they will still be running this in a year.

The stack: feedparser for fetching, either the OpenAI or Anthropic SDK for summarization, smtplib for delivery, and a systemd timer on a cheap VPS for scheduling. Total dependency count: three packages.

Project layout

Setup
mkdir -p ~/digest && cd ~/digest
python -m venv .venv
source .venv/bin/activate
pip install feedparser openai anthropic

# You will create these files:
# config.py     - feeds, limits, provider selection
# fetch.py      - RSS fetching + dedupe state
# summarize.py  - pluggable LLM call
# send.py       - SMTP delivery
# main.py       - glue

config.py - one place for every knob

Everything you might want to change lives here. Secrets stay in environment variables; config.py only reads them. The one required decision is LLM_PROVIDER, which defaults to openai.

config.py
"""Central configuration. Secrets come from environment variables."""

import os
from pathlib import Path

FEEDS = [
    "https://hnrss.org/frontpage",
    "https://feeds.arstechnica.com/arstechnica/index",
    "https://www.theverge.com/rss/index.xml",
]

MAX_ARTICLES = 12
SNIPPET_CHARS = 400

# "openai" or "anthropic"
LLM_PROVIDER = os.environ.get("LLM_PROVIDER", "openai")
OPENAI_MODEL = "gpt-4o-mini"
ANTHROPIC_MODEL = "claude-haiku-4-5"

STATE_FILE = Path(__file__).with_name("state.json")

SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASS = os.environ.get("SMTP_PASS", "")
MAIL_FROM = os.environ.get("MAIL_FROM", SMTP_USER)
MAIL_TO = os.environ.get("MAIL_TO", SMTP_USER)

fetch.py - feeds in, unseen articles out

The fetcher returns plain dictionaries and knows nothing about LLMs or email. Dedupe state is a JSON file holding the set of URLs already sent, capped so it cannot grow forever. URLs are normalized by stripping query strings, because several major feeds rotate tracking parameters and would otherwise slip past a naive comparison.

fetch.py
"""Fetch RSS feeds and return articles not yet sent."""

import json
import sys
from urllib.parse import urlsplit, urlunsplit

import feedparser

import config


def _normalize(url: str) -> str:
    parts = urlsplit(url)
    return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))


def load_state() -> set:
    if config.STATE_FILE.exists():
        data = json.loads(config.STATE_FILE.read_text(encoding="utf-8"))
        return set(data.get("seen", []))
    return set()


def save_state(seen: set) -> None:
    payload = {"seen": sorted(seen)[-5000:]}
    config.STATE_FILE.write_text(json.dumps(payload), encoding="utf-8")


def fetch_new(seen: set) -> list:
    articles = []
    for feed_url in config.FEEDS:
        parsed = feedparser.parse(feed_url)
        if parsed.bozo and not parsed.entries:
            print(f"warning: unparseable feed {feed_url}", file=sys.stderr)
            continue
        for entry in parsed.entries:
            raw_url = entry.get("link", "")
            if not raw_url:
                continue
            url = _normalize(raw_url)
            if url in seen:
                continue
            snippet = " ".join(entry.get("summary", "").split())
            articles.append(
                {
                    "title": entry.get("title", "(no title)"),
                    "source": parsed.feed.get("title", feed_url),
                    "url": url,
                    "snippet": snippet[: config.SNIPPET_CHARS],
                }
            )
    return articles[: config.MAX_ARTICLES]

summarize.py - pluggable OpenAI or Anthropic

One public function, summarize(articles), and two private provider functions behind it. The SDK imports happen inside the provider functions, so you can deploy with only one of the two packages installed if you prefer. Note the Anthropic API requires max_tokens explicitly; the OpenAI chat API does not.

summarize.py
"""Turn a list of articles into a digest via the configured LLM."""

import config

PROMPT = """You write a personal daily news digest.
Articles are listed below (title, source, URL, snippet).

Rules:
- Open with one sentence summarizing the day.
- Then one section per story worth reading, at most 8 sections:
  bold headline, 2-3 factual sentences, then the URL on its own line.
- Drop duplicates and thin content. No hype, no filler.
- Output HTML fragments only (h3, p, a). No html or body tags.

Articles:
{articles}
"""


def _listing(articles: list) -> str:
    return "\n\n".join(
        f"Title: {a['title']}\nSource: {a['source']}\n"
        f"URL: {a['url']}\nSnippet: {a['snippet']}"
        for a in articles
    )


def _openai_digest(prompt: str) -> str:
    from openai import OpenAI

    client = OpenAI()
    response = client.chat.completions.create(
        model=config.OPENAI_MODEL,
        messages=[{"role": "user", "content": prompt}],
        temperature=0.4,
    )
    return response.choices[0].message.content


def _anthropic_digest(prompt: str) -> str:
    import anthropic

    client = anthropic.Anthropic()
    response = client.messages.create(
        model=config.ANTHROPIC_MODEL,
        max_tokens=2000,
        messages=[{"role": "user", "content": prompt}],
    )
    return response.content[0].text


def summarize(articles: list) -> str:
    prompt = PROMPT.format(articles=_listing(articles))
    if config.LLM_PROVIDER == "anthropic":
        return _anthropic_digest(prompt)
    return _openai_digest(prompt)

Switching providers is now a deployment decision, not a code change: set LLM_PROVIDER=anthropic and ANTHROPIC_API_KEY, restart nothing, done. claude-haiku-4-5 and gpt-4o-mini sit in the same price-performance band for this job; run each for a week and keep whichever digest you prefer reading.

send.py and main.py

send.py
"""Deliver the digest over SMTP."""

import smtplib
from datetime import date
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

import config


def send(digest_html: str) -> None:
    today = date.today().strftime("%A, %d %B %Y")
    msg = MIMEMultipart("alternative")
    msg["Subject"] = f"News digest - {today}"
    msg["From"] = config.MAIL_FROM
    msg["To"] = config.MAIL_TO

    body = (
        "<div style='font-family:Georgia,serif;max-width:600px;"
        "margin:0 auto;line-height:1.5'>"
        f"<h2>Daily digest</h2><p>{today}</p>{digest_html}</div>"
    )
    msg.attach(MIMEText("Open in an HTML mail client.", "plain"))
    msg.attach(MIMEText(body, "html"))

    with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=30) as s:
        s.starttls()
        s.login(config.SMTP_USER, config.SMTP_PASS)
        s.send_message(msg)
main.py
"""Glue: fetch -> summarize -> send -> persist state."""

import fetch
import send
import summarize


def main() -> None:
    seen = fetch.load_state()
    articles = fetch.fetch_new(seen)
    if not articles:
        print("Nothing new, not sending.")
        return

    digest = summarize.summarize(articles)
    send.send(digest)

    for a in articles:
        seen.add(a["url"])
    fetch.save_state(seen)
    print(f"Sent {len(articles)} articles.")


if __name__ == "__main__":
    main()

The state file is written only after a successful send, so a crash mid-run means the same articles are retried next time rather than lost. That ordering is the single most important line of main.py.

Deploy on a 5 USD VPS with a systemd timer

Any bottom-tier VPS (Hetzner, DigitalOcean, whatever) runs this comfortably - it needs Python and about 30 seconds of CPU a day. systemd timers beat cron here for three concrete reasons: journalctl gives you searchable logs for free, Persistent=true fires a missed run after a reboot, and EnvironmentFile handles secrets cleanly.

/etc/systemd/system/digest.service
[Unit]
Description=Daily news digest
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=digest
WorkingDirectory=/home/digest/digest
EnvironmentFile=/home/digest/digest/.env
ExecStart=/home/digest/digest/.venv/bin/python main.py
/etc/systemd/system/digest.timer
[Unit]
Description=Run the news digest every morning

[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
RandomizedDelaySec=120

[Install]
WantedBy=timers.target
Enable and test
sudo systemctl daemon-reload
sudo systemctl enable --now digest.timer

# Run once immediately to test:
sudo systemctl start digest.service

# Check the logs:
journalctl -u digest.service -n 50

The EnvironmentFile format is KEY=value lines without the export keyword. If you reuse a shell-style .env with export statements, systemd will fail to parse it - this is the most common deployment stumble.

No VPS? The same code runs on GitHub Actions on a schedule - set the secrets in the repo, pip install in the workflow, run main.py, and commit state.json back (or cache it) since runners are wiped between jobs. Actions schedules can fire 5-15 minutes late, which does not matter for a newsletter.

What this really costs

ItemReality
Setup time4-8 hours including VPS provisioning, systemd unit debugging and the inevitable SMTP authentication detour.
Monthly running costVPS 4-6 USD. LLM around 0.05-1.50 USD per month at one daily issue on gpt-4o-mini or claude-haiku-4-5. GitHub Actions path: 0 USD infrastructure.
Email deliverabilityFine when mailing yourself via Gmail SMTP. Your own domain needs SPF, DKIM and DMARC configured, and a fresh domain still starts with poor sender reputation.
Ongoing maintenance1-3 hours per month: dead feeds, OS security updates on the VPS, SDK breaking changes (both OpenAI and Anthropic ship them), occasional prompt re-tuning.

The modular layout front-loads a little extra work and pays it back the first time anything changes: a provider price hike, a feed you want to preprocess differently, a decision to move from SMTP to an email API. Each of those becomes a one-file change instead of surgery on a monolith.

Be honest with yourself about the maintenance line. The code will not rot fast, but the world around it does: feeds move, models get renamed, TLS requirements tighten. If an hour a month of tinkering sounds like fun, this is a great little system to own. If it sounds like a chore, that feeling is data too.

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

OpenAI or Anthropic for the summarization step?

For this workload they are interchangeable in cost and both good enough. gpt-4o-mini and claude-haiku-4-5 each produce solid digests; the differences are stylistic. The pluggable design exists precisely so you can trial both for a week and keep the one you prefer reading.

Why a systemd timer instead of cron?

Three practical wins: journalctl gives you real logs without redirect gymnastics, Persistent=true runs a missed job after a reboot, and EnvironmentFile is a cleaner secrets mechanism than sourcing shell files inside a crontab line. Cron works fine too - the timer is nicer, not necessary.

How do I add a new feed or remove a noisy one?

Edit the FEEDS list in config.py and nothing else. The state file keys on article URLs, not feeds, so adding or removing feeds never causes duplicates or lost history.

Can this send to multiple subscribers?

Technically you can loop over recipients in send.py, but you then own unsubscribe handling, bounce processing and sender reputation - each a bigger project than this whole digest. For anything beyond a handful of consenting friends, use proper newsletter infrastructure.

More guides