How I Built an AI Email Digest App with Cursor
From overflowing inbox to a scored reading list, with BART, Hugging Face, Gmail API, and Cursor in one afternoon
Do You Feel Swamped by Email Subscriptions?

If you’re like me, your email is probably overflowing with newsletters and notifications from all the subscriptions you’ve signed up for. It feels like a constant battle to catch up with all the “useful” insights that fill your inbox.
Since I started actively reading on Medium and Substack, my inbox has been flooded with notifications. From topics I follow to suggestions based on articles I’ve spent time on, the emails just keep coming. I dive into them, hoping to consume all the great information, but I never seem to get through it all. Inevitably, I end up marking many emails as “read,” even though I haven’t touched them.
This got me thinking:
What if I used GenAI to create a Subscription Summarizer?
A tool that could sift through my inbox, extract the key information, and give me concise, actionable insights.
_If you want to skip the setup and go straight to gmail automation, my recent [gmail-mcp setup for multiple accounts](https://buildtolaunch.ai/p/gmail-mcp-multi-account-claude-code-guide?r=4o2hk2) will worth the read._
_If you want to understand how Cursor works before building, start with **[my deep dive on Cursor for AI coding](https://buildtolaunch.ai/p/cursor-2-ai-coding-deep-dive)**. This project puts that foundation into practice._
_If you want ideas for what to build next, start with **[my Claude Code project ideas list](https://buildtolaunch.ai/p/claude-code-project-ideas)**. This is one of the projects in it._
Project Goals
For this project, I set out to build a program that could:
Connect to my Gmail account to retrieve all unread subscription emails.
Parse the emails to extract recommended article links.
Use AI to read those articles and generate summaries for me.
Ultimately, the program should save me hours of reading by surfacing only the most important and relevant information in a digestible format.
Choice of Model and Tools
To achieve this, I chose the following tools and frameworks:
Model: Hugging Face’s BART, a transformer model well-suited for text summarization tasks.
Programming Language: Barebones Python, keeping it lightweight and straightforward.
Frameworks: Hugging Face Transformers library for NLP, and Gmail API for email access.
To ensure I chose the most suitable model, I dedicated two articles to exploring the strengths and weaknesses of various relevant models:
Before you touch code: the spec prompt
The single biggest mistake builders make with Cursor is starting with “write me the code.” That produces working code for the wrong spec.
Write a CURSOR.md file first. It takes 10 minutes and saves 2 hours of rework.
Mine looked like this:
# Email Digest App
## What this does
Fetches unread newsletter emails from Gmail, extracts article links,
summarizes with Claude API, scores, and outputs a ranked list.
## Constraints
- Only process emails from the last 7 days
- Skip emails without external links (promotions, transactional)
- Max 20 articles per run (API cost control)
- No persistent storage for now — output to terminal or text file
## Output format
Ranked list with: title, source, score (0-100), 3-sentence summary
Then I opened Cursor and said: “Read CURSOR.md and build the app. Ask me before adding anything that isn’t in the spec.”
That last sentence matters. Without it, Cursor will add features you didn’t ask for — caching, database storage, a Flask UI. All of it is “helpful.” None of it is what you needed for a first working version.
Step 1: Gmail API connection
Gmail API setup is the most friction-heavy part of this project. It took me 45 minutes the first time. Here’s the short path.
Go to Google Cloud Console, create a project, enable the Gmail API, create OAuth credentials (Desktop app type), and download the credentials.json. Put it in your project root.
Then tell Cursor in Composer:
Set up Gmail OAuth with the credentials.json I’ve added. Scope:
gmail.readonly. On first run, open browser for auth. Save the token so I don’t re-auth every time.
Cursor generated this:
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
import os, json
SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']
def get_gmail_service():
creds = None
if os.path.exists('token.json'):
creds = Credentials.from_authorized_user_file('token.json', SCOPES)
if not creds or not creds.valid:
flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
creds = flow.run_local_server(port=0)
with open('token.json', 'w') as f:
f.write(creds.to_json())
return build('gmail', 'v1', credentials=creds)
First run opens a browser tab, you approve, and it saves a token. Every subsequent run skips the auth flow.
Step 2: Fetch unread newsletter emails
Now we pull emails. The Gmail API’s query syntax lets us filter precisely — this is where you avoid processing your transactional email noise.
def fetch_newsletter_emails(service, max_results=50):
query = 'is:unread newer_than:7d category:promotions OR label:newsletters'
result = service.users().messages().list(
userId='me', q=query, maxResults=max_results
).execute()
messages = result.get('messages', [])
emails = []
for msg in messages:
detail = service.users().messages().get(
userId='me', id=msg['id'], format='full'
).execute()
emails.append(detail)
return emails
What broke here: Gmail’s category filter is inconsistent. Some newsletters land in Primary, not Promotions. I ended up telling Cursor to add a sender-list check — if the sender is in a NEWSLETTER_SENDERS list in my config, always include it regardless of category.
NEWSLETTER_SENDERS = [
'substack.com', 'beehiiv.com', 'mailchimp.com',
'buildtolaunch.substack.com', 'your-favorites-here.com'
]
Lesson: when Cursor gives you working code and it’s still not doing what you want, describe the gap in plain English. Don’t try to edit the code yourself. “Some newsletters land in Primary, not Promotions — add a sender domain check so those are always included” generated exactly the right fix in one Composer prompt.
Step 3: Extract article links
Newsletter emails are noisy. One email might have 15 links — tracking pixels, unsubscribe links, social icons, and maybe 3 actual article URLs.
I told Cursor:
Parse the HTML body of each email. Extract links that point to actual articles — not unsubscribe URLs, not social links, not links that go back to the newsletter platform itself. Return a list of (title, url) tuples.
Cursor added filtering logic using heuristics: skip links containing unsubscribe, mailto:, utm_source=email (unless the destination looks like an article), and links pointing to common shorteners without following them.
Then newspaper3k handles the actual article extraction:
from newspaper import Article
def extract_article(url):
try:
article = Article(url)
article.download()
article.parse()
return {
'title': article.title,
'text': article.text[:4000], # Cap at 4k chars for API cost control
'url': url,
}
except Exception:
return None
The 4,000 character cap is deliberate. Claude can summarize an article from a strong excerpt — you don’t need the full 8,000-word piece. On a 20-article run this keeps API cost under $0.05.

Step 4: Summarize with Claude API
This is where older versions of projects like this (which used BART from Hugging Face) break down. BART summaries are stiff and often miss the point. Claude summaries read like a smart colleague actually read the piece.
import anthropic
client = anthropic.Anthropic()
def summarize_article(article: dict) -> str:
prompt = f"""Summarize this article in exactly 3 sentences.
Focus on: the main argument, one concrete insight, and why someone should read it.
Be direct. No filler.
Title: {article['title']}
Content: {article['text']}"""
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=200,
messages=[{"role": "user", "content": prompt}]
)
return message.content[0].text
Using claude-haiku-4-5 here, not Sonnet. Haiku is fast, cheap, and more than capable for summarization. On 20 articles, a full run costs ~$0.02.
The prompt does three things: constrains length (3 sentences = no padding), specifies what to focus on (argument, insight, read-worthiness), and bans filler. Without “Be direct. No filler,” the model will add “In this article, the author explores...” before everything.
Step 5: Score and rank
A flat list of summaries is still overwhelming. The recommender layer solves this.
def score_article(article: dict, summary: str) -> dict:
scores = {}
# Content quality: does the summary differ meaningfully from the title?
# If they say the same thing, the article is probably shallow.
title_words = set(article['title'].lower().split())
summary_words = set(summary.lower().split())
overlap = len(title_words & summary_words) / max(len(title_words), 1)
scores['content_quality'] = round((1 - overlap) * 100, 1)
# Readability: Flesch Reading Ease (higher = easier to read)
import textstat
scores['readability'] = round(textstat.flesch_reading_ease(article['text']), 1)
# Relevance: does the article text support the title claim?
# Simple keyword overlap between title and first 500 chars of text
first_chunk = set(article['text'][:500].lower().split())
scores['relevance'] = round(len(title_words & first_chunk) / max(len(title_words), 1) * 100, 1)
# Length score: sweet spot is 800-2500 words (actionable but not a thesis)
word_count = len(article['text'].split())
if 800 <= word_count <= 2500:
scores['length_score'] = 100
elif word_count < 800:
scores['length_score'] = round(word_count / 800 * 100, 1)
else:
scores['length_score'] = round(max(0, 100 - (word_count - 2500) / 50), 1)
weights = {'content_quality': 0.4, 'readability': 0.3, 'relevance': 0.2, 'length_score': 0.1}
overall = sum(scores[k] * weights[k] for k in weights)
return {**scores, 'overall': round(overall, 1)}
The content_quality score is rough but useful: if the article’s summary just repeats the title, the piece probably has no substance. High title-summary overlap is a signal for a thin article.
Step 6: Output the digest
def print_digest(results: list):
sorted_results = sorted(results, key=lambda x: x['score']['overall'], reverse=True)
print(f"\n{'='*60}")
print(f"YOUR READING LIST — {len(sorted_results)} articles ranked")
print(f"{'='*60}\n")
for i, item in enumerate(sorted_results, 1):
s = item['score']
print(f"#{i} [{s['overall']:.0f}/100] {item['title']}")
print(f" {item['url']}")
print(f" {item['summary']}")
print(f" Quality:{s['content_quality']:.0f} | Readability:{s['readability']:.0f} | Relevance:{s['relevance']:.0f}")
print()
Sample output from a real run:
#1 [84/100] The 5 Hiring Mistakes That Kill Startup Momentum
https://newsletter.example.com/hiring-mistakes
Most startup hiring fails not at sourcing but at evaluation. The author...
Quality:91 | Readability:78 | Relevance:82
#2 [71/100] How We Cut Cloud Costs by 40% Without Touching Our Architecture
...
Now, instead of skimming through every summary, I can sort articles by their overall score and focus on the top ones. This way, I only spend time on the content that’s truly worth reading.

What I’d do differently
Use streaming for the summaries. Waiting for 20 sequential API calls takes ~45 seconds. Claude’s API supports streaming and concurrent requests — wrapping the summarize calls in asyncio.gather() would bring this under 10 seconds.
Add a sender reputation signal. Some newsletters are consistently excellent; others are consistently thin. If I track scores per sender over time, I can weight known-good sources higher regardless of the article-level score.
Smoke test the Gmail auth before the full run. On two occasions, my token expired mid-run and the script crashed after processing 15 articles. A 3-line auth check at startup would prevent that. For how I think about smoke testing before running AI pipelines, see this guide.
How to use Cursor effectively for this kind of project
Three patterns that made a real difference:
1. One feature per Composer session. When I asked for “the whole Gmail + parsing + summarization pipeline” in one go, I got something that sort of worked but had bugs in every layer. When I asked for Gmail auth alone, then parsing alone, then summarization, each piece was solid. Cursor’s Composer is most reliable when it’s making a bounded change, not re-architecting everything at once.
2. Use Chat to understand before you ask Composer to change. Before adding the scoring system, I opened Cursor Chat (Cmd+L) and asked: “What are three ways to implement an article quality score? List the tradeoffs.” The answer surfaced the title-summary overlap approach, which I wouldn’t have thought of. Chat costs nothing and helps you write a better Composer prompt. For a deeper look at prompting AI coding tools effectively, that guide covers the full spectrum.
3. When something breaks, paste the error and the code together. Not just the error message. “This function produces KeyError: 'text' when the article scraper returns None — here’s the function, here’s the error, here’s a sample of what newspaper3k returns on failure” gets a correct fix immediately. Use Cmd+K to highlight the specific function and the @ symbol to attach the relevant file — Cursor will fix the exact thing that’s wrong instead of guessing.
For a full breakdown of how I approach building production-ready apps with AI coding tools, this article covers the system.
Going further
This app is a good first Cursor project because it’s real (you’ll actually use it), it touches three distinct layers (API auth, data processing, AI calls), and it fails in interesting ways that teach you something.
Once it’s working:
Schedule it with a cron job so it runs every morning and emails you the digest
Add a sender reputation table so Cursor can help you tune which sources get boosted
Swap the terminal output for a simple HTML file — one Composer prompt
If you’re looking for your next build, the Claude Code project ideas list has 15 more projects at this level of complexity — each with a spec prompt you can adapt for your .cursorrules.
🎁 Your gifts
.cursorrules template for this project — copy this into any Cursor project folder and modify the “What this does” section:
# Your App Name
## What this does
[One paragraph description of what the app does end-to-end]
## Constraints
- [Hard limit 1 — time range, record count, API cost]
- [Hard limit 2 — what to skip or ignore]
- [Hard limit 3 — storage or output format]
## Output format
[Exactly what you want to see: ranked list / JSON / terminal print / file]
5 Composer prompts I used to build this (copy and adapt):
“Read this spec and build the app. Ask me before adding anything that isn’t in the spec.”
“Set up Gmail OAuth with the credentials.json I’ve added. Scope:
gmail.readonly. On first run, open browser for auth. Save the token so I don’t re-auth every time.”“Parse the HTML body of each email. Extract links that point to actual articles — not unsubscribe URLs, not social links, not links that go back to the newsletter platform itself. Return a list of (title, url) tuples.”
“Some newsletters land in Primary, not Promotions — add a sender domain check so those are always included.”
“This function produces
[paste error]when[describe failure condition]— here’s the function, here’s the error, here’s a sample of what returns on failure.”
If this inspires you on how automating with gmail works, share it with more people who may benefit from it.
— Jenny