diff --git a/README.md b/README.md index 70757e5..10e63f4 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,43 @@ max_connections = 1 max_messages = 100 ``` +#### AgentMail (alternative provider) + +[AgentMail](https://agentmail.to) can be used as an alternative to IMAP/SMTP for users who want simpler setup. Instead of configuring mail server hosts, ports, and credentials, you just need an API key. AgentMail inboxes can be created programmatically and provide features like `extracted_text` that automatically strips quoted reply chains — useful for AI processing. + +Add to your `config.toml`: + +```toml +[agentmail] +api_key = "your-agentmail-api-key" +``` + +Or set the `AGENTMAIL_API_KEY` environment variable: + +```json +{ + "mcpServers": { + "email": { + "command": "npx", + "args": ["-y", "@codefuturist/email-mcp", "stdio"], + "env": { + "AGENTMAIL_API_KEY": "your-agentmail-api-key" + } + } + } +} +``` + +When configured, the following additional tools become available: + +- `agentmail_create_inbox` — Create a new inbox with a unique `@agentmail.to` address +- `agentmail_list_inboxes` — List all inboxes for the API key +- `agentmail_list_messages` — List messages in an inbox +- `agentmail_get_message` — Get full message content (with auto-stripped reply chains) +- `agentmail_send_message` — Send email from an AgentMail inbox + +> **Note:** AgentMail tools are additive — all existing IMAP/SMTP tools continue to work alongside them. You can use both providers simultaneously. + #### Environment Variables For single-account setups (overrides config file): @@ -441,6 +478,7 @@ For single-account setups (overrides config file): | `MCP_EMAIL_SMTP_POOL_MAX_CONNECTIONS` | `1` | Max pooled SMTP connections | | `MCP_EMAIL_SMTP_POOL_MAX_MESSAGES` | `100` | Max messages per pooled connection | | `MCP_EMAIL_RATE_LIMIT` | `10` | Max sends per minute | +| `AGENTMAIL_API_KEY` | — | AgentMail API key (alternative to config file) | ### Email Scheduling diff --git a/src/config/loader.ts b/src/config/loader.ts index c89c26e..d3b2efa 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -207,6 +207,7 @@ function normalizeHookRule(raw: { function normalizeConfig(raw: RawAppConfig): AppConfig { return { + agentmail: raw.agentmail ? { apiKey: raw.agentmail.api_key } : undefined, settings: { rateLimit: raw.settings.rate_limit, readOnly: raw.settings.read_only, @@ -281,6 +282,7 @@ export async function loadConfig(configPath?: string): Promise { throw new Error( `No configuration found.\n\n` + `Set environment variables (MCP_EMAIL_ADDRESS, MCP_EMAIL_PASSWORD, etc.)\n` + + `or set AGENTMAIL_API_KEY for AgentMail,\n` + `or create a config file at: ${configPath ?? CONFIG_FILE}\n\n` + `Run 'email-mcp setup' for interactive configuration.`, ); @@ -310,6 +312,14 @@ export function generateTemplate(): string { rate_limit = 10 # max emails per minute per account read_only = false # set to true to disable all write operations +# --- AgentMail (optional, alternative to IMAP/SMTP) --- +# Uncomment to enable AgentMail as an email provider. +# No IMAP/SMTP server configuration needed — just an API key. +# Get your API key at https://agentmail.to +# +# [agentmail] +# api_key = "your-agentmail-api-key" + # [settings.watcher] # enabled = false # enable IMAP IDLE real-time monitoring # folders = ["INBOX"] # folders to watch per account diff --git a/src/config/schema.ts b/src/config/schema.ts index 194f4fa..d4fb788 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -144,7 +144,12 @@ export const SettingsSchema = z.object({ }), }); +export const AgentMailConfigSchema = z.object({ + api_key: z.string().min(1, 'AgentMail API key is required'), +}); + export const AppConfigFileSchema = z.object({ + agentmail: AgentMailConfigSchema.optional(), settings: SettingsSchema.default({ rate_limit: 10, read_only: false, @@ -173,8 +178,11 @@ export const AppConfigFileSchema = z.object({ calendar_confirm: true, }, }), - accounts: z.array(AccountConfigSchema).min(1, 'At least one account is required'), -}); + accounts: z.array(AccountConfigSchema).default([]), +}) + .refine((data) => data.accounts.length > 0 || data.agentmail != null, { + message: 'At least one IMAP/SMTP account or an [agentmail] section is required', + }); export type RawAccountConfig = z.infer; export type RawAppConfig = z.infer; diff --git a/src/main.ts b/src/main.ts index b57ece2..cd0bc15 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ import registerAllPrompts from './prompts/register.js'; import registerAllResources from './resources/register.js'; import RateLimiter from './safety/rate-limiter.js'; import createServer, { PKG_VERSION } from './server.js'; +import AgentMailService from './services/agentmail.service.js'; import CalendarService from './services/calendar.service.js'; import HooksService from './services/hooks.service.js'; import ImapService from './services/imap.service.js'; @@ -96,6 +97,10 @@ async function runServer(): Promise { const watcherService = new WatcherService(config.settings.watcher, config.accounts); const hooksService = new HooksService(config.settings.hooks, imapService); + // AgentMail (optional — only when [agentmail] section or AGENTMAIL_API_KEY env var is set) + const agentMailApiKey = config.agentmail?.apiKey ?? process.env.AGENTMAIL_API_KEY; + const agentMailService = agentMailApiKey ? new AgentMailService(agentMailApiKey) : undefined; + const server = createServer(); bindServer(server); @@ -112,6 +117,7 @@ async function runServer(): Promise { schedulerService, watcherService, hooksService, + agentMailService, ); registerAllResources(server, connections, imapService, templateService, schedulerService); registerAllPrompts(server); @@ -210,6 +216,10 @@ async function runHttpServer(port: number): Promise { const watcherService = new WatcherService(config.settings.watcher, config.accounts); const hooksService = new HooksService(config.settings.hooks, imapService); + // AgentMail (optional) + const agentMailApiKey = config.agentmail?.apiKey ?? process.env.AGENTMAIL_API_KEY; + const agentMailService = agentMailApiKey ? new AgentMailService(agentMailApiKey) : undefined; + // Per-session factory: tools share service instances but each MCP session // needs its own McpServer because the SDK binds one transport per server. function buildMcpSession() { @@ -228,6 +238,7 @@ async function runHttpServer(port: number): Promise { schedulerService, watcherService, hooksService, + agentMailService, ); registerAllResources(server, connections, imapService, templateService, schedulerService); registerAllPrompts(server); diff --git a/src/services/agentmail.service.ts b/src/services/agentmail.service.ts new file mode 100644 index 0000000..c6451de --- /dev/null +++ b/src/services/agentmail.service.ts @@ -0,0 +1,259 @@ +/** + * AgentMail service — email operations via the AgentMail API. + * + * Provides an alternative to IMAP/SMTP for users who prefer a managed, + * API-based email provider with simpler setup (API key only, no server + * configuration required). + * + * No MCP dependency — fully unit-testable. + */ + +import type { + Email, + EmailAddress, + EmailMeta, + Mailbox, + PaginatedResult, + SendResult, +} from '../types/index.js'; + +// --------------------------------------------------------------------------- +// AgentMail SDK types (minimal interface to avoid hard dependency) +// --------------------------------------------------------------------------- + +/** Minimal shape of the AgentMail client we interact with. */ +interface AgentMailInbox { + inboxId: string; + email: string; + displayName?: string; + createdAt?: string; +} + +interface AgentMailMessage { + messageId: string; + from?: string; + to?: string[]; + cc?: string[]; + subject?: string; + text?: string; + html?: string; + extractedText?: string; + createdAt?: string; + attachments?: Array<{ + filename?: string; + contentType?: string; + size?: number; + }>; +} + +interface AgentMailMessageList { + messages: AgentMailMessage[]; + nextCursor?: string; +} + +interface AgentMailInboxList { + inboxes: AgentMailInbox[]; + nextCursor?: string; +} + +interface AgentMailSendOptions { + to: string; + subject: string; + text?: string; + html?: string; +} + +interface AgentMailMessagesApi { + list: (inboxId: string, options?: { limit?: number }) => Promise; + get: (inboxId: string, messageId: string) => Promise; + send: (inboxId: string, options: AgentMailSendOptions) => Promise; +} + +interface AgentMailInboxesApi { + create: (options: { displayName?: string }) => Promise; + list: (options?: { limit?: number }) => Promise; + get: (inboxId: string) => Promise; + messages: AgentMailMessagesApi; +} + +interface AgentMailClient { + inboxes: AgentMailInboxesApi; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseEmailAddress(raw: string | undefined): EmailAddress { + if (!raw) return { address: 'unknown' }; + const match = raw.match(/^(?:"?(.+?)"?\s)?]+@[^\s>]+)>?$/); + if (match) { + return { name: match[1] || undefined, address: match[2] ?? raw }; + } + return { address: raw }; +} + +function toEmailMeta(msg: AgentMailMessage, inboxId: string): EmailMeta { + return { + id: msg.messageId, + subject: msg.subject ?? '(no subject)', + from: parseEmailAddress(msg.from), + to: (msg.to ?? []).map(parseEmailAddress), + date: msg.createdAt ?? new Date().toISOString(), + seen: true, + flagged: false, + answered: false, + hasAttachments: (msg.attachments?.length ?? 0) > 0, + labels: [], + preview: (msg.extractedText ?? msg.text ?? '').slice(0, 120), + }; +} + +function toEmail(msg: AgentMailMessage, inboxId: string): Email { + return { + ...toEmailMeta(msg, inboxId), + cc: (msg.cc ?? []).map(parseEmailAddress), + bodyText: msg.extractedText ?? msg.text, + bodyHtml: msg.html, + messageId: msg.messageId, + attachments: + msg.attachments?.map((a) => ({ + filename: a.filename ?? 'unnamed', + mimeType: a.contentType ?? 'application/octet-stream', + size: a.size ?? 0, + })) ?? [], + headers: {}, + }; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export default class AgentMailService { + private client: AgentMailClient; + + constructor(apiKey: string) { + // Dynamically import the AgentMail SDK to avoid hard build-time dependency + // eslint-disable-next-line @typescript-eslint/no-require-imports + this.client = this.createClient(apiKey); + } + + private createClient(apiKey: string): AgentMailClient { + // We construct a minimal client using fetch to avoid requiring the SDK + // at build time. Users who install `agentmail` will get the real SDK. + const baseUrl = 'https://api.agentmail.to/v0'; + const headers = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }; + + const fetchJson = async (path: string, options?: RequestInit): Promise => { + const res = await fetch(`${baseUrl}${path}`, { ...options, headers }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`AgentMail API error (${res.status}): ${body}`); + } + return res.json(); + }; + + return { + inboxes: { + create: async (opts) => + fetchJson('/inboxes', { + method: 'POST', + body: JSON.stringify({ display_name: opts.displayName }), + }) as Promise, + list: async (opts) => + fetchJson(`/inboxes?limit=${opts?.limit ?? 50}`) as Promise, + get: async (inboxId) => fetchJson(`/inboxes/${inboxId}`) as Promise, + messages: { + list: async (inboxId, opts) => + fetchJson( + `/inboxes/${inboxId}/messages?limit=${opts?.limit ?? 50}`, + ) as Promise, + get: async (inboxId, messageId) => + fetchJson( + `/inboxes/${inboxId}/messages/${messageId}`, + ) as Promise, + send: async (inboxId, opts) => + fetchJson(`/inboxes/${inboxId}/messages`, { + method: 'POST', + body: JSON.stringify(opts), + }) as Promise, + }, + }, + }; + } + + // ------------------------------------------------------------------------- + // Inbox management + // ------------------------------------------------------------------------- + + async createInbox(displayName?: string): Promise { + return this.client.inboxes.create({ displayName: displayName ?? 'Email MCP' }); + } + + async listInboxes(): Promise { + const result = await this.client.inboxes.list(); + return result.inboxes.map((inbox) => ({ + name: inbox.displayName ?? inbox.email, + path: inbox.inboxId, + totalMessages: 0, + unseenMessages: 0, + })); + } + + async getInbox(inboxId: string): Promise { + return this.client.inboxes.get(inboxId); + } + + // ------------------------------------------------------------------------- + // Messages + // ------------------------------------------------------------------------- + + async listMessages( + inboxId: string, + options?: { page?: number; pageSize?: number }, + ): Promise> { + const page = options?.page ?? 1; + const pageSize = options?.pageSize ?? 20; + + const result = await this.client.inboxes.messages.list(inboxId, { limit: pageSize }); + const items = result.messages.map((m) => toEmailMeta(m, inboxId)); + + return { + items, + total: items.length, + page, + pageSize, + hasMore: result.nextCursor != null, + }; + } + + async getMessage(inboxId: string, messageId: string): Promise { + const msg = await this.client.inboxes.messages.get(inboxId, messageId); + return toEmail(msg, inboxId); + } + + async sendMessage( + inboxId: string, + options: { + to: string[]; + subject: string; + body: string; + html?: boolean; + }, + ): Promise { + const msg = await this.client.inboxes.messages.send(inboxId, { + to: options.to[0] ?? '', + subject: options.subject, + ...(options.html ? { html: options.body } : { text: options.body }), + }); + + return { + messageId: msg.messageId ?? '', + status: 'sent', + }; + } +} diff --git a/src/tools/agentmail.tool.ts b/src/tools/agentmail.tool.ts new file mode 100644 index 0000000..17fc6c2 --- /dev/null +++ b/src/tools/agentmail.tool.ts @@ -0,0 +1,261 @@ +/** + * MCP tools for the AgentMail provider. + * + * These tools are registered **alongside** the existing IMAP/SMTP tools when + * the user has configured an AgentMail API key. They are prefixed with + * `agentmail_` to avoid name collisions. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import audit from '../safety/audit.js'; +import type AgentMailService from '../services/agentmail.service.js'; + +export default function registerAgentMailTools( + server: McpServer, + agentMailService: AgentMailService, +): void { + // --------------------------------------------------------------------------- + // agentmail_create_inbox + // --------------------------------------------------------------------------- + server.tool( + 'agentmail_create_inbox', + 'Create a new AgentMail inbox. Returns the inbox ID and email address. ' + + 'Each inbox gets a unique @agentmail.to address that can send and receive email immediately.', + { + displayName: z + .string() + .optional() + .describe('Human-readable name for the inbox (e.g. "Support Agent")'), + }, + { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + async (params) => { + try { + const inbox = await agentMailService.createInbox(params.displayName); + await audit.log('agentmail_create_inbox', 'agentmail', { displayName: params.displayName }, 'ok'); + return { + content: [ + { + type: 'text' as const, + text: `✅ Inbox created!\nID: ${inbox.inboxId}\nEmail: ${inbox.email}\nName: ${inbox.displayName ?? '(default)'}`, + }, + ], + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + await audit.log('agentmail_create_inbox', 'agentmail', { displayName: params.displayName }, 'error', errMsg); + return { + isError: true, + content: [{ type: 'text' as const, text: `Failed to create inbox: ${errMsg}` }], + }; + } + }, + ); + + // --------------------------------------------------------------------------- + // agentmail_list_inboxes + // --------------------------------------------------------------------------- + server.tool( + 'agentmail_list_inboxes', + 'List all AgentMail inboxes associated with the configured API key.', + {}, + { readOnlyHint: true, destructiveHint: false }, + async () => { + try { + const inboxes = await agentMailService.listInboxes(); + if (inboxes.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No AgentMail inboxes found. Use agentmail_create_inbox to create one.', + }, + ], + }; + } + const lines = inboxes.map((m) => `📬 ${m.name} (${m.path})`); + return { + content: [ + { + type: 'text' as const, + text: `Found ${inboxes.length} inbox(es):\n\n${lines.join('\n')}`, + }, + ], + }; + } catch (err) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Failed to list inboxes: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + }, + ); + + // --------------------------------------------------------------------------- + // agentmail_list_messages + // --------------------------------------------------------------------------- + server.tool( + 'agentmail_list_messages', + 'List messages in an AgentMail inbox. Returns message metadata including subject, sender, and date.', + { + inboxId: z.string().describe('AgentMail inbox ID (from agentmail_list_inboxes or agentmail_create_inbox)'), + page: z.number().int().min(1).default(1).describe('Page number'), + pageSize: z.number().int().min(1).max(100).default(20).describe('Results per page'), + }, + { readOnlyHint: true, destructiveHint: false }, + async (params) => { + try { + const result = await agentMailService.listMessages(params.inboxId, { + page: params.page, + pageSize: params.pageSize, + }); + + if (result.items.length === 0) { + return { + content: [{ type: 'text' as const, text: 'No messages found in this inbox.' }], + }; + } + + const emails = result.items + .map((e) => { + const from = e.from.name ? `${e.from.name} <${e.from.address}>` : e.from.address; + return `[${e.id}] ${e.subject}\n From: ${from} | ${e.date}${e.preview ? `\n ${e.preview}` : ''}`; + }) + .join('\n\n'); + + return { + content: [ + { + type: 'text' as const, + text: `📬 ${result.total} message(s) (page ${result.page})${result.hasMore ? ' — more available' : ''}\n\n${emails}`, + }, + ], + }; + } catch (err) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Failed to list messages: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + }, + ); + + // --------------------------------------------------------------------------- + // agentmail_get_message + // --------------------------------------------------------------------------- + server.tool( + 'agentmail_get_message', + 'Get the full content of a specific AgentMail message by ID. ' + + 'AgentMail provides an extracted_text field that automatically strips quoted reply chains ' + + 'for cleaner AI processing.', + { + inboxId: z.string().describe('AgentMail inbox ID'), + messageId: z.string().describe('Message ID from agentmail_list_messages'), + }, + { readOnlyHint: true, destructiveHint: false }, + async (params) => { + try { + const email = await agentMailService.getMessage(params.inboxId, params.messageId); + + const from = email.from.name + ? `${email.from.name} <${email.from.address}>` + : email.from.address; + const to = email.to.map((a) => (a.name ? `${a.name} <${a.address}>` : a.address)).join(', '); + + const parts = [ + `📧 ${email.subject}`, + `From: ${from}`, + `To: ${to}`, + `Date: ${email.date}`, + `ID: ${email.messageId}`, + ]; + + if (email.attachments.length > 0) { + parts.push( + `📎 Attachments: ${email.attachments.map((a) => `${a.filename} (${a.mimeType})`).join(', ')}`, + ); + } + + parts.push('', '--- Body ---', ''); + parts.push(email.bodyText ?? email.bodyHtml ?? '(no content)'); + + return { + content: [{ type: 'text' as const, text: parts.join('\n') }], + }; + } catch (err) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Failed to get message: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + }; + } + }, + ); + + // --------------------------------------------------------------------------- + // agentmail_send_message + // --------------------------------------------------------------------------- + server.tool( + 'agentmail_send_message', + 'Send an email from an AgentMail inbox. Supports plain text or HTML body.', + { + inboxId: z.string().describe('AgentMail inbox ID to send from'), + to: z.array(z.string().email()).min(1).describe('Recipient email addresses'), + subject: z.string().describe('Email subject'), + body: z.string().describe('Email body content'), + html: z.boolean().default(false).describe('Send as HTML (default: plain text)'), + }, + { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + async (params) => { + try { + const result = await agentMailService.sendMessage(params.inboxId, { + to: params.to, + subject: params.subject, + body: params.body, + html: params.html, + }); + await audit.log( + 'agentmail_send_message', + 'agentmail', + { to: params.to, subject: params.subject }, + 'ok', + ); + return { + content: [ + { + type: 'text' as const, + text: `✅ Email sent via AgentMail!\nTo: ${params.to.join(', ')}\nSubject: ${params.subject}\nMessage-ID: ${result.messageId}`, + }, + ], + }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + await audit.log( + 'agentmail_send_message', + 'agentmail', + { to: params.to, subject: params.subject }, + 'error', + errMsg, + ); + return { + isError: true, + content: [{ type: 'text' as const, text: `Failed to send email: ${errMsg}` }], + }; + } + }, + ); +} diff --git a/src/tools/register.ts b/src/tools/register.ts index c6b0363..ffb714b 100644 --- a/src/tools/register.ts +++ b/src/tools/register.ts @@ -16,8 +16,10 @@ import type SchedulerService from '../services/scheduler.service.js'; import type SmtpService from '../services/smtp.service.js'; import type TemplateService from '../services/template.service.js'; import type WatcherService from '../services/watcher.service.js'; +import type AgentMailService from '../services/agentmail.service.js'; import type { AppConfig } from '../types/index.js'; import registerAccountsTools from './accounts.tool.js'; +import registerAgentMailTools from './agentmail.tool.js'; import registerAnalyticsTools from './analytics.tool.js'; import registerAttachmentTools from './attachments.tool.js'; import registerBulkTools from './bulk.tool.js'; @@ -50,6 +52,7 @@ export default function registerAllTools( schedulerService: SchedulerService, watcherService: WatcherService, hooksService: HooksService, + agentMailService?: AgentMailService, ): void { const { readOnly } = config.settings; @@ -84,4 +87,9 @@ export default function registerAllTools( registerTemplateWriteTools(server, templateService, imapService, smtpService); registerSchedulerTools(server, schedulerService); } + + // AgentMail tools — registered when an AgentMail API key is configured + if (agentMailService) { + registerAgentMailTools(server, agentMailService); + } } diff --git a/src/types/index.ts b/src/types/index.ts index 6203f97..8b97cad 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -137,7 +137,12 @@ export interface HooksConfig { calendarConfirm?: boolean; } +export interface AgentMailConfig { + apiKey: string; +} + export interface AppConfig { + agentmail?: AgentMailConfig; settings: { rateLimit: number; readOnly: boolean;