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
You have a Gmail tab open right now with more unread newsletters than you’ll ever read. This is the full build: Gmail API connection, link extraction, Claude API summarization, and a four-signal quality scorer — run in Cursor’s Composer in six steps, under $0.05 per run, done in an afternoon.
My inbox has 847 unread newsletters. Not because I stopped caring — because I signed up for more than I could ever read, and the ones worth reading are buried under the ones that aren’t.
I built a fix for this. The project: an AI email digest app with Cursor as the coding environment, Gmail API for the data, and Claude’s API doing the summarization inside the app. It connects to Gmail, extracts article links from newsletters, summarizes each one, scores them on four quality signals, and outputs a ranked reading list. The whole thing runs in under 30 seconds.
I’ve shipped a dozen Python projects with Cursor at this point. This one is a good first real project — real enough that you’ll actually keep using it, complex enough that you’ll learn something when it breaks.
This is the complete walkthrough — including what broke, what I’d do differently, and the prompting approach that made Cursor’s AI actually useful instead of just fast.
If you want to skip the setup and go straight to gmail automation, my recent gmail-mcp setup for multiple accounts will worth the read.
If you want to understand how Cursor works before building, start with my deep dive on Cursor for AI coding. This project puts that foundation into practice.
What’s inside:
The spec-first mistake — why opening Composer and typing “write me the code” costs you 2 hours, and the 10-minute
.cursorrulesfix that prevents itThe full pipeline — 6 steps from inbox to ranked reading list:
Step 1: Gmail API connection → OAuth setup in one Composer prompt, token persistence so you never re-auth
Step 2: Newsletter fetcher → query syntax + sender-domain fallback that catches what Gmail’s Promotions category misses
Step 3: Article link extractor → strips unsubscribes, tracking pixels, social icons — returns only real article URLs
Step 4: Claude Haiku summarizer at $0.02/run → 3-sentence format with no filler, exact prompt included
Step 5: Four-signal quality scorer → content quality (40%), readability (30%), relevance (20%), length (10%)
Step 6: Ranked digest output → sorted by overall score, with title + URL + summary + per-signal breakdown
What I’d do differently — async summaries under 10 seconds, sender reputation tracking, auth smoke test
.cursorrulestemplate + 5 Composer prompts — grab it at the end
🎁 .cursorrules template + the 5 Composer prompts I used to build this — grab them at the end.
If you want ideas for what to build next, start with my Claude Code project ideas list. This is one of the projects in it.
What We’ll Build:
For this project, I set out to build a program that could:
Connects to your Gmail account and fetches unread newsletters
Parses each email to extract article URLs
Sends each URL to the AI API for a 3-sentence summary
Scores each article on content quality, readability, relevance, and length
Outputs a ranked list — so you read the best ones first, or skip the rest
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







Yes, my email IS overflowing with new emails. It wasn't like this before I started Substack, but now ......
I’d try it!