← All posts
Guide April 18, 2026 17 mins

Building an AI Agent That Sends Personalized Emails

Learn to build an AI agent that drafts and sends personalized emails using CRM data. Step-by-step guide with code examples and real-world applications.

TM

The Mailable Team

Published April 18, 2026

Building an AI Agent That Sends Personalized Emails

You have a list of prospects. Each one deserves a personalized email. Manually writing fifty variations? That’s not a business strategy—that’s a time sink. An AI agent that reads your CRM data and generates personalized emails on demand can ship that work in minutes, not days.

This guide walks you through building a minimal but functional AI agent that drafts personalized emails using CRM data and an email tool. By the end, you’ll understand how to wire together language models, data sources, and email generation into a system that scales with your team.

What Is an AI Email Agent, and Why Build One?

An AI email agent is a system that takes a prompt (often enriched with data from your CRM), passes it to a language model, and generates or sends emails without human intervention. Think of it as a junior marketer who never sleeps, always follows your tone, and can process hundreds of personalization variables simultaneously.

The business case is straightforward:

  • Speed: Ship sequences in hours instead of weeks.
  • Scale: Handle personalization for hundreds of contacts without hiring more writers.
  • Consistency: Every email reflects your brand voice and strategy.
  • Integration: Wire it directly into your product via API, embed it in workflows, or run it as a standalone tool.

When you’re a small team, you don’t have the budget for enterprise email platforms like Braze or Customer.io. You also don’t have a dedicated email designer or copywriter. An AI agent bridges that gap—it’s the closest thing to having a junior marketer on payroll who costs nothing.

The foundation of any AI agent is understanding how language models work with external tools. Modern LLMs like GPT-4 can’t directly query your database or send emails. They need tools—functions that let them retrieve data, process it, and execute actions. This is called function calling, and it’s the backbone of agentic systems.

Understanding Function Calling and Tool Use

Function calling is the mechanism that lets language models invoke external services. Instead of generating plain text, a model can output structured instructions to call a function—like “retrieve customer data” or “send an email.”

Here’s how it works in practice:

  1. You define a set of tools (functions) your agent can use.
  2. You describe each tool to the model: what it does, what inputs it needs, what it returns.
  3. The model receives a prompt, decides which tools to use, and outputs a function call.
  4. Your code executes that function and returns the result to the model.
  5. The model uses that result to generate the next step or final output.

For example, if you ask your agent to “draft a personalized email for john@acme.com,” the agent might:

  1. Call a “get_customer_data” function with john@acme.com as input.
  2. Receive JSON with his name, company, purchase history, and engagement level.
  3. Call a “generate_email” function with that data plus your template or tone instructions.
  4. Receive a draft email.
  5. Optionally call a “send_email” function to dispatch it.

OpenAI’s official documentation on function calling provides the technical specification. For Python developers, LangChain’s agents tutorial shows how to build this pattern with a popular framework.

The key insight: the model doesn’t “know” how to send email or query your database. You teach it by providing tools. The model learns to call them in sequence to solve problems.

Designing Your Agent’s Toolset

Before you write code, define what your agent needs to do. Start minimal. You need three core tools:

Tool 1: Retrieve CRM Data

Your agent needs to fetch customer information. This might be a direct database query, an API call to Salesforce, HubSpot, or a simple CSV lookup. The tool should accept an identifier (email, customer ID) and return relevant fields.

Example output:

{
  "name": "John Smith",
  "company": "Acme Corp",
  "industry": "Manufacturing",
  "last_purchase": "2024-01-15",
  "purchase_value": "$5,200",
  "engagement_level": "high",
  "pain_points": "Inventory management"
}

This data becomes context for email generation. The more relevant fields you include, the more personalized the output.

Tool 2: Generate Email Content

This is where your AI agent shines. The tool accepts:

  • Customer data (from Tool 1)
  • Email purpose (“nurture,” “upsell,” “win-back”)
  • Tone or brand voice guidelines
  • Optional template or structure

It returns a draft email—subject line and body. This is where you might call Mailable’s API or your own language model endpoint.

Tool 3: Send or Store Email

Depending on your workflow, you might:

  • Send immediately via SMTP or an email service API.
  • Store the draft for human review before sending.
  • Log it to a database for auditing.

For safety and compliance, most teams start with storage and review. You can automate sending once you’re confident in the output quality.

Building a Minimal Agent: Step-by-Step

Let’s build a concrete example. We’ll use Python, OpenAI’s API, and a simple in-memory CRM (you’d swap this for your real database).

Step 1: Set Up Your Environment

Install dependencies:

pip install openai python-dotenv

Create a .env file with your OpenAI API key:

OPENAI_API_KEY=your_key_here

Step 2: Define Your Tools

Tools are Python functions with type hints and docstrings. The model reads the docstring to understand what the function does.

import json
from typing import Any

# Simulated CRM database
CRM_DATA = {
    "john@acme.com": {
        "name": "John Smith",
        "company": "Acme Corp",
        "industry": "Manufacturing",
        "last_purchase": "2024-01-15",
        "purchase_value": "$5,200",
        "engagement_level": "high",
        "pain_points": "Inventory management, cost reduction"
    },
    "jane@techco.com": {
        "name": "Jane Doe",
        "company": "TechCo",
        "industry": "Software",
        "last_purchase": "2023-11-20",
        "purchase_value": "$12,000",
        "engagement_level": "medium",
        "pain_points": "API integration, scaling"
    }
}

def get_customer_data(email: str) -> dict:
    """
    Retrieve customer data from CRM by email address.
    Returns customer profile including name, company, purchase history, and pain points.
    """
    return CRM_DATA.get(email, {"error": "Customer not found"})

def generate_email(customer_data: dict, email_purpose: str, tone: str = "professional") -> dict:
    """
    Generate a personalized email based on customer data and purpose.
    Purpose can be: 'nurture', 'upsell', 'win-back', 'onboarding'.
    Returns subject line and body.
    """
    # In a real system, this would call an LLM or Mailable's API.
    # For now, we'll return a template-based response.
    name = customer_data.get("name", "Valued Customer")
    company = customer_data.get("company", "")
    pain_points = customer_data.get("pain_points", "")
    
    if email_purpose == "nurture":
        subject = f"Quick tip for {company}: solving {pain_points.split(',')[0].strip()}"
        body = f"Hi {name},\n\nI noticed {company} has been focused on {pain_points}. We've helped similar companies in {customer_data.get('industry', 'your industry')} reduce costs by 30%.\n\nWorth a quick chat?\n\nBest regards"
    elif email_purpose == "upsell":
        subject = f"{name}, your next opportunity at {company}"
        body = f"Hi {name},\n\nSince you purchased our {customer_data.get('last_purchase', 'solution')}, we've released features that could save {company} even more time.\n\nLet's talk about what's new.\n\nBest regards"
    else:
        subject = f"We miss you, {name}"
        body = f"Hi {name},\n\nIt's been a while since we last connected. We'd love to catch up and hear how {company} is doing.\n\nBest regards"
    
    return {"subject": subject, "body": body}

def send_email(to_email: str, subject: str, body: str) -> dict:
    """
    Send an email or store it for review.
    Returns status and email ID.
    """
    email_id = f"email_{hash(to_email + subject) % 10000}"
    return {
        "status": "draft_stored",
        "email_id": email_id,
        "to": to_email,
        "subject": subject,
        "message": "Email stored for review. Approve in dashboard to send."
    }

Step 3: Wire Tools to OpenAI’s Function Calling API

Define tool schemas that tell the model what functions exist:

from openai import OpenAI

client = OpenAI()

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_customer_data",
            "description": "Retrieve customer data from CRM by email address",
            "parameters": {
                "type": "object",
                "properties": {
                    "email": {
                        "type": "string",
                        "description": "Customer email address"
                    }
                },
                "required": ["email"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "generate_email",
            "description": "Generate personalized email content based on customer data and purpose",
            "parameters": {
                "type": "object",
                "properties": {
                    "customer_data": {
                        "type": "object",
                        "description": "Customer profile data"
                    },
                    "email_purpose": {
                        "type": "string",
                        "enum": ["nurture", "upsell", "win-back", "onboarding"],
                        "description": "Purpose of the email"
                    },
                    "tone": {
                        "type": "string",
                        "description": "Tone of voice (e.g., professional, casual, friendly)"
                    }
                },
                "required": ["customer_data", "email_purpose"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "send_email",
            "description": "Send email or store for review",
            "parameters": {
                "type": "object",
                "properties": {
                    "to_email": {
                        "type": "string",
                        "description": "Recipient email address"
                    },
                    "subject": {
                        "type": "string",
                        "description": "Email subject line"
                    },
                    "body": {
                        "type": "string",
                        "description": "Email body content"
                    }
                },
                "required": ["to_email", "subject", "body"]
            }
        }
    }
]

Step 4: Execute the Agent Loop

The agent loop is where the magic happens. You send a prompt, the model decides which tools to call, you execute those tools, and feed results back to the model until it completes the task.

def run_agent(user_prompt: str):
    """
    Run the email agent with a given prompt.
    The agent will decide which tools to use and in what order.
    """
    messages = [
        {"role": "system", "content": "You are an AI email agent. Your job is to draft and send personalized emails based on customer data. Always retrieve customer data first, then generate the email, then send it. Be concise and professional."},
        {"role": "user", "content": user_prompt}
    ]
    
    print(f"\nUser: {user_prompt}\n")
    
    # Agentic loop
    while True:
        response = client.chat.completions.create(
            model="gpt-4",
            messages=messages,
            tools=tools,
            tool_choice="auto"
        )
        
        # Check if model wants to use tools
        if response.stop_reason == "tool_calls":
            # Process each tool call
            for tool_call in response.tool_calls:
                tool_name = tool_call.function.name
                tool_args = json.loads(tool_call.function.arguments)
                
                print(f"Agent calling: {tool_name}")
                print(f"Arguments: {tool_args}\n")
                
                # Execute the appropriate tool
                if tool_name == "get_customer_data":
                    result = get_customer_data(tool_args["email"])
                elif tool_name == "generate_email":
                    result = generate_email(tool_args["customer_data"], tool_args["email_purpose"], tool_args.get("tone", "professional"))
                elif tool_name == "send_email":
                    result = send_email(tool_args["to_email"], tool_args["subject"], tool_args["body"])
                else:
                    result = {"error": f"Unknown tool: {tool_name}"}
                
                print(f"Tool result: {json.dumps(result, indent=2)}\n")
                
                # Add tool result to messages
                messages.append({"role": "assistant", "content": response.content})
                messages.append({
                    "role": "user",
                    "content": json.dumps({"tool_name": tool_name, "result": result})
                })
        else:
            # Model has finished (stop_reason == "end_turn" or "stop")
            final_response = response.choices[0].message.content
            print(f"Agent: {final_response}")
            break

# Run the agent
if __name__ == "__main__":
    run_agent("Draft and send a nurture email to john@acme.com")
    run_agent("Create an upsell email for jane@techco.com")

When you run this, the agent will:

  1. Receive your prompt.
  2. Decide to call get_customer_data with john@acme.com.
  3. Receive customer data.
  4. Decide to call generate_email with that data and “nurture” purpose.
  5. Receive the draft email.
  6. Decide to call send_email to store it.
  7. Report back with confirmation.

All of this happens in one API call to OpenAI—the model orchestrates the workflow.

Integrating with Real Email Services

The example above stores emails for review. To actually send, you’d integrate with an email service. Here’s how you’d modify the send_email function to use a real provider:

Using Mailable’s API

If you’re using Mailable to generate and send emails, you’d call their API endpoint directly. Mailable is purpose-built for this use case—it takes a prompt and returns production-ready email templates, which your agent can then dispatch.

import requests

def send_email_via_mailable(to_email: str, subject: str, body: str, api_key: str):
    """
    Send email via Mailable's API.
    Mailable handles rendering, compliance, and delivery.
    """
    response = requests.post(
        "https://api.mailable.dev/send",
        json={
            "to": to_email,
            "subject": subject,
            "body": body
        },
        headers={"Authorization": f"Bearer {api_key}"}
    )
    return response.json()

Using SMTP or SendGrid

For SMTP, you’d use Python’s smtplib:

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

def send_email_via_smtp(to_email: str, subject: str, body: str, smtp_config: dict):
    """
    Send email via SMTP (Gmail, custom server, etc.).
    """
    msg = MIMEMultipart("alternative")
    msg["Subject"] = subject
    msg["From"] = smtp_config["from_email"]
    msg["To"] = to_email
    
    msg.attach(MIMEText(body, "plain"))
    
    with smtplib.SMTP_SSL(smtp_config["smtp_server"], smtp_config["smtp_port"]) as server:
        server.login(smtp_config["username"], smtp_config["password"])
        server.sendmail(smtp_config["from_email"], to_email, msg.as_string())
    
    return {"status": "sent", "to": to_email}

Or use SendGrid’s Python library:

from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail

def send_email_via_sendgrid(to_email: str, subject: str, body: str, api_key: str):
    """
    Send email via SendGrid.
    """
    message = Mail(
        from_email="noreply@yourcompany.com",
        to_emails=to_email,
        subject=subject,
        plain_text_content=body
    )
    sg = SendGridAPIClient(api_key)
    response = sg.send(message)
    return {"status": "sent", "message_id": response.headers.get("X-Message-Id")}

The principle is the same: your agent calls a function, that function dispatches to your email service, and results come back to the agent.

Real-World Example: Building an Outreach Sequence

Let’s say you want to run a win-back campaign. You have 50 inactive customers. Manually writing 50 emails is out of the question. Here’s how your agent handles it:

Define the Campaign

inactive_customers = [
    "john@acme.com",
    "jane@techco.com",
    # ... 48 more
]

campaign_prompt = """
I'm running a win-back campaign for inactive customers.
For each customer in this list, draft a personalized win-back email that:
1. References their last purchase and how long it's been.
2. Mentions a new feature or improvement relevant to their industry.
3. Includes a clear CTA to schedule a call.
4. Uses a friendly but professional tone.

After drafting, store each email for review.

Customers: {customers}
""".format(customers=", ".join(inactive_customers))

run_agent(campaign_prompt)

Your agent would:

  1. Loop through each customer.
  2. Call get_customer_data for each.
  3. Call generate_email with win-back purpose.
  4. Call send_email to store drafts.
  5. Return a summary of all emails created.

All of this happens in one agent execution. You review the drafts in your dashboard, approve, and they ship.

Adding Safety and Guardrails

Before deploying an email agent to production, add guardrails:

1. Approval Workflows

Always require human review before sending. Store drafts, let a human approve, then send.

def send_email(to_email: str, subject: str, body: str, require_approval: bool = True) -> dict:
    """
    Send email or store for review.
    """
    if require_approval:
        # Store draft, return ID for approval
        email_id = f"email_{hash(to_email + subject) % 10000}"
        # Save to database
        return {
            "status": "awaiting_approval",
            "email_id": email_id,
            "approval_url": f"https://yourapp.com/approve/{email_id}"
        }
    else:
        # Send immediately (only for transactional emails)
        return {"status": "sent", "message_id": "..."}

2. Content Validation

Check for spam triggers, compliance issues, or brand violations before sending.

def validate_email_content(subject: str, body: str) -> dict:
    """
    Check email for compliance, spam triggers, etc.
    """
    issues = []
    
    if len(subject) > 100:
        issues.append("Subject line too long (>100 chars)")
    
    if "click here" in body.lower():
        issues.append("Generic CTA detected. Use specific language.")
    
    if body.count("!") > 3:
        issues.append("Too many exclamation marks. Tone too aggressive.")
    
    return {
        "is_valid": len(issues) == 0,
        "issues": issues
    }

3. Rate Limiting

Prevent your agent from sending too many emails too fast, which could trigger spam filters or violate provider rate limits.

from datetime import datetime, timedelta

class RateLimiter:
    def __init__(self, max_emails_per_hour: int = 100):
        self.max_emails_per_hour = max_emails_per_hour
        self.email_timestamps = []
    
    def can_send(self) -> bool:
        now = datetime.now()
        # Remove timestamps older than 1 hour
        self.email_timestamps = [
            ts for ts in self.email_timestamps
            if now - ts < timedelta(hours=1)
        ]
        return len(self.email_timestamps) < self.max_emails_per_hour
    
    def record_send(self):
        self.email_timestamps.append(datetime.now())

Connecting to Your Headless Workflow

If you’re embedding this agent into a product or workflow, you’ll want to expose it via API. Here’s a minimal FastAPI example:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class EmailRequest(BaseModel):
    prompt: str
    customer_email: str
    email_purpose: str

@app.post("/api/generate-email")
async def generate_personalized_email(request: EmailRequest):
    """
    API endpoint to generate a personalized email.
    """
    try:
        # Run the agent with the provided prompt
        result = run_agent(f"{request.prompt} for {request.customer_email}")
        return {"status": "success", "result": result}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/api/email/{email_id}")
async def get_email_draft(email_id: str):
    """
    Retrieve a stored email draft.
    """
    # Fetch from database
    return {"email_id": email_id, "status": "draft", "content": "..."}

@app.post("/api/email/{email_id}/approve")
async def approve_and_send(email_id: str):
    """
    Approve an email draft and send it.
    """
    # Validate, then send
    return {"status": "sent", "email_id": email_id}

This gives you a headless email agent that other tools can call. Your product team can integrate it into workflows. Your marketing team can use it via API.

Advanced Patterns: MCP and Autonomous Workflows

If you want to go further, you can use Model Context Protocol (MCP) to standardize how your agent interacts with tools. This is especially useful if you’re building a larger system with multiple agents.

Instead of defining tools inline, you’d define them as MCP servers that your agent connects to. Mailable supports MCP, which means you can build a workflow where:

  1. Your agent retrieves CRM data via one MCP server.
  2. It calls Mailable’s MCP email server to generate and send.
  3. It logs results to a database via another MCP server.

All standardized, composable, and easy to test.

Avoiding Common Pitfalls

When you’re building email agents, watch out for:

1. Hallucinated Data

LLMs can invent customer details that don’t exist. Always fetch real data from your CRM before generating emails. Don’t let the model “imagine” what a customer might need.

2. Tone Drift

Without clear guidelines, generated emails can sound robotic or off-brand. Provide detailed tone instructions and examples. Test outputs before deploying.

3. Spam and Compliance

Personalized emails are great. But if they trigger spam filters or violate CAN-SPAM, GDPR, or CASL, you’ve got a problem. Always include unsubscribe links, validate sender reputation, and monitor bounce rates.

4. Over-Automation

Not every email should be fully automated. Transactional emails (password resets, order confirmations) are safe. High-stakes outreach (enterprise sales, sensitive accounts) should have human review. Know the difference.

Measuring Success

Once your agent is live, track these metrics:

  • Emails generated: How many did your agent create?
  • Approval rate: What percentage of drafts did humans approve?
  • Send rate: How many approved emails actually shipped?
  • Open rate: Are personalized emails opened more than generic ones?
  • Click-through rate: Do personalized emails drive more engagement?
  • Unsubscribe rate: Is personalization causing more opt-outs?
  • Reply rate: Are recipients replying to agent-generated emails?

Start with a small batch (50–100 emails) and compare against your baseline. If personalized emails outperform, scale.

Connecting Everything: The Full Picture

Here’s how all the pieces fit together:

  1. User provides a prompt: “Draft win-back emails for inactive customers.”
  2. Agent receives the prompt: Loaded with tools and system instructions.
  3. Agent calls get_customer_data: Retrieves profiles for each customer.
  4. Agent calls generate_email: Uses CRM data and purpose to draft personalized emails.
  5. Agent calls send_email: Stores drafts for review.
  6. Human approves: Reviews drafts in a dashboard.
  7. Emails ship: Via SMTP, SendGrid, Mailable, or your provider of choice.
  8. Results tracked: Open rates, click rates, replies logged.
  9. Agent learns: Future prompts can reference past performance.

This is the workflow that lets small teams compete with enterprise email platforms. No designer. No copywriter. Just an AI agent, a prompt, and your CRM data.

If you want to accelerate this further, tools like Mailable handle the email generation and delivery piece. You focus on the agent logic and data integration. That’s the builder-to-builder approach: use specialized tools, wire them together, and ship fast.

Getting Started Today

You don’t need a perfect system to start. Begin with:

  1. A simple CRM lookup: Even a CSV file works.
  2. One email type: Nurture, upsell, or win-back. Pick one.
  3. Manual review before send: Always.
  4. One provider: SMTP, SendGrid, or Mailable.

Build, test, measure. Once you’re confident, add more email types, automate review, scale to more customers.

The code in this guide is minimal on purpose. It’s meant to teach concepts, not to be production-ready out of the box. Real systems need error handling, logging, monitoring, and compliance checks. But the core pattern—prompts → function calls → email generation → delivery—is the foundation.

For a deeper dive into agentic patterns, check out how teams have built email agents that handle customer support or explored the landscape of AI agents for email marketing. You can also see real-world applications of agentic AI in personalized outreach, which highlights both the power and the responsibility of automated email.

The tools exist. The patterns are proven. The only question is: what will you build?

Next Steps

Once you’ve built your first agent:

  • Integrate with your actual CRM: Connect to Salesforce, HubSpot, or your database.
  • Add more tools: Segment customers, fetch product data, check inventory.
  • Expand email types: Onboarding, re-engagement, product updates.
  • Monitor compliance: Set up bounce handling, unsubscribe tracking, spam score monitoring.
  • Automate approval: Use heuristics to auto-approve low-risk emails (e.g., password resets).
  • Connect via API: Let your product team call the agent from their workflows.

The future of email marketing isn’t hiring more writers. It’s building smarter agents that scale with your business. Start small, iterate fast, and ship.