Auto-Reply Email Agent

Overview

Learn how to build an email auto-reply agent that automatically responds to incoming emails. This beginner-friendly example demonstrates the core concepts of building with AgentMail: receiving webhooks, processing email events, and sending automated replies.

What You’ll Build

By the end of this guide, you’ll have a working auto-reply agent that:

  1. Receives incoming emails to a dedicated AgentMail inbox
  2. Processes webhook events in real-time
  3. Extracts sender information (name and email)
  4. Generates personalized replies using a template
  5. Sends automated responses back to the sender

Here’s what the user experience looks like:

User sends email → Agentmail inbox receives → Agent processes it → User gets reply
"Hi John, thank you for your email!
I've received your message and will
get back to you within 24 hours..."

Prerequisites

Before you begin, make sure you have:

Required:

Project Setup

Step 1: Create Project Directory

Create a new directory for your agent:

$mkdir auto-reply-agent
>cd auto-reply-agent

Step 2: Create the Agent Code

Create a file named agent.py and paste the following code:

1"""
2Auto-Reply Email Agent
3
4A simple example showing how to build an email auto-reply bot with AgentMail.
5This agent automatically responds to incoming emails with personalized messages.
6"""
7
8import os
9from dotenv import load_dotenv
10
11# Load environment variables before importing AgentMail
12load_dotenv()
13
14from flask import Flask, request, Response
15import ngrok
16from agentmail import AgentMail
17import threading
18
19# Configuration
20PORT = 8080
21INBOX_USERNAME = os.getenv("INBOX_USERNAME", "auto-reply")
22WEBHOOK_DOMAIN = os.getenv("WEBHOOK_DOMAIN")
23
24# Initialize Flask app and AgentMail client
25app = Flask(__name__)
26client = AgentMail()
27processed_messages = set() # Track processed message IDs to prevent duplicates
28
29
30def setup_agentmail():
31 """Create inbox and webhook with idempotency."""
32 print("Setting up AgentMail infrastructure...")
33
34 # Create inbox (or get existing one)
35 try:
36 inbox = client.inboxes.create(
37 username=INBOX_USERNAME,
38 client_id=f"{INBOX_USERNAME}-inbox"
39 )
40 print(f"✓ Inbox created: {inbox.inbox_id}")
41 except Exception as e:
42 if "already exists" in str(e).lower():
43 inbox_id = f"{INBOX_USERNAME}@agentmail.to"
44 class SimpleInbox:
45 def __init__(self, inbox_id):
46 self.inbox_id = inbox_id
47 inbox = SimpleInbox(inbox_id)
48 print(f"✓ Using existing inbox: {inbox.inbox_id}")
49 else:
50 raise
51
52 # Start ngrok tunnel
53 listener = ngrok.forward(PORT, domain=WEBHOOK_DOMAIN, authtoken_from_env=True)
54
55 # Create webhook (or get existing one)
56 try:
57 webhook = client.webhooks.create(
58 url=f"{listener.url()}/webhook/agentmail",
59 event_types=["message.received"],
60 inbox_ids=[inbox.inbox_id],
61 client_id=f"{INBOX_USERNAME}-webhook"
62 )
63 print(f"✓ Webhook created")
64 except Exception as e:
65 if "already exists" in str(e).lower():
66 print(f"Webhook already exists")
67 else:
68 raise
69
70 print(f"\n✓ Setup complete!")
71 print(f"Inbox: {inbox.inbox_id}")
72 print(f"Webhook: {listener.url()}/webhook/agentmail\n")
73
74 return inbox, listener
75
76
77def generate_reply(sender_name, subject):
78 """Generate auto-reply message using a template."""
79 return (
80 f"Hi {sender_name},\n\n"
81 f"Thank you for your email! I've received your message and will get back to you within 24 hours.\n\n"
82 f"If your matter is urgent, please reply with \"URGENT\" in the subject line.\n\n"
83 f"Best regards,\n"
84 f"Auto-Reply Agent"
85 )
86
87
88def process_and_reply(message_id, inbox_id, from_field, subject, message):
89 """Process incoming message and send reply in background."""
90 # Extract sender email and name
91 if '<' in from_field and '>' in from_field:
92 sender_email = from_field.split('<')[1].split('>')[0].strip()
93 sender_name = from_field.split('<')[0].strip()
94 if not sender_name or ',' in sender_name:
95 sender_name = sender_email.split('@')[0].title()
96 else:
97 sender_email = from_field.strip()
98 sender_name = sender_email.split('@')[0].title() if '@' in sender_email else 'Friend'
99
100 # Log incoming email
101 print(f"Processing email from {sender_email}: {subject}")
102
103 # Generate and send auto-reply
104 try:
105 reply_text = generate_reply(sender_name, subject)
106 client.inboxes.messages.reply(
107 inbox_id=inbox_id,
108 message_id=message_id,
109 to=[sender_email],
110 text=reply_text
111 )
112 print(f"Auto-reply sent to {sender_email}\n")
113 except Exception as e:
114 print(f"Error: {e}\n")
115
116
117@app.route('/webhook/agentmail', methods=['POST'])
118def receive_webhook():
119 """Webhook endpoint to receive incoming email notifications."""
120 payload = request.json
121 event_type = payload.get('type') or payload.get('event_type')
122
123 # Ignore outgoing messages
124 if event_type == 'message.sent':
125 return Response(status=200)
126
127 message = payload.get('message', {})
128 message_id = message.get('message_id')
129 inbox_id = message.get('inbox_id')
130 from_field = message.get('from_', '') or message.get('from', '')
131
132 # Validate required fields
133 if not message_id or not inbox_id or not from_field:
134 return Response(status=200)
135
136 # prevent duplicate
137 if message_id in processed_messages:
138 return Response(status=200)
139 processed_messages.add(message_id)
140
141 subject = message.get('subject', '(no subject)')
142
143 # Process in background thread and return immediately
144 thread = threading.Thread(
145 target=process_and_reply,
146 args=(message_id, inbox_id, from_field, subject, message)
147 )
148 thread.daemon = True
149 thread.start()
150
151 return Response(status=200)
152
153
154if __name__ == '__main__':
155 print("\n" + "="*60)
156 print("AUTO-REPLY EMAIL AGENT")
157 print("="*60 + "\n")
158
159 inbox, listener = setup_agentmail()
160
161 print(f"Agent is ready!")
162 print(f"Send emails to: {inbox.inbox_id}")
163 print(f"\nWaiting for incoming emails...\n")
164
165 app.run(port=PORT)

Step 3: Create Requirements File

Create a file named requirements.txt:

agentmail
flask>=3.0.0
ngrok>=1.0.0
python-dotenv>=1.0.0

Step 4: Install Dependencies

Install the required Python packages:

$pip install -r requirements.txt

Step 5: Configure Environment Variables

Create a .env file with your credentials:

1# AgentMail Configuration
2AGENTMAIL_API_KEY=your_agentmail_api_key_here
3
4# Ngrok Configuration
5NGROK_AUTHTOKEN=your_ngrok_authtoken_here
6WEBHOOK_DOMAIN=your-name.ngrok-free.app
7
8# Inbox Settings
9INBOX_USERNAME=auto-reply

Code Walkthrough

Let’s understand how the agent works by breaking down the key components.

Architecture Overview

┌─────────────┐
│ Someone │
│ sends │ ──────► ┌──────────────┐
│ email │ │ AgentMail │
└─────────────┘ │ Inbox │
└──────┬───────┘
│ Webhook
┌──────────────┐
│ Ngrok │
│ Tunnel │
└──────┬───────┘
┌──────────────┐
│ Your Flask │
│ Server │
└──────┬───────┘
┌──────────────┐
│ Generate │
│ & Send │
│ Reply │
└──────────────┘

1. Initialization

1import os
2from dotenv import load_dotenv
3
4# Load environment variables FIRST
5load_dotenv()
6
7from flask import Flask, request, Response
8import ngrok
9from agentmail import AgentMail
10
11# Initialize the AgentMail client
12client = AgentMail() # Reads AGENTMAIL_API_KEY from environment
13app = Flask(__name__)

Key points:

  • Load .env variables before importing AgentMail
  • AgentMail SDK automatically reads AGENTMAIL_API_KEY from environment
  • Flask creates the web server for receiving webhooks

2. Setting Up Infrastructure

The setup_agentmail() function creates your inbox and webhook:

1def setup_agentmail():
2 """Create inbox and webhook with idempotency."""
3
4 # Create inbox (or get existing one)
5 try:
6 inbox = client.inboxes.create(
7 username=INBOX_USERNAME,
8 client_id=f"{INBOX_USERNAME}-inbox" # ← Idempotency key
9 )
10 print(f"✓ Inbox created: {inbox.inbox_id}")
11 except Exception as e:
12 if "already exists" in str(e).lower():
13 # Inbox already exists, that's fine!
14 inbox_id = f"{INBOX_USERNAME}@agentmail.to"
15 inbox = SimpleInbox(inbox_id)

Why client_id?

The client_id parameter makes this operation idempotent - you can run it multiple times without creating duplicates. If the inbox already exists, AgentMail returns the existing one.

1 # Start ngrok tunnel
2 listener = ngrok.forward(PORT, domain=WEBHOOK_DOMAIN, authtoken_from_env=True)
3
4 # Create webhook
5 webhook = client.webhooks.create(
6 url=f"{listener.url()}/webhook/agentmail",
7 event_types=["message.received"], # ← Only subscribe to incoming emails
8 client_id=f"{INBOX_USERNAME}-webhook" # ← Idempotency
9 )

What’s happening:

  1. Ngrok creates a public URL that forwards to localhost:8080
  2. We register a webhook with AgentMail
  3. AgentMail will POST to this URL when emails arrive

3. Processing Webhooks

The webhook endpoint receives incoming email notifications:

1@app.route('/webhook/agentmail', methods=['POST'])
2def receive_webhook():
3 """Webhook endpoint to receive incoming email notifications."""
4 payload = request.json
5 event_type = payload.get('type') or payload.get('event_type')
6
7 # Ignore outgoing messages (prevents infinite loops!)
8 if event_type == 'message.sent':
9 return Response(status=200)

Why ignore message.sent?

When your agent sends a reply, AgentMail triggers a message.sent webhook. If we don’t filter this out, the agent would treat its own replies as new emails and respond to itself infinitely!

By returning 200, we tell AgentMail “I received this webhook successfully, but I’m choosing not to process it.”

4. Extracting Email Data

1 message = payload.get('message', {})
2 message_id = message.get('message_id')
3 inbox_id = message.get('inbox_id')
4 from_field = message.get('from_', '') or message.get('from', '')
5
6 # Validate required fields
7 if not message_id or not inbox_id or not from_field:
8 return Response(status=200) # Gracefully skip incomplete data

Webhook payload structure:

1{
2 "type": "message.received",
3 "message": {
4 "message_id": "abc123...",
5 "inbox_id": "auto-reply@agentmail.to",
6 "from_": "John Doe <john@example.com>",
7 "subject": "Hello",
8 "text": "Email body content..."
9 }
10}

5. Parsing Sender Information

Email addresses can come in different formats. We handle both:

1# Extract sender email and name
2if '<' in from_field and '>' in from_field:
3 # Format: "John Doe <john@example.com>"
4 sender_email = from_field.split('<')[1].split('>')[0].strip()
5 sender_name = from_field.split('<')[0].strip()
6 if not sender_name or ',' in sender_name:
7 # Name is empty or has comma, use email username
8 sender_name = sender_email.split('@')[0].title()
9else:
10 # Format: "john@example.com"
11 sender_email = from_field.strip()
12 sender_name = sender_email.split('@')[0].title()

Examples:

  • "John Doe <john@example.com>" → name: “John Doe”, email: “john@example.com
  • "john@example.com" → name: “John”, email: “john@example.com
  • "Last, First <name@example.com>" → name: “Name”, email: “name@example.com

6. Generating the Reply

1def generate_reply(sender_name, subject):
2 """Generate auto-reply message using a template."""
3 return (
4 f"Hi {sender_name},\n\n"
5 f"Thank you for your email! I've received your message and will get back to you within 24 hours.\n\n"
6 f"If your matter is urgent, please reply with \"URGENT\" in the subject line.\n\n"
7 f"Best regards,\n"
8 f"Auto-Reply Agent"
9 )

This simple template-based approach requires no AI or external APIs. The reply is personalized with the sender’s name.

7. Sending the Reply

1try:
2 reply_text = generate_reply(sender_name, subject)
3 client.inboxes.messages.reply(
4 inbox_id=inbox_id,
5 message_id=message_id,
6 to=[sender_email], # ← Must be a list!
7 text=reply_text
8 )
9 print(f"Auto-reply sent to {sender_email}\n")
10except Exception as e:
11 print(f"Error: {e}\n")
12
13return Response(status=200) # Always return 200 to acknowledge webhook

Important details:

  • to parameter must be a list of email addresses
  • message_id links the reply to the original email (threading)
  • Always return 200 status to acknowledge the webhook
  • Errors are logged but don’t crash the server

Why always return 200?

Even if sending the reply fails, we return 200 to AgentMail. This tells AgentMail “I received and processed this webhook.” If we returned an error status, AgentMail would retry sending the webhook multiple times, which isn’t helpful for application errors.

Running the Agent

Start the agent:

$python agent.py

You should see output like this:

============================================================
AUTO-REPLY EMAIL AGENT
============================================================
Setting up AgentMail infrastructure...
✓ Inbox created: auto-reply@agentmail.to
✓ Webhook created
✓ Setup complete!
Inbox: auto-reply@agentmail.to
Webhook: https://your-name.ngrok-free.app/webhook/agentmail
Agent is ready!
Send emails to: auto-reply@agentmail.to
Reply mode: Template-based
Waiting for incoming emails...
* Running on http://127.0.0.1:8080

Success! Your agent is now running and ready to receive emails.

Leave this terminal window open - closing it will stop the agent.

Testing Your Agent

Let’s verify everything works by sending a test email.

Send a Test Email

  1. Open your personal email (Gmail, Outlook, etc.)

  2. Compose a new email:

    To: auto-reply@agentmail.to
    Subject: Testing my auto-reply agent
    Body: Hi there! This is a test message.
  3. Send the email

Watch the Magic Happen

In your terminal, you should see:

Email from youremail@gmail.com: Testing my auto-reply agent
Auto-reply sent to youremail@gmail.com

Check Your Inbox

Within seconds, you should receive an automated reply:

Hi Youremail,
Thank you for your email! I've received your message and will get back to you within 24 hours.
If your matter is urgent, please reply with "URGENT" in the subject line.
Best regards,
Auto-Reply Agent

It works! You just built and tested your first AgentMail agent.

The agent extracted your name, personalized the message, and sent an instant reply.

Customization

You can customize the auto-reply message by editing the generate_reply() function in agent.py.

The function has access to:

  • sender_name - The sender’s name extracted from their email
  • subject - The subject line of the email

Simply modify the text in the return statement to change what your agent replies with.

Troubleshooting

Common Issues

Problem: Python dependencies not installed.

Solution:

$pip install -r requirements.txt

If using a virtual environment, make sure it’s activated first:

$source venv/bin/activate # macOS/Linux
>venv\Scripts\activate # Windows

Problem: Invalid or missing API key.

Solutions:

  1. Check your .env file has the correct AGENTMAIL_API_KEY
  2. Verify the API key is valid in your AgentMail Dashboard
  3. Make sure there are no extra spaces or quotes around the key
  4. Ensure .env is in the same directory as agent.py

Test your API key:

1from agentmail import AgentMail
2client = AgentMail()
3print(client.inboxes.list()) # Should succeed

Problem: Invalid or missing ngrok auth token.

Solutions:

  1. Get your auth token from ngrok dashboard
  2. Update NGROK_AUTHTOKEN in .env
  3. Verify the token has no extra spaces

Alternatively, configure ngrok globally:

$ngrok config add-authtoken YOUR_TOKEN

Checklist:

  • Is the agent running? (python agent.py should show “Waiting for incoming emails…”)
  • Is ngrok tunnel active? (Check console output for webhook URL)
  • Did you send email to the correct inbox? (Check console for inbox address)
  • Is the webhook URL accessible? Test with: curl https://your-domain.ngrok-free.app/webhook/agentmail

Debug steps:

  1. Add logging to see webhook payloads:
1@app.route('/webhook/agentmail', methods=['POST'])
2def receive_webhook():
3 payload = request.json
4 print(f"📨 Received webhook: {json.dumps(payload, indent=2)}")
5 # ... rest of code ...
  1. Check ngrok dashboard for webhook requests:

  2. Verify webhook is registered:

1client = AgentMail()
2webhooks = client.webhooks.list()
3print(webhooks)

Problem: Another process is using port 8080.

Solution 1: Kill the process using the port

$# macOS/Linux
>lsof -ti:8080 | xargs kill -9
>
># Windows
>netstat -ano | findstr :8080
>taskkill /PID <PID> /F

Solution 2: Use a different port

1PORT = 8081 # Change in agent.py

Problem: Webhook received but no reply sent.

Debug steps:

  1. Check console for error messages
  2. Verify the reply API parameters:
1print(f"Sending to: {sender_email}")
2print(f"From inbox: {inbox_id}")
3print(f"Reply text: {reply_text[:50]}...")
  1. Test the reply API directly:
1client.inboxes.messages.reply(
2 inbox_id="your-inbox@agentmail.to",
3 message_id="test-message-id",
4 to=["test@example.com"],
5 text="Test reply"
6)
  1. Ensure to is a list (common mistake):
1# Wrong
2to=sender_email
3
4# Correct
5to=[sender_email]

Congratulations! You’ve built your first AgentMail agent.

Advanced Feature: AI-Powered Replies

Want to upgrade your agent with intelligent, context-aware responses? You can add AI-powered replies using OpenAI.

Step 1: Install OpenAI

$pip install openai

Step 2: Add your OpenAI API key to .env

1# AI Configuration
2USE_AI_REPLY=true
3OPENAI_API_KEY=sk-your_actual_openai_api_key_here

Step 3: Add thread history and AI reply functions to agent.py

After the generate_reply() function, add:

1def get_thread_history(thread_id):
2 """Fetch conversation history for the thread."""
3 try:
4 thread = client.threads.get(thread_id=thread_id)
5 return thread.messages if hasattr(thread, 'messages') else []
6 except Exception as e:
7 print(f"Failed to fetch thread history: {e}")
8 return []
9
10
11def format_thread_for_ai(messages):
12 """Format thread messages into conversation history for AI."""
13 conversation = []
14
15 for msg in messages:
16 if hasattr(msg, 'from_'):
17 sender = msg.from_
18 text = msg.text or msg.html or ""
19 else:
20 sender = msg.get('from_', '') or msg.get('from', '')
21 text = msg.get('text', '') or msg.get('html', '') or msg.get('body', '')
22
23 if '<' in sender and '>' in sender:
24 sender = sender.split('<')[1].split('>')[0].strip()
25
26 if text:
27 conversation.append(f"From: {sender}\n{text}")
28
29 return "\n\n---\n\n".join(reversed(conversation))
30
31
32def generate_ai_reply(sender_name, email_body, subject, thread_history=""):
33 """Generate AI-powered reply using OpenAI with thread context."""
34 try:
35 context = f"Email thread history:\n\n{thread_history}\n\n---\n\nLatest message from {sender_name}:\nSubject: {subject}\n{email_body}" if thread_history else f"Subject: {subject}\nFrom: {sender_name}\n{email_body}"
36
37 response = openai_client.chat.completions.create(
38 model="gpt-4o-mini",
39 messages=[
40 {
41 "role": "system",
42 "content": "You are an intelligent email assistant. Read the email thread and respond in a helpful, contextual way. If this is a follow-up in a conversation, acknowledge what was previously discussed. If you can provide helpful information based on the context, do so. If the question requires detailed research or expertise you don't have, acknowledge receipt and set expectations. Be conversational, professional, and concise."
43 },
44 {
45 "role": "user",
46 "content": f"{context}\n\nGenerate a helpful reply that considers the conversation history. Keep it concise (2-4 sentences) but be actually helpful if you can address their question or continue the conversation meaningfully."
47 }
48 ],
49 max_tokens=250,
50 temperature=0.7
51 )
52 return response.choices[0].message.content
53 except Exception as e:
54 print(f"AI generation failed, using template: {e}")
55 return generate_reply(sender_name, subject)

Step 4: Update the webhook handler

In the receive_webhook() function, add thread_id extraction and replace the reply generation section:

1 subject = message.get('subject', '(no subject)')
2 thread_id = message.get('thread_id', '') # Add this line
3
4 # Log incoming email
5 print(f"Email from {sender_email}: {subject}")
6
7 # Generate and send auto-reply
8 try:
9 if USE_AI_REPLY:
10 email_body = message.get('text', '') or message.get('body', '')
11
12 # Fetch thread history for context
13 thread_history = ""
14 if thread_id:
15 print(f"Fetching thread history for: {thread_id[:20]}...")
16 messages = get_thread_history(thread_id)
17 if messages:
18 thread_history = format_thread_for_ai(messages)
19 print(f"Found {len(messages)} messages in thread")
20
21 reply_text = generate_ai_reply(sender_name, email_body, subject, thread_history)
22 print("Using AI-generated reply with thread context")
23 else:
24 reply_text = generate_reply(sender_name, subject)
25 print("Using template reply")
26
27 client.inboxes.messages.reply(
28 inbox_id=inbox_id,
29 message_id=message_id,
30 to=[sender_email],
31 text=reply_text
32 )
33 print(f"Auto-reply sent to {sender_email}\n")
34 except Exception as e:
35 print(f"Error: {e}\n")

How it works:

  • Agent fetches entire email thread using client.threads.get(thread_id)
  • Thread history is formatted and passed to OpenAI for context-aware replies
  • AI can reference previous messages and provide more intelligent responses
  • If OpenAI API fails, keep trying for three times

Result:

Your agent now has conversation memory. When replying to follow-up emails, the AI sees the entire conversation history and can provide contextual, intelligent responses that reference previous exchanges instead of generic auto-replies.


If you build something cool with AgentMail, we’d love to hear about it. Share in our Discord community!