Menu
Public repository / 3af23b0e937520000e1aa43c7e2eb9de2675abd1
AkurAI-Mail / tools/akurai-mail-mcp.ts
Latest change 7a284f40b2637792d0599c38c4cac0342da676d7 - Merge live EC2 ops copies into scripts/ and tools/ by Ólafur Búi Ólafsson
#!/usr/bin/env bun
/**
* AkurAI-Mail MCP Server — maintained, inspectable interface for mail operations.
*
* Permission tiers:
* "read" — any agent
* "draft" — write/mutation (flag, archive, move, draft-create/update)
* "send" — requires idempotencyKey + approvalProof (string token proving human approval)
*
* Every call is audited. Credentials and full message bodies are NEVER stored in audit.
*/
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, "..");
// AKURAI_MAIL_SERVER_CLI overrides the server CLI path (tests use a fake CLI).
const SERVER_CLI = process.env.AKURAI_MAIL_SERVER_CLI ?? join(REPO_ROOT, "scripts/akurai-mail-server");
const INDEX_CLI = join(REPO_ROOT, "tools/akurai-mail-index.ts");
// ── Permission scope (least-privilege gate) ─────────────────────────────────
// AKURAI_MAIL_MCP_SCOPE caps the maximum tier this server exposes, so a
// read-only agent can be deployed that cannot mutate:
// "read" — read-only (no draft/mutation, no send)
// "draft" — read + draft/flag/archive/move, no external send
// "send" — full surface (send still needs idempotencyKey + approvalProof)
// "admin" — user/alias/domain/setting management (root CLI via sudo)
// Defaults to "send" for backward compatibility.
type PermissionTier = "read" | "draft" | "send" | "admin";
const PERMISSION_RANK: Record<PermissionTier, number> = { read: 0, draft: 1, send: 2, admin: 3 };
const SCOPE: PermissionTier = ((): PermissionTier => {
const raw = (process.env.AKURAI_MAIL_MCP_SCOPE ?? "send").toLowerCase();
return raw === "read" || raw === "draft" || raw === "send" || raw === "admin" ? raw : "send";
})();
// ── CLI response types ─────────────────────────────────────────────────────
interface CliResult {
ok: boolean;
error?: string;
raw?: string;
[key: string]: unknown;
}
interface MessageResult {
ok: boolean;
message?: {
id?: string;
folder?: string;
from?: string;
to?: string;
cc?: string;
subject?: string;
date?: string;
bodyText?: string;
bodyHtml?: string;
attachments?: AttachmentMeta[];
[key: string]: unknown;
};
}
interface AttachmentMeta {
index: number;
filename: string;
contentType: string;
size: number;
}
interface DraftResult {
ok: boolean;
draft?: string;
}
// ── Audit log ───────────────────────────────────────────────────────────────
const AUDIT_LOG = process.env.AKURAI_MAIL_MCP_AUDIT_LOG ?? join(REPO_ROOT, ".akurai-mail-mcp-audit.jsonl");
type AuditEntry = {
at: string;
kind: "read" | "draft" | "send" | "admin";
email: string;
tool: string;
detail: Record<string, unknown>;
};
async function appendAudit(kind: AuditEntry["kind"], email: string, tool: string, detail: Record<string, unknown>) {
const entry: AuditEntry = {
at: new Date().toISOString(),
kind,
email,
tool,
detail: redactForAudit(detail),
};
try {
const existing = await Bun.file(AUDIT_LOG).text().catch(() => "");
await Bun.write(AUDIT_LOG, existing + JSON.stringify(entry) + "\n");
} catch {
// audit failure must not halt the operation
}
}
function redactForAudit(d: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys: Record<string, true> = { password: true, secret: true, token: true, approvalProof: true, body: true, bodyHtml: true, bodyText: true, data: true };
const r: Record<string, unknown> = {};
for (const [k, v] of Object.entries(d)) {
if (sensitiveKeys[k]) {
r[k] = typeof v === "string" && v.length > 4 ? v.slice(0, 4) + "…" : "[REDACTED]";
} else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
r[k] = redactForAudit(v as Record<string, unknown>);
} else {
r[k] = v;
}
}
return r;
}
// Admin CLI calls need root on the VM; set AKURAI_MAIL_CLI_SUDO=1 to prefix sudo -n.
const CLI_PREFIX = process.env.AKURAI_MAIL_CLI_SUDO === "1" ? ["sudo", "-n"] : [];
// ── CLI invocation helpers ──────────────────────────────────────────────────
async function runServer(args: string[]): Promise<CliResult> {
const proc = Bun.spawnSync([...CLI_PREFIX, SERVER_CLI, ...args], {
cwd: REPO_ROOT,
});
if (proc.exitCode !== 0) {
const stderr = proc.stderr.toString().trim();
throw new Error(stderr || `server CLI exited ${proc.exitCode}`);
}
const stdout = proc.stdout.toString().trim();
let result: CliResult;
try {
result = JSON.parse(stdout) as CliResult;
} catch {
result = { ok: true, raw: stdout };
}
return result;
}
async function runIndex(args: string[]): Promise<CliResult> {
const proc = Bun.spawnSync(["bun", "run", INDEX_CLI, ...args], {
cwd: join(__dirname),
env: { ...process.env },
});
if (proc.exitCode !== 0) {
const stderr = proc.stderr.toString().trim();
throw new Error(stderr || `index CLI exited ${proc.exitCode}`);
}
const stdout = proc.stdout.toString().trim();
let result: CliResult;
try {
result = JSON.parse(stdout) as CliResult;
} catch {
result = { ok: true, raw: stdout };
}
return result;
}
// ── Idempotency store (in-memory) ───────────────────────────────────────────
const _SetCtor: SetConstructor = Set;
const sentIds = new _SetCtor<string>();
function checkIdempotency(key: string): boolean {
if (sentIds.has(key)) return false;
sentIds.add(key);
return true;
}
// ── Approval proof validation ───────────────────────────────────────────────
function validateApprovalProof(proof: string): boolean {
if (!proof) return false;
return /^(ALLOW|[0-9a-fA-F]{8,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.test(proof);
}
// ── Recipient validation ────────────────────────────────────────────────────
function validateEmailList(raw: string): string[] {
if (!raw || !raw.trim()) return [];
const addresses = raw.split(/[,;]/).map(s => s.trim()).filter(Boolean);
for (const addr of addresses) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addr)) {
throw new Error(`Invalid email address: ${addr}`);
}
}
return addresses;
}
// ── MCP Protocol types ─────────────────────────────────────────────────────
type JsonRpcMessage = {
jsonrpc: "2.0";
id?: number | string | null;
method?: string;
params?: Record<string, unknown>;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
};
type ToolMeta = {
permission: "read" | "draft" | "send" | "admin";
description: string;
};
type ToolDefinition = {
name: string;
meta: ToolMeta;
inputSchema: {
type: "object";
properties: Record<string, unknown>;
required: string[];
additionalProperties: boolean;
};
};
// ── Tool definitions ────────────────────────────────────────────────────────
const emailProp = { type: "string" as const, description: "Mailbox email address (e.g. user@olibuijr.com)" };
const messageIdProp = { type: "string" as const, description: "Base64-encoded message ID from a list/read response" };
const folderProp = {
type: "string" as const,
description: "Folder name: Inbox, Sent, Drafts, Archive, Spam, Trash",
enum: ["Inbox", "Sent", "Drafts", "Archive", "Spam", "Trash"] as string[],
};
function toolDefinitions(): ToolDefinition[] {
const readTools: ToolDefinition[] = [
{
name: "mail_accounts",
meta: { permission: "read", description: "List all configured mail accounts" },
inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
},
{
name: "mail_folder_list",
meta: { permission: "read", description: "List folders and unread/total counts for a mailbox" },
inputSchema: { type: "object", properties: { email: emailProp }, required: ["email"], additionalProperties: false },
},
{
name: "mail_message_list",
meta: { permission: "read", description: "List messages in a folder with cursor-based pagination" },
inputSchema: {
type: "object",
properties: { email: emailProp, folder: { ...folderProp, default: "Inbox" }, query: { type: "string", default: "" }, limit: { type: "number", default: 40 }, cursor: { type: "string", default: "" } },
required: ["email"],
additionalProperties: false,
},
},
{
name: "mail_message_read",
meta: { permission: "read", description: "Read a specific message with body text, HTML, and attachments" },
inputSchema: {
type: "object",
properties: { email: emailProp, id: messageIdProp, markRead: { type: "boolean", default: false } },
required: ["email", "id"],
additionalProperties: false,
},
},
{
name: "mail_attachment_list",
meta: { permission: "read", description: "List attachment metadata for a message" },
inputSchema: { type: "object", properties: { email: emailProp, id: messageIdProp }, required: ["email", "id"], additionalProperties: false },
},
{
name: "mail_attachment_download",
meta: { permission: "read", description: "Download attachment data (base64-encoded)" },
inputSchema: { type: "object", properties: { email: emailProp, id: messageIdProp, index: { type: "number" } }, required: ["email", "id", "index"], additionalProperties: false },
},
{
name: "mail_thread_search",
meta: { permission: "read", description: "Semantic search across mail using the indexed search engine" },
inputSchema: {
type: "object",
properties: { query: { type: "string" }, account: { type: "string" }, limit: { type: "number", default: 10 } },
required: ["query"],
additionalProperties: false,
},
},
{
name: "mail_signals",
meta: { permission: "read", description: "Compute conversation signals (reply-needed, follow-up, priority)" },
inputSchema: {
type: "object",
properties: { email: emailProp, folder: { ...folderProp, default: "Inbox" } },
required: ["email"],
additionalProperties: false,
},
},
{
name: "mail_audit_log",
meta: { permission: "read", description: "Read the recent MCP audit log (no credentials or bodies)" },
inputSchema: { type: "object", properties: { limit: { type: "number", default: 20 } }, required: [], additionalProperties: false },
},
{ name: "mail_spam_policy_get", meta: { permission: "read", description: "Read anti-spam policy" }, inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false } },
{ name: "mail_quarantine_list", meta: { permission: "read", description: "List quarantined messages" }, inputSchema: { type: "object", properties: { account: emailProp, cursor: { type: "string" }, limit: { type: "number", minimum: 1, maximum: 100 } }, required: [], additionalProperties: false } },
{ name: "mail_address_health", meta: { permission: "read", description: "Read mailbox and domain authentication health" }, inputSchema: { type: "object", properties: { account: emailProp, domain: { type: "string" } }, required: [], additionalProperties: false } },
{ name: "mail_reputation_status", meta: { permission: "read", description: "Read outbound reputation observations" }, inputSchema: { type: "object", properties: { domain: { type: "string" }, address: emailProp, days: { type: "number", minimum: 1, maximum: 90 } }, required: [], additionalProperties: false } },
{ name: "mail_spam_policy_change", meta: { permission: "draft", description: "Change anti-spam policy; requires confirmation" }, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["add", "remove", "update"] }, kind: { type: "string", enum: ["sender", "domain"] }, value: { type: "string" }, disposition: { type: "string", enum: ["allow", "block"] }, reason: { type: "string", minLength: 1 }, expiresAt: { type: ["string", "null"] }, expectedVersion: { type: "number" }, approvalProof: { type: "string" } }, required: ["operation", "kind", "value", "reason", "expectedVersion", "approvalProof"], additionalProperties: false } },
{ name: "mail_spam_scan", meta: { permission: "draft", description: "Scan mailbox for spam; requires confirmation" }, inputSchema: { type: "object", properties: { account: emailProp, approvalProof: { type: "string" } }, required: ["approvalProof"], additionalProperties: false } },
{ name: "mail_quarantine_release", meta: { permission: "draft", description: "Release quarantined messages; requires confirmation" }, inputSchema: { type: "object", properties: { account: emailProp, messageIds: { type: "array", items: messageIdProp, minItems: 1, maxItems: 100 }, allowSender: { type: "boolean" }, reason: { type: "string", minLength: 1 }, approvalProof: { type: "string" } }, required: ["account", "messageIds", "reason", "approvalProof"], additionalProperties: false } },
{ name: "mail_quarantine_confirm", meta: { permission: "draft", description: "Confirm quarantined messages as spam; requires confirmation" }, inputSchema: { type: "object", properties: { account: emailProp, messageIds: { type: "array", items: messageIdProp, minItems: 1, maxItems: 100 }, block: { type: "boolean" }, reason: { type: "string", minLength: 1 }, approvalProof: { type: "string" } }, required: ["account", "messageIds", "reason", "approvalProof"], additionalProperties: false } },
];
const draftTools: ToolDefinition[] = [
{
name: "mail_draft_create",
meta: { permission: "draft", description: "Create a new draft in the Drafts folder" },
inputSchema: {
type: "object",
properties: { email: emailProp, to: { type: "string" }, subject: { type: "string" }, body: { type: "string" }, cc: { type: "string" } },
required: ["email"],
additionalProperties: false,
},
},
{
name: "mail_reply_prepare",
meta: { permission: "draft", description: "Prepare a reply draft with proper threading headers" },
inputSchema: {
type: "object",
properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true } },
required: ["email", "messageId", "body"],
additionalProperties: false,
},
},
{
name: "mail_reply_all_prepare",
meta: { permission: "draft", description: "Prepare a reply-all draft with all original recipients" },
inputSchema: {
type: "object",
properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true } },
required: ["email", "messageId", "body"],
additionalProperties: false,
},
},
{
name: "mail_flag",
meta: { permission: "draft", description: "Star (flag) or unstar a message" },
inputSchema: {
type: "object",
properties: { email: emailProp, id: messageIdProp, flagged: { type: "boolean" } },
required: ["email", "id", "flagged"],
additionalProperties: false,
},
},
{
name: "mail_archive",
meta: { permission: "draft", description: "Move a message to Archive folder" },
inputSchema: { type: "object", properties: { email: emailProp, id: messageIdProp }, required: ["email", "id"], additionalProperties: false },
},
{
name: "mail_move",
meta: { permission: "draft", description: "Move a message to a target folder" },
inputSchema: {
type: "object",
properties: { email: emailProp, id: messageIdProp, target: folderProp },
required: ["email", "id", "target"],
additionalProperties: false,
},
},
];
const sendTools: ToolDefinition[] = [
{
name: "mail_send",
meta: {
permission: "send",
description: "Send a new email. Requires idempotencyKey + approvalProof for external send gate.",
},
inputSchema: {
type: "object",
properties: {
email: emailProp,
to: { type: "string" },
cc: { type: "string" },
bcc: { type: "string" },
subject: { type: "string" },
body: { type: "string" },
signature: { type: "string" },
idempotencyKey: { type: "string" },
approvalProof: { type: "string" },
},
required: ["email", "to", "subject", "body", "idempotencyKey", "approvalProof"],
additionalProperties: false,
},
},
{
name: "mail_reply",
meta: {
permission: "send",
description: "Send a reply to an existing message. Requires idempotencyKey + approvalProof.",
},
inputSchema: {
type: "object",
properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true }, idempotencyKey: { type: "string" }, approvalProof: { type: "string" } },
required: ["email", "messageId", "body", "idempotencyKey", "approvalProof"],
additionalProperties: false,
},
},
{
name: "mail_reply_all",
meta: {
permission: "send",
description: "Send a reply-all. Requires idempotencyKey + approvalProof.",
},
inputSchema: {
type: "object",
properties: { email: emailProp, messageId: messageIdProp, body: { type: "string" }, includeOriginal: { type: "boolean", default: true }, idempotencyKey: { type: "string" }, approvalProof: { type: "string" } },
required: ["email", "messageId", "body", "idempotencyKey", "approvalProof"],
additionalProperties: false,
},
},
];
const adminTools: ToolDefinition[] = [
{
name: "mail_status",
meta: { permission: "admin", description: "Full mail system status: users, aliases, domains, queue, DNS health, anti-spam summary" },
inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
},
{
name: "mail_user_add",
meta: { permission: "admin", description: "Create a mailbox; returns the generated password when password is omitted" },
inputSchema: { type: "object", properties: { email: emailProp, password: { type: "string" } }, required: ["email"], additionalProperties: false },
},
{
name: "mail_password_set",
meta: { permission: "admin", description: "Rotate a mailbox password; returns the generated password when password is omitted" },
inputSchema: { type: "object", properties: { email: emailProp, password: { type: "string" } }, required: ["email"], additionalProperties: false },
},
{
name: "mail_alias_add",
meta: { permission: "admin", description: "Create an alias address that delivers to a target mailbox" },
inputSchema: { type: "object", properties: { alias: { ...emailProp, description: "Alias address" }, target: emailProp }, required: ["alias", "target"], additionalProperties: false },
},
{
name: "mail_domain_list",
meta: { permission: "admin", description: "List mail domains and their DNS status" },
inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
},
{
name: "mail_domain_check",
meta: { permission: "admin", description: "Check a domain's mail DNS records (MX, SPF, DMARC, DKIM)" },
inputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"], additionalProperties: false },
},
{
name: "mail_domain_add",
meta: { permission: "admin", description: "Add a mail domain" },
inputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"], additionalProperties: false },
},
{
name: "mail_domain_remove",
meta: { permission: "admin", description: "Remove a mail domain" },
inputSchema: { type: "object", properties: { domain: { type: "string" } }, required: ["domain"], additionalProperties: false },
},
{
name: "mail_settings_get",
meta: { permission: "read", description: "Read webmail settings store: contacts, signatures, vacation, rules, preferences" },
inputSchema: { type: "object", properties: {}, required: [], additionalProperties: false },
},
{
name: "mail_setting_set",
meta: { permission: "admin", description: "Set one webmail setting: kind = contact | signature | vacation | rule | preferences, payload per kind" },
inputSchema: {
type: "object",
properties: { email: emailProp, kind: { type: "string", enum: ["contact", "signature", "vacation", "rule", "preferences"] }, payload: { type: "object" } },
required: ["email", "kind", "payload"],
additionalProperties: false,
},
},
];
return [...readTools, ...draftTools, ...sendTools, ...adminTools];
}
// ── Anti-spam / quarantine mutations (draft tier, approvalProof required) ──
const SPAM_FOLDER = "Spam";
function policyField(kind: string, disposition: string): "blocklist" | "allowlist" | "domainBlocklist" {
if (kind === "domain") return "domainBlocklist";
return disposition === "allow" ? "allowlist" : "blocklist";
}
function senderAddress(from: string): string {
const bracketed = from.match(/<([^<>\s]+@[^<>\s]+)>/);
return (bracketed ? bracketed[1]! : from).trim().toLowerCase();
}
async function messageSender(email: string, id: string): Promise<string> {
const result = await runServer(["webmail-read", email, id]) as Record<string, unknown>;
const message = (result.message ?? {}) as Record<string, unknown>;
return senderAddress(String(message.from ?? ""));
}
async function changePolicyList(field: string, mode: string, values: string[], extra: Record<string, unknown>): Promise<CliResult> {
return await runServer(["anti-spam-config", JSON.stringify({ field, mode, values, ...extra })]);
}
async function handleSpamMutation(name: string, rawArgs: Record<string, unknown>): Promise<unknown> {
const reason = String(rawArgs.reason ?? "");
if (name === "mail_spam_scan") {
const email = rawArgs.account === undefined ? [] : ["--email", String(rawArgs.account)];
const result = await runServer(["anti-spam-scan", ...email]);
await appendAudit("draft", email[1] ?? "system", name, {});
return result;
}
if (name === "mail_spam_policy_change") {
const operation = String(rawArgs.operation);
const kind = String(rawArgs.kind);
const disposition = String(rawArgs.disposition ?? "block");
const mode = operation === "remove" ? "remove" : "add";
const result = await changePolicyList(policyField(kind, disposition), mode, [String(rawArgs.value)], {
disposition, reason, expiresAt: rawArgs.expiresAt ?? null, expectedVersion: Number(rawArgs.expectedVersion), createdBy: "mcp",
});
await appendAudit("draft", "system", name, { operation, kind, disposition });
return result;
}
const email = String(rawArgs.account);
const ids = (rawArgs.messageIds as unknown[]).map(String);
const senders = new Set<string>();
const release = name === "mail_quarantine_release";
const listSenders = release ? rawArgs.allowSender === true : rawArgs.block === true;
for (const id of ids) {
if (listSenders) senders.add(await messageSender(email, id));
if (release) await runServer(["webmail-action", email, id, "move", "--target", "Inbox"]);
}
let policy: CliResult | undefined;
const values = [...senders].filter(Boolean);
if (values.length) {
policy = await changePolicyList(release ? "allowlist" : "blocklist", "add", values, {
disposition: release ? "allow" : "block", reason, source: "message-feedback", createdBy: "mcp",
});
}
await appendAudit("draft", email, name, { count: ids.length, senders: values.length });
return { ok: true, folder: release ? "Inbox" : SPAM_FOLDER, messages: ids.length, senders: values, policy };
}
// ── Tool handler ────────────────────────────────────────────────────────────
async function handleToolCall(name: string, rawArgs: Record<string, unknown>): Promise<unknown> {
const def = toolDefinitions().find(t => t.name === name);
if (!def) throw new Error(`Unknown tool: ${name}`);
const permission = def.meta.permission;
if (PERMISSION_RANK[permission] > PERMISSION_RANK[SCOPE]) throw new Error(`Tool "${name}" requires "${permission}" scope but server runs in "${SCOPE}" scope`);
if (permission === "send") {
const idempotencyKey = String(rawArgs.idempotencyKey ?? "");
if (!idempotencyKey) throw new Error("idempotencyKey is required for send operations");
if (!checkIdempotency(idempotencyKey)) return { ok: true, deduplicated: true };
if (!validateApprovalProof(String(rawArgs.approvalProof ?? ""))) throw new Error("Invalid approvalProof");
}
switch (name) {
case "mail_spam_policy_get": return await runServer(["spam-summary"]);
case "mail_address_health":
case "mail_reputation_status": return await runServer(["status"]);
case "mail_quarantine_list": {
const email = String(rawArgs.account ?? "");
if (!email) return await runServer(["spam-summary"]);
const limit = Math.min(100, Math.max(1, Number(rawArgs.limit ?? 40)));
const result = await runServer(["webmail-state", email, "--folder", "Spam", "--limit", String(limit), "--cursor", String(rawArgs.cursor ?? "")]);
await appendAudit("read", email, name, { limit });
return result;
}
case "mail_spam_policy_change":
case "mail_spam_scan":
case "mail_quarantine_release":
case "mail_quarantine_confirm":
if (!validateApprovalProof(String(rawArgs.approvalProof ?? ""))) throw new Error("Invalid approvalProof");
return await handleSpamMutation(name, rawArgs);
}
switch (name) {
// ── Read tools ──
case "mail_accounts": {
const result = await runServer(["status"]) as Record<string, unknown>;
await appendAudit("read", "system", name, {});
const users = Array.isArray(result.users) ? result.users : [];
const accounts = users
.map((user) => user && typeof user === "object" ? (user as Record<string, unknown>).email : undefined)
.filter((email): email is string => typeof email === "string");
return { ok: true, accounts };
}
case "mail_folder_list": {
const email = String(rawArgs.email);
const state = await runServer(["webmail-state", email, "--folder", "Inbox", "--limit", "1"]);
await appendAudit("read", email, name, { email });
const folders = (state as Record<string, unknown>).folders;
return { ok: true, folders: Array.isArray(folders) ? folders : [] };
}
case "mail_message_list": {
const email = String(rawArgs.email);
const folder = String(rawArgs.folder ?? "Inbox");
const query = String(rawArgs.query ?? "");
const limit = Math.min(Number(rawArgs.limit) || 40, 100);
const cursor = String(rawArgs.cursor ?? "");
const result = await runServer(["webmail-state", email, "--folder", folder, "--query", query, "--limit", String(limit), "--cursor", cursor]);
await appendAudit("read", email, name, { email, folder, limit });
return result;
}
case "mail_message_read": {
const email = String(rawArgs.email);
const id = String(rawArgs.id);
const markRead = Boolean(rawArgs.markRead);
const cliArgs = ["webmail-read", email, id];
if (markRead) cliArgs.push("--mark-read");
const result = await runServer(cliArgs);
await appendAudit("read", email, name, { email, id, markRead });
return result;
}
case "mail_attachment_list": {
const email = String(rawArgs.email);
const id = String(rawArgs.id);
const result = await runServer(["webmail-read", email, id]) as unknown as MessageResult;
const attachments = result.message?.attachments ?? [];
await appendAudit("read", email, name, { email, id, attachmentCount: attachments.length });
return { ok: true, attachments };
}
case "mail_attachment_download": {
const email = String(rawArgs.email);
const id = String(rawArgs.id);
const index = Number(rawArgs.index);
const result = await runServer(["webmail-attachment", email, id, String(index)]);
await appendAudit("read", email, name, { email, id, index });
return result;
}
case "mail_thread_search": {
const query = String(rawArgs.query);
const account = rawArgs.account ? String(rawArgs.account) : "";
const limit = Math.min(Number(rawArgs.limit) || 10, 50);
const indexArgs = ["search", query, "--json"];
if (account) indexArgs.push("--account", account);
if (limit) indexArgs.push("--limit", String(limit));
const result = await runIndex(indexArgs);
await appendAudit("read", account || "all", name, { query, limit });
return result;
}
case "mail_signals": {
const email = String(rawArgs.email);
const folder = String(rawArgs.folder ?? "Inbox");
const result = await runServer(["webmail-signals", email, "--folder", folder]);
await appendAudit("read", email, name, { email, folder });
return result;
}
case "mail_audit_log": {
const limit = Math.min(Number(rawArgs.limit) || 20, 100);
try {
const file = Bun.file(AUDIT_LOG);
const text = await file.text().catch(() => "");
if (!text.trim()) return { ok: true, entries: [] };
const lines = text.trim().split("\n").filter(Boolean);
const entries = lines.slice(-limit).map(l => JSON.parse(l) as AuditEntry);
return { ok: true, entries };
} catch {
return { ok: true, entries: [] };
}
}
// ── Draft tools ──
case "mail_draft_create": {
const email = String(rawArgs.email);
const payload = JSON.stringify({
from: email,
to: String(rawArgs.to ?? ""),
cc: String(rawArgs.cc ?? ""),
subject: String(rawArgs.subject ?? ""),
body: String(rawArgs.body ?? ""),
});
const result = await runServer(["webmail-draft", email, payload]);
await appendAudit("draft", email, name, { email, to: rawArgs.to, subject: rawArgs.subject });
return result;
}
case "mail_reply_prepare": {
const email = String(rawArgs.email);
const messageId = String(rawArgs.messageId);
const body = String(rawArgs.body);
const includeOriginal = Boolean(rawArgs.includeOriginal);
const original = await runServer(["webmail-read", email, messageId]) as unknown as MessageResult;
const msg = original.message ?? {};
const subject = msg.subject?.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`;
const to = msg.from || "";
let fullBody = body;
if (includeOriginal && msg.bodyText) {
fullBody = body + "\n\nOn " + (msg.date || "unknown") + ", " + (msg.from || "someone") + " wrote:\n> " + msg.bodyText.split("\n").join("\n> ");
}
const payload = JSON.stringify({ from: email, to, subject, body: fullBody });
const result = await runServer(["webmail-draft", email, payload]) as unknown as DraftResult;
await appendAudit("draft", email, name, { email, messageId, subject, to });
return { ok: true, draft: result.draft, preparedReply: { to, subject, body: fullBody } };
}
case "mail_reply_all_prepare": {
const email = String(rawArgs.email);
const messageId = String(rawArgs.messageId);
const body = String(rawArgs.body);
const includeOriginal = Boolean(rawArgs.includeOriginal);
const original = await runServer(["webmail-read", email, messageId]) as unknown as MessageResult;
const msg = original.message ?? {};
const subject = msg.subject?.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`;
const selfEmail = email.toLowerCase();
const from = msg.from || "";
const originalTo = (msg.to || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);
const originalCc = (msg.cc || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);
const replyTo = [from, ...originalTo].filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
const replyCc = originalCc.filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
let fullBody = body;
if (includeOriginal && msg.bodyText) {
fullBody = body + "\n\nOn " + (msg.date || "unknown") + ", " + (msg.from || "someone") + " wrote:\n> " + msg.bodyText.split("\n").join("\n> ");
}
const payload = JSON.stringify({ from: email, to: replyTo, cc: replyCc, subject, body: fullBody });
const result = await runServer(["webmail-draft", email, payload]) as unknown as DraftResult;
await appendAudit("draft", email, name, { email, messageId, subject, to: replyTo, cc: replyCc });
return { ok: true, draft: result.draft, preparedReply: { to: replyTo, cc: replyCc, subject, body: fullBody } };
}
case "mail_flag": {
const email = String(rawArgs.email);
const id = String(rawArgs.id);
const flagged = Boolean(rawArgs.flagged);
const result = await runServer(["webmail-action", email, id, flagged ? "star" : "unstar"]);
await appendAudit("draft", email, name, { email, id, flagged });
return result;
}
case "mail_archive": {
const email = String(rawArgs.email);
const id = String(rawArgs.id);
const result = await runServer(["webmail-action", email, id, "archive"]);
await appendAudit("draft", email, name, { email, id });
return result;
}
case "mail_move": {
const email = String(rawArgs.email);
const id = String(rawArgs.id);
const target = String(rawArgs.target);
const result = await runServer(["webmail-action", email, id, "move", "--target", target]);
await appendAudit("draft", email, name, { email, id, target });
return result;
}
// ── Send tools ──
case "mail_send": {
const email = String(rawArgs.email);
const to = String(rawArgs.to);
const cc = String(rawArgs.cc ?? "");
const bcc = String(rawArgs.bcc ?? "");
const subject = String(rawArgs.subject);
const body = String(rawArgs.body);
const signature = String(rawArgs.signature ?? "");
validateEmailList(to);
if (cc) validateEmailList(cc);
if (bcc) validateEmailList(bcc);
const payload = JSON.stringify({ from: email, to, cc, bcc, subject, body, signature });
const result = await runServer(["webmail-send", email, payload]);
await appendAudit("send", email, name, { email, to, cc, bccCount: bcc ? bcc.split(/[,;]/).length : 0, subject });
return result;
}
case "mail_reply":
case "mail_reply_all": {
const email = String(rawArgs.email);
const messageId = String(rawArgs.messageId);
const body = String(rawArgs.body);
const includeOriginal = Boolean(rawArgs.includeOriginal);
const original = await runServer(["webmail-read", email, messageId]) as unknown as MessageResult;
const msg = original.message ?? {};
const subject = msg.subject?.startsWith("Re:") ? msg.subject : `Re: ${msg.subject}`;
let fullBody = body;
if (includeOriginal && msg.bodyText) {
fullBody = body + "\n\nOn " + (msg.date || "unknown") + ", " + (msg.from || "someone") + " wrote:\n> " + msg.bodyText.split("\n").join("\n> ");
}
const selfEmail = email.toLowerCase();
const from = msg.from || "";
const originalTo = (msg.to || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);
const originalCc = (msg.cc || "").split(/[,;]/).map((s: string) => s.trim()).filter(Boolean);
let toAddr: string;
let ccAddr: string;
if (name === "mail_reply_all") {
toAddr = [from, ...originalTo].filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
ccAddr = originalCc.filter((a: string) => a.toLowerCase() !== selfEmail).join(", ");
} else {
toAddr = from;
ccAddr = "";
}
validateEmailList(toAddr);
if (ccAddr) validateEmailList(ccAddr);
const payload = JSON.stringify({ from: email, to: toAddr, cc: ccAddr, subject, body: fullBody });
const result = await runServer(["webmail-send", email, payload]);
await appendAudit("send", email, name, { email, messageId, to: toAddr, cc: ccAddr, subject });
return result;
}
// ── Admin tools ──
case "mail_status": {
const result = await runServer(["status"]);
await appendAudit("admin", "system", name, {});
return result;
}
case "mail_user_add": {
const email = String(rawArgs.email);
const password = rawArgs.password === undefined ? [] : ["--password", String(rawArgs.password)];
const result = await runServer(["add-user", email, ...password]);
await appendAudit("admin", email, name, { email });
return result;
}
case "mail_password_set": {
const email = String(rawArgs.email);
const password = rawArgs.password === undefined ? [] : ["--password", String(rawArgs.password)];
const result = await runServer(["set-password", email, ...password]);
await appendAudit("admin", email, name, { email });
return result;
}
case "mail_alias_add": {
const alias = String(rawArgs.alias);
const target = String(rawArgs.target);
const result = await runServer(["add-alias", alias, target]);
await appendAudit("admin", alias, name, { alias, target });
return result;
}
case "mail_domain_list": {
const result = await runServer(["domain-list"]);
await appendAudit("admin", "system", name, {});
return result;
}
case "mail_domain_check": {
const domain = String(rawArgs.domain);
const result = await runServer(["domain-check", domain]);
await appendAudit("admin", "system", name, { domain });
return result;
}
case "mail_domain_add": {
const domain = String(rawArgs.domain);
const result = await runServer(["domain-add", domain]);
await appendAudit("admin", "system", name, { domain });
return result;
}
case "mail_domain_remove": {
const domain = String(rawArgs.domain);
const result = await runServer(["domain-remove", domain]);
await appendAudit("admin", "system", name, { domain });
return result;
}
case "mail_settings_get": {
const result = await runServer(["webmail-export"]);
await appendAudit("read", "system", name, {});
return result;
}
case "mail_setting_set": {
const email = rawArgs.email === undefined ? [] : [String(rawArgs.email)];
const kind = String(rawArgs.kind);
const payload = JSON.stringify(rawArgs.payload ?? {});
const result = await runServer(["webmail-config", ...email, kind, payload]);
await appendAudit("admin", email[0] ?? "system", name, { email: email[0], kind });
return result;
}
default:
throw new Error(`Unhandled tool: ${name}`);
}
}
// ── Protocol I/O ───────────────────────────────────────────────────────────
const reader = Bun.stdin.stream();
const decoder = new TextDecoder();
let buffer = "";
// Transport mode of the connected client: framed (Content-Length) or
// line-delimited raw JSON. Responses mirror whichever the client used.
let lineMode = false;
async function sendMessage(msg: JsonRpcMessage) {
const json = JSON.stringify(msg);
if (lineMode) {
await Bun.write(Bun.stdout, json + "\n");
return;
}
const header = `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n`;
await Bun.write(Bun.stdout, header + json);
}
async function handleRequest(msg: JsonRpcMessage): Promise<void> {
const method = msg.method ?? "";
const id = msg.id;
const params = (msg.params ?? {}) as Record<string, unknown>;
try {
switch (method) {
case "initialize": {
await sendMessage({
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2025-03-26",
capabilities: { tools: {}, resources: {} },
serverInfo: { name: "akurai-mail-mcp", version: "0.5.0" },
},
});
// Send initialized notification
await sendMessage({
jsonrpc: "2.0",
method: "notifications/initialized",
} as unknown as JsonRpcMessage);
break;
}
case "tools/list": {
await sendMessage({
jsonrpc: "2.0",
id,
result: { tools: toolDefinitions().filter(t => PERMISSION_RANK[t.meta.permission] <= PERMISSION_RANK[SCOPE]) },
});
break;
}
case "tools/call": {
const rawParams = params as { name?: string; arguments?: Record<string, unknown> };
const toolName = String(rawParams.name ?? "");
const toolArgs = rawParams.arguments ?? {};
const result = await handleToolCall(toolName, toolArgs);
await sendMessage({
jsonrpc: "2.0",
id,
result: {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
},
});
break;
}
case "notifications/initialized":
// Ignore — client acknowledging server init
break;
case "shutdown":
await sendMessage({ jsonrpc: "2.0", id, result: null });
process.exit(0);
default: {
if (id != null) {
await sendMessage({
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Method not found: ${method}` },
});
}
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (id != null) {
await sendMessage({
jsonrpc: "2.0",
id,
error: { code: -32603, message },
});
}
}
}
// ── Main loop ──────────────────────────────────────────────────────────────
for await (const chunk of reader) {
buffer += decoder.decode(chunk, { stream: true });
// Framed transport (LSP-style Content-Length headers).
while (true) {
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const match = buffer.slice(0, headerEnd).match(/Content-Length:\s*(\d+)/i);
if (!match) { buffer = buffer.slice(headerEnd + 4); continue; }
const length = parseInt(match[1], 10);
const bodyStart = headerEnd + 4;
if (buffer.length < bodyStart + length) break;
const body = buffer.slice(bodyStart, bodyStart + length);
buffer = buffer.slice(bodyStart + length);
try {
const msg = JSON.parse(body) as JsonRpcMessage;
handleRequest(msg); // fire-and-forget
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await sendMessage({
jsonrpc: "2.0",
id: null,
error: { code: -32700, message: `Parse error: ${message}` },
});
}
}
// Line-delimited fallback (newline-separated or bare JSON object, e.g.
// zeroclaw stdio MCP sends one JSON object with no trailing newline)
// — only when no partial framed message is pending in the buffer.
if (!/content-length:/i.test(buffer)) {
lineMode = true;
let nl: number;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line.startsWith("{")) continue;
try {
handleRequest(JSON.parse(line) as JsonRpcMessage);
} catch {
// ignore malformed lines
}
}
const trimmed = buffer.trim();
if (trimmed.startsWith("{")) {
try {
handleRequest(JSON.parse(trimmed) as JsonRpcMessage);
buffer = "";
} catch {
// incomplete JSON — wait for more bytes
}
}
}
}