AI Email Assistant with Outlook

Build a CLI tool that drafts tone-matched supplier email replies using Claude.

Introduction

30 Second Summary

Every day, supplier emails pile up in your inbox asking for confirmations, follow-ups, and status updates. Responding to each one takes time you could spend on higher-value work.

In this project, you will build a Python command-line tool that reads your Outlook inbox, extracts action items from supplier emails using AI, and drafts tone-matched replies for your approval.

What You'll Build

A Python CLI assistant that connects to your Outlook mailbox via Microsoft Graph API, analyzes incoming emails with Claude, and drafts replies matching your personal writing style.

By the end of this project, you'll have:

  • Microsoft Graph API integration that reads and sends emails from your real Outlook inbox.
  • A Claude-powered email analyzer that extracts action items, deadlines, and recommended next steps from supplier messages.
  • A tone-matching system trained on your own sent emails using few-shot prompting.
  • A complete human-in-the-loop workflow where you review, edit, and send AI-drafted emails directly from your terminal.
  • Secret Mission: A weekly digest command that summarizes all unread supplier emails into a prioritized action list with deadlines.

Are there any prerequisites?

You'll need Python 3.14 installed, a Microsoft 365 work account with Outlook, and IT admin access to register an app in Microsoft Entra. You'll also need an Anthropic API account with a small amount of credits (around $5).

Set Up Your Development Environment

Before you can build an AI email assistant, you need a workspace with the right tools connected. Your assistant will rely on two external services: the Anthropic API (for Claude's language intelligence) and the Microsoft Graph API (for reading and sending Outlook emails).

In this step, you'll create an isolated Python project, install the libraries that talk to those services, and set up your Anthropic API credentials so Claude is ready to analyze emails.

In this step, get ready to:
  • Create your project folder and Python virtual environment.
  • Install the required Python packages.
  • Create an Anthropic API account and store your key.
Create your project folder and virtual environment

A virtual environment is a self-contained copy of Python where you install packages without affecting the rest of your system. Every Python project should have its own virtual environment so dependencies never clash.

  • Open PowerShell by pressing the Windows key, typing PowerShell, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir email-assistant
cd email-assistant
  • Confirm the folder exists by running:
ls

You should see the email-assistant folder listed in the output.

  • Create and activate your virtual environment by running these commands:
python -m venv venv
.\venv\Scripts\Activate.ps1

What do these commands do?

  • python -m venv venv creates a new virtual environment in a folder called venv.
  • .\venv\Scripts\Activate.ps1 activates the environment so any packages you install go into this project only.
  • Check that your virtual environment is active. You should see (venv) at the beginning of your PowerShell prompt.

Seeing a 'scripts is disabled' error?

Windows blocks PowerShell scripts by default. Fix this by running the following command, then try the activation again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Type Y when prompted to confirm.

help me fix the scripts disabled error when activating my Python virtual environment in PowerShell

Install the required Python packages

Your email assistant depends on four packages that handle AI, authentication, and email communication. You'll pin them to specific versions in a requirements.txt file so your setup is reproducible.

  • Create the requirements.txt file by running this command:
New-Item requirements.txt
  • Open requirements.txt in a text editor and paste the following content:
anthropic==0.104.0
msgraph-sdk==1.56.0
azure-identity==1.21.0
python-dotenv==1.1.0

What are these packages?

  • anthropic connects your app to Claude for email analysis and drafting.
  • msgraph-sdk is Microsoft's official Python SDK for the Graph API, used to read and send Outlook emails.
  • azure-identity handles OAuth 2.0 authentication with your Microsoft 365 account.
  • python-dotenv loads your API keys from a .env file so secrets stay out of your code.
  • Save requirements.txt.
  • Install all four packages by running this command:
pip install -r requirements.txt

This may take a minute as the Microsoft Graph SDK is a large package. When it finishes, you should see Successfully installed followed by the package names in your terminal.

Seeing an OSError about file paths?

The Microsoft Graph SDK package contains deeply nested files that can exceed Windows path limits. Enable long paths in your environment to fix this.

Search for 'Enable Win32 Long Paths' in your Windows settings, or ask your IT admin to enable it via Group Policy.

help me fix the OSError about long file paths when installing msgraph-sdk on Windows

✔️ Awesome, I've got everything!

Great. Make sure you've saved requirements.txt before moving on.

ⓧ I'd like to double check the full code

anthropic==0.104.0
msgraph-sdk==1.56.0
azure-identity==1.21.0
python-dotenv==1.1.0
Create an Anthropic API account and store your API key

Claude needs an API key to authenticate your requests. You'll create an account on Anthropic's developer console, generate a key, and store it securely in a local .env file that Python can read at runtime.

  • Open your browser and navigate to console.anthropic.com.
  • Create an account or sign in if you already have one.
  • Once signed in, click API Keys in the left sidebar.
  • Click Create Key.
  • Give your key a name like email-assistant and click Create Key.
  • Copy your API key immediately. You will not be able to see it again after closing this dialog.

What about credits?

Anthropic charges per API call on a pay-as-you-go basis. Add at least $5 in credits under the Billing section. This project typically costs $0.05 to $0.50 total depending on how many emails you process.

  • Back in PowerShell, create your .env file by running:
New-Item .env
  • Open .env in your text editor and paste the following content:
ANTHROPIC_API_KEY=your-api-key-here
AZURE_CLIENT_ID=your-client-id-here
AZURE_TENANT_ID=your-tenant-id-here

What are these three variables?

  • ANTHROPIC_API_KEY authenticates your requests to Claude.
  • AZURE_CLIENT_ID and AZURE_TENANT_ID identify your Microsoft Entra app registration. You'll fill these in during the next step.
  • Replace your-api-key-here with the API key you copied from the Anthropic console.
  • Leave the AZURE_CLIENT_ID and AZURE_TENANT_ID values as placeholders for now.
  • Save .env.

Keep your .env file private

Never commit .env to version control. If you add Git to this project later, create a .gitignore file and add .env to it.

✔️ Awesome, I've got everything!

Great. Make sure you've saved .env with your real Anthropic key filled in.

ⓧ I'd like to double check the full code

ANTHROPIC_API_KEY=your-api-key-here
AZURE_CLIENT_ID=your-client-id-here
AZURE_TENANT_ID=your-tenant-id-here

Your development environment is locked and loaded. Next up, you'll register your app in Microsoft Entra so it can securely access your Outlook mailbox.

Register Your App in Microsoft Entra

Your development environment is ready with all packages installed. Now you need to give your Python script permission to access your Outlook mailbox.

Microsoft requires every app that accesses user data to be registered in Microsoft Entra (formerly Azure AD). This registration tells Microsoft who your app is and what data it can access. In this step, you will work with your IT admin to register the app and grant the permissions it needs to read and send emails on your behalf.

In this step, get ready to:
  • Register a new application in the Microsoft Entra admin center.
  • Configure Microsoft Graph delegated permissions for email access.
  • Save your app credentials to your project's environment file.
Register your app in Microsoft Entra

Your email assistant needs an identity that Microsoft recognizes before it can access your mailbox. The app registration creates that identity and produces two IDs your code uses to authenticate.

Why do I need my IT admin for this?

In an organizational Microsoft 365 tenant, registering apps and granting mail permissions requires admin-level access. Your IT admin controls which apps can read and send email on behalf of users in the organization.

If you are the IT admin for your tenant, you can complete these steps yourself.

  • Open your browser and navigate to the Microsoft Entra admin center at entra.microsoft.com.
  • Sign in with your organizational (work) account.
  • In the left sidebar, expand Identity.
  • Expand Applications.
  • Click App registrations.
  • Click New registration at the top of the page.
  • Enter Email Assistant in the Name field.
  • Under Supported account types, select Accounts in this organizational directory only.
  • Click Register.

You are now on the app's Overview page. Keep this page open.

  • Under Manage in the left menu, click Authentication.
  • Under Platform configurations, click Add a platform.
  • Select Mobile and desktop applications.
  • Under Custom redirect URIs, enter http://localhost.
  • Click Configure.

What is a redirect URI?

After you authenticate in the browser, Microsoft needs a URL to send the authentication response back to. For desktop CLI tools, http://localhost is the standard choice. It tells Microsoft to send the token back to your local machine.

Don't see the platform options?

Make sure you clicked Authentication in the left menu under Manage. The Add a platform button appears under Platform configurations on the Authentication page.

help me find the platform configuration settings in Microsoft Entra

Configure API permissions

Your app registration exists, but it has no permission to access email yet. You need to add Microsoft Graph delegated permissions so the app can read your inbox and send messages on your behalf.

  • Under Manage in the left menu, click API permissions.
  • Click Add a permission.
  • Select Microsoft Graph.
  • Select Delegated permissions.
  • Search for and check the box next to User.Read.
  • Search for and check the box next to Mail.Read.
  • Search for and check the box next to Mail.Send.
  • Click Add permissions.

What are delegated permissions?

Delegated permissions let your app act on behalf of a signed-in user. Your app can only do what you (the signed-in user) are allowed to do. It cannot access other people's mailboxes or act without you being present.

This is different from application permissions, which let an app access any mailbox in the organization without a user present.

Now your IT admin needs to approve these permissions for the organization. The Mail.Read and Mail.Send permissions require admin consent in organizational tenants.

  • On the API permissions page, ask your IT admin to click Grant admin consent for [your organization].
  • When prompted, click Yes to confirm.
  • Verify that all three permissions show a green checkmark with Granted status.

Admin consent button is greyed out?

Only users with admin-level roles (such as Cloud Application Administrator or Global Administrator) can grant consent. If you are not an admin, send this page to your IT admin and ask them to click the consent button for you.

help me get admin consent for my Microsoft Entra app permissions

Enable public client flows and save your credentials

Your app uses the OAuth 2.0 device code flow to authenticate. This flow is designed for CLI tools where you cannot embed a web browser directly. You need to enable public client flows for this to work.

  • Under Manage in the left menu, click Authentication.
  • Scroll down to the Advanced settings section.
  • Under Allow public client flows, select Yes.
  • Click Save at the top of the page.

Why enable public client flows?

A public client is an app that cannot securely store a secret. Your CLI tool runs on your local machine where anyone with access could inspect the code. By marking it as a public client, Microsoft allows the device code flow for authentication instead of requiring a client secret.

Now copy your app's two identifiers into your project's .env file.

  • In the left menu, click Overview.
  • Copy the Application (client) ID value.
  • Copy the Directory (tenant) ID value.
  • Switch back to your code editor and open your .env file.
  • Replace your-client-id-here with the Application (client) ID you copied.
  • Replace your-tenant-id-here with the Directory (tenant) ID you copied.

Your .env file should now look like this (with your actual IDs in place of the placeholders):

ANTHROPIC_API_KEY=your-api-key-here
AZURE_CLIENT_ID=your-client-id-here
AZURE_TENANT_ID=your-tenant-id-here

What do these IDs do?

The AZURE_CLIENT_ID tells Microsoft which registered app is requesting access. The AZURE_TENANT_ID tells Microsoft which organization directory to authenticate against.

Together, they let your Python script identify itself when requesting an access token.

  • Save your .env file.

Not sure which ID is which?

On the app's Overview page, the Application (client) ID is the GUID that identifies your specific app. The Directory (tenant) ID identifies your organization's Microsoft Entra directory. Both are displayed near the top of the Overview page.

help me find my Application ID and Tenant ID in Microsoft Entra

✔️ Awesome, I've got everything!

Make sure you have saved your .env file with both IDs filled in.

ⓧ I'd like to double check the full code

Your .env file should contain these three lines (with your real values replacing the placeholders):

ANTHROPIC_API_KEY=your-api-key-here
AZURE_CLIENT_ID=your-client-id-here
AZURE_TENANT_ID=your-tenant-id-here

Your app registration is complete. Microsoft now recognizes your email assistant and knows what it is allowed to do. Next, you will write the Python code that uses these credentials to connect to your Outlook inbox.

Connect to Your Outlook Inbox

Your Microsoft Entra app is registered and has the permissions it needs. Now it's time to write the Python code that actually connects to your Outlook mailbox.

In this step, you'll create a client module that authenticates with Microsoft Graph API using the device code flow. This flow is ideal for CLI tools because it lets you sign in through a browser without needing a local redirect server.

In this step, get ready to:
  • Create graph_client.py with a Graph client that authenticates via device code.
  • Implement functions to list inbox messages, fetch individual emails, and send replies.
  • Test the authentication flow and verify your inbox messages appear in the terminal.
Create graph_client.py and initialize the Graph client

The OAuth 2.0 device code flow works by displaying a code in your terminal. You visit a URL in your browser, enter that code, and sign in with your Microsoft account. Once authenticated, your Python script receives an access token automatically.

  • Create a new file called graph_client.py in your project folder.
  • Add the imports, environment variable loading, and the get_graph_client() function by pasting this code:
import asyncio
from azure.identity import DeviceCodeCredential
from msgraph import GraphServiceClient
from msgraph.generated.users.item.messages.messages_request_builder import MessagesRequestBuilder
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import SendMailPostRequestBody
from msgraph.generated.models.message import Message
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.recipient import Recipient
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Read app registration details from environment
CLIENT_ID = os.getenv("AZURE_CLIENT_ID")
TENANT_ID = os.getenv("AZURE_TENANT_ID")
SCOPES = ["User.Read", "Mail.Read", "Mail.Send"]


def get_graph_client():
    # Create a credential that uses the device code flow for authentication
    credential = DeviceCodeCredential(
        client_id=CLIENT_ID,
        tenant_id=TENANT_ID,
    )
    # Initialize the Graph client with the credential and required scopes
    client = GraphServiceClient(credentials=credential, scopes=SCOPES)
    return client

What does this code do?

load_dotenv() reads your .env file so the client ID and tenant ID are available as environment variables.

DeviceCodeCredential handles the authentication flow. When the Graph client makes its first API call, this credential will prompt you to sign in via your browser.

The SCOPES list tells Microsoft which permissions your app is requesting. These match the delegated permissions you configured in Microsoft Entra.

Add inbox and email operation functions

With the client initialized, you need functions to interact with your mailbox. The main one fetches your 10 most recent emails with sender, subject, and date information.

  • Below the get_graph_client() function, add the list_inbox() function:
async def list_inbox(client, top=10):
    # Configure query to fetch recent emails with specific fields
    query_params = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        top=top,
        select=["subject", "from", "receivedDateTime", "body", "isRead"],
        orderby=["receivedDateTime DESC"],
    )
    request_config = MessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration(
        query_parameters=query_params,
    )
    # Execute the request against the signed-in user's mailbox
    messages = await client.me.messages.get(request_configuration=request_config)
    return messages.value if messages and messages.value else []

What does this code do?

This async function queries the Microsoft Graph /me/messages endpoint. The select parameter limits which fields are returned to keep the response lightweight.

The orderby parameter sorts results by most recent first. The top parameter limits results to 10 messages by default.

  • Below list_inbox(), add the get_message() and send_reply() helper functions:
async def get_message(client, message_id):
    # Fetch a single message by its ID
    message = await client.me.messages.by_message_id(message_id).get()
    return message


async def send_reply(client, to_email, subject, body_content):
    # Build the email message object
    message = Message()
    message.subject = subject
    message.body = ItemBody()
    message.body.content_type = BodyType.Text
    message.body.content = body_content
    message.to_recipients = []

    # Set the recipient
    recipient = Recipient()
    recipient.email_address = EmailAddress()
    recipient.email_address.address = to_email
    message.to_recipients.append(recipient)

    # Send the email via Graph API
    request_body = SendMailPostRequestBody()
    request_body.message = message
    request_body.save_to_sent_items = True

    await client.me.send_mail.post(body=request_body)

What does this code do?

get_message() fetches the full details of a single email by its ID. You'll use this later when analyzing a specific email.

send_reply() constructs a Message object with recipient, subject, and body, then sends it through the Graph API's send_mail endpoint. Setting save_to_sent_items = True keeps a copy in your Sent folder.

  • Save graph_client.py.

Seeing import errors in your editor?

Make sure your virtual environment is activated. If your editor shows red underlines on the msgraph imports, confirm the packages installed successfully by running:

pip list

You should see msgraph-sdk, azure-identity, and python-dotenv in the output.

help me fix import errors in graph_client.py

✔️ Awesome, I've got everything!

Great. Double check you've saved graph_client.py before moving on.

ⓧ I'd like to double check the full code

Here's the complete graph_client.py file:

import asyncio
from azure.identity import DeviceCodeCredential
from msgraph import GraphServiceClient
from msgraph.generated.users.item.messages.messages_request_builder import MessagesRequestBuilder
from msgraph.generated.users.item.send_mail.send_mail_post_request_body import SendMailPostRequestBody
from msgraph.generated.models.message import Message
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.body_type import BodyType
from msgraph.generated.models.email_address import EmailAddress
from msgraph.generated.models.recipient import Recipient
import os
from dotenv import load_dotenv

load_dotenv()

CLIENT_ID = os.getenv("AZURE_CLIENT_ID")
TENANT_ID = os.getenv("AZURE_TENANT_ID")
SCOPES = ["User.Read", "Mail.Read", "Mail.Send"]


def get_graph_client():
    credential = DeviceCodeCredential(
        client_id=CLIENT_ID,
        tenant_id=TENANT_ID,
    )
    client = GraphServiceClient(credentials=credential, scopes=SCOPES)
    return client


async def list_inbox(client, top=10):
    query_params = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters(
        top=top,
        select=["subject", "from", "receivedDateTime", "body", "isRead"],
        orderby=["receivedDateTime DESC"],
    )
    request_config = MessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration(
        query_parameters=query_params,
    )
    messages = await client.me.messages.get(request_configuration=request_config)
    return messages.value if messages and messages.value else []


async def get_message(client, message_id):
    message = await client.me.messages.by_message_id(message_id).get()
    return message


async def send_reply(client, to_email, subject, body_content):
    message = Message()
    message.subject = subject
    message.body = ItemBody()
    message.body.content_type = BodyType.Text
    message.body.content = body_content
    message.to_recipients = []

    recipient = Recipient()
    recipient.email_address = EmailAddress()
    recipient.email_address.address = to_email
    message.to_recipients.append(recipient)

    request_body = SendMailPostRequestBody()
    request_body.message = message
    request_body.save_to_sent_items = True

    await client.me.send_mail.post(body=request_body)
Test the connection to your inbox

Time to verify that everything works. When you run this test, the device code flow will kick in and prompt you to authenticate through your browser.

  • Test your Graph connection by running this command:
python -c 'import asyncio; from graph_client import get_graph_client, list_inbox; client = get_graph_client(); msgs = asyncio.run(list_inbox(client)); print(str(len(msgs)) + " messages found:"); [print("  " + m.subject) for m in msgs]'

What will happen next?

Your terminal will display a message asking you to visit https://microsoft.com/devicelogin and enter a code. This is the device code flow in action.

  • Copy the code shown in your terminal.
  • Open https://microsoft.com/devicelogin in your browser.
  • Paste the code and sign in with your Microsoft 365 work account.
  • Accept the permissions prompt when asked.

After you sign in, switch back to your terminal. You should see a list of your most recent email subjects printed.

  • Confirm you see output showing your recent email subjects in the terminal.

Not seeing the device code prompt?

Make sure your .env file has the correct AZURE_CLIENT_ID and AZURE_TENANT_ID values from your Microsoft Entra app registration.

If you see a permission error after signing in, confirm your IT admin clicked Grant admin consent for the Mail.Read and Mail.Send permissions in the app registration.

Also verify that Allow public client flows is enabled under the app's Authentication settings.

help me troubleshoot device code authentication with Microsoft Graph

Why device code flow instead of a browser redirect?

A redirect-based flow requires running a local web server to catch the callback URL. The device code flow skips that complexity entirely. Your CLI prints a code, you sign in on any browser, and the script picks up the token automatically.

This makes it perfect for terminal-based tools where you want secure authentication without managing ports or localhost certificates.

Your Graph client is connected and you can read your inbox programmatically. Next up, you'll use Claude to analyze those emails and extract action items automatically.

Analyze Emails with Claude

You can now read your inbox programmatically. The next challenge is making sense of all those emails without reading each one yourself.

In this step, you will build an email analyzer powered by Claude. It will extract the key information from any supplier email: what actions you need to take, what deadlines exist, and what the best next step is.

In this step, get ready to:
  • Create an email analysis module with a structured system prompt.
  • Build a function that sends emails to Claude for analysis.
  • Test the analyzer against a real email from your inbox.
Create the analyzer module with a system prompt

The system prompt is what tells Claude exactly how to analyze each email. You will use XML tags to structure the output so your code can reliably parse the results later.

  • Create a new file called email_analyzer.py in your project folder.
  • Add the imports, client initialization, and system prompt by pasting this code:
from anthropic import Anthropic
import os
from dotenv import load_dotenv

load_dotenv()

# Initialize the Anthropic client using the key from .env
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

# System prompt that instructs Claude how to analyze emails
ANALYSIS_SYSTEM_PROMPT = """You are an email analysis assistant for a business professional.
When given an email, extract the following in a structured format:

<action_items>List each specific action required, one per line</action_items>
<deadlines>List any deadlines or time-sensitive mentions</deadlines>
<questions>List any questions that need answering</questions>
<recommended_next_step>Suggest the single most important next action</recommended_next_step>
<urgency>Rate as: low, medium, or high</urgency>

Be concise and specific. If a field has no items, write "None identified."
"""

What does this code do?

The Anthropic client is initialized with your API key from the .env file. This is the same key you created in Step 1.

The ANALYSIS_SYSTEM_PROMPT uses XML tags like <action_items> and <deadlines> to define a structured output format. Claude will fill in each tagged section, making the response predictable and parseable.

Why XML tags for structured output?

Claude is trained to understand and follow XML-style formatting instructions. By wrapping each field in tags, you get consistent output every time. Without this structure, Claude might return a free-form paragraph that is harder to work with programmatically.

Build the Claude API call

Now you need the function that actually sends an email to Claude and gets the analysis back. This function takes three inputs: the email subject, sender, and body text.

  • Add the analyze_email function below the system prompt in email_analyzer.py:
def analyze_email(subject, sender, body):
    # Send the email to Claude with the analysis system prompt
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=ANALYSIS_SYSTEM_PROMPT,
        messages=[
            {
                "role": "user",
                "content": f"""Analyze this email:

From: {sender}
Subject: {subject}

Body:
{body}""",
            }
        ],
    )
    return response.content[0].text

What does this function do?

The client.messages.create() call sends a request to the claude-sonnet-4-6 model. The system parameter passes your structured prompt, and the messages parameter contains the actual email content formatted as a user message.

The max_tokens=1024 caps the response length. Email analyses are concise, so 1024 tokens is plenty. The function returns the text content from Claude's response.

  • Save email_analyzer.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

from anthropic import Anthropic
import os
from dotenv import load_dotenv

load_dotenv()

client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

ANALYSIS_SYSTEM_PROMPT = """You are an email analysis assistant for a business professional.
When given an email, extract the following in a structured format:

<action_items>List each specific action required, one per line</action_items>
<deadlines>List any deadlines or time-sensitive mentions</deadlines>
<questions>List any questions that need answering</questions>
<recommended_next_step>Suggest the single most important next action</recommended_next_step>
<urgency>Rate as: low, medium, or high</urgency>

Be concise and specific. If a field has no items, write "None identified."
"""


def analyze_email(subject, sender, body):
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=ANALYSIS_SYSTEM_PROMPT,
        messages=[
            {
                "role": "user",
                "content": f"""Analyze this email:

From: {sender}
Subject: {subject}

Body:
{body}""",
            }
        ],
    )
    return response.content[0].text
Test the analyzer with a real email

Time to verify the analyzer works against a real email from your inbox. You will write a small test script that fetches your inbox, lets you pick an email, and runs it through the analyzer.

  • Create a new file called test_analyzer.py and paste the following code:
import asyncio
from graph_client import get_graph_client, list_inbox
from email_analyzer import analyze_email
import re


async def main():
    # Connect to Outlook and fetch inbox
    print("Connecting to Outlook...")
    client = get_graph_client()
    messages = await list_inbox(client)

    # Display available emails
    print("\nYour recent emails:")
    for i, msg in enumerate(messages, 1):
        sender = "Unknown"
        if msg.from_ and msg.from_.email_address:
            sender = msg.from_.email_address.address
        print(f"  [{i}] {msg.subject} (from: {sender})")

    # Let the user pick one
    choice = int(input("\nEnter email number to analyze: ")) - 1
    selected = messages[choice]

    # Extract plain text from the email body
    body = ""
    if selected.body and selected.body.content:
        body = re.sub(r"<[^>]+>", "", selected.body.content)
        body = re.sub(r"\s+", " ", body).strip()

    sender = selected.from_.email_address.address if selected.from_ else "Unknown"

    # Run the analysis
    print("\nAnalyzing email...")
    result = analyze_email(selected.subject, sender, body)

    print("\n--- Analysis ---")
    print(result)
    print("--- End Analysis ---")


if __name__ == "__main__":
    asyncio.run(main())

What does this test script do?

This script connects to your Outlook inbox using the graph_client module you built in Step 3. It lists your recent emails, lets you pick one by number, strips any HTML from the body, and passes the email through your analyze_email function.

The re.sub() calls handle HTML email bodies. Outlook often stores emails as HTML, so this regex strips the tags to get plain text that Claude can analyze cleanly.

  • Run the test script by executing this command:
python test_analyzer.py
  • If prompted, complete the device code sign-in in your browser (same flow as Step 3).
  • Select a supplier email (or any email with action items) from the numbered list.

You should see a structured analysis with action items, deadlines, questions, a recommended next step, and an urgency rating. Each section is wrapped in XML tags.

Seeing an authentication or API error?

  • If you see a message about an invalid API key, open your .env file and confirm ANTHROPIC_API_KEY is set correctly with no extra spaces.
  • If the device code sign-in fails, check that your AZURE_CLIENT_ID and AZURE_TENANT_ID in .env match what your IT admin provided.
  • If you see a module import error, make sure your virtual environment is activated and all packages from requirements.txt are installed.

Help me debug my email analyzer test script.

Your email analyzer is working. Next, you will teach Claude to write replies that match your personal writing style.

Train Your Writing Style

Your email analyzer can now extract action items and deadlines from supplier messages. The next challenge is getting Claude to draft replies that sound like you, not like a generic AI.

The key to natural-sounding AI drafts is few-shot prompting. You give Claude real examples of your writing, and it learns your tone, formality, sentence structure, and sign-off style from those examples. In this step, you will collect your own sent emails and build a loader that formats them into a style guide for Claude.

In this step, get ready to:
  • Create a folder of your sent supplier emails as few-shot style examples.
  • Build a style loader that reads and formats those examples into a prompt.
  • Test the style prompt by asking Claude to describe your writing style back to you.
Create your style examples folder

Claude needs real examples of your writing to replicate your tone. The more representative the examples, the better Claude matches your style.

  • Create the style_examples/ folder in your project directory by running this command:
mkdir style_examples
  • Open your Outlook Sent Items folder in your browser or desktop app.
  • Find 5-10 emails you have sent to suppliers. Look for a variety: follow-ups, confirmations, questions, and status updates.
  • For each email, create a .txt file inside style_examples/ with the email content. Name them sequentially: 01_follow_up.txt, 02_confirmation.txt, etc.

What should each example file look like?

Each file should contain the full email body as you wrote it, including greeting and sign-off. You can redact specific names or sensitive details if needed. Here is an example structure:

Hi Sarah,

Thanks for sending over the updated pricing sheet. I've reviewed it with our procurement team and we're good to move forward with the Q3 order.

Could you confirm the lead time for the 500-unit batch? We'd need delivery by August 15th at the latest.

Let me know if that works.

Best,
Alex

The goal is to capture your natural patterns: how you open emails, how long your sentences are, how you close, and how direct or warm you tend to be.

  • Confirm your files exist by running:
ls style_examples/

You should see your .txt files listed in the output.

Folder is empty or missing?

Make sure you created the style_examples/ folder inside your project directory, not somewhere else on your system. Each file must end with the .txt extension for the loader to find it.

If you cannot access your Outlook Sent Items right now, create 3-5 example files with realistic placeholder emails that match how you typically write. You can replace them with real examples later.

Need help finding your sent emails?

Why few-shot examples instead of fine-tuning?

Anthropic does not offer public fine-tuning for Claude. The recommended approach for customization is detailed system prompts combined with few-shot examples.

This works well for tone matching. Anthropic's own documentation recommends 3-5 structured examples for best results. Your 5-10 email samples give Claude enough signal to replicate your style without overfitting to a single message.

Build the style loader module

Now you need a Python module that reads those example files and formats them into a structured prompt section that Claude can learn from.

  • Create a new file called style_loader.py in your project folder.
  • Add the imports, constant, and the load_style_examples() function by pasting this code:
import os

EXAMPLES_DIR = "style_examples"


def load_style_examples():
    examples = []
    if not os.path.exists(EXAMPLES_DIR):
        os.makedirs(EXAMPLES_DIR)
        return examples

    for filename in sorted(os.listdir(EXAMPLES_DIR)):
        if filename.endswith(".txt"):
            filepath = os.path.join(EXAMPLES_DIR, filename)
            with open(filepath, "r", encoding="utf-8") as f:
                content = f.read()
            examples.append(content)
    return examples

What does this code do?

  • EXAMPLES_DIR = "style_examples" sets the folder name as a constant so you can reference it throughout the module.
  • os.path.exists() checks if the folder exists. If it does not, the function creates it and returns an empty list.
  • sorted(os.listdir(...)) reads all files in alphabetical order. This is why you numbered your files: it gives Claude a consistent, predictable sequence.
  • The .endswith(".txt") filter ensures only your email text files are loaded, ignoring any hidden or system files.
  • Below the load_style_examples() function, add the build_style_prompt() function that formats examples into Claude's prompt:
def build_style_prompt(examples):
    if not examples:
        return "No style examples provided. Write in a professional, concise business tone."

    style_section = "Here are examples of how the user writes emails. Match this tone, formality level, and structure:\n\n"
    for i, example in enumerate(examples, 1):
        style_section += f"<example_{i}>\n{example}\n</example_{i}>\n\n"

    style_section += """Based on these examples, match the user's:
- Level of formality
- Sentence length and structure
- Greeting and sign-off style
- Use of punctuation and emphasis
- Overall tone (direct, warm, formal, casual, etc.)
"""
    return style_section

What does this code do?

  • If no examples are loaded, it returns a safe fallback instructing Claude to use a generic professional tone.
  • Each example is wrapped in numbered XML tags like <example_1> and </example_1>. Claude uses XML tags to clearly distinguish between examples and instructions.
  • The closing instructions tell Claude exactly which dimensions of your writing to pay attention to: formality, sentence structure, greetings, punctuation, and overall tone.
  • Save style_loader.py.

Seeing an indentation error?

Make sure build_style_prompt() is at the same indentation level as load_style_examples(). Both functions should start at column 0 with no leading spaces. Check for any accidental tabs mixed with spaces.

Still stuck? Help me fix an indentation error in my style_loader.py

✔️ Awesome, I've got everything!

Great. Double check you have saved style_loader.py before moving on.

ⓧ I'd like to double check the full code

import os

EXAMPLES_DIR = "style_examples"


def load_style_examples():
    examples = []
    if not os.path.exists(EXAMPLES_DIR):
        os.makedirs(EXAMPLES_DIR)
        return examples

    for filename in sorted(os.listdir(EXAMPLES_DIR)):
        if filename.endswith(".txt"):
            filepath = os.path.join(EXAMPLES_DIR, filename)
            with open(filepath, "r", encoding="utf-8") as f:
                content = f.read()
            examples.append(content)
    return examples


def build_style_prompt(examples):
    if not examples:
        return "No style examples provided. Write in a professional, concise business tone."

    style_section = "Here are examples of how the user writes emails. Match this tone, formality level, and structure:\n\n"
    for i, example in enumerate(examples, 1):
        style_section += f"<example_{i}>\n{example}\n</example_{i}>\n\n"

    style_section += """Based on these examples, match the user's:
- Level of formality
- Sentence length and structure
- Greeting and sign-off style
- Use of punctuation and emphasis
- Overall tone (direct, warm, formal, casual, etc.)
"""
    return style_section
Test the style prompt with Claude

The best way to verify your style loader works is to ask Claude to describe your writing style based on the examples. If Claude can articulate your patterns, it can replicate them.

  • Create a test script called test_style.py by pasting the following:
from anthropic import Anthropic
from style_loader import load_style_examples, build_style_prompt
import os
from dotenv import load_dotenv

load_dotenv()

# Load your email examples and build the style prompt
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
examples = load_style_examples()
style_prompt = build_style_prompt(examples)

print(f"Loaded {len(examples)} style examples.")
print("\nAsking Claude to describe your writing style...\n")

# Ask Claude to analyze and describe the writing style
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    system=style_prompt,
    messages=[
        {
            "role": "user",
            "content": "Based on the examples provided, describe this person's email writing style in 3-4 bullet points. Focus on tone, formality, sentence structure, and sign-off patterns.",
        }
    ],
)

print(response.content[0].text)

What does this test do?

This script loads your examples, builds the style prompt, and passes it to Claude as a system prompt. Then it asks Claude to describe what it sees in your writing patterns. If Claude can identify your tone, greeting style, and sentence structure, the few-shot examples are working.

  • Run the test by executing this command:
python test_style.py

You should see Claude print a description of your writing style based on your examples. It will identify patterns like your greeting style, level of formality, and how you close emails.

Seeing "Loaded 0 style examples"?

This means the loader found no .txt files in style_examples/. Make sure your email files are saved with the .txt extension and are inside the style_examples/ folder in the same directory as test_style.py.

Need more help? Why isn't my style loader finding any example files?

Getting better style results

If Claude's description does not match how you think you write, try adding more diverse examples. Include at least one short email (2-3 sentences) and one longer email (a full paragraph). The variety helps Claude distinguish your actual patterns from coincidental one-offs.

  • Once you have confirmed the test works, delete the test file:
del test_style.py

Your style training data is ready. Next up, you will combine this style prompt with the email analyzer to draft replies that sound like you wrote them yourself.

Draft and Send Replies

Your email analyzer can extract action items and deadlines from supplier messages. Now it's time to close the loop by actually drafting replies that sound like you wrote them.

In this step, you'll combine the style system from the previous step with the email analysis to generate tone-matched reply drafts. You'll also design a safe approval workflow where nothing sends without your explicit confirmation.

In this step, get ready to:
  • Create the email drafting module that generates tone-matched replies using Claude.
  • Design a human-in-the-loop approval flow with send, edit, regenerate, and cancel options.
  • Understand how the send function delivers approved drafts via Microsoft Graph.
Create the email drafter module

The drafter is where your style examples and the email analysis come together. It sends Claude a system prompt that acts as a ghostwriting brief, combining your communication rules with your real email examples.

  • Create a new file called email_drafter.py in your project folder.
  • Add the following code to set up the Anthropic client and define the drafting function:
from anthropic import Anthropic
from style_loader import load_style_examples, build_style_prompt
import os
from dotenv import load_dotenv

load_dotenv()

client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))


def draft_reply(subject, sender, original_body, analysis):
    # Load your sent email examples as few-shot style references
    examples = load_style_examples()
    style_prompt = build_style_prompt(examples)

    # Build the ghostwriter system prompt combining style guide and rules
    system_prompt = f"""You are a ghostwriter for a business professional. Your job is to draft email replies that perfectly match their writing style.

{style_prompt}

Rules:
- Write ONLY the email body (no subject line)
- Do not invent facts or commitments not supported by the context
- Keep the reply focused and actionable
- Match the length typical of the user's examples
"""

    # Call Claude with the original email context and analysis
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=system_prompt,
        messages=[
            {
                "role": "user",
                "content": f"""Draft a reply to this email.

Original email from: {sender}
Subject: {subject}

Original body:
{original_body}

My analysis of this email:
{analysis}

Write a reply that addresses the key points and matches my communication style.""",
            }
        ],
    )
    return response.content[0].text

What does this code do?

The draft_reply() function orchestrates three inputs into a single Claude API call:

  • load_style_examples() and build_style_prompt() pull your sent emails from the style_examples/ folder and format them into a few-shot prompt section.
  • The system prompt combines your style examples with explicit rules that prevent Claude from hallucinating commitments or adding a subject line.
  • The user message provides the full context: who sent the email, what it says, and your analysis of the action items. Claude uses all of this to write a reply that reads like you wrote it.
  • Save email_drafter.py.
  • Confirm the file exists by running:
ls email_drafter.py
  • You should see email_drafter.py listed in the output.

Seeing an import error when you test later?

If Python can't find style_loader, make sure style_loader.py is in the same folder as email_drafter.py. Both files need to be in the project root.

Also check that your virtual environment is activated. You should see the environment name in your terminal prompt.

Why can't Python find my style_loader import?

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

from anthropic import Anthropic
from style_loader import load_style_examples, build_style_prompt
import os
from dotenv import load_dotenv

load_dotenv()

client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))


def draft_reply(subject, sender, original_body, analysis):
    examples = load_style_examples()
    style_prompt = build_style_prompt(examples)

    system_prompt = f"""You are a ghostwriter for a business professional. Your job is to draft email replies that perfectly match their writing style.

{style_prompt}

Rules:
- Write ONLY the email body (no subject line)
- Do not invent facts or commitments not supported by the context
- Keep the reply focused and actionable
- Match the length typical of the user's examples
"""

    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=system_prompt,
        messages=[
            {
                "role": "user",
                "content": f"""Draft a reply to this email.

Original email from: {sender}
Subject: {subject}

Original body:
{original_body}

My analysis of this email:
{analysis}

Write a reply that addresses the key points and matches my communication style.""",
            }
        ],
    )
    return response.content[0].text
Design the human-in-the-loop approval flow

An AI assistant that auto-sends emails on your behalf would be dangerous. One hallucinated commitment or misread tone could damage a supplier relationship.

The safe pattern is "AI proposes, you approve." Your assistant will draft a reply, display it for your review, and then let you decide what happens next.

The approval flow offers four options after displaying each draft:

  • [S]end sends the draft exactly as shown via Microsoft Graph.
  • [E]dit opens a simple input editor so you can rewrite parts of the draft before sending.
  • [R]egenerate asks Claude for a fresh version if the first draft misses the mark.
  • [C]ancel discards the draft entirely. Nothing sends.

Why human-in-the-loop matters for email AI

Claude is excellent at matching tone, but it can still make errors. It might infer a deadline that wasn't stated, agree to terms you haven't approved, or misread the sender's intent.

The approval step catches these issues before they reach your supplier. Over time, as you build trust in the drafts, you'll find yourself sending them with fewer edits. But the safety net is always there.

You'll wire this approval flow into the main CLI menu in the next step. For now, understand that the loop works like this: call draft_reply(), display the result, prompt for a choice, and either send, edit, regenerate, or cancel based on the user's input.

Understand how send_reply delivers approved drafts

When you approve a draft, it needs to travel from your terminal into the recipient's inbox. The send_reply() function in graph_client.py handles this using Microsoft Graph's send mail endpoint.

  • Switch back to graph_client.py and scroll to the send_reply() function at the bottom of the file.
  • Review the function signature:
async def send_reply(client, to_email, subject, body_content):
    message = Message()
    message.subject = subject
    message.body = ItemBody()
    message.body.content_type = BodyType.Text
    message.body.content = body_content
    message.to_recipients = []

    recipient = Recipient()
    recipient.email_address = EmailAddress()
    recipient.email_address.address = to_email
    message.to_recipients.append(recipient)

    request_body = SendMailPostRequestBody()
    request_body.message = message
    request_body.save_to_sent_items = True

    await client.me.send_mail.post(body=request_body)

What does send_reply do?

This function constructs a complete email message using the Microsoft Graph SDK's model classes:

  • It builds a Message object with the subject, body (as plain text), and recipient address.
  • save_to_sent_items = True ensures the email appears in your Outlook Sent folder, just like a normal send.
  • client.me.send_mail.post() sends the message through your authenticated Graph connection using the Mail.Send permission you configured in Microsoft Entra.

When the approval flow calls this function, it prefixes the subject with "Re:" so the reply threads correctly in the recipient's inbox. The approved body (whether the original draft or your edited version) becomes the message content.

Why plain text instead of HTML?

The function uses BodyType.Text rather than HTML for the reply body. Claude generates plain text drafts, and plain text emails render cleanly in every email client. For business supplier communication, plain text also reads as more personal and less automated.

You now have every piece of the puzzle: inbox reading, email analysis, style matching, reply drafting, and sending. Next up, you'll tie them all together into a single menu-driven CLI that runs the full workflow end-to-end.

Build the Main CLI Menu

You now have all the building blocks: a Graph client for Outlook, an email analyzer powered by Claude, a style loader, and a reply drafter. The missing piece is a single entry point that ties them all together into a smooth workflow.

In this step, you will create main.py. This file gives you a menu-driven CLI where you can view your inbox, pick an email, analyze it, draft a tone-matched reply, and send it. All without leaving your terminal.

In this step, get ready to:
  • Create a CLI menu that connects your modules into one interactive workflow.
  • Add error handling for auth failures, rate limits, and API issues.
  • Run the complete end-to-end flow from inbox to sent email.
Create the CLI menu in main.py

The menu will display numbered options and loop until you choose to exit. Each option calls the corresponding function from your existing modules.

  • Create a new file called main.py in your project folder.
  • Add the imports and the display_inbox() helper function by pasting this code:
import asyncio
from graph_client import get_graph_client, list_inbox, get_message, send_reply
from email_analyzer import analyze_email
from email_drafter import draft_reply


def display_inbox(messages):
    print("\n" + "=" * 60)
    print("INBOX")
    print("=" * 60)
    for i, msg in enumerate(messages, 1):
        sender_name = "Unknown"
        if msg.from_ and msg.from_.email_address:
            sender_name = msg.from_.email_address.address
        read_status = " " if msg.is_read else "*"
        print(f"  {read_status}[{i}] {msg.subject}")
        print(f"       From: {sender_name}")
        print(f"       Date: {msg.received_date_time}")
        print()
    print("=" * 60)

What does this code do?

  • The imports pull in every function you built across your other modules: get_graph_client, list_inbox, send_reply from Graph, plus analyze_email and draft_reply from the AI modules.
  • display_inbox() formats your messages into a numbered list. Unread emails get a * marker so they stand out.
  • Below display_inbox(), add the get_email_body_text() helper that strips HTML from email bodies:

def get_email_body_text(message):
    if message.body and message.body.content:
        content = message.body.content
        if message.body.content_type == "html":
            import re
            content = re.sub(r"<[^>]+>", "", content)
            content = re.sub(r"\s+", " ", content).strip()
        return content
    return ""

Why strip HTML?

Outlook emails often arrive with HTML formatting. Feeding raw HTML tags to Claude wastes tokens and confuses the analysis.

The regex approach removes all tags and collapses whitespace into clean plain text. For this project scope, regex is sufficient. A production tool would use a dedicated HTML parser.

  • Now add the main async function with the menu loop. Paste this below get_email_body_text():

async def main():
    print("\n=== AI Email Assistant ===\n")
    print("Connecting to Outlook...")
    client = get_graph_client()

    messages = []
    current_analysis = None
    current_draft = None
    selected_message = None

    while True:
        print("\nOptions:")
        print("  [1] View inbox")
        print("  [2] Analyze an email")
        print("  [3] Draft a reply")
        print("  [4] Send approved draft")
        print("  [0] Exit")

        choice = input("\nChoice: ").strip()

        if choice == "0":
            print("Goodbye!")
            break

        elif choice == "1":
            print("\nFetching inbox...")
            messages = await list_inbox(client)
            display_inbox(messages)

        elif choice == "2":
            if not messages:
                print("Load inbox first (option 1).")
                continue
            num = input("Email number to analyze: ").strip()
            try:
                idx = int(num) - 1
                selected_message = messages[idx]
            except (ValueError, IndexError):
                print("Invalid selection.")
                continue

            sender = selected_message.from_.email_address.address if selected_message.from_ else "Unknown"
            body = get_email_body_text(selected_message)

            print("\nAnalyzing email...")
            current_analysis = analyze_email(
                selected_message.subject, sender, body
            )
            print("\n--- Analysis ---")
            print(current_analysis)
            print("--- End Analysis ---")

        elif choice == "3":
            if not selected_message or not current_analysis:
                print("Analyze an email first (option 2).")
                continue

            sender = selected_message.from_.email_address.address if selected_message.from_ else "Unknown"
            body = get_email_body_text(selected_message)

            while True:
                print("\nDrafting reply...")
                current_draft = draft_reply(
                    selected_message.subject, sender, body, current_analysis
                )
                print("\n--- Draft Reply ---")
                print(current_draft)
                print("--- End Draft ---")

                action = input("\n[S]end / [E]dit / [R]egenerate / [C]ancel: ").strip().upper()

                if action == "S":
                    break
                elif action == "E":
                    print("Enter your edited reply (type END on a new line when done):")
                    lines = []
                    while True:
                        line = input()
                        if line.strip() == "END":
                            break
                        lines.append(line)
                    current_draft = "\n".join(lines)
                    print("\nUpdated draft saved.")
                    break
                elif action == "R":
                    continue
                elif action == "C":
                    current_draft = None
                    print("Draft cancelled.")
                    break

        elif choice == "4":
            if not current_draft or not selected_message:
                print("Draft a reply first (option 3).")
                continue

            sender = selected_message.from_.email_address.address if selected_message.from_ else "Unknown"
            subject = selected_message.subject
            if not subject.lower().startswith("re:"):
                subject = f"Re: {subject}"

            print(f"\nReady to send to: {sender}")
            print(f"Subject: {subject}")
            print(f"Body preview: {current_draft[:100]}...")
            confirm = input("\nConfirm send? [Y/N]: ").strip().upper()

            if confirm == "Y":
                print("Sending...")
                await send_reply(client, sender, subject, current_draft)
                print("Email sent successfully!")
                current_draft = None
                current_analysis = None
                selected_message = None
            else:
                print("Send cancelled.")

        else:
            print("Invalid option.")


if __name__ == "__main__":
    asyncio.run(main())

What does the main function do?

The function starts by connecting to Outlook via get_graph_client(), which triggers the device code authentication flow on first run.

  • Option 1 fetches the 10 most recent inbox messages and displays them as a numbered list.
  • Option 2 lets you pick a message by number, then sends it through your Claude-powered analyzer.
  • Option 3 drafts a reply using your style examples, then gives you a human-in-the-loop inner loop: [S]end, [E]dit, [R]egenerate, or [C]ancel.
  • Option 4 sends the approved draft via Microsoft Graph, adding a Re: prefix to the subject if one is missing.

The asyncio.run(main()) entry point at the bottom starts the async event loop when you run the script directly.

  • Save main.py.

Seeing a syntax error?

  • Check that all f-strings have matching opening and closing braces. A stray { without a } causes an immediate syntax error.
  • Make sure the indentation is consistent. Each elif block must align with the if choice == "0" line above it.
  • Verify that the inner while True: loop inside option 3 is indented one level deeper than the elif choice == "3": block.

Help me fix a syntax error in my main.py menu loop

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and move on to the next substep.

ⓧ I'd like to double check the full code

import asyncio
from graph_client import get_graph_client, list_inbox, get_message, send_reply
from email_analyzer import analyze_email
from email_drafter import draft_reply


def display_inbox(messages):
    print("\n" + "=" * 60)
    print("INBOX")
    print("=" * 60)
    for i, msg in enumerate(messages, 1):
        sender_name = "Unknown"
        if msg.from_ and msg.from_.email_address:
            sender_name = msg.from_.email_address.address
        read_status = " " if msg.is_read else "*"
        print(f"  {read_status}[{i}] {msg.subject}")
        print(f"       From: {sender_name}")
        print(f"       Date: {msg.received_date_time}")
        print()
    print("=" * 60)


def get_email_body_text(message):
    if message.body and message.body.content:
        content = message.body.content
        if message.body.content_type == "html":
            import re
            content = re.sub(r"<[^>]+>", "", content)
            content = re.sub(r"\s+", " ", content).strip()
        return content
    return ""


async def main():
    print("\n=== AI Email Assistant ===\n")
    print("Connecting to Outlook...")
    client = get_graph_client()

    messages = []
    current_analysis = None
    current_draft = None
    selected_message = None

    while True:
        print("\nOptions:")
        print("  [1] View inbox")
        print("  [2] Analyze an email")
        print("  [3] Draft a reply")
        print("  [4] Send approved draft")
        print("  [0] Exit")

        choice = input("\nChoice: ").strip()

        if choice == "0":
            print("Goodbye!")
            break

        elif choice == "1":
            print("\nFetching inbox...")
            messages = await list_inbox(client)
            display_inbox(messages)

        elif choice == "2":
            if not messages:
                print("Load inbox first (option 1).")
                continue
            num = input("Email number to analyze: ").strip()
            try:
                idx = int(num) - 1
                selected_message = messages[idx]
            except (ValueError, IndexError):
                print("Invalid selection.")
                continue

            sender = selected_message.from_.email_address.address if selected_message.from_ else "Unknown"
            body = get_email_body_text(selected_message)

            print("\nAnalyzing email...")
            current_analysis = analyze_email(
                selected_message.subject, sender, body
            )
            print("\n--- Analysis ---")
            print(current_analysis)
            print("--- End Analysis ---")

        elif choice == "3":
            if not selected_message or not current_analysis:
                print("Analyze an email first (option 2).")
                continue

            sender = selected_message.from_.email_address.address if selected_message.from_ else "Unknown"
            body = get_email_body_text(selected_message)

            while True:
                print("\nDrafting reply...")
                current_draft = draft_reply(
                    selected_message.subject, sender, body, current_analysis
                )
                print("\n--- Draft Reply ---")
                print(current_draft)
                print("--- End Draft ---")

                action = input("\n[S]end / [E]dit / [R]egenerate / [C]ancel: ").strip().upper()

                if action == "S":
                    break
                elif action == "E":
                    print("Enter your edited reply (type END on a new line when done):")
                    lines = []
                    while True:
                        line = input()
                        if line.strip() == "END":
                            break
                        lines.append(line)
                    current_draft = "\n".join(lines)
                    print("\nUpdated draft saved.")
                    break
                elif action == "R":
                    continue
                elif action == "C":
                    current_draft = None
                    print("Draft cancelled.")
                    break

        elif choice == "4":
            if not current_draft or not selected_message:
                print("Draft a reply first (option 3).")
                continue

            sender = selected_message.from_.email_address.address if selected_message.from_ else "Unknown"
            subject = selected_message.subject
            if not subject.lower().startswith("re:"):
                subject = f"Re: {subject}"

            print(f"\nReady to send to: {sender}")
            print(f"Subject: {subject}")
            print(f"Body preview: {current_draft[:100]}...")
            confirm = input("\nConfirm send? [Y/N]: ").strip().upper()

            if confirm == "Y":
                print("Sending...")
                await send_reply(client, sender, subject, current_draft)
                print("Email sent successfully!")
                current_draft = None
                current_analysis = None
                selected_message = None
            else:
                print("Send cancelled.")

        else:
            print("Invalid option.")


if __name__ == "__main__":
    asyncio.run(main())
Add error handling for common issues

Your CLI already includes several layers of defensive code. Let's walk through the patterns that protect against the most common runtime failures.

What error handling is built in?

  • Guard clauses prevent out-of-order actions. If the user picks option 2 without loading the inbox first, the menu prints a friendly message and loops back instead of crashing.
  • The try/except (ValueError, IndexError) block in option 2 catches invalid email selections. Typing a letter or a number outside the range triggers a clean Invalid selection. message.
  • Expired tokens are handled by the azure-identity library automatically. DeviceCodeCredential refreshes the token silently or re-prompts the device code flow when a token expires.
  • Graph API rate limits return HTTP 429 responses. The msgraph-sdk has built-in retry middleware that handles 429 responses with exponential backoff before surfacing the error.
  • Claude API errors (network issues, rate limits) surface as Python exceptions from the anthropic SDK. These print a traceback to your terminal with a clear error message describing the failure.

Add custom exception handling later

For a production tool, you would wrap each API call in a try/except block to catch anthropic.APIError and Graph exceptions individually. For this project, the built-in retry behavior and Python's default traceback give you enough information to debug issues.

Run the complete workflow end-to-end

Time to see everything come together. You will run the full cycle: view your inbox, select a supplier email, analyze it, generate a tone-matched draft, review it, and send it.

  • Make sure your virtual environment is active in PowerShell (you should see the (venv) prefix in your prompt).
  • Run the assistant by executing this command:
python main.py

You should see the === AI Email Assistant === banner followed by a device code prompt in your terminal.

  • Follow the device code instructions: visit the URL shown and enter the code to authenticate.
  • Once authenticated, the menu appears. Press 1 and Enter to load your inbox.
  • Pick a supplier email by typing its number and pressing Enter.
  • Press 2 to analyze it. You should see Claude's structured analysis with action items, deadlines, questions, and a recommended next step.
  • Press 3 to draft a reply. Claude generates a response matching your writing style from the examples.
  • Review the draft. Choose S to approve it for sending, E to rewrite it yourself, R to ask Claude for a fresh draft, or C to discard it.
  • Press 4 to send the approved draft. Confirm with Y and check your Outlook Sent Items folder.

What should I see?

After option 1, your terminal shows a numbered list of recent emails with senders and dates. Unread messages have a * next to them.

After option 4 confirms, you see Email sent successfully! and the draft resets. Open Outlook in your browser to verify the reply landed in your Sent Items.

Authentication failing or inbox empty?

  • If the device code prompt doesn't appear, confirm that Allow public client flows is enabled in your app registration's Authentication settings.
  • If authentication succeeds but the inbox returns nothing, verify that your Mail.Read permission has admin consent granted in the Entra admin center.
  • If you see a permissions error when sending, check that Mail.Send also has admin consent.

Help me debug authentication issues with my Microsoft Graph email assistant

Your AI email assistant is fully operational. You have a complete human-in-the-loop workflow where Claude drafts and you decide. Next up, the Secret Mission challenges you to build a weekly digest that prioritizes all your outstanding supplier action items.

Secret mission

Add a Weekly Supplier Digest Command

Your email assistant handles one email at a time right now. What if you could get a single prioritized summary of every unread supplier email from the past week, grouped by urgency? That's exactly what you'll build in this secret mission.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and uses pay-per-use APIs, so there are no ongoing costs unless you actively run the tool.

Resources you used:

  • Project folder containing all Python files, virtual environment, .env file, and style_examples/ folder.
  • Microsoft Entra app registration ("Email Assistant") with delegated permissions.
  • Anthropic API key stored in the Claude Console.

Keep everything running

No action needed. Choose this if you're still actively building or plan to use the email assistant regularly.

  • Your project folder stays on your Desktop with all code intact.
  • Your Microsoft Entra app registration remains active. It only operates when you run the script and authenticate.
  • Your Anthropic API key stays active. You are only charged when you make API calls.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and registrations stay in place for when you return.

  • Stop the script if it is running by pressing Ctrl+C in your terminal.
  • Deactivate your virtual environment by running:
deactivate
  • Your project files, app registration, and API key all remain in place. No ongoing costs are incurred while the tool is idle.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete your project folder by running these commands in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force .\email-assistant
  • Delete the Microsoft Entra app registration. Ask your IT admin to sign in to the Microsoft Entra admin center.
  • Browse to Identity > Applications > App registrations.
  • Select the Email Assistant app from the list.
  • On the Overview page, click Delete and confirm.
  • Revoke your Anthropic API key. Go to the Claude Console at console.anthropic.com.
  • Navigate to your API keys page in Settings.
  • Find the key you created for this project and disable or delete it.

Nice Work!

Nice Work!

Nice work! You just built a fully functional AI email assistant that connects to your real Outlook mailbox, analyzes supplier emails, and drafts replies in your personal writing style.

You've learned how to:

  • Integrated Microsoft Graph API with OAuth 2.0 device code flow to read and send Outlook emails programmatically from a Python CLI.
  • Built a Claude-powered email analyzer that extracts action items, deadlines, and recommended next steps from supplier communication.
  • Created a tone-matching system using few-shot prompting with your own sent emails, and implemented a safe human-in-the-loop approval workflow where nothing sends without your confirmation.
  • Added a weekly digest command that summarizes all unread supplier emails into a prioritized action list grouped by urgency.

Ready to quiz yourself?