mirror of
https://github.com/beilunyang/moemail.git
synced 2026-07-03 05:12:07 +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:
2
packages/core/.gitignore
vendored
Normal file
2
packages/core/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
15
packages/core/package.json
Normal file
15
packages/core/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
90
packages/core/src/api.ts
Normal file
90
packages/core/src/api.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { loadConfig } from "./config.js";
|
||||
import { ApiError, AuthError, ConfigError, PermissionError, QuotaError } from "./errors.js";
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const config = loadConfig();
|
||||
|
||||
if (!config.apiUrl) {
|
||||
throw new ConfigError(
|
||||
"API URL not configured. Run `moemail config set api-url <url>` or set MOEMAIL_API_URL.",
|
||||
);
|
||||
}
|
||||
if (!config.apiKey) {
|
||||
throw new ConfigError(
|
||||
"API Key not configured. Run `moemail config set api-key <key>` or set MOEMAIL_API_KEY.",
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${config.apiUrl.replace(/\/$/, "")}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
"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 === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let data: any = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Non-JSON body (e.g. empty error). Leave data as null.
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const message = data?.error || `HTTP ${res.status}`;
|
||||
|
||||
// Distinguish the error classes that a MoeMail Pro server returns so callers
|
||||
// can surface them accurately instead of lumping everything into "auth failed".
|
||||
switch (res.status) {
|
||||
case 401:
|
||||
throw new AuthError(message);
|
||||
case 403:
|
||||
throw new PermissionError(message);
|
||||
case 429:
|
||||
throw new QuotaError(message, data?.monthlyLimit, data?.monthlyUsed);
|
||||
default:
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
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),
|
||||
};
|
||||
56
packages/core/src/config.ts
Normal file
56
packages/core/src/config.ts
Normal file
@@ -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: "https://moemail.app", 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) — this is how the MCP server is configured.
|
||||
if (process.env.MOEMAIL_API_URL) config.apiUrl = process.env.MOEMAIL_API_URL;
|
||||
if (process.env.MOEMAIL_API_KEY) config.apiKey = process.env.MOEMAIL_API_KEY;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function saveConfig(key: string, value: string): void {
|
||||
if (!existsSync(CONFIG_DIR)) {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
let config: Record<string, string> = {};
|
||||
if (existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
"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));
|
||||
}
|
||||
61
packages/core/src/errors.ts
Normal file
61
packages/core/src/errors.ts
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
11
packages/core/src/index.ts
Normal file
11
packages/core/src/index.ts
Normal file
@@ -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";
|
||||
60
packages/core/src/poll.ts
Normal file
60
packages/core/src/poll.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
4
packages/core/src/util.ts
Normal file
4
packages/core/src/util.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Convert epoch ms timestamp to ISO 8601 string. */
|
||||
export function msToIso(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
16
packages/core/tsconfig.json
Normal file
16
packages/core/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user