Build an Email Management AI Agent in Python
The average knowledge worker spends 28% of their workday managing email. Most of that time is spent on mechanical tasks: reading threads to understand context, deciding what needs a reply, drafting similar responses, and organizing messages into folders. An AI email agent can handle all of these tasks — reading emails, classifying them by urgency and topic, drafting replies in your voice, and sending or forwarding with your approval.
This tutorial builds a practical email management agent that connects to Gmail via the Gmail API, classifies incoming emails, drafts contextually appropriate replies, and integrates a human-approval gate before any email is actually sent. It is designed to be a starting point you can adapt to your specific workflow.
What You'll Learn#
- How to authenticate with the Gmail API using OAuth2
- How to build LangChain tools for reading, searching, and drafting emails
- How to classify emails by priority and topic using LLM-structured output
- How to maintain thread context for coherent reply drafting
- How to integrate approval gates before sending to prevent accidental sends
Prerequisites#
- Python 3.10+
- Google account with Gmail API enabled (see Step 1)
- OpenAI API key
- Basic understanding of LangChain agent patterns
- Familiarity with AI agent concepts
Architecture Overview#
The agent has three operational modes:
- Triage Mode — Reads the last N unread emails, classifies each by priority and topic, and returns a structured triage report
- Reply Mode — Given an email thread, drafts a contextually appropriate reply in the user's style
- Compose Mode — Drafts a new outbound email from a natural language description
All modes that involve sending require explicit approval (via the terminal in development, or via the HITL API pattern in production).
Step 1: Gmail API Setup#
- Go to console.cloud.google.com
- Create a new project (or use existing)
- Enable the Gmail API in APIs & Services
- Create an OAuth 2.0 Client ID (Desktop Application type)
- Download the
credentials.jsonfile to your project directory
Install dependencies:
pip install langchain==0.3.0 langchain-openai==0.2.0 \
google-auth==2.35.0 google-auth-oauthlib==1.2.1 \
google-auth-httplib2==0.2.0 google-api-python-client==2.147.0 \
python-dotenv==1.0.1
Step 2: Gmail Authentication#
# gmail_auth.py
import os
import json
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
]
TOKEN_FILE = "token.json"
CREDENTIALS_FILE = "credentials.json"
def get_gmail_service():
"""Authenticate and return an authorized Gmail API service object."""
creds = None
if os.path.exists(TOKEN_FILE):
creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES)
creds = flow.run_local_server(port=0)
with open(TOKEN_FILE, "w") as token:
token.write(creds.to_json())
return build("gmail", "v1", credentials=creds)
# Test authentication
if __name__ == "__main__":
service = get_gmail_service()
profile = service.users().getProfile(userId="me").execute()
print(f"Authenticated as: {profile['emailAddress']}")
Run python gmail_auth.py once to complete the OAuth flow and save token.json.
Step 3: Email Reading and Parsing Tools#
# email_tools.py
import base64
import email as email_lib
from typing import Optional
from gmail_auth import get_gmail_service
service = get_gmail_service()
def decode_body(payload: dict) -> str:
"""Extract plain text body from a Gmail message payload."""
body = ""
if "parts" in payload:
for part in payload["parts"]:
if part["mimeType"] == "text/plain":
data = part["body"].get("data", "")
body = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
break
elif "body" in payload:
data = payload["body"].get("data", "")
if data:
body = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
return body
def get_email_headers(headers: list) -> dict:
"""Extract common headers from a Gmail message."""
header_map = {h["name"].lower(): h["value"] for h in headers}
return {
"subject": header_map.get("subject", "(no subject)"),
"from": header_map.get("from", ""),
"to": header_map.get("to", ""),
"date": header_map.get("date", ""),
"reply_to": header_map.get("reply-to", header_map.get("from", "")),
}
def list_unread_emails(max_results: int = 10) -> list[dict]:
"""Fetch the most recent unread emails."""
results = service.users().messages().list(
userId="me",
labelIds=["INBOX", "UNREAD"],
maxResults=max_results,
).execute()
messages = results.get("messages", [])
emails = []
for msg in messages:
full = service.users().messages().get(
userId="me", id=msg["id"], format="full"
).execute()
payload = full.get("payload", {})
headers = get_email_headers(payload.get("headers", []))
body = decode_body(payload)
emails.append({
"id": msg["id"],
"thread_id": full.get("threadId"),
"subject": headers["subject"],
"from": headers["from"],
"to": headers["to"],
"date": headers["date"],
"body": body[:2000], # Truncate to avoid token overflow
"snippet": full.get("snippet", ""),
})
return emails
def get_thread(thread_id: str) -> list[dict]:
"""Fetch all messages in a thread."""
thread = service.users().threads().get(userId="me", id=thread_id).execute()
messages = []
for msg in thread.get("messages", []):
payload = msg.get("payload", {})
headers = get_email_headers(payload.get("headers", []))
body = decode_body(payload)
messages.append({
"id": msg["id"],
"from": headers["from"],
"date": headers["date"],
"body": body[:1500],
})
return messages
Step 4: Email Classification#
# classifier.py
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
class EmailClassification(BaseModel):
priority: str = Field(description="urgent, high, medium, or low")
category: str = Field(description="customer, internal, newsletter, spam, support, sales, personal")
requires_reply: bool = Field(description="True if this email requires a human response")
sentiment: str = Field(description="positive, neutral, negative")
summary: str = Field(description="One sentence summary of the email")
suggested_action: str = Field(description="Brief action recommendation")
classifier_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
classifier_chain = ChatPromptTemplate.from_messages([
("system", """You are an email triage assistant.
Classify emails by priority, category, and required action.
Priority guidelines: urgent=needs response within 1h, high=today, medium=this week, low=whenever."""),
("human", "From: {sender}\nSubject: {subject}\n\nBody:\n{body}"),
]) | classifier_llm.with_structured_output(EmailClassification)
def classify_email(email: dict) -> EmailClassification:
return classifier_chain.invoke({
"sender": email["from"],
"subject": email["subject"],
"body": email["body"],
})
Step 5: Reply Drafting and Send Tool#
# draft_and_send.py
import base64
from email.mime.text import MIMEText
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from gmail_auth import get_gmail_service
from email_tools import get_thread
service = get_gmail_service()
def draft_reply(thread_id: str, user_instructions: str = "") -> str:
"""Draft a reply to an email thread using the full thread context."""
thread_messages = get_thread(thread_id)
thread_text = "\n\n---\n".join(
f"From: {m['from']}\n{m['body']}" for m in thread_messages
)
llm = ChatOpenAI(model="gpt-4o", temperature=0.3)
chain = ChatPromptTemplate.from_messages([
("system", """You are drafting an email reply.
Write in a professional, concise style. Match the tone of the thread.
Do not add unnecessary pleasantries. Get to the point quickly.
{instructions}"""),
("human", "Thread:\n{thread}\n\nWrite a reply:"),
]) | llm
result = chain.invoke({
"thread": thread_text,
"instructions": f"Additional instructions: {user_instructions}" if user_instructions else "",
})
return result.content
def send_email(to: str, subject: str, body: str, thread_id: str = None) -> str:
"""Create and send an email. Returns the sent message ID."""
msg = MIMEText(body)
msg["to"] = to
msg["subject"] = subject
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
send_body = {"raw": raw}
if thread_id:
send_body["threadId"] = thread_id
result = service.users().messages().send(
userId="me", body=send_body
).execute()
return f"Sent. Message ID: {result['id']}"
def create_draft(to: str, subject: str, body: str) -> str:
"""Save as draft without sending — safer for human review."""
msg = MIMEText(body)
msg["to"] = to
msg["subject"] = subject
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
result = service.users().drafts().create(
userId="me", body={"message": {"raw": raw}}
).execute()
return f"Draft saved. Draft ID: {result['id']}"
Step 6: The Email Agent Loop#
# agent.py
from langchain_openai import ChatOpenAI
from langchain.agents import create_openai_tools_agent, AgentExecutor
from langchain.tools import tool
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from email_tools import list_unread_emails
from classifier import classify_email
from draft_and_send import draft_reply, send_email, create_draft
@tool
def triage_inbox(max_emails: int = 10) -> str:
"""Read and classify the most recent unread emails. Returns a triage report."""
emails = list_unread_emails(max_emails)
if not emails:
return "No unread emails found."
lines = ["Email Triage Report", "=" * 40]
for email in emails:
clf = classify_email(email)
lines.append(
f"[{clf.priority.upper()}] {email['subject']}\n"
f" From: {email['from']}\n"
f" Category: {clf.category} | Reply needed: {clf.requires_reply}\n"
f" Summary: {clf.summary}\n"
f" Action: {clf.suggested_action}\n"
)
return "\n".join(lines)
@tool
def draft_email_reply(thread_id: str, instructions: str = "") -> str:
"""Draft a reply to an email thread. Saves as a draft — does not send."""
draft_text = draft_reply(thread_id, instructions)
return f"Draft created:\n\n{draft_text}"
@tool
def save_draft_to_gmail(to: str, subject: str, body: str) -> str:
"""Save a composed email as a Gmail draft for human review before sending."""
return create_draft(to, subject, body)
def build_email_agent() -> AgentExecutor:
llm = ChatOpenAI(model="gpt-4o", temperature=0)
tools = [triage_inbox, draft_email_reply, save_draft_to_gmail]
prompt = ChatPromptTemplate.from_messages([
("system", """You are an email management assistant.
Help triage emails, draft replies, and organize the inbox.
IMPORTANT: Always save emails as drafts — never call send_email directly.
The human will review and approve drafts before they are sent."""),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
agent = create_openai_tools_agent(llm, tools, prompt)
return AgentExecutor(agent=agent, tools=tools, verbose=True)
Note that send_email is intentionally excluded from the agent's tool list. The agent can only create drafts, enforcing the human-approval pattern described in the HITL tutorial. Add Langfuse observability to audit which emails the agent processes and what actions it takes.
What's Next#
- Add approval workflows before sending using human-in-the-loop patterns
- Deploy this agent as a scheduled service with the Docker deployment guide
- Connect email insights to a data analysis pipeline with the data analyst agent
- Review AI agent security best practices for handling sensitive email content
- Build a companion LangGraph multi-agent system that routes different email types to specialized sub-agents