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
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install feedparser openaifeedparser 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.
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.
#!/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.
# 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>&1The 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.
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 pushAdd 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
| Item | Reality |
|---|---|
| Setup time | 3-6 hours. The Python takes an hour; SMTP debugging, spam-folder testing and cron quirks take the rest. |
| API cost per month | Roughly 0.05-1.50 USD at one daily issue with gpt-4o-mini. Pennies unless you scale up article count or model tier. |
| Email deliverability | Gmail-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 maintenance | 1-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.