mirror of
https://github.com/beilunyang/moemail.git
synced 2026-09-08 08:46:36 +08:00
feat(cli,mcp): extract @moemail/core and add MCP server; release 1.0.0
Extract the HTTP client and config into a new @moemail/core package shared by the CLI and a new @moemail/mcp server, so both frontends talk to the same MoeMail API through one code path. - core: api client (now throws typed ConfigError/AuthError/PermissionError/ QuotaError instead of process.exit), config, msToIso, and a transport- agnostic pollForNewMessage helper. - cli: consume @moemail/core; route command errors through a shared fail() that preserves exit codes (config/auth = 2, else = 1). Bump to 1.0.0. - mcp: new stdio MCP server exposing 8 tools (create/list/read/wait/send/ delete); wait_for_email is bounded and returns a timeout status to retry. Configured via MOEMAIL_API_KEY / MOEMAIL_API_URL env. Release 1.0.0. Docs: - Fix packages/cli/README.md (config set, send --content not --body, full flag table). - Add MCP section to both root READMEs; complete the CLI command list (send, list, message-level delete). - SKILL.md: --json works before or after the subcommand. - Ignore bun.lock in package gitignores. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,6 @@ $CLI --json read --email-id "$ID" --message-id "$MSG_ID"
|
|||||||
|
|
||||||
## Important details
|
## 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.
|
- Call `create` once and parse both `id` and `address` from the same JSON result.
|
||||||
- Check both `content` and `html` when reading HTML-heavy messages.
|
- Check both `content` and `html` when reading HTML-heavy messages.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<a href="#webhook-integration">Webhook Integration</a> •
|
<a href="#webhook-integration">Webhook Integration</a> •
|
||||||
<a href="#openapi">OpenAPI</a> •
|
<a href="#openapi">OpenAPI</a> •
|
||||||
<a href="#cli-tool">CLI Tool</a> •
|
<a href="#cli-tool">CLI Tool</a> •
|
||||||
|
<a href="#mcp-server">MCP Server</a> •
|
||||||
<a href="#environment-variables">Environment Variables</a> •
|
<a href="#environment-variables">Environment Variables</a> •
|
||||||
<a href="#github-oauth-app-configuration">Github OAuth Config</a> •
|
<a href="#github-oauth-app-configuration">Github OAuth Config</a> •
|
||||||
<a href="#google-oauth-app-configuration">Google OAuth Config</a> •
|
<a href="#google-oauth-app-configuration">Google OAuth Config</a> •
|
||||||
@@ -555,13 +556,25 @@ moemail config set api-key YOUR_API_KEY
|
|||||||
# Create temporary email
|
# Create temporary email
|
||||||
moemail create --domain moemail.app --expiry 1h --json
|
moemail create --domain moemail.app --expiry 1h --json
|
||||||
|
|
||||||
|
# List mailboxes
|
||||||
|
moemail list --json
|
||||||
|
|
||||||
|
# List messages in a mailbox
|
||||||
|
moemail list --email-id <id> --json
|
||||||
|
|
||||||
# Wait for new messages (polling)
|
# Wait for new messages (polling)
|
||||||
moemail wait --email-id <id> --timeout 120 --json
|
moemail wait --email-id <id> --timeout 120 --json
|
||||||
|
|
||||||
# Read message content
|
# Read message content
|
||||||
moemail read --email-id <id> --message-id <id> --json
|
moemail read --email-id <id> --message-id <id> --json
|
||||||
|
|
||||||
# Delete email
|
# Send an email from the temporary address
|
||||||
|
moemail send --email-id <id> --to user@example.com --subject "Hello" --content "Body text" --json
|
||||||
|
|
||||||
|
# Delete a single message
|
||||||
|
moemail delete --email-id <id> --message-id <id>
|
||||||
|
|
||||||
|
# Delete the whole mailbox
|
||||||
moemail delete --email-id <id>
|
moemail delete --email-id <id>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -598,6 +611,47 @@ moemail skill install --platform codex
|
|||||||
|
|
||||||
For full documentation, see [packages/cli/README.md](packages/cli/README.md).
|
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
|
## Environment Variables
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|||||||
+54
-1
@@ -30,6 +30,7 @@
|
|||||||
<a href="#Webhook 集成">Webhook 集成</a> •
|
<a href="#Webhook 集成">Webhook 集成</a> •
|
||||||
<a href="#OpenAPI">OpenAPI</a> •
|
<a href="#OpenAPI">OpenAPI</a> •
|
||||||
<a href="#cli-工具">CLI 工具</a> •
|
<a href="#cli-工具">CLI 工具</a> •
|
||||||
|
<a href="#mcp-服务器">MCP 服务器</a> •
|
||||||
<a href="#环境变量">环境变量</a> •
|
<a href="#环境变量">环境变量</a> •
|
||||||
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
|
<a href="#Github OAuth App 配置">Github OAuth App 配置</a> •
|
||||||
<a href="#Google OAuth App 配置">Google OAuth App 配置</a> •
|
<a href="#Google OAuth App 配置">Google OAuth App 配置</a> •
|
||||||
@@ -807,13 +808,25 @@ moemail config set api-key YOUR_API_KEY
|
|||||||
# 创建临时邮箱
|
# 创建临时邮箱
|
||||||
moemail create --domain moemail.app --expiry 1h --json
|
moemail create --domain moemail.app --expiry 1h --json
|
||||||
|
|
||||||
|
# 列出邮箱
|
||||||
|
moemail list --json
|
||||||
|
|
||||||
|
# 列出邮箱内的邮件
|
||||||
|
moemail list --email-id <id> --json
|
||||||
|
|
||||||
# 等待新邮件(轮询)
|
# 等待新邮件(轮询)
|
||||||
moemail wait --email-id <id> --timeout 120 --json
|
moemail wait --email-id <id> --timeout 120 --json
|
||||||
|
|
||||||
# 读取邮件内容
|
# 读取邮件内容
|
||||||
moemail read --email-id <id> --message-id <id> --json
|
moemail read --email-id <id> --message-id <id> --json
|
||||||
|
|
||||||
# 删除邮箱
|
# 从临时地址发件
|
||||||
|
moemail send --email-id <id> --to user@example.com --subject "你好" --content "正文内容" --json
|
||||||
|
|
||||||
|
# 删除单封邮件
|
||||||
|
moemail delete --email-id <id> --message-id <id>
|
||||||
|
|
||||||
|
# 删除整个邮箱
|
||||||
moemail delete --email-id <id>
|
moemail delete --email-id <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)。
|
详细文档见 [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)。
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
本项目使用以下环境变量:
|
本项目使用以下环境变量:
|
||||||
|
|||||||
+15
-10
@@ -10,11 +10,15 @@ npm i -g @moemail/cli
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Configure default domain
|
### 1. Configure endpoint and API key
|
||||||
```bash
|
```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
|
### 2. Create a temporary email
|
||||||
```bash
|
```bash
|
||||||
moemail create --expiry 1h
|
moemail create --expiry 1h
|
||||||
@@ -29,13 +33,14 @@ moemail wait --email-id <email_id> --timeout 120
|
|||||||
|
|
||||||
| Command | Description | Key Flags |
|
| Command | Description | Key Flags |
|
||||||
|---------|-------------|-----------|
|
|---------|-------------|-----------|
|
||||||
| `config` | Set default domain and options | `--domain <domain>`, `--expiry <duration>` |
|
| `config set` | Set `api-url` or `api-key` | `config set <api-url\|api-key> <value>` |
|
||||||
| `create` | Create a temporary email address | `--domain <domain>`, `--expiry <duration>`, `--json` |
|
| `config list` | Show current configuration | — |
|
||||||
| `list` | List all temporary emails | `--json` |
|
| `create` | Create a temporary email address | `--name <prefix>`, `--domain <domain>`, `--expiry <1h\|24h\|3d\|permanent>`, `--json` |
|
||||||
| `wait` | Wait for incoming messages | `--email-id <id>`, `--timeout <seconds>`, `--json` |
|
| `list` | List mailboxes, or messages in a mailbox | `--email-id <id>`, `--cursor <cursor>`, `--json` |
|
||||||
| `read` | Read email message content | `--email-id <id>`, `--message-id <id>`, `--json` |
|
| `wait` | Wait for incoming messages | `--email-id <id>`, `--timeout <seconds>`, `--interval <seconds>`, `--json` |
|
||||||
| `send` | Send email from temporary address | `--email-id <id>`, `--to <address>`, `--subject <text>`, `--body <text>`, `--json` |
|
| `read` | Read email message content | `--email-id <id>`, `--message-id <id>`, `--format <text\|html>`, `--json` |
|
||||||
| `delete` | Delete temporary email | `--email-id <id>` |
|
| `send` | Send email from temporary address | `--email-id <id>`, `--to <address>`, `--subject <text>`, `--content <text>`, `--json` |
|
||||||
|
| `delete` | Delete a mailbox, or a single message | `--email-id <id>`, `--message-id <id>` |
|
||||||
| `skill install` | Install AI agent skill | `--platform <claude\|codex\|all>` |
|
| `skill install` | Install AI agent skill | `--platform <claude\|codex\|all>` |
|
||||||
|
|
||||||
## Agent Workflow Example
|
## Agent Workflow Example
|
||||||
@@ -90,7 +95,7 @@ All commands support `--json` flag for structured output, making them ideal for
|
|||||||
- **Exit Codes**:
|
- **Exit Codes**:
|
||||||
- `0`: Command succeeded
|
- `0`: Command succeeded
|
||||||
- `1`: Runtime error (invalid input, service error)
|
- `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
|
## Project Links
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@moemail/cli",
|
"name": "@moemail/cli",
|
||||||
"version": "0.1.2",
|
"version": "1.0.0",
|
||||||
"description": "Agent-first CLI for MoeMail temporary email service",
|
"description": "Agent-first CLI for MoeMail temporary email service",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -15,5 +15,9 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^12.0.0"
|
"commander": "^12.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@moemail/core": "file:../core",
|
||||||
|
"@types/node": "^20.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ moemail --json read --email-id "$ID" --message-id "$MSG_ID"
|
|||||||
| `send` | `--email-id`, `--to`, `--subject`, `--content` | — |
|
| `send` | `--email-id`, `--to`, `--subject`, `--content` | — |
|
||||||
| `delete` | `--email-id` | — |
|
| `delete` | `--email-id` | — |
|
||||||
|
|
||||||
**Always put `--json` before the subcommand:**
|
**`--json` is a global flag — it works before or after the subcommand:**
|
||||||
```bash
|
```bash
|
||||||
moemail --json create --expiry 24h # ✅ correct
|
moemail --json create --expiry 24h # both work
|
||||||
moemail create --expiry 24h --json # ❌ wrong position
|
moemail create --expiry 24h --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## JSON Output Shapes
|
## JSON Output Shapes
|
||||||
@@ -83,7 +83,6 @@ moemail create --expiry 24h --json # ❌ wrong position
|
|||||||
| Mistake | Fix |
|
| Mistake | Fix |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| Calling `create` twice to get id + address | Call once, save to variable, parse both fields |
|
| 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 |
|
| Timeout too short for slow services | Use `--timeout 300` for unreliable senders |
|
||||||
| Inbox expired mid-test | Use `--expiry permanent` for long-running workflows |
|
| 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 |
|
| Using `content` field for HTML emails | Check both `content` (plain text) and `html` fields |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { loadConfig, saveConfig } from "../config.js";
|
import { loadConfig, saveConfig } from "@moemail/core";
|
||||||
|
|
||||||
export function registerConfigCommand(program: Command) {
|
export function registerConfigCommand(program: Command) {
|
||||||
const cmd = program.command("config").description("Configure API endpoint and API Key");
|
const cmd = program.command("config").description("Configure API endpoint and API Key");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { api } from "../api.js";
|
import { api, msToIso } from "@moemail/core";
|
||||||
import { log, printJson, printText, msToIso } from "../output.js";
|
import { fail, log, printJson, printText } from "../output.js";
|
||||||
|
|
||||||
const EXPIRY_MAP: Record<string, number> = {
|
const EXPIRY_MAP: Record<string, number> = {
|
||||||
"1h": 3600000,
|
"1h": 3600000,
|
||||||
@@ -54,9 +54,8 @@ export function registerCreateCommand(program: Command) {
|
|||||||
printText(`Created: ${result.email} (${expiryLabel})`);
|
printText(`Created: ${result.email} (${expiryLabel})`);
|
||||||
printText(`ID: ${result.id}`);
|
printText(`ID: ${result.id}`);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
log(`Error: ${e.message}`);
|
fail(e);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { api } from "../api.js";
|
import { api } from "@moemail/core";
|
||||||
import { log, printJson, printText } from "../output.js";
|
import { fail, printJson, printText } from "../output.js";
|
||||||
|
|
||||||
export function registerDeleteCommand(program: Command) {
|
export function registerDeleteCommand(program: Command) {
|
||||||
program
|
program
|
||||||
@@ -26,9 +26,8 @@ export function registerDeleteCommand(program: Command) {
|
|||||||
printText(`Deleted mailbox ${opts.emailId}`);
|
printText(`Deleted mailbox ${opts.emailId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
log(`Error: ${e.message}`);
|
fail(e);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { api } from "../api.js";
|
import { api, msToIso } from "@moemail/core";
|
||||||
import { log, printJson, printText, msToIso } from "../output.js";
|
import { fail, printJson, printText } from "../output.js";
|
||||||
|
|
||||||
export function registerListCommand(program: Command) {
|
export function registerListCommand(program: Command) {
|
||||||
program
|
program
|
||||||
@@ -59,9 +59,8 @@ export function registerListCommand(program: Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
log(`Error: ${e.message}`);
|
fail(e);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { api } from "../api.js";
|
import { api, msToIso } from "@moemail/core";
|
||||||
import { log, printJson, printText, msToIso } from "../output.js";
|
import { fail, printJson, printText } from "../output.js";
|
||||||
|
|
||||||
export function registerReadCommand(program: Command) {
|
export function registerReadCommand(program: Command) {
|
||||||
program
|
program
|
||||||
@@ -37,9 +37,8 @@ export function registerReadCommand(program: Command) {
|
|||||||
printText(msg.content || "(no text content)");
|
printText(msg.content || "(no text content)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
log(`Error: ${e.message}`);
|
fail(e);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { api } from "../api.js";
|
import { api } from "@moemail/core";
|
||||||
import { log, printJson, printText } from "../output.js";
|
import { fail, printJson, printText } from "../output.js";
|
||||||
|
|
||||||
export function registerSendCommand(program: Command) {
|
export function registerSendCommand(program: Command) {
|
||||||
program
|
program
|
||||||
@@ -27,9 +27,8 @@ export function registerSendCommand(program: Command) {
|
|||||||
} else {
|
} else {
|
||||||
printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`);
|
printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
log(`Error: ${e.message}`);
|
fail(e);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { api } from "../api.js";
|
import { msToIso, pollForNewMessage } from "@moemail/core";
|
||||||
import { log, printJson, printText, msToIso } from "../output.js";
|
import { fail, log, printJson, printText } from "../output.js";
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerWaitCommand(program: Command) {
|
export function registerWaitCommand(program: Command) {
|
||||||
program
|
program
|
||||||
@@ -17,29 +13,20 @@ export function registerWaitCommand(program: Command) {
|
|||||||
const json = program.opts().json;
|
const json = program.opts().json;
|
||||||
const timeout = parseInt(opts.timeout, 10);
|
const timeout = parseInt(opts.timeout, 10);
|
||||||
const interval = parseInt(opts.interval, 10);
|
const interval = parseInt(opts.interval, 10);
|
||||||
const emailId = opts.emailId;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initial = (await api.listMessages(emailId)) as any;
|
const result = await pollForNewMessage(opts.emailId, {
|
||||||
const knownIds = new Set<string>(initial.messages.map((m: any) => m.id));
|
timeoutMs: timeout * 1000,
|
||||||
|
intervalMs: interval * 1000,
|
||||||
|
onTick: (elapsed) => log(`Polling... (${elapsed}/${timeout}s)`),
|
||||||
|
});
|
||||||
|
|
||||||
const startTime = Date.now();
|
if (result.status === "timeout") {
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
if (elapsed >= timeout) {
|
|
||||||
log(`Timeout: no new messages received within ${timeout}s`);
|
log(`Timeout: no new messages received within ${timeout}s`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`Polling... (${elapsed}/${timeout}s)`);
|
const msg = result.message!;
|
||||||
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) {
|
if (json) {
|
||||||
printJson({
|
printJson({
|
||||||
messageId: msg.id,
|
messageId: msg.id,
|
||||||
@@ -51,12 +38,8 @@ export function registerWaitCommand(program: Command) {
|
|||||||
printText(`New message from ${msg.from_address}: "${msg.subject}"`);
|
printText(`New message from ${msg.from_address}: "${msg.subject}"`);
|
||||||
printText(`Message ID: ${msg.id}`);
|
printText(`Message ID: ${msg.id}`);
|
||||||
}
|
}
|
||||||
return;
|
} catch (e) {
|
||||||
}
|
fail(e);
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
log(`Error: ${e.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const program = new Command();
|
|||||||
program
|
program
|
||||||
.name("moemail")
|
.name("moemail")
|
||||||
.description("MoeMail CLI — Agent-friendly temporary email tool")
|
.description("MoeMail CLI — Agent-friendly temporary email tool")
|
||||||
.version("0.1.2")
|
.version("1.0.0")
|
||||||
.option("--json", "output as JSON");
|
.option("--json", "output as JSON");
|
||||||
|
|
||||||
registerConfigCommand(program);
|
registerConfigCommand(program);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AuthError, ConfigError } from "@moemail/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print JSON to stdout (for --json mode).
|
* 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 {
|
export function fail(e: unknown): never {
|
||||||
return new Date(ms).toISOString();
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
log(`Error: ${message}`);
|
||||||
|
process.exit(e instanceof ConfigError || e instanceof AuthError ? 2 : 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
"types": ["node"],
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { log } from "./output.js";
|
import { ApiError, AuthError, ConfigError, PermissionError, QuotaError } from "./errors.js";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public status: number,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(
|
async function request(
|
||||||
method: string,
|
method: string,
|
||||||
@@ -18,12 +9,14 @@ async function request(
|
|||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
if (!config.apiUrl) {
|
if (!config.apiUrl) {
|
||||||
log("Error: API URL not configured. Run: moemail config set api-url <url>");
|
throw new ConfigError(
|
||||||
process.exit(2);
|
"API URL not configured. Run `moemail config set api-url <url>` or set MOEMAIL_API_URL.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
log("Error: API Key not configured. Run: moemail config set api-key <key>");
|
throw new ConfigError(
|
||||||
process.exit(2);
|
"API Key not configured. Run `moemail config set api-key <key>` or set MOEMAIL_API_KEY.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${config.apiUrl.replace(/\/$/, "")}${path}`;
|
const url = `${config.apiUrl.replace(/\/$/, "")}${path}`;
|
||||||
@@ -40,24 +33,37 @@ async function request(
|
|||||||
body: body ? JSON.stringify(body) : undefined,
|
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) {
|
if (res.status === 204) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
let data: any = null;
|
||||||
|
try {
|
||||||
if (!res.ok) {
|
data = await res.json();
|
||||||
throw new ApiError(res.status, (data as any).error || `HTTP ${res.status}`);
|
} catch {
|
||||||
|
// Non-JSON body (e.g. empty error). Leave data as null.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
return data;
|
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 = {
|
export const api = {
|
||||||
getConfig: () => request("GET", "/api/config"),
|
getConfig: () => request("GET", "/api/config"),
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function loadConfig(): CliConfig {
|
|||||||
} catch {}
|
} 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_URL) config.apiUrl = process.env.MOEMAIL_API_URL;
|
||||||
if (process.env.MOEMAIL_API_KEY) config.apiKey = process.env.MOEMAIL_API_KEY;
|
if (process.env.MOEMAIL_API_KEY) config.apiKey = process.env.MOEMAIL_API_KEY;
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { api } from "./api.js";
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
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<PollResult> {
|
||||||
|
const initial = (await api.listMessages(emailId)) as any;
|
||||||
|
const knownIds = new Set<string>(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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Convert epoch ms timestamp to ISO 8601 string. */
|
||||||
|
export function msToIso(ms: number): string {
|
||||||
|
return new Date(ms).toISOString();
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<string, number> = {
|
||||||
|
"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<CallToolResult>): Promise<CallToolResult> {
|
||||||
|
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 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user