Build a Smart Email Triage System with Claude in an Afternoon
A practical afternoon project: pipe your emails through Claude to auto-classify, prioritize, and draft responses. Less inbox anxiety, more actual work done.
Field Guide · Issue No. 04
Email is a productivity tax. On a typical workday, knowledge workers spend 28% of their time managing their inbox — reading, sorting, triaging, drafting replies. Most of that time is not spent thinking. It is spent making the same low-stakes classification decisions over and over again: is this urgent? Does this need a reply? Who should actually handle this?
These are exactly the kinds of decisions Claude is good at. In this post we will build a simple email triage system that reads your emails, classifies them, assigns priorities, and optionally drafts a response. You can build the core version in an afternoon and have something genuinely useful by end of day.
What We Are Building
The system has four stages:
- Fetch — pull emails from your inbox (we will use Gmail via IMAP)
- Classify — use Claude to assign each email a category and priority
- Route — take action based on the classification (label, move, flag)
- Draft — optionally generate a reply for emails that need one
We will keep the scope realistic. This is a script you run manually or schedule with cron, not a fully autonomous agent. That constraint actually makes it more useful — you stay in control, and you can inspect what Claude is doing before it touches anything important.
Setup
You will need Python 3.10+, the Anthropic SDK, and a Gmail account with IMAP enabled.
pip install anthropic python-dotenv
Create a .env file:
ANTHROPIC_API_KEY=your_key_here
GMAIL_USER=you@gmail.com
GMAIL_APP_PASSWORD=your_app_password
For Gmail, you will need to generate an App Password (not your regular account password) — this is under your Google account's security settings. Enable IMAP in Gmail settings while you are there.
Fetching Emails
We will pull unread emails from the inbox and parse them into a clean structure:
import imaplib
import email
from email.header import decode_header
from dataclasses import dataclass
from typing import Optional
import os
from dotenv import load_dotenv
load_dotenv()
@dataclass
class ParsedEmail:
uid: str
sender: str
subject: str
body: str
date: str
def fetch_unread_emails(max_count: int = 20) -> list[ParsedEmail]:
mail = imaplib.IMAP4_SSL("imap.gmail.com")
mail.login(os.getenv("GMAIL_USER"), os.getenv("GMAIL_APP_PASSWORD"))
mail.select("inbox")
_, uids = mail.uid("search", None, "UNSEEN")
uid_list = uids[0].split()[-max_count:] # Limit to recent unread
emails = []
for uid in uid_list:
_, msg_data = mail.uid("fetch", uid, "(RFC822)")
raw = msg_data[0][1]
msg = email.message_from_bytes(raw)
subject = decode_header(msg["Subject"])[0][0]
if isinstance(subject, bytes):
subject = subject.decode()
sender = msg.get("From", "")
date = msg.get("Date", "")
body = extract_body(msg)
emails.append(ParsedEmail(
uid=uid.decode(),
sender=sender,
subject=subject or "(no subject)",
body=body[:2000], # Truncate long emails
date=date,
))
mail.logout()
return emails
def extract_body(msg) -> str:
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
return part.get_payload(decode=True).decode("utf-8", errors="replace")
else:
return msg.get_payload(decode=True).decode("utf-8", errors="replace")
return ""
A few things worth noting: we truncate email bodies to 2000 characters. Most of what matters for triage is in the first few paragraphs, and shorter inputs mean faster, cheaper API calls. We also cap the fetch at 20 emails per run — enough to be useful without overwhelming the API or your API budget.
Classifying with Claude
Here is the core of the system. We send each email to Claude with a classification prompt and parse the structured output:
import anthropic
import json
from dataclasses import dataclass
client = anthropic.Anthropic()
CATEGORIES = [
"action_required", # You need to do something
"reply_needed", # Someone is waiting for a response
"fyi_only", # Informational, no action needed
"newsletter", # Bulk/marketing content
"automated", # System notifications, alerts
"calendar", # Meeting invites, scheduling
"spam", # Junk
]
TRIAGE_PROMPT = """
You are an email triage assistant. Analyze this email and return a JSON classification.
From: {sender}
Subject: {subject}
Date: {date}
Body:
{body}
Return a JSON object with exactly these fields:
- category: one of {categories}
- priority: one of "urgent", "high", "normal", "low"
- reason: one sentence explaining your classification (max 20 words)
- reply_needed: true or false
- suggested_reply: a short draft reply if reply_needed is true, otherwise null
Criteria for priority:
- urgent: deadline today, system down, explicit urgency from known contact
- high: deadline this week, waiting on you to unblock something
- normal: standard work communication, no immediate deadline
- low: FYI, newsletters, automated messages
Return only the JSON. No explanation, no markdown fences.
""".strip()
@dataclass
class TriageResult:
category: str
priority: str
reason: str
reply_needed: bool
suggested_reply: Optional[str]
def triage_email(email: ParsedEmail) -> TriageResult:
prompt = TRIAGE_PROMPT.format(
sender=email.sender,
subject=email.subject,
date=email.date,
body=email.body,
categories=", ".join(CATEGORIES),
)
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
raw = response.content[0].text.strip()
try:
data = json.loads(raw)
return TriageResult(
category=data.get("category", "fyi_only"),
priority=data.get("priority", "normal"),
reason=data.get("reason", ""),
reply_needed=data.get("reply_needed", False),
suggested_reply=data.get("suggested_reply"),
)
except json.JSONDecodeError:
# Fallback: return a safe default rather than crashing
return TriageResult(
category="fyi_only",
priority="normal",
reason="Parse error — review manually",
reply_needed=False,
suggested_reply=None,
)
The prompt includes explicit categories and priority criteria rather than leaving it to Claude's judgment. Specificity here matters: if you say "reply needed: yes/no," you will get a wide range of interpretations. If you define what each priority level means, you get consistent results.
The fallback on JSON parse failure is intentional. Claude very rarely produces malformed JSON when asked for it directly (no markdown, no explanation), but "very rarely" is not "never." Crash-proof handling is not optional in a script you run every day.
Running the Pipeline
Wire it together and print the results:
def run_triage():
print("Fetching unread emails...")
emails = fetch_unread_emails(max_count=20)
if not emails:
print("Inbox clear. Nothing to triage.")
return
print(f"Triaging {len(emails)} emails...\n")
results = []
for email in emails:
result = triage_email(email)
results.append((email, result))
# Sort by priority
priority_order = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
results.sort(key=lambda x: priority_order.get(x[1].priority, 99))
for email, result in results:
print(f"[{result.priority.upper()}] {email.subject}")
print(f" From: {email.sender}")
print(f" Category: {result.category}")
print(f" Reason: {result.reason}")
if result.reply_needed and result.suggested_reply:
print(f" Draft reply:\n {result.suggested_reply[:200]}")
print()
if __name__ == "__main__":
run_triage()
Run it:
python triage.py
On a 20-email inbox, this typically takes 15-25 seconds and costs less than $0.10 in API calls. For most people, that is a worthwhile trade for a sorted, annotated view of their inbox.
Limitations to Know About
Before you build on top of this, some honest caveats:
Claude makes mistakes on context-dependent emails. If an email from your manager reads "Can you handle the Friday thing?" with no other context, Claude will almost certainly misclassify it. The model has no memory of your ongoing projects, relationships, or what "the Friday thing" is. Treat the output as a first pass, not a final verdict.
Email parsing is messier than it looks. HTML emails, threads with quoted replies, forwarded messages, and non-standard encodings all produce noisy inputs. The body truncation at 2000 characters handles most cases reasonably, but you will occasionally see weird results caused by parsing artifacts.
The system learns nothing. Every run starts from scratch. If you consistently disagree with a classification, you have to update the prompt — there is no built-in feedback loop. This is a feature as much as a limitation (it is simple and predictable), but it means the system's quality ceiling is the quality of your prompt.
Do not auto-send drafted replies. The generated replies in this tutorial are surfaced for human review, not sent automatically. Auto-sending AI-generated emails without review is an easy way to have a bad day.
Where to Take It Next
Once the core loop is working, there are several natural extensions:
Gmail label automation. Use the Gmail API (instead of raw IMAP) to programmatically apply labels, archive newsletters, and move automated notifications out of your inbox. The classification is already there; you just need to act on it.
Slack forwarding for urgent items. If something is classified urgent, post it to a Slack channel or DM yourself via the Slack API. Good for when you are away from email but monitoring Slack.
Calendar integration. Detect meeting requests and invites, extract the time/place, and create a draft calendar event. The classification category: calendar gives you the hook.
Thread awareness. The current system treats every email as a standalone item. Fetching the full thread via the Gmail API and including prior messages in the prompt dramatically improves accuracy for replies that reference earlier context.
Feedback loop. Add a simple mechanism — even just a text file — where you log disagreements with classifications. Periodically review them and update the prompt or add examples. A few hours of curation work compounds into noticeably better results over time.
The architecture here is intentionally minimal. The triage logic lives in a single function, the prompt is readable and editable, and the whole thing is a script you can understand in one sitting. Build it, run it for a week, and see what you actually want to improve. That empirical feedback will tell you what to add — not this tutorial.
Your inbox is not going to triage itself. But with an afternoon and a few dozen lines of Python, you can make it a lot less painful.