B
Briefed

How to build an AI newsletter with the ChatGPT API

6 min read · Last updated July 8, 2026

This guide walks through a complete, working pipeline: pull articles from RSS feeds, deduplicate them against a local file so you never summarize the same story twice, have the ChatGPT API (gpt-4o-mini) write a short digest, and send it to your inbox as an HTML email. It is one Python file, one cron line, and about an hour of setup if everything goes smoothly - realistically longer, and the cost table at the end is honest about that.

You need Python 3.10 or newer, an OpenAI API key with a few dollars of credit, and an SMTP account you can send from. For a personal project the easiest SMTP option is a Gmail app password: enable 2-step verification on your Google account, then create an app password under Security settings. Do not use your normal Gmail password - Google blocks it for SMTP.

Step 1: Install dependencies

Install
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install feedparser openai

feedparser handles every flavor of RSS and Atom you will realistically meet, including malformed feeds. The openai package is the official v1 SDK - the code below uses the current client style (OpenAI() plus chat.completions.create), not the deprecated openai.ChatCompletion calls you still find in older tutorials.

Step 2: Configuration via environment variables

The script reads all secrets from environment variables. For local testing, put them in a file called .env and source it before running (or use direnv). Never hardcode the API key: the moment you push the script to GitHub, scanners will find it and OpenAI will revoke it.

.env
export OPENAI_API_KEY="sk-..."
export SMTP_HOST="smtp.gmail.com"
export SMTP_PORT="587"
export SMTP_USER="you@gmail.com"
export SMTP_PASS="your-16-char-app-password"
export NEWSLETTER_TO="you@gmail.com"
export NEWSLETTER_FROM="you@gmail.com"

Gmail app passwords are 16 characters with no spaces. If SMTP login fails with 535, the password is wrong or 2-step verification is not enabled - those two causes cover almost every failure.

Step 3: The full script

Save this as newsletter.py. It does five things in order: load the set of URLs it has already sent, fetch every feed, keep only unseen articles, ask gpt-4o-mini to write the digest, and email the result. If there is nothing new, it exits without sending - an empty newsletter trains you to ignore the real ones.

newsletter.py
#!/usr/bin/env python3
"""Daily AI newsletter: RSS feeds -> gpt-4o-mini digest -> email."""

import os
import smtplib
import sys
from datetime import date
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path

import feedparser
from openai import OpenAI

# ---------------------------------------------------------------- settings

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

MAX_ARTICLES = 12          # cap what we send to the model
SEEN_FILE = Path(__file__).with_name("seen_urls.txt")

PROMPT_TEMPLATE = """You are writing today's issue of a personal news digest.
Below is a list of articles (title, source, URL, summary snippet).

Write a digest with:
- A one-sentence overview of the day at the top.
- One short section per story worth reading (max 8 sections).
  Each section: a bold headline, then 2-3 sentences of summary,
  then the URL on its own line.
- Skip duplicates and low-value items. Plain factual tone, no hype.

Output clean HTML fragments only (h3, p, a tags). No <html> or <body> tags.

Articles:
{articles}
"""

# ---------------------------------------------------------------- dedupe


def load_seen() -> set:
    if SEEN_FILE.exists():
        return set(SEEN_FILE.read_text(encoding="utf-8").splitlines())
    return set()


def save_seen(seen: set) -> None:
    # Keep the file bounded so it does not grow forever.
    lines = sorted(seen)[-5000:]
    SEEN_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")


# ---------------------------------------------------------------- fetch


def fetch_new_articles(seen: set) -> list:
    articles = []
    for feed_url in FEEDS:
        parsed = feedparser.parse(feed_url)
        if parsed.bozo and not parsed.entries:
            print(f"warning: could not parse {feed_url}", file=sys.stderr)
            continue
        for entry in parsed.entries:
            url = entry.get("link", "")
            if not url or url in seen:
                continue
            summary = entry.get("summary", "")
            # Strip HTML crudely and truncate to keep the prompt small.
            plain = " ".join(summary.split())[:400]
            articles.append(
                {
                    "title": entry.get("title", "(no title)"),
                    "source": parsed.feed.get("title", feed_url),
                    "url": url,
                    "snippet": plain,
                }
            )
    return articles[:MAX_ARTICLES]


# ---------------------------------------------------------------- summarize


def write_digest(articles: list) -> str:
    client = OpenAI()  # reads OPENAI_API_KEY from the environment
    listing = "\n\n".join(
        f"Title: {a['title']}\nSource: {a['source']}\n"
        f"URL: {a['url']}\nSnippet: {a['snippet']}"
        for a in articles
    )
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "user", "content": PROMPT_TEMPLATE.format(articles=listing)}
        ],
        temperature=0.4,
    )
    return response.choices[0].message.content


# ---------------------------------------------------------------- email


def build_email(digest_html: str) -> MIMEMultipart:
    today = date.today().strftime("%A, %d %B %Y")
    msg = MIMEMultipart("alternative")
    msg["Subject"] = f"Your digest - {today}"
    msg["From"] = os.environ["NEWSLETTER_FROM"]
    msg["To"] = os.environ["NEWSLETTER_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>"
        f"{digest_html}"
        "<hr><p style='color:#888;font-size:12px'>"
        "Sent by my newsletter script.</p></div>"
    )
    msg.attach(MIMEText("Your digest - open in an HTML client.", "plain"))
    msg.attach(MIMEText(body, "html"))
    return msg


def send_email(msg: MIMEMultipart) -> None:
    host = os.environ["SMTP_HOST"]
    port = int(os.environ.get("SMTP_PORT", "587"))
    with smtplib.SMTP(host, port, timeout=30) as server:
        server.starttls()
        server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
        server.send_message(msg)


# ---------------------------------------------------------------- main


def main() -> None:
    seen = load_seen()
    articles = fetch_new_articles(seen)
    if not articles:
        print("No new articles today, skipping send.")
        return

    digest = write_digest(articles)
    send_email(build_email(digest))

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


if __name__ == "__main__":
    main()

A few deliberate choices worth understanding. The seen-URLs file lives next to the script and is capped at 5,000 lines, so it never grows unbounded. MAX_ARTICLES caps the prompt size, which caps your API cost - 12 articles with 400-character snippets is roughly 2,000-3,000 input tokens. The prompt asks for HTML fragments rather than a full document because most email clients handle inline fragments inside a styled wrapper better than model-generated full pages. And the script exits cleanly when there is nothing new instead of sending a hollow issue.

Test it manually first. Source your .env, run python newsletter.py, and confirm the email arrives and renders. Then delete a few lines from seen_urls.txt and run it again to confirm dedupe works both ways.

Step 4: Schedule it with cron

On any Linux box or Mac, crontab is the shortest path. Cron does not load your shell profile, so the crontab line has to source the env file itself and use absolute paths.

crontab -e
# Every day at 07:00 local time
0 7 * * * . /home/you/newsletter/.env && /home/you/newsletter/.venv/bin/python /home/you/newsletter/newsletter.py >> /home/you/newsletter/cron.log 2>&1

The redirect to cron.log matters more than it looks: cron failures are silent by default, and the first time your feed provider changes something you will want the traceback.

Alternative: GitHub Actions instead of cron

If you do not have an always-on machine, GitHub Actions runs this for free on a schedule. Two caveats. First, scheduled workflows can start 5-15 minutes late during busy periods, occasionally more - fine for a newsletter. Second, the runner is wiped after every job, so the seen-URLs file needs persistence; the workflow below commits it back to the repo, which is crude but reliable for a private repo.

.github/workflows/newsletter.yml
name: daily-newsletter
on:
  schedule:
    - cron: "0 6 * * *"   # 06:00 UTC daily
  workflow_dispatch: {}

permissions:
  contents: write

jobs:
  send:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install feedparser openai
      - name: Run newsletter
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          SMTP_HOST: ${{ secrets.SMTP_HOST }}
          SMTP_PORT: ${{ secrets.SMTP_PORT }}
          SMTP_USER: ${{ secrets.SMTP_USER }}
          SMTP_PASS: ${{ secrets.SMTP_PASS }}
          NEWSLETTER_TO: ${{ secrets.NEWSLETTER_TO }}
          NEWSLETTER_FROM: ${{ secrets.NEWSLETTER_FROM }}
        run: python newsletter.py
      - name: Persist seen URLs
        run: |
          git config user.name "newsletter-bot"
          git config user.email "actions@users.noreply.github.com"
          git add seen_urls.txt
          git diff --cached --quiet || git commit -m "update seen urls"
          git push

Add each secret under the repository Settings, Secrets and variables, Actions. The workflow_dispatch trigger lets you run it by hand from the Actions tab, which is how you should test it the first few times.

Tuning the prompt

The default prompt produces a competent but generic digest. Three edits improve it fast. Tell the model who you are and what you care about (one sentence of persona beats a paragraph of rules). Give it permission to say a story is minor - otherwise every item gets inflated to sound important. And pin the section count: models drift toward padding, so max 8 sections with skip low-value items is doing real work in that prompt.

Expect the output quality to wobble across days. Some days the model over-summarizes, some days it quotes half the snippet back at you. That is normal at this model tier; gpt-4o-mini is the right cost point for a personal daily job, and you can swap the model string for a stronger model on days you care.

What this really costs

ItemReality
Setup time3-6 hours. The Python takes an hour; SMTP debugging, spam-folder testing and cron quirks take the rest.
API cost per monthRoughly 0.05-1.50 USD at one daily issue with gpt-4o-mini. Pennies unless you scale up article count or model tier.
Email deliverabilityGmail-to-yourself is usually fine. Sending from your own domain needs SPF, DKIM and DMARC records or you land in spam. Never reliable for sending to other people at volume.
Ongoing maintenance1-3 hours per month. Feeds die or change URLs, the OpenAI SDK gets breaking releases roughly yearly, prompts drift and need re-tuning.

The build is genuinely cheap; the ownership is not free. The recurring tax is small but real: a feed silently 404s and your digest gets thinner without telling you, a model deprecation email arrives, your app password gets rotated by a security policy. Budget an hour a month of attention or the pipeline decays quietly.

If you want the newsletter delivered to more than one person, this architecture is the wrong starting point - per-recipient personalization, unsubscribe handling and deliverability are each bigger than everything above combined. For a personal daily digest, though, this script is a solid, boring, fixable machine, and that is exactly what you want.

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

Which OpenAI model should I use for a newsletter digest?

gpt-4o-mini is the right default: at one issue a day the cost rounds to pennies and the summaries are good enough. Move up a tier only if you notice the digest missing the point of articles, and expect roughly 15-30x the token price when you do.

Can I use Gmail to send the newsletter?

Yes, for sending to yourself or a couple of addresses. Create an app password (requires 2-step verification) and use smtp.gmail.com on port 587 with STARTTLS. Gmail caps you at around 500 recipients per day and is not suitable for a real mailing list.

Why does my scheduled run work locally but fail in cron?

Cron runs with a minimal environment: no shell profile, no PATH additions, no env vars. The two standard fixes are using the absolute path to the venv Python and sourcing your .env file inside the crontab line, exactly as shown above.

How do I stop the same article appearing twice?

The script keeps a seen_urls.txt file and skips any URL already in it. Note that some feeds change tracking parameters on URLs between fetches; if you see duplicates, normalize URLs (strip query strings) before comparing.

More guides