diff --git a/.claude/skills/moemail/SKILL.md b/.claude/skills/moemail/SKILL.md index 0aacccf..5dc22d6 100644 --- a/.claude/skills/moemail/SKILL.md +++ b/.claude/skills/moemail/SKILL.md @@ -83,6 +83,6 @@ $CLI --json read --email-id "$ID" --message-id "$MSG_ID" ## Important details -- Put `--json` before the subcommand. +- `--json` is a global flag and works before or after the subcommand. - Call `create` once and parse both `id` and `address` from the same JSON result. - Check both `content` and `html` when reading HTML-heavy messages. diff --git a/README.md b/README.md index dc474f7..79ae56e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Webhook IntegrationOpenAPICLI Tool • + MCP ServerEnvironment VariablesGithub OAuth ConfigGoogle OAuth Config • @@ -555,13 +556,25 @@ moemail config set api-key YOUR_API_KEY # Create temporary email moemail create --domain moemail.app --expiry 1h --json +# List mailboxes +moemail list --json + +# List messages in a mailbox +moemail list --email-id --json + # Wait for new messages (polling) moemail wait --email-id --timeout 120 --json # Read message content moemail read --email-id --message-id --json -# Delete email +# Send an email from the temporary address +moemail send --email-id --to user@example.com --subject "Hello" --content "Body text" --json + +# Delete a single message +moemail delete --email-id --message-id + +# Delete the whole mailbox moemail delete --email-id ``` @@ -598,6 +611,47 @@ moemail skill install --platform codex For full documentation, see [packages/cli/README.md](packages/cli/README.md). +## MCP Server + +MoeMail also ships an [MCP](https://modelcontextprotocol.io) server, so any +MCP-capable client (Claude Desktop, Cursor, Cline, …) gets native temporary-email +tools without shelling out to the CLI. + +### Tools + +| Tool | Description | +|------|-------------| +| `create_email` | Create a temporary mailbox (`1h` / `24h` / `3d` / `permanent`) | +| `list_emails` | List mailboxes owned by the API key | +| `list_messages` | List messages in a mailbox | +| `read_message` | Read full text/HTML of a message | +| `wait_for_email` | Poll for a new message (bounded; returns `status: "timeout"` to retry) | +| `send_email` | Send from a temporary address | +| `delete_email` | Delete a mailbox | +| `delete_message` | Delete a single message | + +### Setup + +Add the server to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`). +Credentials are passed via environment variables: + +```json +{ + "mcpServers": { + "moemail": { + "command": "npx", + "args": ["-y", "@moemail/mcp"], + "env": { + "MOEMAIL_API_KEY": "YOUR_API_KEY", + "MOEMAIL_API_URL": "https://moemail.app" + } + } + } +} +``` + +For full documentation, see [packages/mcp/README.md](packages/mcp/README.md). + ## Environment Variables ### Authentication diff --git a/README.zh-CN.md b/README.zh-CN.md index a8edc37..4b7167b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,6 +30,7 @@ Webhook 集成OpenAPICLI 工具 • + MCP 服务器环境变量Github OAuth App 配置Google OAuth App 配置 • @@ -807,13 +808,25 @@ moemail config set api-key YOUR_API_KEY # 创建临时邮箱 moemail create --domain moemail.app --expiry 1h --json +# 列出邮箱 +moemail list --json + +# 列出邮箱内的邮件 +moemail list --email-id --json + # 等待新邮件(轮询) moemail wait --email-id --timeout 120 --json # 读取邮件内容 moemail read --email-id --message-id --json -# 删除邮箱 +# 从临时地址发件 +moemail send --email-id --to user@example.com --subject "你好" --content "正文内容" --json + +# 删除单封邮件 +moemail delete --email-id --message-id + +# 删除整个邮箱 moemail delete --email-id ``` @@ -837,6 +850,46 @@ CONTENT=$(moemail read --email-id $EMAIL_ID --message-id $MSG_ID --json) 详细文档见 [packages/cli/README.md](packages/cli/README.md)。 +## MCP 服务器 + +MoeMail 同时提供 [MCP](https://modelcontextprotocol.io) 服务器,让任意支持 MCP 的客户端 +(Claude Desktop、Cursor、Cline 等)无需调用 CLI 即可原生使用临时邮箱工具。 + +### 工具 + +| 工具 | 说明 | +|------|------| +| `create_email` | 创建临时邮箱(`1h` / `24h` / `3d` / `permanent`) | +| `list_emails` | 列出该 API Key 下的邮箱 | +| `list_messages` | 列出邮箱内的邮件 | +| `read_message` | 读取邮件完整内容(文本 / HTML) | +| `wait_for_email` | 轮询等待新邮件(有时间上限;超时返回 `status: "timeout"` 可再次调用续等) | +| `send_email` | 从临时地址发件 | +| `delete_email` | 删除邮箱 | +| `delete_message` | 删除单封邮件 | + +### 配置 + +在 MCP 客户端配置中加入该服务器(例如 Claude Desktop 的 `claude_desktop_config.json`), +凭证通过环境变量传入: + +```json +{ + "mcpServers": { + "moemail": { + "command": "npx", + "args": ["-y", "@moemail/mcp"], + "env": { + "MOEMAIL_API_KEY": "你的_API_KEY", + "MOEMAIL_API_URL": "https://moemail.app" + } + } + } +} +``` + +详细文档见 [packages/mcp/README.md](packages/mcp/README.md)。 + ## 环境变量 本项目使用以下环境变量: diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f8e097..98d7762 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10,11 +10,15 @@ npm i -g @moemail/cli ## Quick Start -### 1. Configure default domain +### 1. Configure endpoint and API key ```bash -moemail config --domain moemail.app +moemail config set api-url https://moemail.app +moemail config set api-key YOUR_API_KEY ``` +> Config is read from `~/.moemail/config.json`, or overridden by the +> `MOEMAIL_API_URL` / `MOEMAIL_API_KEY` environment variables. + ### 2. Create a temporary email ```bash moemail create --expiry 1h @@ -29,13 +33,14 @@ moemail wait --email-id --timeout 120 | Command | Description | Key Flags | |---------|-------------|-----------| -| `config` | Set default domain and options | `--domain `, `--expiry ` | -| `create` | Create a temporary email address | `--domain `, `--expiry `, `--json` | -| `list` | List all temporary emails | `--json` | -| `wait` | Wait for incoming messages | `--email-id `, `--timeout `, `--json` | -| `read` | Read email message content | `--email-id `, `--message-id `, `--json` | -| `send` | Send email from temporary address | `--email-id `, `--to
`, `--subject `, `--body `, `--json` | -| `delete` | Delete temporary email | `--email-id ` | +| `config set` | Set `api-url` or `api-key` | `config set ` | +| `config list` | Show current configuration | — | +| `create` | Create a temporary email address | `--name `, `--domain `, `--expiry <1h\|24h\|3d\|permanent>`, `--json` | +| `list` | List mailboxes, or messages in a mailbox | `--email-id `, `--cursor `, `--json` | +| `wait` | Wait for incoming messages | `--email-id `, `--timeout `, `--interval `, `--json` | +| `read` | Read email message content | `--email-id `, `--message-id `, `--format `, `--json` | +| `send` | Send email from temporary address | `--email-id `, `--to
`, `--subject `, `--content `, `--json` | +| `delete` | Delete a mailbox, or a single message | `--email-id `, `--message-id ` | | `skill install` | Install AI agent skill | `--platform ` | ## Agent Workflow Example @@ -90,7 +95,7 @@ All commands support `--json` flag for structured output, making them ideal for - **Exit Codes**: - `0`: Command succeeded - `1`: Runtime error (invalid input, service error) - - `2`: Configuration error (missing domain, invalid credentials) + - `2`: Configuration or authentication error (missing `api-url`/`api-key`, invalid credentials) ## Project Links diff --git a/packages/cli/package.json b/packages/cli/package.json index a308a42..a3da650 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@moemail/cli", - "version": "0.1.2", + "version": "1.0.0", "description": "Agent-first CLI for MoeMail temporary email service", "type": "module", "bin": { @@ -15,5 +15,9 @@ "license": "MIT", "dependencies": { "commander": "^12.0.0" + }, + "devDependencies": { + "@moemail/core": "file:../core", + "@types/node": "^20.0.0" } } diff --git a/packages/cli/skill/SKILL.md b/packages/cli/skill/SKILL.md index f85a0dc..0089396 100644 --- a/packages/cli/skill/SKILL.md +++ b/packages/cli/skill/SKILL.md @@ -62,10 +62,10 @@ moemail --json read --email-id "$ID" --message-id "$MSG_ID" | `send` | `--email-id`, `--to`, `--subject`, `--content` | — | | `delete` | `--email-id` | — | -**Always put `--json` before the subcommand:** +**`--json` is a global flag — it works before or after the subcommand:** ```bash -moemail --json create --expiry 24h # ✅ correct -moemail create --expiry 24h --json # ❌ wrong position +moemail --json create --expiry 24h # both work +moemail create --expiry 24h --json ``` ## JSON Output Shapes @@ -83,7 +83,6 @@ moemail create --expiry 24h --json # ❌ wrong position | Mistake | Fix | |---------|-----| | Calling `create` twice to get id + address | Call once, save to variable, parse both fields | -| `--json` after subcommand | Move `--json` before the subcommand | | Timeout too short for slow services | Use `--timeout 300` for unreliable senders | | Inbox expired mid-test | Use `--expiry permanent` for long-running workflows | | Using `content` field for HTML emails | Check both `content` (plain text) and `html` fields | diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index ed1cb70..ff2bffd 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { loadConfig, saveConfig } from "../config.js"; +import { loadConfig, saveConfig } from "@moemail/core"; export function registerConfigCommand(program: Command) { const cmd = program.command("config").description("Configure API endpoint and API Key"); diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index fca97f3..46c9f1a 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { api } from "../api.js"; -import { log, printJson, printText, msToIso } from "../output.js"; +import { api, msToIso } from "@moemail/core"; +import { fail, log, printJson, printText } from "../output.js"; const EXPIRY_MAP: Record = { "1h": 3600000, @@ -54,9 +54,8 @@ export function registerCreateCommand(program: Command) { printText(`Created: ${result.email} (${expiryLabel})`); printText(`ID: ${result.id}`); } - } catch (e: any) { - log(`Error: ${e.message}`); - process.exit(1); + } catch (e) { + fail(e); } }); } diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index 169dd5c..71f2017 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { api } from "../api.js"; -import { log, printJson, printText } from "../output.js"; +import { api } from "@moemail/core"; +import { fail, printJson, printText } from "../output.js"; export function registerDeleteCommand(program: Command) { program @@ -26,9 +26,8 @@ export function registerDeleteCommand(program: Command) { printText(`Deleted mailbox ${opts.emailId}`); } } - } catch (e: any) { - log(`Error: ${e.message}`); - process.exit(1); + } catch (e) { + fail(e); } }); } diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 8c32c26..4ad0ffe 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { api } from "../api.js"; -import { log, printJson, printText, msToIso } from "../output.js"; +import { api, msToIso } from "@moemail/core"; +import { fail, printJson, printText } from "../output.js"; export function registerListCommand(program: Command) { program @@ -59,9 +59,8 @@ export function registerListCommand(program: Command) { } } } - } catch (e: any) { - log(`Error: ${e.message}`); - process.exit(1); + } catch (e) { + fail(e); } }); } diff --git a/packages/cli/src/commands/read.ts b/packages/cli/src/commands/read.ts index 5e7a085..d5ed009 100644 --- a/packages/cli/src/commands/read.ts +++ b/packages/cli/src/commands/read.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { api } from "../api.js"; -import { log, printJson, printText, msToIso } from "../output.js"; +import { api, msToIso } from "@moemail/core"; +import { fail, printJson, printText } from "../output.js"; export function registerReadCommand(program: Command) { program @@ -37,9 +37,8 @@ export function registerReadCommand(program: Command) { printText(msg.content || "(no text content)"); } } - } catch (e: any) { - log(`Error: ${e.message}`); - process.exit(1); + } catch (e) { + fail(e); } }); } diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts index d733fd7..f9692b0 100644 --- a/packages/cli/src/commands/send.ts +++ b/packages/cli/src/commands/send.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; -import { api } from "../api.js"; -import { log, printJson, printText } from "../output.js"; +import { api } from "@moemail/core"; +import { fail, printJson, printText } from "../output.js"; export function registerSendCommand(program: Command) { program @@ -27,9 +27,8 @@ export function registerSendCommand(program: Command) { } else { printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`); } - } catch (e: any) { - log(`Error: ${e.message}`); - process.exit(1); + } catch (e) { + fail(e); } }); } diff --git a/packages/cli/src/commands/wait.ts b/packages/cli/src/commands/wait.ts index db5d050..689dadf 100644 --- a/packages/cli/src/commands/wait.ts +++ b/packages/cli/src/commands/wait.ts @@ -1,10 +1,6 @@ import { Command } from "commander"; -import { api } from "../api.js"; -import { log, printJson, printText, msToIso } from "../output.js"; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +import { msToIso, pollForNewMessage } from "@moemail/core"; +import { fail, log, printJson, printText } from "../output.js"; export function registerWaitCommand(program: Command) { program @@ -17,46 +13,33 @@ export function registerWaitCommand(program: Command) { const json = program.opts().json; const timeout = parseInt(opts.timeout, 10); const interval = parseInt(opts.interval, 10); - const emailId = opts.emailId; try { - const initial = (await api.listMessages(emailId)) as any; - const knownIds = new Set(initial.messages.map((m: any) => m.id)); + const result = await pollForNewMessage(opts.emailId, { + timeoutMs: timeout * 1000, + intervalMs: interval * 1000, + onTick: (elapsed) => log(`Polling... (${elapsed}/${timeout}s)`), + }); - const startTime = Date.now(); - - while (true) { - const elapsed = Math.floor((Date.now() - startTime) / 1000); - if (elapsed >= timeout) { - log(`Timeout: no new messages received within ${timeout}s`); - process.exit(1); - } - - log(`Polling... (${elapsed}/${timeout}s)`); - await sleep(interval * 1000); - - const current = (await api.listMessages(emailId)) as any; - const newMessages = current.messages.filter((m: any) => !knownIds.has(m.id)); - - if (newMessages.length > 0) { - const msg = newMessages[0]; - if (json) { - printJson({ - messageId: msg.id, - from: msg.from_address, - subject: msg.subject, - receivedAt: msg.received_at ? msToIso(msg.received_at) : null, - }); - } else { - printText(`New message from ${msg.from_address}: "${msg.subject}"`); - printText(`Message ID: ${msg.id}`); - } - return; - } + if (result.status === "timeout") { + log(`Timeout: no new messages received within ${timeout}s`); + process.exit(1); } - } catch (e: any) { - log(`Error: ${e.message}`); - process.exit(1); + + const msg = result.message!; + if (json) { + printJson({ + messageId: msg.id, + from: msg.from_address, + subject: msg.subject, + receivedAt: msg.received_at ? msToIso(msg.received_at) : null, + }); + } else { + printText(`New message from ${msg.from_address}: "${msg.subject}"`); + printText(`Message ID: ${msg.id}`); + } + } catch (e) { + fail(e); } }); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index eec4660..7f899ed 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,7 +14,7 @@ const program = new Command(); program .name("moemail") .description("MoeMail CLI — Agent-friendly temporary email tool") - .version("0.1.2") + .version("1.0.0") .option("--json", "output as JSON"); registerConfigCommand(program); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 9cee3da..36454d2 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -1,3 +1,5 @@ +import { AuthError, ConfigError } from "@moemail/core"; + /** * Print JSON to stdout (for --json mode). */ @@ -20,8 +22,11 @@ export function log(message: string): void { } /** - * Convert epoch ms timestamp to ISO 8601 string. + * Print an error to stderr and exit. Config/auth problems exit 2 (preserving + * the previous behaviour); everything else exits 1. */ -export function msToIso(ms: number): string { - return new Date(ms).toISOString(); +export function fail(e: unknown): never { + const message = e instanceof Error ? e.message : String(e); + log(`Error: ${message}`); + process.exit(e instanceof ConfigError || e instanceof AuthError ? 2 : 1); } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 49b7e36..70884f8 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -8,6 +8,7 @@ "outDir": "dist", "rootDir": "src", "declaration": true, + "types": ["node"], "skipLibCheck": true }, "include": ["src"] diff --git a/packages/core/.gitignore b/packages/core/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/core/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..c0fe53b --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@moemail/core", + "version": "0.1.0", + "description": "Shared HTTP client and config for MoeMail CLI and MCP server", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": ["src"], + "license": "MIT", + "devDependencies": { + "@types/node": "^20.0.0" + } +} diff --git a/packages/cli/src/api.ts b/packages/core/src/api.ts similarity index 60% rename from packages/cli/src/api.ts rename to packages/core/src/api.ts index ce85f77..d280f54 100644 --- a/packages/cli/src/api.ts +++ b/packages/core/src/api.ts @@ -1,14 +1,5 @@ import { loadConfig } from "./config.js"; -import { log } from "./output.js"; - -export class ApiError extends Error { - constructor( - public status: number, - message: string, - ) { - super(message); - } -} +import { ApiError, AuthError, ConfigError, PermissionError, QuotaError } from "./errors.js"; async function request( method: string, @@ -18,12 +9,14 @@ async function request( const config = loadConfig(); if (!config.apiUrl) { - log("Error: API URL not configured. Run: moemail config set api-url "); - process.exit(2); + throw new ConfigError( + "API URL not configured. Run `moemail config set api-url ` or set MOEMAIL_API_URL.", + ); } if (!config.apiKey) { - log("Error: API Key not configured. Run: moemail config set api-key "); - process.exit(2); + throw new ConfigError( + "API Key not configured. Run `moemail config set api-key ` or set MOEMAIL_API_KEY.", + ); } const url = `${config.apiUrl.replace(/\/$/, "")}${path}`; @@ -40,22 +33,35 @@ async function request( body: body ? JSON.stringify(body) : undefined, }); - if (res.status === 401 || res.status === 403) { - log("Error: Authentication failed. Check your API Key."); - process.exit(2); - } - if (res.status === 204) { return null; } - const data = await res.json(); - - if (!res.ok) { - throw new ApiError(res.status, (data as any).error || `HTTP ${res.status}`); + let data: any = null; + try { + data = await res.json(); + } catch { + // Non-JSON body (e.g. empty error). Leave data as null. } - return data; + if (res.ok) { + return data; + } + + const message = data?.error || `HTTP ${res.status}`; + + // Distinguish the error classes that a MoeMail Pro server returns so callers + // can surface them accurately instead of lumping everything into "auth failed". + switch (res.status) { + case 401: + throw new AuthError(message); + case 403: + throw new PermissionError(message); + case 429: + throw new QuotaError(message, data?.monthlyLimit, data?.monthlyUsed); + default: + throw new ApiError(res.status, message); + } } export const api = { diff --git a/packages/cli/src/config.ts b/packages/core/src/config.ts similarity index 94% rename from packages/cli/src/config.ts rename to packages/core/src/config.ts index a7626fd..8a80992 100644 --- a/packages/cli/src/config.ts +++ b/packages/core/src/config.ts @@ -22,7 +22,7 @@ export function loadConfig(): CliConfig { } catch {} } - // Env overrides (higher priority) + // Env overrides (higher priority) — this is how the MCP server is configured. if (process.env.MOEMAIL_API_URL) config.apiUrl = process.env.MOEMAIL_API_URL; if (process.env.MOEMAIL_API_KEY) config.apiKey = process.env.MOEMAIL_API_KEY; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..ca606a1 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,61 @@ +/** + * Error classes shared by CLI and MCP frontends. + * + * The core HTTP client throws these instead of writing to stderr / exiting, + * so each frontend can decide how to surface them (CLI: print + exit, + * MCP: structured isError result). + */ + +/** Configuration is missing (apiUrl or apiKey not set). */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +/** Generic non-2xx API response. */ +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +/** 401 — API key invalid / authentication failed. */ +export class AuthError extends ApiError { + constructor(message = "Authentication failed. Check your API Key.") { + super(401, message); + this.name = "AuthError"; + } +} + +/** + * 403 — request rejected for permission reasons. On a MoeMail Pro server this + * covers: no OpenAPI permission, domain requires a higher role, or permanent + * mailbox requires Duke. The server message is passed through verbatim. + */ +export class PermissionError extends ApiError { + constructor(message: string) { + super(403, message); + this.name = "PermissionError"; + } +} + +/** + * 429 — monthly OpenAPI call quota exceeded (MoeMail Pro). Carries the quota + * figures from the response body when present. + */ +export class QuotaError extends ApiError { + constructor( + message: string, + public monthlyLimit?: number, + public monthlyUsed?: number, + ) { + super(429, message); + this.name = "QuotaError"; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..cba5181 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,11 @@ +export { api } from "./api.js"; +export { loadConfig, saveConfig, type CliConfig } from "./config.js"; +export { + ApiError, + AuthError, + ConfigError, + PermissionError, + QuotaError, +} from "./errors.js"; +export { pollForNewMessage, type NewMessage, type PollResult } from "./poll.js"; +export { msToIso } from "./util.js"; diff --git a/packages/core/src/poll.ts b/packages/core/src/poll.ts new file mode 100644 index 0000000..3bf6f02 --- /dev/null +++ b/packages/core/src/poll.ts @@ -0,0 +1,60 @@ +import { api } from "./api.js"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface NewMessage { + id: string; + from_address: string; + subject: string; + received_at?: number; +} + +export interface PollResult { + status: "received" | "timeout"; + message?: NewMessage; + elapsedSec: number; +} + +/** + * Poll a mailbox until a message that wasn't present at the start arrives, or + * the timeout elapses. Transport-agnostic: the CLI wraps it with stderr + * progress, the MCP server returns the result as structured JSON. + * + * On timeout this resolves with `status: "timeout"` rather than throwing, so an + * MCP client can simply call the tool again to keep waiting. + */ +export async function pollForNewMessage( + emailId: string, + opts: { + timeoutMs: number; + intervalMs: number; + onTick?: (elapsedSec: number) => void; + }, +): Promise { + const initial = (await api.listMessages(emailId)) as any; + const knownIds = new Set(initial.messages.map((m: any) => m.id)); + + const startTime = Date.now(); + + while (true) { + const elapsedSec = Math.floor((Date.now() - startTime) / 1000); + if (elapsedSec >= opts.timeoutMs / 1000) { + return { status: "timeout", elapsedSec }; + } + + opts.onTick?.(elapsedSec); + await sleep(opts.intervalMs); + + const current = (await api.listMessages(emailId)) as any; + const fresh = current.messages.filter((m: any) => !knownIds.has(m.id)); + if (fresh.length > 0) { + return { + status: "received", + message: fresh[0], + elapsedSec: Math.floor((Date.now() - startTime) / 1000), + }; + } + } +} diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts new file mode 100644 index 0000000..8f2b276 --- /dev/null +++ b/packages/core/src/util.ts @@ -0,0 +1,4 @@ +/** Convert epoch ms timestamp to ISO 8601 string. */ +export function msToIso(ms: number): string { + return new Date(ms).toISOString(); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..6fb5ae5 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/mcp/.gitignore b/packages/mcp/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/mcp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..0bb7345 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,54 @@ +# @moemail/mcp + +MCP (Model Context Protocol) server for [MoeMail](https://moemail.app) — gives any +MCP-capable agent (Claude Desktop, Cursor, Cline, …) native tools for temporary +email: create a mailbox, wait for a verification email, read it, send, and clean up. + +It shares the same HTTP client and config as `@moemail/cli` via `@moemail/core`, so +it talks to the exact same MoeMail API (authenticated with an `X-API-Key`). + +## Tools + +| Tool | Description | +|------|-------------| +| `create_email` | Create a temporary mailbox (`expiry`: `1h` / `24h` / `3d` / `permanent`) | +| `list_emails` | List mailboxes owned by the API key | +| `list_messages` | List messages in a mailbox | +| `read_message` | Read full text/HTML of a message | +| `wait_for_email` | Poll for a new message (bounded, max 90s; returns `status: "timeout"` to retry) | +| `send_email` | Send from a temporary address (needs send permission) | +| `delete_email` | Delete a mailbox | +| `delete_message` | Delete a single message | + +## Configuration + +The server reads credentials from environment variables: + +- `MOEMAIL_API_KEY` (required) — your MoeMail API key +- `MOEMAIL_API_URL` (optional) — defaults to `https://moemail.app` + +## Usage + +Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "moemail": { + "command": "npx", + "args": ["-y", "@moemail/mcp"], + "env": { + "MOEMAIL_API_KEY": "mk_xxx", + "MOEMAIL_API_URL": "https://moemail.app" + } + } + } +} +``` + +## Notes + +- API keys authenticate against `/api/emails*` and `/api/config*` — the same surface + the CLI uses. +- Authentication, permission, and rate-limit failures from the server are surfaced as + descriptive tool errors rather than generic failures. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..6f34016 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,24 @@ +{ + "name": "@moemail/mcp", + "version": "1.0.0", + "description": "MCP server for MoeMail temporary email service", + "type": "module", + "bin": { + "moemail-mcp": "dist/index.js" + }, + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target=node", + "dev": "bun run ./src/index.ts" + }, + "files": ["dist"], + "keywords": ["email", "temporary", "mcp", "agent", "ai"], + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@moemail/core": "file:../core", + "@types/node": "^20.0.0" + } +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000..7a887c1 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerTools } from "./tools.js"; + +async function main() { + const server = new McpServer({ + name: "moemail", + version: "1.0.0", + }); + + registerTools(server); + + const transport = new StdioServerTransport(); + await server.connect(transport); + // stdout is reserved for the MCP protocol; log to stderr only. + console.error("MoeMail MCP server running on stdio"); +} + +main().catch((err) => { + console.error("Fatal:", err); + process.exit(1); +}); diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts new file mode 100644 index 0000000..af97751 --- /dev/null +++ b/packages/mcp/src/tools.ts @@ -0,0 +1,265 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { + api, + AuthError, + ConfigError, + msToIso, + PermissionError, + pollForNewMessage, + QuotaError, +} from "@moemail/core"; + +const EXPIRY_MAP: Record = { + "1h": 3600000, + "24h": 86400000, + "3d": 259200000, + permanent: 0, +}; + +/** Cap a tool-level wait well below typical MCP client timeouts. */ +const WAIT_MAX_SEC = 90; +const WAIT_DEFAULT_SEC = 60; + +function errorText(e: unknown): string { + if (e instanceof QuotaError) { + const quota = + e.monthlyLimit != null ? ` (used ${e.monthlyUsed ?? "?"}/${e.monthlyLimit} this month)` : ""; + return `Monthly API quota exceeded${quota}: ${e.message}`; + } + if (e instanceof PermissionError) return `Permission denied: ${e.message}`; + if (e instanceof AuthError) return `Authentication failed: ${e.message}`; + if (e instanceof ConfigError) return `Configuration error: ${e.message}`; + return e instanceof Error ? e.message : String(e); +} + +function ok(data: unknown): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; +} + +/** Run a tool body, mapping thrown core errors to an isError result. */ +async function run(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + return { content: [{ type: "text", text: errorText(e) }], isError: true }; + } +} + +export function registerTools(server: McpServer): void { + server.registerTool( + "create_email", + { + title: "Create temporary email", + description: + "Create a temporary email address. Returns its id and address. Use the id for all later operations.", + inputSchema: { + name: z.string().optional().describe("Email prefix (random if omitted)"), + domain: z.string().optional().describe("Email domain (first configured domain if omitted)"), + expiry: z + .enum(["1h", "24h", "3d", "permanent"]) + .default("1h") + .describe("Lifetime of the mailbox"), + }, + }, + ({ name, domain, expiry }) => + run(async () => { + const expiryTime = EXPIRY_MAP[expiry]; + + let resolvedDomain: string; + if (domain) { + resolvedDomain = domain; + } else { + const config = (await api.getConfig()) as any; + const domains: string[] = + config.emailDomains?.split(",").map((d: string) => d.trim()) ?? []; + if (!domains.length) { + throw new Error("No email domains configured on server. Pass `domain` explicitly."); + } + resolvedDomain = domains[0]; + } + + const result = (await api.createEmail({ name, expiryTime, domain: resolvedDomain })) as any; + const expiresAt = expiryTime === 0 ? null : msToIso(Date.now() + expiryTime); + return ok({ id: result.id, address: result.email, expiresAt }); + }), + ); + + server.registerTool( + "list_emails", + { + title: "List mailboxes", + description: "List temporary mailboxes owned by this API key.", + inputSchema: { + cursor: z.string().optional().describe("Pagination cursor from a previous call"), + }, + }, + ({ cursor }) => + run(async () => { + const data = (await api.listEmails(cursor)) as any; + return ok({ + emails: data.emails.map((e: any) => ({ + id: e.id, + address: e.address, + expiresAt: e.expiresAt || null, + })), + nextCursor: data.nextCursor, + total: data.total, + }); + }), + ); + + server.registerTool( + "list_messages", + { + title: "List messages", + description: "List messages received in a mailbox (newest first).", + inputSchema: { + emailId: z.string().describe("Mailbox id from create_email"), + cursor: z.string().optional().describe("Pagination cursor from a previous call"), + }, + }, + ({ emailId, cursor }) => + run(async () => { + const data = (await api.listMessages(emailId, cursor)) as any; + return ok({ + messages: data.messages.map((m: any) => ({ + id: m.id, + from: m.from_address, + subject: m.subject, + receivedAt: m.received_at ? msToIso(m.received_at) : null, + })), + nextCursor: data.nextCursor, + total: data.total, + }); + }), + ); + + server.registerTool( + "read_message", + { + title: "Read message", + description: "Read the full content (text + html) of a single message.", + inputSchema: { + emailId: z.string().describe("Mailbox id"), + messageId: z.string().describe("Message id from list_messages or wait_for_email"), + }, + }, + ({ emailId, messageId }) => + run(async () => { + const data = (await api.getMessage(emailId, messageId)) as any; + const msg = data.message; + return ok({ + id: msg.id, + from: msg.from_address, + to: msg.to_address, + subject: msg.subject, + content: msg.content, + html: msg.html, + receivedAt: msg.received_at ? msToIso(msg.received_at) : null, + type: msg.type, + }); + }), + ); + + server.registerTool( + "wait_for_email", + { + title: "Wait for a new email", + description: + `Poll a mailbox until a new message arrives or the timeout elapses (max ${WAIT_MAX_SEC}s). ` + + `On timeout this returns { status: "timeout" } instead of failing — call it again to keep waiting.`, + inputSchema: { + emailId: z.string().describe("Mailbox id to watch"), + timeoutSec: z + .number() + .int() + .min(1) + .max(WAIT_MAX_SEC) + .default(WAIT_DEFAULT_SEC) + .describe(`Max seconds to wait (<= ${WAIT_MAX_SEC})`), + intervalSec: z.number().int().min(1).max(30).default(5).describe("Seconds between polls"), + }, + }, + ({ emailId, timeoutSec, intervalSec }) => + run(async () => { + const result = await pollForNewMessage(emailId, { + timeoutMs: Math.min(timeoutSec, WAIT_MAX_SEC) * 1000, + intervalMs: intervalSec * 1000, + }); + + if (result.status === "timeout") { + return ok({ + status: "timeout", + elapsedSec: result.elapsedSec, + hint: "No new message yet. Call wait_for_email again to continue waiting.", + }); + } + + const msg = result.message!; + return ok({ + status: "received", + elapsedSec: result.elapsedSec, + message: { + messageId: msg.id, + from: msg.from_address, + subject: msg.subject, + receivedAt: msg.received_at ? msToIso(msg.received_at) : null, + }, + }); + }), + ); + + server.registerTool( + "send_email", + { + title: "Send email", + description: "Send an email from a temporary address (requires send permission on the server).", + inputSchema: { + emailId: z.string().describe("Mailbox id to send from"), + to: z.string().describe("Recipient email address"), + subject: z.string().describe("Email subject"), + content: z.string().describe("Email body text"), + }, + }, + ({ emailId, to, subject, content }) => + run(async () => { + const result = (await api.sendEmail(emailId, { to, subject, content })) as any; + return ok({ success: true, remainingEmails: result.remainingEmails }); + }), + ); + + server.registerTool( + "delete_email", + { + title: "Delete mailbox", + description: "Delete an entire temporary mailbox and all its messages.", + inputSchema: { + emailId: z.string().describe("Mailbox id to delete"), + }, + }, + ({ emailId }) => + run(async () => { + await api.deleteEmail(emailId); + return ok({ success: true, deleted: emailId }); + }), + ); + + server.registerTool( + "delete_message", + { + title: "Delete message", + description: "Delete a single message from a mailbox.", + inputSchema: { + emailId: z.string().describe("Mailbox id"), + messageId: z.string().describe("Message id to delete"), + }, + }, + ({ emailId, messageId }) => + run(async () => { + await api.deleteMessage(emailId, messageId); + return ok({ success: true, deleted: messageId }); + }), + ); +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000..c7677ba --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src"] +}