From 1ce4f3e9fad7ab095c70241b2a486e7a2dfbe02b Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 13:44:46 +0800 Subject: [PATCH 01/20] docs: add MoeMail CLI design spec for agent-first CLI tool Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/2026-03-22-moemail-cli-design.md | 239 +++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 specs/2026-03-22-moemail-cli-design.md diff --git a/specs/2026-03-22-moemail-cli-design.md b/specs/2026-03-22-moemail-cli-design.md new file mode 100644 index 0000000..ca1f707 --- /dev/null +++ b/specs/2026-03-22-moemail-cli-design.md @@ -0,0 +1,239 @@ +# MoeMail CLI — Agent-First Command Line Tool + +## Overview + +A CLI tool that wraps MoeMail's existing OpenAPI, optimized for AI Agent workflows. Agents can create temporary emails, wait for incoming messages, read content, and manage mailboxes through simple shell commands. + +**Goal:** Make MoeMail a first-class tool in any AI Agent's toolchain with minimal friction — one command per action, structured JSON output, zero server-side changes. + +## Architecture + +``` +Agent (Claude / GPT / Custom) + ↓ shell call +moemail CLI (npm package, bun-built single JS file) + ↓ HTTPS + X-API-Key header +MoeMail Server (existing Next.js API, no changes) + ↓ +Cloudflare D1 / Email Workers +``` + +- **Language:** TypeScript +- **Build:** `bun build ./src/index.ts --outdir ./dist --target=node` +- **Distribution:** npm package, `npm i -g moemail-cli` +- **Binary:** `package.json` `bin` field points to `dist/index.js` +- **Location:** `packages/moemail-cli/` in the monorepo +- **Server changes:** None. CLI is a pure API client. + +## Configuration + +Stored at `~/.moemail/config.json`: + +```json +{ + "apiUrl": "https://moemail.app", + "apiKey": "mk_xxxxxxxx" +} +``` + +Environment variable overrides (higher priority): +- `MOEMAIL_API_URL` +- `MOEMAIL_API_KEY` + +## Commands + +All commands support `--json` for JSON output and `--help` for usage info. + +### `moemail config` + +```bash +# Interactive setup +moemail config + +# Direct set +moemail config set api-url https://moemail.app +moemail config set api-key mk_xxxxxxxx + +# View current config +moemail config list +``` + +### `moemail create` + +```bash +# Random prefix, 1h expiry (defaults) +moemail create + +# With parameters +moemail create --name test --domain moemail.app --expiry 24h +``` + +`--expiry` options: `1h` | `24h` | `3d` | `7d` | `permanent` + +Default output: +``` +Created: test@moemail.app (expires in 24 hours) +ID: abc-123 +``` + +JSON output (`--json`): +```json +{"id": "abc-123", "address": "test@moemail.app", "expiresAt": "2026-03-23T12:00:00Z"} +``` + +### `moemail list` + +```bash +# List all mailboxes +moemail list + +# With pagination +moemail list --cursor xxx +``` + +### `moemail wait` + +The core command for Agent workflows. Polls the API until a new message arrives or timeout. + +```bash +# Wait for new message (default: 120s timeout, 5s interval) +moemail wait --email-id xxx + +# Custom timeout and interval +moemail wait --email-id xxx --timeout 60 --interval 3 +``` + +**Behavior:** +1. Record current message count/IDs on start +2. Poll `GET /api/emails/{id}` every `--interval` seconds +3. Compare with initial state to detect new messages +4. On new message: output message summary and exit with code 0 +5. On timeout: exit with code 1 + +Default output: +``` +Polling... (15/120s) +New message from no-reply@github.com: "Verify your email" +Message ID: msg-456 +``` + +JSON output (`--json`): +```json +{"messageId": "msg-456", "from": "no-reply@github.com", "subject": "Verify your email", "receivedAt": "2026-03-22T12:05:00Z"} +``` + +### `moemail read` + +```bash +# Read message (default: plain text) +moemail read --email-id xxx --message-id yyy + +# HTML format +moemail read --email-id xxx --message-id yyy --format html +``` + +`--format` options: `text` (default) | `html` + +### `moemail send` + +```bash +moemail send --email-id xxx --to user@example.com --subject "Hello" --content "Body text" +``` + +### `moemail delete` + +```bash +# Delete mailbox and all messages +moemail delete --email-id xxx + +# Delete single message +moemail delete --email-id xxx --message-id yyy +``` + +## Output Specification + +### Modes + +| Mode | Flag | Format | Use case | +|------|------|--------|----------| +| Text | (default) | Human-readable | Debugging, manual use | +| JSON | `--json` | Single-line JSON on stdout | Agent consumption | + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Operation failed (timeout, not found, bad params) | +| 2 | Auth failed (invalid/expired API Key) | + +### Error Handling + +- Errors write to **stderr**, never stdout +- In `--json` mode, stdout contains only valid JSON (or nothing on failure) +- Agent reads stdout for data, checks exit code for success/failure + +```bash +$ moemail wait --email-id xxx --timeout 10 --json +# stderr: Polling... (3/10s) +# stderr: Timeout: no new messages received +# stdout: (empty) +# exit code: 1 +``` + +## Project Structure + +``` +packages/moemail-cli/ +├── src/ +│ ├── index.ts # Entry point, command registration +│ ├── commands/ +│ │ ├── config.ts # config command +│ │ ├── create.ts # create command +│ │ ├── list.ts # list command +│ │ ├── wait.ts # wait command (client-side polling) +│ │ ├── read.ts # read command +│ │ ├── send.ts # send command +│ │ └── delete.ts # delete command +│ ├── api.ts # HTTP client wrapping all API calls +│ ├── config.ts # Read/write ~/.moemail/config.json +│ └── output.ts # Output formatting (text / json) +├── package.json +├── tsconfig.json +└── README.md +``` + +## Dependencies + +- **commander** — CLI argument parsing and subcommand routing +- All other needs (HTTP, filesystem) use Node.js built-in APIs + +## Relationship to Main Project + +- Lives in `packages/moemail-cli/`, published as a separate npm package +- No code shared with the main Next.js app +- Only coupling is the API contract (URLs, request/response shapes) +- Server-side: zero changes required + +## Typical Agent Workflow + +```bash +# 1. Create a temporary mailbox +EMAIL=$(moemail create --domain moemail.app --expiry 1h --json) +EMAIL_ID=$(echo $EMAIL | jq -r '.id') +ADDRESS=$(echo $EMAIL | jq -r '.address') + +# 2. Use the address to sign up for a service (agent does this elsewhere) + +# 3. Wait for verification email +MSG=$(moemail wait --email-id $EMAIL_ID --timeout 120 --json) +MSG_ID=$(echo $MSG | jq -r '.messageId') + +# 4. Read the email content +CONTENT=$(moemail read --email-id $EMAIL_ID --message-id $MSG_ID --json) + +# 5. Agent extracts verification code from content (LLM does this) + +# 6. Clean up +moemail delete --email-id $EMAIL_ID +``` From 2168fd519a4977d53bf4263ef7299f745c8791d0 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 13:48:12 +0800 Subject: [PATCH 02/20] =?UTF-8?q?docs:=20fix=20spec=20issues=20from=20revi?= =?UTF-8?q?ew=20=E2=80=94=20expiry=20mapping,=20send=20auth,=20JSON=20outp?= =?UTF-8?q?ut=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 7d expiry option (not supported by API) - Document send endpoint needs API Key auth support (server change) - Add JSON output examples for all commands - Add list-messages subcommand (list --email-id) - Specify wait detection algorithm (ID-based, not count-based) - Document field naming convention (camelCase) and timestamp format - Add version command Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/2026-03-22-moemail-cli-design.md | 59 ++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/specs/2026-03-22-moemail-cli-design.md b/specs/2026-03-22-moemail-cli-design.md index ca1f707..2872840 100644 --- a/specs/2026-03-22-moemail-cli-design.md +++ b/specs/2026-03-22-moemail-cli-design.md @@ -4,7 +4,9 @@ A CLI tool that wraps MoeMail's existing OpenAPI, optimized for AI Agent workflows. Agents can create temporary emails, wait for incoming messages, read content, and manage mailboxes through simple shell commands. -**Goal:** Make MoeMail a first-class tool in any AI Agent's toolchain with minimal friction — one command per action, structured JSON output, zero server-side changes. +**Goal:** Make MoeMail a first-class tool in any AI Agent's toolchain with minimal friction — one command per action, structured JSON output, minimal server-side changes. + +**Server-side changes:** One small change required — the `POST /api/emails/{id}/send` endpoint currently uses session-only auth. It needs to be updated to also support API Key auth (switch from `auth()` to `getUserId()`) so the CLI `send` command works. ## Architecture @@ -13,7 +15,7 @@ Agent (Claude / GPT / Custom) ↓ shell call moemail CLI (npm package, bun-built single JS file) ↓ HTTPS + X-API-Key header -MoeMail Server (existing Next.js API, no changes) +MoeMail Server (existing Next.js API, one minor auth change for send endpoint) ↓ Cloudflare D1 / Email Workers ``` @@ -23,7 +25,7 @@ Cloudflare D1 / Email Workers - **Distribution:** npm package, `npm i -g moemail-cli` - **Binary:** `package.json` `bin` field points to `dist/index.js` - **Location:** `packages/moemail-cli/` in the monorepo -- **Server changes:** None. CLI is a pure API client. +- **Server changes:** One change — update send endpoint to support API Key auth. ## Configuration @@ -42,7 +44,9 @@ Environment variable overrides (higher priority): ## Commands -All commands support `--json` for JSON output and `--help` for usage info. +All commands support `--json` for JSON output, `--help` for usage info, and `moemail --version` for version. + +**Field naming convention:** CLI JSON output uses camelCase (`messageId`, `receivedAt`, `fromAddress`). The API uses snake_case (`message_id`, `received_at`, `from_address`). The CLI transforms all field names to camelCase, and converts epoch ms timestamps to ISO 8601 strings. ### `moemail config` @@ -68,7 +72,14 @@ moemail create moemail create --name test --domain moemail.app --expiry 24h ``` -`--expiry` options: `1h` | `24h` | `3d` | `7d` | `permanent` +`--expiry` options and API mapping: + +| CLI flag | API `expiryTime` (ms) | +|----------|----------------------| +| `1h` | `3600000` | +| `24h` | `86400000` | +| `3d` | `259200000` | +| `permanent` | `0` | Default output: ``` @@ -81,16 +92,31 @@ JSON output (`--json`): {"id": "abc-123", "address": "test@moemail.app", "expiresAt": "2026-03-23T12:00:00Z"} ``` +Note: The API returns `{ id, email }`. The CLI renames `email` → `address` for clarity, and computes `expiresAt` from the chosen expiry option. + ### `moemail list` ```bash # List all mailboxes moemail list +# List messages in a mailbox +moemail list --email-id xxx + # With pagination moemail list --cursor xxx ``` +JSON output — mailboxes (`--json`): +```json +{"emails": [{"id": "abc-123", "address": "test@moemail.app", "expiresAt": "..."}], "nextCursor": "xxx", "total": 5} +``` + +JSON output — messages (`--json --email-id xxx`): +```json +{"messages": [{"id": "msg-1", "from": "sender@example.com", "subject": "Hello", "receivedAt": "..."}], "nextCursor": null, "total": 2} +``` + ### `moemail wait` The core command for Agent workflows. Polls the API until a new message arrives or timeout. @@ -103,10 +129,12 @@ moemail wait --email-id xxx moemail wait --email-id xxx --timeout 60 --interval 3 ``` +**Parameters:** `--timeout` and `--interval` are in seconds. + **Behavior:** -1. Record current message count/IDs on start +1. Fetch current message list, record all existing message IDs in a Set 2. Poll `GET /api/emails/{id}` every `--interval` seconds -3. Compare with initial state to detect new messages +3. Compare returned message IDs against the initial Set to detect new messages (ID-based, not count-based — safe against concurrent deletes) 4. On new message: output message summary and exit with code 0 5. On timeout: exit with code 1 @@ -134,12 +162,22 @@ moemail read --email-id xxx --message-id yyy --format html `--format` options: `text` (default) | `html` +JSON output (`--json`): +```json +{"id": "msg-456", "from": "no-reply@github.com", "to": "test@moemail.app", "subject": "Verify your email", "content": "Your code is 123456", "html": "

Your code is 123456

", "receivedAt": "2026-03-22T12:05:00Z", "type": "received"} +``` + ### `moemail send` ```bash moemail send --email-id xxx --to user@example.com --subject "Hello" --content "Body text" ``` +JSON output (`--json`): +```json +{"success": true, "remainingEmails": 4} +``` + ### `moemail delete` ```bash @@ -150,6 +188,11 @@ moemail delete --email-id xxx moemail delete --email-id xxx --message-id yyy ``` +JSON output (`--json`): +```json +{"success": true} +``` + ## Output Specification ### Modes @@ -213,7 +256,7 @@ packages/moemail-cli/ - Lives in `packages/moemail-cli/`, published as a separate npm package - No code shared with the main Next.js app - Only coupling is the API contract (URLs, request/response shapes) -- Server-side: zero changes required +- Server-side: one change — send endpoint auth support for API Key ## Typical Agent Workflow From f7f9e2af368ff26c2c518d50db40b92215fa8b1d Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:21:29 +0800 Subject: [PATCH 03/20] docs: add CI/CD publishing, agent discoverability, rename to packages/cli - Add publish-cli.yml workflow triggered by cli-v* tags - Add agent discoverability section (help, README, llms.txt) - Rename packages/moemail-cli to packages/cli Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/2026-03-22-moemail-cli-design.md | 126 ++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 3 deletions(-) diff --git a/specs/2026-03-22-moemail-cli-design.md b/specs/2026-03-22-moemail-cli-design.md index 2872840..99d4218 100644 --- a/specs/2026-03-22-moemail-cli-design.md +++ b/specs/2026-03-22-moemail-cli-design.md @@ -24,7 +24,7 @@ Cloudflare D1 / Email Workers - **Build:** `bun build ./src/index.ts --outdir ./dist --target=node` - **Distribution:** npm package, `npm i -g moemail-cli` - **Binary:** `package.json` `bin` field points to `dist/index.js` -- **Location:** `packages/moemail-cli/` in the monorepo +- **Location:** `packages/cli/` in the monorepo - **Server changes:** One change — update send endpoint to support API Key auth. ## Configuration @@ -227,7 +227,7 @@ $ moemail wait --email-id xxx --timeout 10 --json ## Project Structure ``` -packages/moemail-cli/ +packages/cli/ ├── src/ │ ├── index.ts # Entry point, command registration │ ├── commands/ @@ -253,11 +253,131 @@ packages/moemail-cli/ ## Relationship to Main Project -- Lives in `packages/moemail-cli/`, published as a separate npm package +- Lives in `packages/cli/`, published as a separate npm package - No code shared with the main Next.js app - Only coupling is the API contract (URLs, request/response shapes) - Server-side: one change — send endpoint auth support for API Key +## CI/CD Publishing + +CLI 通过独立的 GitHub Actions workflow 发布到 npm,与主项目的 deploy workflow 分开。 + +### Workflow: `.github/workflows/publish-cli.yml` + +**触发条件:** +- Push tag 匹配 `cli-v*`(如 `cli-v1.0.0`),与主项目的 `v*` tag 区分 + +**流程:** +1. Checkout 代码 +2. Setup pnpm + Node.js 20 +3. Install dependencies(`pnpm install --frozen-lockfile`) +4. Build CLI(`cd packages/cli && bun build ./src/index.ts --outdir ./dist --target=node`) +5. Publish to npm(`cd packages/cli && npm publish --access public`) + +**所需 Secrets:** +- `NPM_TOKEN`:npm publish token,在 GitHub repo Settings → Secrets 中配置 + +### 发布步骤 + +```bash +# 1. 更新 packages/cli/package.json 中的 version +# 2. Commit & push +# 3. 打 tag 并推送 +git tag cli-v1.0.0 +git push origin cli-v1.0.0 +``` + +### 版本策略 + +CLI 独立版本号,不与主项目同步。遵循 semver: +- **patch**:bug fix +- **minor**:新增命令或 flag +- **major**:破坏性变更(命令重命名、输出格式变更) + +## Agent Discoverability + +三层机制让 AI Agent 知道如何使用 CLI。 + +### 1. CLI 内置 Help + +Commander 自动生成,Agent 调用一次即可获取完整用法: + +```bash +$ moemail --help +Usage: moemail [options] [command] + +MoeMail CLI — Agent-friendly temporary email tool + +Options: + -V, --version output the version number + --json output as JSON + -h, --help display help for command + +Commands: + config configure API endpoint and API Key + create [options] create a temporary email address + list [options] list mailboxes or messages + wait [options] wait for a new email to arrive + read [options] read an email message + send [options] send an email from a temporary address + delete [options] delete a mailbox or message + +$ moemail create --help +Usage: moemail create [options] + +Create a temporary email address + +Options: + --name email prefix (default: random) + --domain email domain + --expiry 1h | 24h | 3d | permanent (default: "1h") + --json output as JSON + -h, --help display help for command +``` + +### 2. README 文档 + +`packages/cli/README.md` 作为 npm 包首页展示,包含: +- 一句话介绍:Agent-first CLI for MoeMail temporary email service +- 安装命令 +- 快速开始(3 步:config → create → wait) +- 完整命令参考表 +- Agent workflow 示例 +- JSON 输出格式说明 + +### 3. llms.txt + +在 MoeMail 站点根目录提供 `https://moemail.app/llms.txt`,遵循 llms.txt 协议。Agent 访问网站时自动发现可用工具。 + +``` +# MoeMail + +> Temporary email service with CLI tool for AI Agents + +MoeMail provides disposable email addresses. Install the CLI for programmatic access: + +## CLI Tool + +Install: npm i -g moemail-cli + +Setup: moemail config set api-url https://moemail.app && moemail config set api-key YOUR_KEY + +Commands: +- moemail create --domain --expiry <1h|24h|3d|permanent> --json +- moemail list --json +- moemail list --email-id --json +- moemail wait --email-id --timeout --json +- moemail read --email-id --message-id --json +- moemail send --email-id --to --subject --content --json +- moemail delete --email-id --json + +Typical workflow: create email → use address for signup → wait for verification → read content → extract code → delete + +All commands support --json for structured output. Exit code 0 = success, 1 = failure, 2 = auth error. +``` + +**实现方式:** 在 Next.js 的 `public/` 目录下放置 `llms.txt` 静态文件,部署时自动可访问。 + ## Typical Agent Workflow ```bash From 0c096f6c9f445b5a2b9b3cdb3437e88991c9b6a2 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:29:47 +0800 Subject: [PATCH 04/20] docs: add MoeMail CLI implementation plan 15 tasks covering scaffolding, core modules, all 7 commands, server-side send auth fix, README, llms.txt, and CI/CD workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/2026-03-22-moemail-cli-plan.md | 1143 ++++++++++++++++++++++++++ 1 file changed, 1143 insertions(+) create mode 100644 specs/2026-03-22-moemail-cli-plan.md diff --git a/specs/2026-03-22-moemail-cli-plan.md b/specs/2026-03-22-moemail-cli-plan.md new file mode 100644 index 0000000..ec832ab --- /dev/null +++ b/specs/2026-03-22-moemail-cli-plan.md @@ -0,0 +1,1143 @@ +# MoeMail CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an agent-first CLI tool that wraps MoeMail's existing REST API, published as an npm package. + +**Architecture:** CLI lives in `packages/cli/`, uses commander for arg parsing, calls MoeMail API via `fetch` with `X-API-Key` auth. One server-side fix needed for the send endpoint auth. + +**Tech Stack:** TypeScript, Bun (build), commander (CLI framework), Node built-in `fetch` and `fs` + +**Spec:** `specs/2026-03-22-moemail-cli-design.md` + +--- + +## File Structure + +``` +packages/cli/ +├── src/ +│ ├── index.ts # Entry point — program definition, command registration +│ ├── commands/ +│ │ ├── config.ts # config set/list subcommands +│ │ ├── create.ts # create temp email +│ │ ├── list.ts # list mailboxes or messages +│ │ ├── wait.ts # poll for new messages +│ │ ├── read.ts # read message content +│ │ ├── send.ts # send email +│ │ └── delete.ts # delete mailbox or message +│ ├── api.ts # HTTP client — all API calls, error handling, auth header +│ ├── config.ts # Config file read/write (~/.moemail/config.json) + env override +│ └── output.ts # Output helpers — json/text formatting, stderr logging +├── package.json +├── tsconfig.json +└── README.md +``` + +**Server-side change:** +- Modify: `app/api/emails/[id]/send/route.ts` — switch from `auth()` to `getUserId()` + +**Agent discoverability:** +- Create: `public/llms.txt` + +**CI/CD:** +- Create: `.github/workflows/publish-cli.yml` + +--- + +### Task 1: Project Scaffolding + +**Files:** +- Create: `packages/cli/package.json` +- Create: `packages/cli/tsconfig.json` +- Create: `packages/cli/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "moemail-cli", + "version": "0.1.0", + "description": "Agent-first CLI for MoeMail temporary email service", + "type": "module", + "bin": { + "moemail": "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", "cli", "agent", "ai"], + "license": "MIT", + "dependencies": { + "commander": "^12.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "skipLibCheck": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create minimal entry point** + +`packages/cli/src/index.ts`: +```ts +#!/usr/bin/env node +import { Command } from "commander"; + +const program = new Command(); + +program + .name("moemail") + .description("MoeMail CLI — Agent-friendly temporary email tool") + .version("0.1.0"); + +program.parse(); +``` + +- [ ] **Step 4: Install dependencies and verify** + +```bash +cd packages/cli && pnpm install +bun run src/index.ts --help +``` + +Expected: commander help output with program name and version. + +- [ ] **Step 5: Verify build** + +```bash +cd packages/cli && bun build ./src/index.ts --outdir ./dist --target=node +node dist/index.js --help +``` + +Expected: same help output. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): scaffold CLI package with commander" +``` + +--- + +### Task 2: Config Module + +**Files:** +- Create: `packages/cli/src/config.ts` +- Create: `packages/cli/src/commands/config.ts` + +- [ ] **Step 1: Implement config read/write module** + +`packages/cli/src/config.ts`: +```ts +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +export interface CliConfig { + apiUrl: string; + apiKey: string; +} + +const CONFIG_DIR = join(homedir(), ".moemail"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +export function loadConfig(): CliConfig { + const config: CliConfig = { apiUrl: "", apiKey: "" }; + + // File config + if (existsSync(CONFIG_FILE)) { + try { + const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + if (raw.apiUrl) config.apiUrl = raw.apiUrl; + if (raw.apiKey) config.apiKey = raw.apiKey; + } catch {} + } + + // Env overrides (higher priority) + 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; + + return config; +} + +export function saveConfig(key: string, value: string): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + let config: Record = {}; + if (existsSync(CONFIG_FILE)) { + try { + config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch {} + } + + // Map CLI key names to config keys + const keyMap: Record = { + "api-url": "apiUrl", + "api-key": "apiKey", + }; + + const configKey = keyMap[key]; + if (!configKey) { + throw new Error(`Unknown config key: ${key}. Valid keys: api-url, api-key`); + } + + config[configKey] = value; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} +``` + +- [ ] **Step 2: Implement config command** + +`packages/cli/src/commands/config.ts`: +```ts +import { Command } from "commander"; +import { loadConfig, saveConfig } from "../config.js"; + +export function registerConfigCommand(program: Command) { + const cmd = program.command("config").description("Configure API endpoint and API Key"); + + cmd + .command("set ") + .description("Set a config value (api-url or api-key)") + .action((key: string, value: string) => { + try { + saveConfig(key, value); + console.error(`Set ${key} successfully.`); + } catch (e: any) { + console.error(e.message); + process.exit(1); + } + }); + + cmd + .command("list") + .description("Show current configuration") + .action(() => { + const config = loadConfig(); + console.log(`api-url: ${config.apiUrl || "(not set)"}`); + console.log(`api-key: ${config.apiKey ? config.apiKey.slice(0, 6) + "..." : "(not set)"}`); + }); +} +``` + +- [ ] **Step 3: Register in index.ts** + +Update `packages/cli/src/index.ts` to import and register: +```ts +#!/usr/bin/env node +import { Command } from "commander"; +import { registerConfigCommand } from "./commands/config.js"; + +const program = new Command(); + +program + .name("moemail") + .description("MoeMail CLI — Agent-friendly temporary email tool") + .version("0.1.0") + .option("--json", "output as JSON"); + +registerConfigCommand(program); + +program.parse(); +``` + +- [ ] **Step 4: Test manually** + +```bash +cd packages/cli +bun run src/index.ts config set api-url https://moemail.app +bun run src/index.ts config set api-key mk_test123 +bun run src/index.ts config list +cat ~/.moemail/config.json +``` + +Expected: config values saved and displayed correctly. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add config module and config command" +``` + +--- + +### Task 3: Output Module + +**Files:** +- Create: `packages/cli/src/output.ts` + +- [ ] **Step 1: Implement output helpers** + +`packages/cli/src/output.ts`: +```ts +/** + * Print JSON to stdout (for --json mode). + */ +export function printJson(data: unknown): void { + console.log(JSON.stringify(data)); +} + +/** + * Print human-readable text to stdout. + */ +export function printText(text: string): void { + console.log(text); +} + +/** + * Log to stderr (progress, errors — never pollutes stdout). + */ +export function log(message: string): void { + console.error(message); +} + +/** + * Convert epoch ms timestamp to ISO 8601 string. + */ +export function msToIso(ms: number): string { + return new Date(ms).toISOString(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/cli/src/output.ts +git commit -m "feat(cli): add output formatting helpers" +``` + +--- + +### Task 4: API Client + +**Files:** +- Create: `packages/cli/src/api.ts` + +- [ ] **Step 1: Implement API client** + +`packages/cli/src/api.ts`: +```ts +import { loadConfig } from "./config.js"; +import { log } from "./output.js"; + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +async function request( + method: string, + path: string, + body?: Record, +): Promise { + const config = loadConfig(); + + if (!config.apiUrl) { + log("Error: API URL not configured. Run: moemail config set api-url "); + process.exit(2); + } + if (!config.apiKey) { + log("Error: API Key not configured. Run: moemail config set api-key "); + process.exit(2); + } + + const url = `${config.apiUrl.replace(/\/$/, "")}${path}`; + const headers: Record = { + "X-API-Key": config.apiKey, + }; + if (body) { + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(url, { + method, + headers, + 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}`); + } + + return data; +} + +export const api = { + getConfig: () => request("GET", "/api/config"), + + createEmail: (body: { name?: string; expiryTime: number; domain: string }) => + request("POST", "/api/emails/generate", body as any), + + listEmails: (cursor?: string) => + request("GET", `/api/emails${cursor ? `?cursor=${cursor}` : ""}`), + + listMessages: (emailId: string, cursor?: string) => + request("GET", `/api/emails/${emailId}${cursor ? `?cursor=${cursor}` : ""}`), + + getMessage: (emailId: string, messageId: string) => + request("GET", `/api/emails/${emailId}/${messageId}`), + + deleteEmail: (emailId: string) => + request("DELETE", `/api/emails/${emailId}`), + + deleteMessage: (emailId: string, messageId: string) => + request("DELETE", `/api/emails/${emailId}/${messageId}`), + + sendEmail: (emailId: string, body: { to: string; subject: string; content: string }) => + request("POST", `/api/emails/${emailId}/send`, body), +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/cli/src/api.ts +git commit -m "feat(cli): add API client module" +``` + +--- + +### Task 5: Create Command + +**Files:** +- Create: `packages/cli/src/commands/create.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement create command** + +`packages/cli/src/commands/create.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +const EXPIRY_MAP: Record = { + "1h": 3600000, + "24h": 86400000, + "3d": 259200000, + permanent: 0, +}; + +export function registerCreateCommand(program: Command) { + program + .command("create") + .description("Create a temporary email address") + .option("--name ", "email prefix") + .option("--domain ", "email domain") + .option("--expiry ", "1h | 24h | 3d | permanent", "1h") + .action(async (opts) => { + const json = program.opts().json; + const expiryTime = EXPIRY_MAP[opts.expiry]; + if (expiryTime === undefined) { + log(`Error: Invalid expiry "${opts.expiry}". Valid: 1h, 24h, 3d, permanent`); + process.exit(1); + } + + try { + // If no domain specified, fetch from server config + let domain = opts.domain; + if (!domain) { + const config = (await api.getConfig()) as any; + const domains = config.emailDomains?.split(",").map((d: string) => d.trim()); + if (!domains?.length) { + log("Error: No email domains configured on server."); + process.exit(1); + } + domain = domains[0]; + } + + const result = (await api.createEmail({ + name: opts.name, + expiryTime, + domain, + })) as any; + + const expiresAt = + expiryTime === 0 + ? null + : msToIso(Date.now() + expiryTime); + + if (json) { + printJson({ + id: result.id, + address: result.email, + expiresAt, + }); + } else { + const expiryLabel = opts.expiry === "permanent" ? "permanent" : `expires in ${opts.expiry}`; + printText(`Created: ${result.email} (${expiryLabel})`); + printText(`ID: ${result.id}`); + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts** + +Add import and call `registerCreateCommand(program)` before `program.parse()`. + +- [ ] **Step 3: Test manually** + +```bash +cd packages/cli +bun run src/index.ts create --help +bun run src/index.ts create --domain moemail.app --expiry 1h --json +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add create command" +``` + +--- + +### Task 6: List Command + +**Files:** +- Create: `packages/cli/src/commands/list.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement list command** + +`packages/cli/src/commands/list.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +export function registerListCommand(program: Command) { + program + .command("list") + .description("List mailboxes or messages") + .option("--email-id ", "list messages in this mailbox") + .option("--cursor ", "pagination cursor") + .action(async (opts) => { + const json = program.opts().json; + try { + if (opts.emailId) { + // List messages + const data = (await api.listMessages(opts.emailId, opts.cursor)) as any; + if (json) { + printJson({ + 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, + }); + } else { + if (!data.messages.length) { + printText("No messages."); + } else { + for (const m of data.messages) { + printText(`[${m.id}] From: ${m.from_address} — ${m.subject}`); + } + printText(`Total: ${data.total}`); + } + } + } else { + // List mailboxes + // Note: GET /api/emails returns raw Drizzle ORM objects. + // expiresAt is a Date serialized to ISO string (not epoch ms). + const data = (await api.listEmails(opts.cursor)) as any; + if (json) { + printJson({ + emails: data.emails.map((e: any) => ({ + id: e.id, + address: e.address, + expiresAt: e.expiresAt || null, // Already ISO string from server + })), + nextCursor: data.nextCursor, + total: data.total, + }); + } else { + if (!data.emails.length) { + printText("No mailboxes."); + } else { + for (const e of data.emails) { + printText(`[${e.id}] ${e.address}`); + } + printText(`Total: ${data.total}`); + } + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts list --json +bun run src/index.ts list --email-id --json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add list command for mailboxes and messages" +``` + +--- + +### Task 7: Wait Command + +**Files:** +- Create: `packages/cli/src/commands/wait.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement wait command** + +`packages/cli/src/commands/wait.ts`: +```ts +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)); +} + +export function registerWaitCommand(program: Command) { + program + .command("wait") + .description("Wait for a new email to arrive") + .requiredOption("--email-id ", "email ID to watch") + .option("--timeout ", "max wait time in seconds", "120") + .option("--interval ", "poll interval in seconds", "5") + .action(async (opts) => { + const json = program.opts().json; + const timeout = parseInt(opts.timeout, 10); + const interval = parseInt(opts.interval, 10); + const emailId = opts.emailId; + + try { + // Record initial message IDs + 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 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; + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts** + +- [ ] **Step 3: Test manually** + +```bash +# In one terminal, create an email and wait +bun run src/index.ts wait --email-id --timeout 30 --interval 3 --json +# Send a test email to it from another source to verify detection +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add wait command with client-side polling" +``` + +--- + +### Task 8: Read Command + +**Files:** +- Create: `packages/cli/src/commands/read.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement read command** + +`packages/cli/src/commands/read.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +export function registerReadCommand(program: Command) { + program + .command("read") + .description("Read an email message") + .requiredOption("--email-id ", "email ID") + .requiredOption("--message-id ", "message ID") + .option("--format ", "text | html", "text") + .action(async (opts) => { + const json = program.opts().json; + try { + const data = (await api.getMessage(opts.emailId, opts.messageId)) as any; + const msg = data.message; + + if (json) { + printJson({ + 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, + }); + } else { + printText(`From: ${msg.from_address}`); + printText(`To: ${msg.to_address}`); + printText(`Subject: ${msg.subject}`); + printText(`---`); + if (opts.format === "html") { + printText(msg.html || "(no HTML content)"); + } else { + printText(msg.content || "(no text content)"); + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts read --email-id --message-id --json +bun run src/index.ts read --email-id --message-id --format html +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add read command" +``` + +--- + +### Task 9: Delete Command + +**Files:** +- Create: `packages/cli/src/commands/delete.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement delete command** + +`packages/cli/src/commands/delete.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText } from "../output.js"; + +export function registerDeleteCommand(program: Command) { + program + .command("delete") + .description("Delete a mailbox or message") + .requiredOption("--email-id ", "email ID") + .option("--message-id ", "message ID (omit to delete entire mailbox)") + .action(async (opts) => { + const json = program.opts().json; + try { + if (opts.messageId) { + await api.deleteMessage(opts.emailId, opts.messageId); + if (json) { + printJson({ success: true }); + } else { + printText(`Deleted message ${opts.messageId}`); + } + } else { + await api.deleteEmail(opts.emailId); + if (json) { + printJson({ success: true }); + } else { + printText(`Deleted mailbox ${opts.emailId}`); + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts delete --email-id --json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add delete command" +``` + +--- + +### Task 10: Server-Side Fix — Send Endpoint Auth + +**Files:** +- Modify: `app/api/emails/[id]/send/route.ts` + +This is required before the CLI `send` command can work with API keys. + +- [ ] **Step 1: Read current send route** + +Read `app/api/emails/[id]/send/route.ts` to locate the `auth()` call. + +- [ ] **Step 2: Replace session auth with dual auth** + +Three changes needed in the file: + +**a) Import `getUserId` and replace auth call (line 2, 52-58):** +```ts +// Add import: +import { getUserId } from "@/lib/apiKey" + +// Replace lines 52-58: +// Before: +const session = await auth() +if (!session?.user?.id) { + return NextResponse.json({ error: "未授权" }, { status: 401 }) +} + +// After: +const userId = await getUserId() +if (!userId) { + return NextResponse.json({ error: "未授权" }, { status: 401 }) +} +``` + +**b) Update checkSendPermission call (line 63):** +```ts +// Before: +const permissionResult = await checkSendPermission(session.user.id) + +// After: +const permissionResult = await checkSendPermission(userId) +``` + +**c) Update ownership check (line 93):** +```ts +// Before: +if (email.userId !== session.user.id) { + +// After: +if (email.userId !== userId) { +``` + +Remove the now-unused `import { auth } from "@/lib/auth"` if no other usage remains. + +- [ ] **Step 3: Verify the existing web UI still works** + +Start the dev server and test sending an email through the web interface to confirm the change doesn't break session-based auth. + +- [ ] **Step 4: Commit** + +```bash +git add app/api/emails/[id]/send/route.ts +git commit -m "fix(api): support API Key auth for send endpoint" +``` + +--- + +### Task 11: Send Command + +**Files:** +- Create: `packages/cli/src/commands/send.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement send command** + +`packages/cli/src/commands/send.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText } from "../output.js"; + +export function registerSendCommand(program: Command) { + program + .command("send") + .description("Send an email from a temporary address") + .requiredOption("--email-id ", "email ID to send from") + .requiredOption("--to
", "recipient email address") + .requiredOption("--subject ", "email subject") + .requiredOption("--content ", "email body text") + .action(async (opts) => { + const json = program.opts().json; + try { + const result = (await api.sendEmail(opts.emailId, { + to: opts.to, + subject: opts.subject, + content: opts.content, + })) as any; + + if (json) { + printJson({ + success: true, + remainingEmails: result.remainingEmails, + }); + } else { + printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`); + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts send --email-id --to test@example.com --subject "Test" --content "Hello" --json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add send command" +``` + +--- + +### Task 12: README + +**Files:** +- Create: `packages/cli/README.md` + +- [ ] **Step 1: Write README** + +Include: +- One-line description: "Agent-first CLI for MoeMail temporary email service" +- Install: `npm i -g moemail-cli` +- Quick start (3 steps: config → create → wait) +- Command reference table (all 7 commands with key flags) +- Agent workflow example (the bash script from the spec) +- JSON output format note +- Exit codes table +- Link to MoeMail main project + +- [ ] **Step 2: Commit** + +```bash +git add packages/cli/README.md +git commit -m "docs(cli): add README with usage guide and agent workflow" +``` + +--- + +### Task 13: llms.txt + +**Files:** +- Create: `public/llms.txt` + +- [ ] **Step 1: Create llms.txt** + +Copy the content from the spec's "llms.txt" section into `public/llms.txt`. + +- [ ] **Step 2: Verify it's accessible** + +With the dev server running, visit `http://localhost:3000/llms.txt` and confirm it serves the text file. + +- [ ] **Step 3: Commit** + +```bash +git add public/llms.txt +git commit -m "feat: add llms.txt for AI agent discoverability" +``` + +--- + +### Task 14: CI/CD — Publish Workflow + +**Files:** +- Create: `.github/workflows/publish-cli.yml` + +- [ ] **Step 1: Create workflow file** + +`.github/workflows/publish-cli.yml`: +```yaml +name: Publish CLI + +on: + push: + tags: + - 'cli-v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: cd packages/cli && pnpm install --frozen-lockfile + + - name: Build + run: cd packages/cli && bun build ./src/index.ts --outdir ./dist --target=node + + - name: Publish to npm + run: cd packages/cli && npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/publish-cli.yml +git commit -m "ci: add CLI publish workflow triggered by cli-v* tags" +``` + +--- + +### Task 15: Final Integration Test + +- [ ] **Step 1: Full workflow test** + +Run the complete agent workflow from the spec against the running dev server: + +```bash +cd packages/cli + +# Configure +bun run src/index.ts config set api-url http://localhost:3000 +bun run src/index.ts config set api-key + +# Create +EMAIL=$(bun run src/index.ts create --expiry 1h --json) +echo $EMAIL + +# List +bun run src/index.ts list --json + +# Wait (in background, then send test email) +# This requires a real email to be sent to the temp address + +# Read (use a message ID from list) +bun run src/index.ts list --email-id --json + +# Delete +bun run src/index.ts delete --email-id --json +``` + +- [ ] **Step 2: Build and test as npm package** + +```bash +cd packages/cli +bun build ./src/index.ts --outdir ./dist --target=node +node dist/index.js --version +node dist/index.js --help +node dist/index.js config list +``` + +- [ ] **Step 3: Final commit** + +```bash +git add -A +git commit -m "feat(cli): complete CLI implementation with all commands" +``` From 5b7ba3e924027f42d873e35ab5e2e1cf34550fdf Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:40:58 +0800 Subject: [PATCH 05/20] feat(cli): scaffold CLI package with commander Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/.gitignore | 2 ++ packages/cli/package.json | 19 +++++++++++++++++++ packages/cli/pnpm-lock.yaml | 23 +++++++++++++++++++++++ packages/cli/src/index.ts | 12 ++++++++++++ packages/cli/tsconfig.json | 14 ++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 packages/cli/.gitignore create mode 100644 packages/cli/package.json create mode 100644 packages/cli/pnpm-lock.yaml create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..8c8861b --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "moemail-cli", + "version": "0.1.0", + "description": "Agent-first CLI for MoeMail temporary email service", + "type": "module", + "bin": { + "moemail": "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", "cli", "agent", "ai"], + "license": "MIT", + "dependencies": { + "commander": "^12.0.0" + } +} diff --git a/packages/cli/pnpm-lock.yaml b/packages/cli/pnpm-lock.yaml new file mode 100644 index 0000000..7c37da3 --- /dev/null +++ b/packages/cli/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + commander: + specifier: ^12.0.0 + version: 12.1.0 + +packages: + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + +snapshots: + + commander@12.1.0: {} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..d8f23d6 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { Command } from "commander"; + +const program = new Command(); + +program + .name("moemail") + .description("MoeMail CLI — Agent-friendly temporary email tool") + .version("0.1.0") + .option("--json", "output as JSON"); + +program.parse(); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..49b7e36 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "skipLibCheck": true + }, + "include": ["src"] +} From 6795719750d78ee7255843a81df7ee1d36e92d21 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:42:11 +0800 Subject: [PATCH 06/20] feat(cli): add output formatting helpers --- packages/cli/src/output.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/cli/src/output.ts diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts new file mode 100644 index 0000000..9cee3da --- /dev/null +++ b/packages/cli/src/output.ts @@ -0,0 +1,27 @@ +/** + * Print JSON to stdout (for --json mode). + */ +export function printJson(data: unknown): void { + console.log(JSON.stringify(data)); +} + +/** + * Print human-readable text to stdout. + */ +export function printText(text: string): void { + console.log(text); +} + +/** + * Log to stderr (progress, errors — never pollutes stdout). + */ +export function log(message: string): void { + console.error(message); +} + +/** + * Convert epoch ms timestamp to ISO 8601 string. + */ +export function msToIso(ms: number): string { + return new Date(ms).toISOString(); +} From 1b9bd76b82eba016fb238add2182f71b949bfebe Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:42:54 +0800 Subject: [PATCH 07/20] feat(cli): add config module and config command Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/config.ts | 28 +++++++++++++++ packages/cli/src/config.ts | 56 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 3 ++ 3 files changed, 87 insertions(+) create mode 100644 packages/cli/src/commands/config.ts create mode 100644 packages/cli/src/config.ts diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 0000000..ed1cb70 --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,28 @@ +import { Command } from "commander"; +import { loadConfig, saveConfig } from "../config.js"; + +export function registerConfigCommand(program: Command) { + const cmd = program.command("config").description("Configure API endpoint and API Key"); + + cmd + .command("set ") + .description("Set a config value (api-url or api-key)") + .action((key: string, value: string) => { + try { + saveConfig(key, value); + console.error(`Set ${key} successfully.`); + } catch (e: any) { + console.error(e.message); + process.exit(1); + } + }); + + cmd + .command("list") + .description("Show current configuration") + .action(() => { + const config = loadConfig(); + console.log(`api-url: ${config.apiUrl || "(not set)"}`); + console.log(`api-key: ${config.apiKey ? config.apiKey.slice(0, 6) + "..." : "(not set)"}`); + }); +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..3d5ce21 --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,56 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +export interface CliConfig { + apiUrl: string; + apiKey: string; +} + +const CONFIG_DIR = join(homedir(), ".moemail"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +export function loadConfig(): CliConfig { + const config: CliConfig = { apiUrl: "", apiKey: "" }; + + // File config + if (existsSync(CONFIG_FILE)) { + try { + const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + if (raw.apiUrl) config.apiUrl = raw.apiUrl; + if (raw.apiKey) config.apiKey = raw.apiKey; + } catch {} + } + + // Env overrides (higher priority) + 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; + + return config; +} + +export function saveConfig(key: string, value: string): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + let config: Record = {}; + if (existsSync(CONFIG_FILE)) { + try { + config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch {} + } + + const keyMap: Record = { + "api-url": "apiUrl", + "api-key": "apiKey", + }; + + const configKey = keyMap[key]; + if (!configKey) { + throw new Error(`Unknown config key: ${key}. Valid keys: api-url, api-key`); + } + + config[configKey] = value; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d8f23d6..6c960e8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { Command } from "commander"; +import { registerConfigCommand } from "./commands/config.js"; const program = new Command(); @@ -9,4 +10,6 @@ program .version("0.1.0") .option("--json", "output as JSON"); +registerConfigCommand(program); + program.parse(); From 542f6ec2f8973b1ad876e0771b410498e85758d5 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:45:17 +0800 Subject: [PATCH 08/20] feat(cli): add API client module --- packages/cli/src/api.ts | 84 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/cli/src/api.ts diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts new file mode 100644 index 0000000..ce85f77 --- /dev/null +++ b/packages/cli/src/api.ts @@ -0,0 +1,84 @@ +import { loadConfig } from "./config.js"; +import { log } from "./output.js"; + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +async function request( + method: string, + path: string, + body?: Record, +): Promise { + const config = loadConfig(); + + if (!config.apiUrl) { + log("Error: API URL not configured. Run: moemail config set api-url "); + process.exit(2); + } + if (!config.apiKey) { + log("Error: API Key not configured. Run: moemail config set api-key "); + process.exit(2); + } + + const url = `${config.apiUrl.replace(/\/$/, "")}${path}`; + const headers: Record = { + "X-API-Key": config.apiKey, + }; + if (body) { + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(url, { + method, + headers, + 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}`); + } + + return data; +} + +export const api = { + getConfig: () => request("GET", "/api/config"), + + createEmail: (body: { name?: string; expiryTime: number; domain: string }) => + request("POST", "/api/emails/generate", body as any), + + listEmails: (cursor?: string) => + request("GET", `/api/emails${cursor ? `?cursor=${cursor}` : ""}`), + + listMessages: (emailId: string, cursor?: string) => + request("GET", `/api/emails/${emailId}${cursor ? `?cursor=${cursor}` : ""}`), + + getMessage: (emailId: string, messageId: string) => + request("GET", `/api/emails/${emailId}/${messageId}`), + + deleteEmail: (emailId: string) => + request("DELETE", `/api/emails/${emailId}`), + + deleteMessage: (emailId: string, messageId: string) => + request("DELETE", `/api/emails/${emailId}/${messageId}`), + + sendEmail: (emailId: string, body: { to: string; subject: string; content: string }) => + request("POST", `/api/emails/${emailId}/send`, body), +}; From 12b5f4afeb18084b99f5ee1ca7bb3b3fa624e22a Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:47:08 +0800 Subject: [PATCH 09/20] feat(cli): add create command --- packages/cli/src/commands/create.ts | 62 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 2 files changed, 64 insertions(+) create mode 100644 packages/cli/src/commands/create.ts diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts new file mode 100644 index 0000000..fca97f3 --- /dev/null +++ b/packages/cli/src/commands/create.ts @@ -0,0 +1,62 @@ +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +const EXPIRY_MAP: Record = { + "1h": 3600000, + "24h": 86400000, + "3d": 259200000, + permanent: 0, +}; + +export function registerCreateCommand(program: Command) { + program + .command("create") + .description("Create a temporary email address") + .option("--name ", "email prefix") + .option("--domain ", "email domain") + .option("--expiry ", "1h | 24h | 3d | permanent", "1h") + .action(async (opts) => { + const json = program.opts().json; + const expiryTime = EXPIRY_MAP[opts.expiry]; + if (expiryTime === undefined) { + log(`Error: Invalid expiry "${opts.expiry}". Valid: 1h, 24h, 3d, permanent`); + process.exit(1); + } + + try { + let domain = opts.domain; + if (!domain) { + const config = (await api.getConfig()) as any; + const domains = config.emailDomains?.split(",").map((d: string) => d.trim()); + if (!domains?.length) { + log("Error: No email domains configured on server."); + process.exit(1); + } + domain = domains[0]; + } + + const result = (await api.createEmail({ + name: opts.name, + expiryTime, + domain, + })) as any; + + const expiresAt = + expiryTime === 0 + ? null + : msToIso(Date.now() + expiryTime); + + if (json) { + printJson({ id: result.id, address: result.email, expiresAt }); + } else { + const expiryLabel = opts.expiry === "permanent" ? "permanent" : `expires in ${opts.expiry}`; + printText(`Created: ${result.email} (${expiryLabel})`); + printText(`ID: ${result.id}`); + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6c960e8..d1569c9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; import { registerConfigCommand } from "./commands/config.js"; +import { registerCreateCommand } from "./commands/create.js"; const program = new Command(); @@ -11,5 +12,6 @@ program .option("--json", "output as JSON"); registerConfigCommand(program); +registerCreateCommand(program); program.parse(); From b891ee465560519f0f5840e8f756ee7c4772218f Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:47:19 +0800 Subject: [PATCH 10/20] feat(cli): add list command for mailboxes and messages --- packages/cli/src/commands/list.ts | 67 +++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 2 files changed, 69 insertions(+) create mode 100644 packages/cli/src/commands/list.ts diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 0000000..8c32c26 --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -0,0 +1,67 @@ +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +export function registerListCommand(program: Command) { + program + .command("list") + .description("List mailboxes or messages") + .option("--email-id ", "list messages in this mailbox") + .option("--cursor ", "pagination cursor") + .action(async (opts) => { + const json = program.opts().json; + try { + if (opts.emailId) { + const data = (await api.listMessages(opts.emailId, opts.cursor)) as any; + if (json) { + printJson({ + 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, + }); + } else { + if (!data.messages.length) { + printText("No messages."); + } else { + for (const m of data.messages) { + printText(`[${m.id}] From: ${m.from_address} — ${m.subject}`); + } + printText(`Total: ${data.total}`); + } + } + } else { + // Note: GET /api/emails returns raw Drizzle ORM objects. + // expiresAt is a Date serialized to ISO string (not epoch ms). + const data = (await api.listEmails(opts.cursor)) as any; + if (json) { + printJson({ + emails: data.emails.map((e: any) => ({ + id: e.id, + address: e.address, + expiresAt: e.expiresAt || null, + })), + nextCursor: data.nextCursor, + total: data.total, + }); + } else { + if (!data.emails.length) { + printText("No mailboxes."); + } else { + for (const e of data.emails) { + printText(`[${e.id}] ${e.address}`); + } + printText(`Total: ${data.total}`); + } + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d1569c9..e9e1c35 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { registerConfigCommand } from "./commands/config.js"; import { registerCreateCommand } from "./commands/create.js"; +import { registerListCommand } from "./commands/list.js"; const program = new Command(); @@ -13,5 +14,6 @@ program registerConfigCommand(program); registerCreateCommand(program); +registerListCommand(program); program.parse(); From 8f4f605094dab97b76649675e40a9ceb9f6cbdb8 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:47:30 +0800 Subject: [PATCH 11/20] feat(cli): add wait command with client-side polling --- packages/cli/src/commands/wait.ts | 62 +++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 2 files changed, 64 insertions(+) create mode 100644 packages/cli/src/commands/wait.ts diff --git a/packages/cli/src/commands/wait.ts b/packages/cli/src/commands/wait.ts new file mode 100644 index 0000000..db5d050 --- /dev/null +++ b/packages/cli/src/commands/wait.ts @@ -0,0 +1,62 @@ +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)); +} + +export function registerWaitCommand(program: Command) { + program + .command("wait") + .description("Wait for a new email to arrive") + .requiredOption("--email-id ", "email ID to watch") + .option("--timeout ", "max wait time in seconds", "120") + .option("--interval ", "poll interval in seconds", "5") + .action(async (opts) => { + 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 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; + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e9e1c35..bc73837 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import { registerConfigCommand } from "./commands/config.js"; import { registerCreateCommand } from "./commands/create.js"; import { registerListCommand } from "./commands/list.js"; +import { registerWaitCommand } from "./commands/wait.js"; const program = new Command(); @@ -15,5 +16,6 @@ program registerConfigCommand(program); registerCreateCommand(program); registerListCommand(program); +registerWaitCommand(program); program.parse(); From 1c7cf2e5fd4ed09e516ed4bc4d8f44974499a5bc Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:47:39 +0800 Subject: [PATCH 12/20] feat(cli): add read command --- packages/cli/src/commands/read.ts | 45 +++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 ++ 2 files changed, 47 insertions(+) create mode 100644 packages/cli/src/commands/read.ts diff --git a/packages/cli/src/commands/read.ts b/packages/cli/src/commands/read.ts new file mode 100644 index 0000000..5e7a085 --- /dev/null +++ b/packages/cli/src/commands/read.ts @@ -0,0 +1,45 @@ +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +export function registerReadCommand(program: Command) { + program + .command("read") + .description("Read an email message") + .requiredOption("--email-id ", "email ID") + .requiredOption("--message-id ", "message ID") + .option("--format ", "text | html", "text") + .action(async (opts) => { + const json = program.opts().json; + try { + const data = (await api.getMessage(opts.emailId, opts.messageId)) as any; + const msg = data.message; + + if (json) { + printJson({ + 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, + }); + } else { + printText(`From: ${msg.from_address}`); + printText(`To: ${msg.to_address}`); + printText(`Subject: ${msg.subject}`); + printText(`---`); + if (opts.format === "html") { + printText(msg.html || "(no HTML content)"); + } else { + printText(msg.content || "(no text content)"); + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bc73837..be796ee 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import { registerConfigCommand } from "./commands/config.js"; import { registerCreateCommand } from "./commands/create.js"; import { registerListCommand } from "./commands/list.js"; import { registerWaitCommand } from "./commands/wait.js"; +import { registerReadCommand } from "./commands/read.js"; const program = new Command(); @@ -17,5 +18,6 @@ registerConfigCommand(program); registerCreateCommand(program); registerListCommand(program); registerWaitCommand(program); +registerReadCommand(program); program.parse(); From f29565c90d81101ffa719bd8e9f9082ed13b3d5d Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:47:47 +0800 Subject: [PATCH 13/20] feat(cli): add delete command --- packages/cli/src/commands/delete.ts | 34 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 ++ 2 files changed, 36 insertions(+) create mode 100644 packages/cli/src/commands/delete.ts diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts new file mode 100644 index 0000000..169dd5c --- /dev/null +++ b/packages/cli/src/commands/delete.ts @@ -0,0 +1,34 @@ +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText } from "../output.js"; + +export function registerDeleteCommand(program: Command) { + program + .command("delete") + .description("Delete a mailbox or message") + .requiredOption("--email-id ", "email ID") + .option("--message-id ", "message ID (omit to delete entire mailbox)") + .action(async (opts) => { + const json = program.opts().json; + try { + if (opts.messageId) { + await api.deleteMessage(opts.emailId, opts.messageId); + if (json) { + printJson({ success: true }); + } else { + printText(`Deleted message ${opts.messageId}`); + } + } else { + await api.deleteEmail(opts.emailId); + if (json) { + printJson({ success: true }); + } else { + printText(`Deleted mailbox ${opts.emailId}`); + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index be796ee..bc8a570 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { registerCreateCommand } from "./commands/create.js"; import { registerListCommand } from "./commands/list.js"; import { registerWaitCommand } from "./commands/wait.js"; import { registerReadCommand } from "./commands/read.js"; +import { registerDeleteCommand } from "./commands/delete.js"; const program = new Command(); @@ -19,5 +20,6 @@ registerCreateCommand(program); registerListCommand(program); registerWaitCommand(program); registerReadCommand(program); +registerDeleteCommand(program); program.parse(); From b09cc284965c43ac198da126d9a87e564ddd47ea Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:48:58 +0800 Subject: [PATCH 14/20] feat(cli): add send command --- packages/cli/src/commands/send.ts | 35 +++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 ++ 2 files changed, 37 insertions(+) create mode 100644 packages/cli/src/commands/send.ts diff --git a/packages/cli/src/commands/send.ts b/packages/cli/src/commands/send.ts new file mode 100644 index 0000000..d733fd7 --- /dev/null +++ b/packages/cli/src/commands/send.ts @@ -0,0 +1,35 @@ +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText } from "../output.js"; + +export function registerSendCommand(program: Command) { + program + .command("send") + .description("Send an email from a temporary address") + .requiredOption("--email-id ", "email ID to send from") + .requiredOption("--to
", "recipient email address") + .requiredOption("--subject ", "email subject") + .requiredOption("--content ", "email body text") + .action(async (opts) => { + const json = program.opts().json; + try { + const result = (await api.sendEmail(opts.emailId, { + to: opts.to, + subject: opts.subject, + content: opts.content, + })) as any; + + if (json) { + printJson({ + success: true, + remainingEmails: result.remainingEmails, + }); + } else { + printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`); + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bc8a570..6ab1254 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { registerListCommand } from "./commands/list.js"; import { registerWaitCommand } from "./commands/wait.js"; import { registerReadCommand } from "./commands/read.js"; import { registerDeleteCommand } from "./commands/delete.js"; +import { registerSendCommand } from "./commands/send.js"; const program = new Command(); @@ -21,5 +22,6 @@ registerListCommand(program); registerWaitCommand(program); registerReadCommand(program); registerDeleteCommand(program); +registerSendCommand(program); program.parse(); From 3c238e25e629e6e1122bb972e58b2b7e598c436e Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:48:59 +0800 Subject: [PATCH 15/20] fix(api): support API Key auth for send endpoint --- app/api/emails/[id]/send/route.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/api/emails/[id]/send/route.ts b/app/api/emails/[id]/send/route.ts index 297cd71..388d542 100644 --- a/app/api/emails/[id]/send/route.ts +++ b/app/api/emails/[id]/send/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getUserId } from "@/lib/apiKey" import { createDb } from "@/lib/db" import { emails, messages } from "@/lib/schema" import { eq } from "drizzle-orm" @@ -49,8 +49,8 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getUserId() + if (!userId) { return NextResponse.json( { error: "未授权" }, { status: 401 } @@ -60,7 +60,7 @@ export async function POST( const { id } = await params const db = createDb() - const permissionResult = await checkSendPermission(session.user.id) + const permissionResult = await checkSendPermission(userId) if (!permissionResult.canSend) { return NextResponse.json( { error: permissionResult.error }, @@ -90,7 +90,7 @@ export async function POST( ) } - if (email.userId !== session.user.id) { + if (email.userId !== userId) { return NextResponse.json( { error: "无权访问此邮箱" }, { status: 403 } From 152059ca3d144b7e86c76709ae3eebbc0ac587e0 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:49:35 +0800 Subject: [PATCH 16/20] docs(cli): add README with usage guide and agent workflow --- packages/cli/README.md | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 packages/cli/README.md diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..72df70c --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,83 @@ +# MoeMail CLI + +Agent-first CLI for MoeMail temporary email service + +## Install + +```bash +npm i -g moemail-cli +``` + +## Quick Start + +### 1. Configure default domain +```bash +moemail config --domain moemail.app +``` + +### 2. Create a temporary email +```bash +moemail create --expiry 1h +``` + +### 3. Wait for messages +```bash +moemail wait --email-id --timeout 120 +``` + +## Command Reference + +| 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 ` | + +## Agent Workflow Example + +The CLI is designed to support agent-first automation. Here's a typical workflow: + +```bash +# Create temporary email and extract details +EMAIL=$(moemail create --domain moemail.app --expiry 1h --json) +EMAIL_ID=$(echo $EMAIL | jq -r '.id') +ADDRESS=$(echo $EMAIL | jq -r '.address') + +# Use ADDRESS for signup or service registration... + +# Wait for verification email +MSG=$(moemail wait --email-id $EMAIL_ID --timeout 120 --json) +MSG_ID=$(echo $MSG | jq -r '.messageId') + +# Read message content +CONTENT=$(moemail read --email-id $EMAIL_ID --message-id $MSG_ID --json) + +# Extract verification code from CONTENT... + +# Cleanup +moemail delete --email-id $EMAIL_ID +``` + +## JSON Output + +All commands support `--json` flag for structured output, making them ideal for agent automation: + +- **Success**: Command output in JSON format to stdout +- **Errors**: Error messages written to stderr +- **Exit Codes**: + - `0`: Command succeeded + - `1`: Runtime error (invalid input, service error) + - `2`: Configuration error (missing domain, invalid credentials) + +## Project Links + +- **Main Project**: https://github.com/beilunyang/moemail +- **Issues & Feedback**: https://github.com/beilunyang/moemail/issues + +## License + +MIT From 897964948448fe4e18737a1aea19b1a82d8db871 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:49:37 +0800 Subject: [PATCH 17/20] feat: add llms.txt for AI agent discoverability --- public/llms.txt | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 public/llms.txt diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..15f0f6b --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,24 @@ +# MoeMail + +> Temporary email service with CLI tool for AI Agents + +MoeMail provides disposable email addresses. Install the CLI for programmatic access: + +## CLI Tool + +Install: npm i -g moemail-cli + +Setup: moemail config set api-url https://moemail.app && moemail config set api-key YOUR_KEY + +Commands: +- moemail create --domain --expiry <1h|24h|3d|permanent> --json +- moemail list --json +- moemail list --email-id --json +- moemail wait --email-id --timeout --json +- moemail read --email-id --message-id --json +- moemail send --email-id --to --subject --content --json +- moemail delete --email-id --json + +Typical workflow: create email → use address for signup → wait for verification → read content → extract code → delete + +All commands support --json for structured output. Exit code 0 = success, 1 = failure, 2 = auth error. From 0ac027f392decd5e31d8ea3e0e3aa18259035c41 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:49:44 +0800 Subject: [PATCH 18/20] ci: add CLI publish workflow triggered by cli-v* tags --- .github/workflows/publish-cli.yml | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/publish-cli.yml diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml new file mode 100644 index 0000000..b639e8b --- /dev/null +++ b/.github/workflows/publish-cli.yml @@ -0,0 +1,38 @@ +name: Publish CLI + +on: + push: + tags: + - 'cli-v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: cd packages/cli && bun build ./src/index.ts --outdir ./dist --target=node + + - name: Publish to npm + run: cd packages/cli && npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 10257db17967fd913f1edf98e596ed360e801a94 Mon Sep 17 00:00:00 2001 From: BeilunYang <786220806@qq.com> Date: Sun, 22 Mar 2026 15:32:31 +0800 Subject: [PATCH 19/20] Rename package from 'moemail-cli' to '@moemail/cli' --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8c8861b..a2f7934 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "moemail-cli", + "name": "@moemail/cli", "version": "0.1.0", "description": "Agent-first CLI for MoeMail temporary email service", "type": "module", From b010ac57602d41f864e7146f43324695c1fa4b0b Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 15:37:02 +0800 Subject: [PATCH 20/20] ci: support manual trigger for CLI publish workflow Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish-cli.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index b639e8b..189dea9 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -4,6 +4,7 @@ on: push: tags: - 'cli-v*' + workflow_dispatch: jobs: publish: