Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";import { execFile as execFileCb, spawn as spawnCb, execFileSync } from "node:child_process";import { promisify } from "node:util";import { access, readFile, stat } from "node:fs/promises";import { readFileSync } from "node:fs";import { basename, join } from "node:path";import { homedir } from "node:os";
const execFile = promisify(execFileCb);
function resolveBin(name, fallbacks) { if (process.env[`AC_MAIL_${name.toUpperCase()}`]) { return process.env[`AC_MAIL_${name.toUpperCase()}`]; } try { return execFileSync("which", [name], { encoding: "utf8" }).trim() || fallbacks[0]; } catch { for (const p of fallbacks) { try { execFileSync("test", ["-x", p]); return p; } catch {} } return name; }}
const HOME = homedir();
// Where the mail actually lives. The maildir, the mu index, and the msmtp// credentials all sit on jasellite — no laptop keeps a copy anymore. So when// AC_MAIL_HOST is set (the default), mu/mbsync/msmtp are run *there* over ssh// and this process only shuttles argv and stdin. Searching the local disk// instead would quietly report an empty inbox, which is far worse than an// error: it reads as "you have no mail" rather than "I looked in the wrong// place". Set AC_MAIL_HOST="" to go local — that's how jasellite runs itself.//// ssh, not the HTTP daemon, is the default on purpose: the daemon is bearer-// authed, and handing that token to every laptop widens who can read the mail.// ssh keys are already per-machine and already scoped to @jeffrey.const MAIL_HOST = process.env.AC_MAIL_HOST ?? "jas@24.144.92.66";const REMOTE = MAIL_HOST !== "";
// Remote binaries come off jasellite's login PATH; only resolve local paths// when we're actually going to exec locally.const MU = REMOTE ? "mu" : resolveBin("mu", ["/opt/homebrew/bin/mu", "/usr/bin/mu"]);const MBSYNC = REMOTE ? "mbsync" : resolveBin("mbsync", ["/opt/homebrew/bin/mbsync", "/usr/bin/mbsync"]);const MSMTP = REMOTE ? "msmtp" : resolveBin("msmtp", ["/opt/homebrew/bin/msmtp", "/usr/bin/msmtp"]);const MAILDIR = process.env.AC_MAIL_MAILDIR || join(HOME, ".mail-all");const MU_DB = process.env.AC_MAIL_MU_DB || join(HOME, ".cache", "mu", "xapian");
// jasellite's login shell is fish, and ssh hands it one flat string — so every// argument we send has to survive a shell re-split on the far side. Single// quotes are literal in both fish and POSIX sh; the only character that needs// care is the quote itself.const shellQuote = (arg) => `'${String(arg).replaceAll("'", `'\\''`)}'`;
// Rewrite a local command into the ssh invocation that runs it on jasellite.// BatchMode keeps a missing key failing fast instead of hanging on a password// prompt (nothing is watching stdin here — we're an MCP server on a pipe).const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"];function remotely(cmd, args) { if (!REMOTE) return [cmd, args]; return ["ssh", [...SSH_OPTS, MAIL_HOST, cmd, ...args.map(shellQuote)]];}const ACCOUNTS = { "ac-mail": "mail@aesthetic.computer", "jas-mail": "me@jas.life", "sotce-mail": "mail@sotce.net",};// Extra mail accounts (e.g. private client inboxes) are merged in from an// untracked config file so their identities never live in this public repo —// same convention as the Slab menubar's ~/.config/slab/mail-accounts.json.// Each entry: { "account": "<mbsync-channel>", "email": "<from address>" }.const EXTRA_ACCOUNTS_PATH = process.env.AC_MAIL_EXTRA_ACCOUNTS || join(HOME, ".config", "slab", "mail-accounts.json");try { for (const entry of JSON.parse(readFileSync(EXTRA_ACCOUNTS_PATH, "utf8"))) { if (entry?.account && entry?.email) ACCOUNTS[entry.account] = entry.email; }} catch { // No extra accounts configured (or file unreadable/malformed) — fine.}const ACCOUNT_NAMES = Object.keys(ACCOUNTS);const INBOX_QUERY_ALL = ACCOUNT_NAMES.map((a) => `maildir:/${a}/INBOX`).join(" OR ");const DEFAULT_ACCOUNT = "ac-mail";const FROM_ADDRESS = ACCOUNTS[DEFAULT_ACCOUNT];const STYLE_GUIDE_PATH = process.env.AC_EMAIL_STYLE_GUIDE || "/workspaces/aesthetic-computer/toolchain/email/style-guide.md";const DEFAULT_EMAIL_STYLE = { forceLowercase: true, defaultSignature: "@jeffrey", appendSignature: true,};
function parseBooleanField(content, fieldName, fallback) { const match = content.match(new RegExp(`^\\s*${fieldName}:\\s*(true|false)\\s*$`, "im")); if (!match) return fallback; return match[1].toLowerCase() === "true";}
function parseStringField(content, fieldName, fallback) { const match = content.match( new RegExp(`^\\s*${fieldName}:\\s*["']?([^"'\n]+)["']?\\s*$`, "im"), ); if (!match) return fallback; return match[1].trim();}
async function loadEmailStyle() { try { const content = await readFile(STYLE_GUIDE_PATH, "utf8"); return { forceLowercase: parseBooleanField(content, "force_lowercase", DEFAULT_EMAIL_STYLE.forceLowercase), defaultSignature: parseStringField( content, "default_signature", DEFAULT_EMAIL_STYLE.defaultSignature, ), appendSignature: parseBooleanField( content, "append_signature_if_missing", DEFAULT_EMAIL_STYLE.appendSignature, ), source: STYLE_GUIDE_PATH, loadedFromGuide: true, }; } catch { return { ...DEFAULT_EMAIL_STYLE, source: STYLE_GUIDE_PATH, loadedFromGuide: false, }; }}
// URLs stay verbatim — Drive file ids and similar are case-sensitive,// and a lowercased link is a dead link.function lowercasePreservingUrls(text) { return text .split(/(https?:\/\/\S+|www\.\S+)/g) .map((segment, i) => (i % 2 ? segment : segment.toLowerCase())) .join("");}
function applyEmailStyle({ subject, body, preserveCase, signature }, style) { let nextSubject = subject; let nextBody = body; const finalSignature = (signature || style.defaultSignature).trim();
if (style.forceLowercase && !preserveCase) { nextSubject = lowercasePreservingUrls(nextSubject); nextBody = lowercasePreservingUrls(nextBody); }
if (style.appendSignature && finalSignature) { const trimmedBody = nextBody.trimEnd(); const endsWithSignature = trimmedBody .toLowerCase() .endsWith(finalSignature.toLowerCase()); nextBody = endsWithSignature ? trimmedBody : `${trimmedBody}\n\n${finalSignature}`; }
return { subject: nextSubject, body: nextBody };}
async function ensureMuIndex() { // The index lives next to the maildir, so when that's remote this question // can only be answered remotely — a local stat would say "missing" and send // us off to re-init an index that already exists on the other machine. if (REMOTE) { try { await run(MU, ["info"], { timeout: 15_000 }); return; } catch { await run(MU, ["init", `--maildir=${MAILDIR}`], { timeout: 30_000 }); await run(MU, ["index"], { timeout: 300_000 }); return; } } try { await access(MU_DB); } catch { await execFile(MU, ["init", `--maildir=${MAILDIR}`]); await execFile(MU, ["index"]); }}
async function run(rawCmd, rawArgs, { input, timeout = 30_000 } = {}) { const [cmd, args] = remotely(rawCmd, rawArgs); if (input !== undefined) { // execFile doesn't pipe input to stdin; use spawn instead. return new Promise((resolve, reject) => { const child = spawnCb(cmd, args, { timeout }); let stdout = "", stderr = "", settled = false; const finish = (fn) => (arg) => { if (settled) return; settled = true; fn(arg); }; const ok = finish(resolve); const fail = finish(reject); child.stdout.on("data", (d) => (stdout += d)); child.stderr.on("data", (d) => (stderr += d)); child.on("close", (code) => { if (code !== 0) return fail(new Error(`${cmd} exited ${code}: ${stderr.trim()}`)); ok({ stdout: stdout.trim(), stderr: stderr.trim() }); }); child.on("error", fail); // Guard the pipe: if the child exits early (bad message, auth failure, // oversized attachment), writing to its stdin emits EPIPE. Without this // handler that error is thrown unhandled and crashes the whole process — // taking the MCP stdio connection down mid-request. Swallow EPIPE and let // the 'close' handler above report the real exit code/stderr instead. child.stdin.on("error", (err) => { if (err && err.code === "EPIPE") return; fail(err); }); try { child.stdin.end(input); } catch (err) { fail(err); } }); } const opts = { timeout, maxBuffer: 10 * 1024 * 1024 }; const { stdout, stderr } = await execFile(cmd, args, opts); return { stdout: stdout.trim(), stderr: stderr.trim() };}
// Every tool registers on a fresh McpServer from this factory. A factory —// not a module-level singleton — because the shared HTTP daemon mode at the// bottom builds one server per request (the SDK's stateless pattern):// parallel Claude sessions would collide JSON-RPC request ids on a shared// instance. Registration only stores closures, so a build per call is cheap.// The body keeps top-level indentation to leave the tool definitions' diff// history readable.function buildServer() {const server = new McpServer({ name: "mail", version: "1.0.0",});
// --- mail_sync ---server.tool("mail_sync", "Sync mail from Gmail using mbsync", {}, async () => { try { const results = []; for (const channel of ACCOUNT_NAMES) { try { const { stdout, stderr } = await run(MBSYNC, [channel], { timeout: 120_000, }); results.push(`${channel}: ok\n${stdout}\n${stderr}`.trim()); } catch (err) { results.push(`${channel}: ${err.message}`); } } // Re-index after sync await run(MU, ["index"], { timeout: 60_000 }); return { content: [ { type: "text", text: `Sync complete.\n${results.join("\n")}`.trim(), }, ], }; } catch (err) { return { content: [{ type: "text", text: `Sync failed: ${err.message}` }], isError: true, }; }});
// --- mail_search ---server.tool( "mail_search", "Search mail using mu find with query string (mu query syntax)", { query: z.string().describe("mu find query string, e.g. 'from:alice subject:hello'") }, async ({ query }) => { try { await ensureMuIndex(); const { stdout } = await run(MU, [ "find", "--format=json", "--sortfield=date", "--reverse", query, ]); return { content: [{ type: "text", text: stdout || "No results." }] }; } catch (err) { // mu returns exit code 4 for no matches if (err.code === 4 || (err.stderr && err.stderr.includes("no matches"))) { return { content: [{ type: "text", text: "No results found." }] }; } return { content: [{ type: "text", text: `Search failed: ${err.message}` }], isError: true, }; } },);
// --- mail_read ---server.tool( "mail_read", "Read a specific email by message-id or file path using mu view", { identifier: z .string() .describe("Message-ID (with or without angle brackets) or file path to the email"), }, async ({ identifier }) => { try { await ensureMuIndex(); let filePath = identifier;
// If it looks like a message-id rather than a path, find the file first if (!identifier.startsWith("/")) { const msgid = identifier.replace(/^<|>$/g, ""); const { stdout } = await run(MU, [ "find", "--format=plain", "--fields=l", `msgid:${msgid}`, ]); filePath = stdout.split("\n")[0]; if (!filePath) { return { content: [{ type: "text", text: "Message not found." }], isError: true, }; } }
const { stdout } = await run(MU, ["view", filePath]); return { content: [{ type: "text", text: stdout }] }; } catch (err) { return { content: [{ type: "text", text: `Read failed: ${err.message}` }], isError: true, }; } },);
// --- mail_inbox ---server.tool( "mail_inbox", `List recent inbox messages sorted by date descending. Default covers all accounts; pass account=${ACCOUNT_NAMES.map((a) => `'${a}'`).join(" | ")} to filter.`, { count: z .number() .optional() .default(20) .describe("Number of messages to return (default 20)"), account: z .enum([...ACCOUNT_NAMES, "all"]) .optional() .default("all") .describe("Which account inbox to list (default all)"), }, async ({ count, account }) => { try { await ensureMuIndex(); const query = account === "all" ? `(${INBOX_QUERY_ALL})` : `maildir:/${account}/INBOX`; const { stdout } = await run(MU, [ "find", "--format=json", "--sortfield=date", "--reverse", `--maxnum=${count}`, query, ]); return { content: [{ type: "text", text: stdout || "Inbox is empty." }] }; } catch (err) { if (err.code === 4 || (err.stderr && err.stderr.includes("no matches"))) { return { content: [{ type: "text", text: "Inbox is empty." }] }; } return { content: [{ type: "text", text: `Inbox failed: ${err.message}` }], isError: true, }; } },);
// --- mail_send ---server.tool( "mail_send", "Send an email via msmtp. Defaults to mail@aesthetic.computer; use from_account to switch. For replies, set in_reply_to and references for proper threading. Supports file attachments via the attachments parameter (array of absolute paths).", { to: z.string().describe("Recipient email address"), cc: z.string().optional().describe("CC email address(es), comma-separated"), subject: z.string().describe("Email subject"), body: z.string().describe("Email body (plain text)"), in_reply_to: z .string() .optional() .describe("Message-ID of the email being replied to (for threading)"), references: z .string() .optional() .describe("Space-separated Message-IDs for the thread (for threading)"), preserve_case: z .boolean() .optional() .default(false) .describe("Keep original subject/body casing when true"), signature: z .string() .optional() .describe("Optional sign-off override; defaults to style-guide signature"), from_account: z .enum(ACCOUNT_NAMES) .optional() .default("ac-mail") .describe(`Which msmtp account to send from (${ACCOUNT_NAMES.join(" | ")})`), attachments: z .array(z.string()) .optional() .describe("Array of absolute file paths to attach (e.g. [\"/path/to/file.pdf\"])"), }, async ({ to, cc, subject, body, in_reply_to, references, preserve_case, signature, from_account, attachments }) => { try { const style = await loadEmailStyle(); const styled = applyEmailStyle({ subject, body, preserveCase: preserve_case, signature, }, style); const account = from_account || DEFAULT_ACCOUNT; const fromAddr = ACCOUNTS[account] || FROM_ADDRESS; const date = new Date().toUTCString(); const headers = [ `Date: ${date}`, `From: ${fromAddr}`, `To: ${to}`, ]; if (cc) headers.push(`Cc: ${cc}`); if (in_reply_to) { const irt = in_reply_to.startsWith("<") ? in_reply_to : `<${in_reply_to}>`; headers.push(`In-Reply-To: ${irt}`); } if (references) { const refs = references .split(/\s+/) .map((r) => (r.startsWith("<") ? r : `<${r}>`)) .join(" "); headers.push(`References: ${refs}`); } headers.push(`Subject: ${styled.subject}`, "MIME-Version: 1.0");
let message; if (attachments && attachments.length > 0) { // MIME multipart with attachments const boundary = `----=_Part_${Date.now()}_${Math.random().toString(36).slice(2)}`; headers.push( `Content-Type: multipart/mixed; boundary="${boundary}"`, ); const parts = [ ...headers, "", `--${boundary}`, "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", "", styled.body, ]; for (const filePath of attachments) { await stat(filePath); // throws if missing const fileData = await readFile(filePath); const name = basename(filePath); const ext = name.split(".").pop()?.toLowerCase(); const mime = ext === "pdf" ? "application/pdf" : ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "html" ? "text/html" : ext === "ai" ? "application/illustrator" : ext === "svg" ? "image/svg+xml" : ext === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "application/octet-stream"; parts.push( `--${boundary}`, `Content-Type: ${mime}; name="${name}"`, "Content-Transfer-Encoding: base64", `Content-Disposition: attachment; filename="${name}"`, "", fileData.toString("base64").replace(/(.{76})/g, "$1\r\n"), ); } parts.push(`--${boundary}--`); message = parts.join("\r\n"); } else { // Plain text (no attachments) headers.push( "Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: 8bit", ); message = [...headers, "", styled.body].join("\r\n"); }
// Larger attachments can take well over the default 30s to upload to the // SMTP relay; scale the timeout with message size (base64-inflated) so big // files don't get SIGTERM'd mid-transfer (which surfaces as "exited null"). const sendTimeout = Math.max(30_000, 30_000 + Math.ceil(message.length / (1024 * 1024)) * 15_000); const { stdout, stderr } = await run(MSMTP, ["-a", account, "-t"], { input: message, timeout: sendTimeout }); return { content: [ { type: "text", text: `Email sent from ${fromAddr} to ${to}${cc ? ` (cc: ${cc})` : ""}.\nStyle guide: ${style.source}${style.loadedFromGuide ? "" : " (defaults used)"}\n${stdout}\n${stderr}`.trim(), }, ], }; } catch (err) { return { content: [{ type: "text", text: `Send failed: ${err.message}` }], isError: true, }; } },);
// --- mail_count ---server.tool( "mail_count", "Return count of unread inbox messages (all accounts, avoids Gmail All Mail duplication)", {}, async () => { try { await ensureMuIndex(); const { stdout } = await run(MU, [ "find", "--format=plain", "--fields=l", `flag:unread AND (${INBOX_QUERY_ALL})`, ]); const count = stdout ? stdout.split("\n").filter(Boolean).length : 0; return { content: [{ type: "text", text: `Unread messages: ${count}` }], }; } catch (err) { if (err.code === 4 || (err.stderr && err.stderr.includes("no matches"))) { return { content: [{ type: "text", text: "Unread messages: 0" }] }; } return { content: [{ type: "text", text: `Count failed: ${err.message}` }], isError: true, }; } },);
return server;}
// Start — stdio by default (Claude spawns one process per session), or a// shared daemon with `--http [port]` so any number of parallel sessions// reuse ONE resident process (wired up by toolchain/mcp/install-daemons.sh// + a local-scope http entry in ~/.claude.json). Stateless streamable// HTTP: fresh server + transport per POST, closed when the response ends.const httpFlag = process.argv.indexOf("--http");if (httpFlag !== -1) { const port = Number(process.argv[httpFlag + 1]) || 7765; // Remote mode (jasellite): AC_MAIL_HTTP_HOST binds beyond loopback — // point it at the tailscale IP so the daemon is tailnet-only, never // the public interface. AC_MAIL_TOKEN adds bearer auth on top. const host = process.env.AC_MAIL_HTTP_HOST || "127.0.0.1"; const token = process.env.AC_MAIL_TOKEN || ""; const { StreamableHTTPServerTransport } = await import( "@modelcontextprotocol/sdk/server/streamableHttp.js" ); const { timingSafeEqual } = await import("node:crypto"); const { createServer } = await import("node:http"); const authorized = (req) => { if (!token) return true; const header = req.headers.authorization || ""; const given = Buffer.from(header.replace(/^Bearer\s+/i, "")); const want = Buffer.from(token); return given.length === want.length && timingSafeEqual(given, want); }; createServer(async (req, res) => { if (!authorized(req)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "unauthorized" }, id: null, })); return; } if (req.method !== "POST") { res.writeHead(405, { "Content-Type": "application/json" }); res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "stateless server: POST only" }, id: null, })); return; } let body = ""; for await (const chunk of req) body += chunk; try { const server = buildServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true, }); res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, JSON.parse(body)); } catch (err) { if (!res.headersSent) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: err.message }, id: null, })); } } }).listen(port, host, () => { console.error( `📬 mail-mcp shared daemon on http://${host}:${port}` + (token ? " (bearer auth)" : ""), ); });} else { await buildServer().connect(new StdioServerTransport());}