Open-source MCP server for email outreach campaigns. Create multi-step sequences with A/B variants, auto-classify replies with AI, and track conversion rates — all powered by IMAP keywords with zero external database.
outbound-tools/
└── packages/
├── api/ Express + MCP server — the deployable (Railway,
│ via the root Dockerfile)
├── toolkit/ Shared library — IMAP/SMTP email ops, Mailpool
│ client, zod schemas, tool definitions + functionMap
└── cli/ Commander.js CLI — wraps the same toolkit
functions for the terminal (not deployed)
packages/toolkit holds all business logic; api and cli are thin adapters over it, consuming it via workspace:*. Build order matters (pnpm build handles it): toolkit compiles first so its dist/ declarations exist before the consumers typecheck.
- Get a
MAILPOOL_API_KEYfrom Mailpool and connect at least one email account - Deploy to Railway:
- Copy your Railway public URL and add it as an MCP server in your
.claude.json:
{
"mcpServers": {
"outbound-tools": {
"url": "https://your-app.up.railway.app/mcp",
"headers": {
"Authorization": "Bearer your-api-key"
}
}
}
}Enroll contacts into audience segments with their metadata for email personalization. Contact data is stored as marker messages in an IMAP Contacts folder — works even for brand-new contacts you've never emailed before (cold outreach).
add_to_audience({ email: "john@acme.com", firstName: "John", company: "Acme", segments: ["q1_launch"] })
add_to_audience({ email: "jane@corp.io", firstName: "Jane", company: "Corp", segments: ["q1_launch"] })
You can list all segments and their contacts at any time:
list_audiences()
→ { segments: [{ name: "q1_launch", count: 2, contacts: ["john@acme.com", "jane@corp.io"] }] }
A campaign links an audience segment to a multi-step email sequence with A/B variants. When the campaign runs, it pulls contacts from the audience automatically.
create_campaign({
email: "me@mycompany.com",
name: "q1_launch",
audience_segment: "q1_launch",
sequence: [
{
step: 1, delay_days: 0,
variants: [
{ name: "a", weight: 50, subject: "Quick question {{firstName}}", text: "Hi {{firstName}}, I saw {{company}} is..." },
{ name: "b", weight: 50, subject: "{{company}} + us?", text: "Hey {{firstName}}, we help companies like {{company}}..." }
]
},
{
step: 2, delay_days: 3,
variants: [
{ name: "a", weight: 100, subject: "", text: "Just following up on my last email..." }
]
},
{
step: 3, delay_days: 5,
variants: [
{ name: "a", weight: 100, subject: "", text: "Last try {{firstName}} — would love 15 min to show you..." }
]
}
]
})
How sequences work:
- Steps execute in order.
delay_daysis the number of days to wait after the previous step before sending. - Variants enable A/B testing. Each variant has a
weight— with two variants atweight: 50, each gets ~50% of contacts. Useweight: 100for a single variant (no split). - Template variables —
{{firstName}},{{lastName}},{{email}},{{company}}are replaced with each contact's data from the audience. - Empty subject on step 2+ means "reply in the same thread" — the email is sent as a reply to the step 1 email with proper
In-Reply-ToandReferencesheaders, so it threads correctly in the recipient's inbox. - Audience-driven — the campaign doesn't store contacts. It reads them from the audience segment at runtime, so adding/removing contacts from the segment updates who gets sent to.
The campaign config is stored as an IMAP draft on the sender account — no external storage.
start_campaign({ email: "me@mycompany.com", campaign: "q1_launch" })
Each call to start_campaign processes every contact and determines what to do:
| Contact state | Action |
|---|---|
| Never sent to | Send step 1 |
| Received step 1, 3+ days ago | Send step 2 |
| Received step 2, 5+ days ago | Send step 3 |
| Received step 2, only 2 days ago | Skip — delay not elapsed |
| Completed all steps | Skip |
Replied with do_not_contact, unsubscribed, bounced, not_interested, or wrong_person |
Skip — terminal status |
Scheduling: start_campaign is idempotent — call it as often as you want. Set up a daily cron (via Railway cron, a scheduler, or the Claude Code /schedule command) to call it automatically. Each run only sends what's due.
Every sent email is automatically tagged with:
campaign_{name}— marks it as part of this campaignstep_{n}— which step was sentvariant_{name}— which A/B variant was used
Replies need to be classified so start_campaign knows when to stop sending (terminal statuses) and so you can measure conversion.
Option A: Automatic (recommended)
Set ANTHROPIC_API_KEY as an environment variable. The server exposes POST /api/v0/classify which uses Claude Haiku to classify every unprocessed reply. Schedule it daily alongside start_campaign.
How it works:
- Lists unprocessed replies, then calls
list_threads(header-based threading viaReferences/In-Reply-To) to pair each reply with its original sent email - Sends each reply to Claude Haiku for classification into one of 9 statuses
- Tags both the reply (INBOX) and, when the sent original is paired, the sent email (SENT) with the status +
classified - Next time
start_campaignruns, it sees the tagged status and skips contacts with terminal statuses
Option B: Manual / agent-driven
Run the /classify-replies skill in Claude Code. The agent reads each reply, classifies it, and calls set_reply_status to tag the emails.
Option C: Per-reply
Call set_reply_status directly for individual replies:
set_reply_status({ email: "me@mycompany.com", uid: 42, status: "meeting_request", sent_uid: 15 })
This tags both the reply and the matching sent email, removing any previous status first (only one status per reply).
get_campaign_analytics({ email: "me@mycompany.com", campaign: "q1_launch" })
Returns:
{
"campaign": "q1_launch",
"totalSent": 6,
"uniqueContacts": 2,
"totalReplied": 2,
"replyRate": 33.33,
"statuses": { "interested": 1, "meeting_request": 1 },
"statusRates": { "interested": 16.67, "meeting_request": 16.67 },
"steps": {
"step_1": { "sent": 2, "variants": { "variant_a": 1, "variant_b": 1 } },
"step_2": { "sent": 2, "variants": { "variant_a": 2 } },
"step_3": { "sent": 2, "variants": { "variant_a": 2 } }
},
"variants": {
"variant_a": { "sent": 5, "interested": 1 },
"variant_b": { "sent": 1, "meeting_request": 1 }
}
}Key metrics:
- Reply rate — % of sent emails that got a classified reply
- Positive reply rate —
interested+meeting_request+information_requestas % of sent - Conversion rate —
meeting_requestas % of sent (meetings booked)
Rates are reported as
"unknown"(not0) when there is nothing to measure — no sent emails, or no replies classified yet. Since status tags are only written by the classifier (theANTHROPIC_API_KEYendpoint or the/classify-repliesskill), an unclassified mailbox reads"unknown"rather than a misleading0%.
- Per-step performance — see which step generates the most replies
- A/B comparison — compare variants by reply count and status breakdown to pick the winner
For per-account analytics (across all campaigns):
get_email_account_analytics({ email: "me@mycompany.com" })
Outbound Tools uses IMAP keywords as a native tagging and classification layer, directly on the email account itself. No external database, no third-party analytics platform, no syncing pipelines.
- Zero infrastructure. Tags like
interested,bounced,do_not_contactare stored as IMAP keywords on each message. Campaign configs are stored as IMAP drafts. Contact metadata lives in an IMAPContactsfolder. The mailbox is the database. - Instant querying. IMAP SEARCH natively supports keyword filtering. Fetching all positive replies or computing bounce rates is a single IMAP command, not a full-table scan.
- Portable and durable. Tags live on the mail server. Switch clients, migrate tools, or read from any IMAP client. Your classification data follows the emails.
- No state to manage. The
classifiedkeyword makes processing incremental. Each run only touches new emails, then marks them done. No cursor, no offset table, no checkpoint file. - Works at scale. Each account is independent. Add 10 or 1,000 mailboxes and the architecture stays the same. IMAP handles the storage and indexing.
Every feature is built on standard IMAP capabilities — no proprietary extensions, no external database.
| IMAP feature | What we use it for |
|---|---|
| Keywords (custom flags) | Tags on emails: reply statuses (interested, bounced, ...), campaign tracking (campaign_q1, step_1, variant_a), audience segments (audience_vip), processing state (classified). Queryable via IMAP SEARCH. |
| SEARCH | Count emails by keyword for analytics (get_email_account_analytics), find unclassified emails, filter by tag expressions. Single command, server-side — no full scan needed. |
| APPEND | Save sent emails to Sent folder, create drafts, store campaign configs as draft messages, create contact marker messages in the Contacts folder. All without SMTP. |
Folder creation (CREATE) |
Auto-create Contacts folder for contact metadata storage. |
| Drafts folder | Store campaign configs as JSON-body draft messages tagged campaign_def. Also used for standard draft management (create, update, send, delete). |
| Contacts folder (custom) | Store contact marker messages with JSON metadata (firstName, lastName, company) and audience flags. Enables cold outreach — contacts exist in the audience before any email is exchanged. |
| Envelope (FROM/TO) | Extract sender/recipient from messages for audience scanning, thread matching, and reply detection — without parsing the full message body. |
Special-use flags (\Sent, \Drafts) |
Auto-discover Sent and Drafts folders across Gmail, Outlook, Mailpool, and other providers. |
| STORE (flag add/remove) | Add and remove tags on messages: set_reply_status ensures one status at a time, add_email_tag/remove_email_tag for arbitrary tagging. |
EXPUNGE (DELETE) |
Delete emails, drafts, and campaign configs. |
| Source fetch | Fetch full RFC822 source for parsing headers (Message-ID, In-Reply-To, References), body, and attachments. Used by reply_to_email, forward_email, get_email, and get_attachment. |
The classifier assigns exactly one status per reply. Use list_reply_statuses to see all available statuses.
| Status | Meaning | Terminal? |
|---|---|---|
interested |
Positive — shows interest, wants to learn more | No |
meeting_request |
Explicitly asked for or accepted a meeting | No |
information_request |
Asked for more details, pricing, or documentation | No |
not_interested |
Polite decline, not a fit right now | Yes |
wrong_person |
Not the right contact, may have referred someone else | Yes |
do_not_contact |
Hard stop — hostile, legal, or compliance concern | Yes |
out_of_office |
Auto-reply or out-of-office response | No |
unsubscribed |
Asked to stop receiving emails | Yes |
bounced |
Delivery failure or bounce notification | Yes |
"Terminal" means start_campaign will stop sending follow-ups to that contact.
list_sent_emails and list_received_emails accept a tag_filter parameter with boolean expressions:
interested -- has tag
meeting_request OR interested -- high-intent replies
NOT classified -- unprocessed emails
do_not_contact OR unsubscribed -- hard stops
campaign_q1_launch AND step_1 -- first step of a campaign
(interested OR meeting_request) AND classified -- combine with parentheses
| Tool | Description |
|---|---|
create_campaign |
Define a campaign with audience segment and multi-step sequence with A/B variants |
start_campaign |
Execute the campaign — sends next pending steps, respects delays and terminal statuses |
get_campaign |
Get the full campaign config |
list_campaigns |
List all campaigns on an account |
delete_campaign |
Delete a campaign config |
get_campaign_analytics |
Full report: reply rate, status breakdown, per-step + per-variant A/B performance |
| Tool | Description |
|---|---|
list_email_accounts |
List all registered mailboxes with status and domain info |
get_email_account_analytics |
Per-account analytics: sent, replied, reply rate, status breakdown |
| Tool | Description |
|---|---|
send_email |
Send an email via SMTP. Auto-saves to Sent folder |
reply_to_email |
Reply in-thread with proper threading headers |
reply_all_to_email |
Reply-all (original To + CC minus yourself) |
forward_email |
Forward with quoted body and original attachments |
| Tool | Description |
|---|---|
list_received_emails |
Paginated inbox emails with tag_filter support |
list_sent_emails |
Paginated sent emails with tag_filter support |
get_email |
Single email by UID — full body, attachments, headers |
get_email_raw |
Raw RFC822 source |
delete_email |
Delete by UID |
get_attachment |
Download attachment (base64) by index |
| Tool | Description |
|---|---|
list_threads |
List all conversation threads for one account via RFC References/In-Reply-To headers (true threading), paginated |
get_thread |
Get all messages in a conversation thread by subject |
| Tool | Description |
|---|---|
list_drafts |
Paginated draft listing |
get_draft |
Single draft by UID |
create_draft |
Compose and save without sending |
update_draft |
Replace draft content |
delete_draft |
Delete a draft |
send_draft |
Send a draft, move to Sent, remove from Drafts |
| Tool | Description |
|---|---|
add_email_tag |
Add an IMAP keyword to a message |
remove_email_tag |
Remove an IMAP keyword from a message |
| Tool | Description |
|---|---|
list_reply_statuses |
List available reply classification statuses |
set_reply_status |
Set reply status on received + sent email (one status at a time) |
| Tool | Description |
|---|---|
add_to_audience |
Add a contact to audience segments with optional metadata (firstName, lastName, company) |
remove_from_audience |
Remove a contact from segments |
list_audiences |
List all segments with contacts |
| Variable | Required | Description |
|---|---|---|
MAILPOOL_API_KEY |
Yes | Your Mailpool API key for email account access |
API_KEY |
Yes | Secures the MCP server and /api/v0/classify endpoint. Auto-generated on Railway via ${{secret()}}. Pass as Authorization: Bearer <key> header or ?api_key=<key> query param. |
ANTHROPIC_API_KEY |
No | Enables auto-classification via POST /api/v0/classify. Only needed if you want the server to classify replies automatically. Without it, use the /classify-replies agent skill instead. |
integrations/airbyte/source-manifest.yml is a low-code connector manifest that syncs the read-only endpoints — accounts, threads, received/sent emails, drafts, analytics, campaigns, audiences — as Airbyte streams. Per-account streams fan out from accounts, so one sync covers every mailbox.
In the Airbyte Connector Builder, choose Import a YAML manifest, paste the file, then set:
api_url— your server URL, e.g.https://your-app.up.railway.appapi_key— yourAPI_KEY