a stdio mcp server for apple mail
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial release: MCP server for read-only macOS Mail.app access

Six tools for searching, reading, and managing email via the Model Context
Protocol. Uses SQLite for metadata queries and AppleScript for message bodies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Austin Parker 5b8d5afd

+1132
+8
.gitignore
··· 1 + dist/ 2 + node_modules/ 3 + .claude/ 4 + .mcp.json 5 + .DS_Store 6 + .env 7 + *.tsbuildinfo 8 + bun.lockb
+8
.mcp.json.example
··· 1 + { 2 + "mcpServers": { 3 + "mailgenie": { 4 + "command": "bun", 5 + "args": ["run", "dist/index.js"] 6 + } 7 + } 8 + }
+40
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Build & Run 6 + 7 + ```bash 8 + bun install # install dependencies 9 + bun run build # tsc → dist/ 10 + bun run start # bun run dist/index.js (stdio MCP transport) 11 + bun test # run unit tests 12 + bun run lint # biome check src/ 13 + bun run lint:fix # biome check --write src/ 14 + bun run typecheck # tsc --noEmit 15 + ``` 16 + 17 + Rebuild before testing MCP changes. The server uses stdio transport. 18 + 19 + ## Architecture 20 + 21 + MailGenie is an MCP (Model Context Protocol) server that provides read-only access to macOS Mail.app. It uses two data sources: 22 + 23 + 1. **SQLite** (`better-sqlite3`, read-only) — queries the Mail.app envelope index at `~/Library/Mail/V10/MailData/Envelope Index` for metadata: subjects, senders, dates, mailboxes, read/flagged status, attachment names, summaries. Override with `MAILGENIE_DB_PATH` env var. 24 + 2. **AppleScript** (`osascript` via `child_process.execFile`) — retrieves message bodies, attachment details, and saves attachments. Messages are located by account UUID + mailbox path + message ROWID. 25 + 26 + ### Data flow 27 + 28 + - `src/db.ts` — opens the SQLite DB (singleton), provides `getAccountMap()` (cached AppleScript call that maps account UUIDs to names), and helpers to parse mailbox URLs (`imap://UUID@server/path` → UUID + decoded path). 29 + - `src/applescript.ts` — thin wrapper around `osascript -e` with 15s timeout and 10MB buffer. 30 + - `src/parse-headers.ts` — helper to parse List-Unsubscribe URLs from raw email headers. 31 + - `src/tools/` — each file exports a Zod schema and an async handler. All handlers return `{ content: [{ type: "text", text: JSON.stringify(...) }] }`. 32 + - `src/index.ts` — registers all 6 tools on the MCP server and starts the stdio transport. 33 + - `src/__tests__/` — unit tests for `db.ts` and `parse-headers.ts` pure functions. 34 + 35 + ### Key conventions 36 + 37 + - `date_received` in the DB is Unix epoch seconds (not macOS Core Data epoch). Use `datetime(msg.date_received, 'unixepoch')` in SQL. 38 + - `messages.ROWID` is the same as AppleScript's `id` property on a message object. 39 + - Account/mailbox filtering is done post-query in JS (not in SQL) because it requires parsing mailbox URLs and resolving UUIDs via the account map. 40 + - Tool handlers resolve account names to UUIDs by substring-matching against the cached account map.
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Austin Parker 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+57
README.md
··· 1 + # MailGenie 2 + 3 + An MCP (Model Context Protocol) server that provides read-only access to macOS Mail.app. It queries the Mail.app SQLite database for message metadata and uses AppleScript to retrieve message bodies and attachments. Designed to work with Claude Code and other MCP-compatible clients. 4 + 5 + ## Requirements 6 + 7 + - macOS 8 + - Mail.app with at least one configured account 9 + - [Bun](https://bun.sh) runtime 10 + - Automation permission for `osascript` to access Mail.app (macOS will prompt on first use) 11 + 12 + ## Responsible Use 13 + 14 + **This tool reads your email.** Please keep the following in mind: 15 + 16 + - Only run MailGenie locally on your own machine. Never expose it over a network. 17 + - Review what MCP permissions your client grants before connecting. 18 + - Your email data stays on your machine — MailGenie does not send data anywhere. 19 + - Be mindful of what you ask an LLM to do with your email content. 20 + 21 + ## Setup 22 + 23 + ```bash 24 + git clone git@tangled.org:aparker.io/mailgenie 25 + cd mailgenie 26 + bun install 27 + bun run build 28 + ``` 29 + 30 + Copy the example MCP config and adjust if needed: 31 + 32 + ```bash 33 + cp .mcp.json.example .mcp.json 34 + ``` 35 + 36 + Then add the server to your MCP client. For Claude Code, the `.mcp.json` in the project root is picked up automatically. 37 + 38 + ## Tools 39 + 40 + | Tool | Description | 41 + |------|-------------| 42 + | `list_accounts` | List all configured Mail.app accounts with message and unread counts | 43 + | `list_mailboxes` | List mailboxes (folders) with counts, optionally filtered by account | 44 + | `get_unread_count` | Get unread message count, optionally filtered by account or mailbox | 45 + | `search_messages` | Search messages by subject, sender, date range, read/flagged status, mailbox, or account | 46 + | `read_message` | Read the full body of a message by ID, including List-Unsubscribe URLs | 47 + | `save_attachment` | Save a message attachment to `/tmp/mailgenie/` | 48 + 49 + ## Configuration 50 + 51 + | Variable | Description | Default | 52 + |----------|-------------|---------| 53 + | `MAILGENIE_DB_PATH` | Path to Mail.app's Envelope Index SQLite database | `~/Library/Mail/V10/MailData/Envelope Index` | 54 + 55 + ## License 56 + 57 + MIT
+23
biome.json
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", 3 + "organizeImports": { 4 + "enabled": true 5 + }, 6 + "formatter": { 7 + "enabled": true, 8 + "indentStyle": "tab", 9 + "lineWidth": 100 10 + }, 11 + "linter": { 12 + "enabled": true, 13 + "rules": { 14 + "recommended": true 15 + } 16 + }, 17 + "javascript": { 18 + "formatter": { 19 + "quoteStyle": "double", 20 + "semicolons": "always" 21 + } 22 + } 23 + }
+34
package.json
··· 1 + { 2 + "name": "mailgenie", 3 + "version": "1.0.0", 4 + "description": "MCP server for read-only access to macOS Mail.app", 5 + "type": "module", 6 + "license": "MIT", 7 + "repository": { 8 + "type": "git", 9 + "url": "https://tangled.org/aparker.io/mailgenie" 10 + }, 11 + "engines": { 12 + "bun": ">=1.0.0" 13 + }, 14 + "scripts": { 15 + "build": "bun run tsc", 16 + "start": "bun run dist/index.js", 17 + "test": "bun test", 18 + "lint": "bunx biome check src/", 19 + "lint:fix": "bunx biome check --write src/", 20 + "typecheck": "bun run tsc --noEmit" 21 + }, 22 + "dependencies": { 23 + "@modelcontextprotocol/sdk": "^1.12.1", 24 + "better-sqlite3": "^11.9.1", 25 + "zod": "^3.24.2" 26 + }, 27 + "devDependencies": { 28 + "@biomejs/biome": "^1.9.0", 29 + "@types/better-sqlite3": "^7.6.13", 30 + "@types/bun": "^1.3.9", 31 + "@types/node": "^22.15.3", 32 + "typescript": "^5.8.3" 33 + } 34 + }
+56
src/__tests__/db.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { extractAccountUuid, extractMailboxPath } from "../db.js"; 3 + 4 + describe("extractAccountUuid", () => { 5 + test("extracts UUID from imap URL with @server", () => { 6 + expect(extractAccountUuid("imap://ABC-123@imap.gmail.com/INBOX")).toBe("ABC-123"); 7 + }); 8 + 9 + test("extracts UUID from imap URL without @server", () => { 10 + expect(extractAccountUuid("imap://ABC-123/INBOX")).toBe("ABC-123"); 11 + }); 12 + 13 + test("handles ews:// scheme", () => { 14 + expect(extractAccountUuid("ews://UUID-456@outlook.com/Inbox")).toBe("UUID-456"); 15 + }); 16 + 17 + test("returns null when no path component", () => { 18 + expect(extractAccountUuid("imap://ABC-123")).toBeNull(); 19 + }); 20 + 21 + test("returns empty string for empty host", () => { 22 + expect(extractAccountUuid("imap:///INBOX")).toBe(""); 23 + }); 24 + 25 + test("handles URL-encoded characters in path without affecting UUID", () => { 26 + expect(extractAccountUuid("imap://UUID-789@server/My%20Folder")).toBe("UUID-789"); 27 + }); 28 + }); 29 + 30 + describe("extractMailboxPath", () => { 31 + test("extracts and decodes path from imap URL", () => { 32 + expect(extractMailboxPath("imap://UUID@server/INBOX")).toBe("INBOX"); 33 + }); 34 + 35 + test("decodes percent-encoded path", () => { 36 + expect(extractMailboxPath("imap://UUID@server/My%20Folder")).toBe("My Folder"); 37 + }); 38 + 39 + test("handles nested path", () => { 40 + expect(extractMailboxPath("imap://UUID@server/INBOX/Subfolder")).toBe("INBOX/Subfolder"); 41 + }); 42 + 43 + test("returns empty string when no path", () => { 44 + expect(extractMailboxPath("imap://UUID")).toBe(""); 45 + }); 46 + 47 + test("handles URL without @server", () => { 48 + expect(extractMailboxPath("imap://UUID/Sent%20Messages")).toBe("Sent Messages"); 49 + }); 50 + 51 + test("handles special characters in path", () => { 52 + expect(extractMailboxPath("imap://UUID@server/%5BGmail%5D/All%20Mail")).toBe( 53 + "[Gmail]/All Mail", 54 + ); 55 + }); 56 + });
+73
src/__tests__/parse-headers.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { parseListUnsubscribe } from "../parse-headers.js"; 3 + 4 + describe("parseListUnsubscribe", () => { 5 + test("extracts single https URL", () => { 6 + const headers = "List-Unsubscribe: <https://example.com/unsub?id=123>\r\n"; 7 + expect(parseListUnsubscribe(headers)).toEqual(["https://example.com/unsub?id=123"]); 8 + }); 9 + 10 + test("extracts multiple URLs", () => { 11 + const headers = "List-Unsubscribe: <https://example.com/unsub>, <mailto:unsub@example.com>\r\n"; 12 + expect(parseListUnsubscribe(headers)).toEqual([ 13 + "https://example.com/unsub", 14 + "mailto:unsub@example.com", 15 + ]); 16 + }); 17 + 18 + test("handles mailto + https combination", () => { 19 + const headers = 20 + "List-Unsubscribe: <mailto:leave@list.example.com>, <https://list.example.com/unsubscribe>\r\n"; 21 + expect(parseListUnsubscribe(headers)).toEqual([ 22 + "mailto:leave@list.example.com", 23 + "https://list.example.com/unsubscribe", 24 + ]); 25 + }); 26 + 27 + test("handles folded headers (continuation lines)", () => { 28 + const headers = 29 + "List-Unsubscribe: <https://example.com/unsub>,\r\n <mailto:unsub@example.com>\r\n"; 30 + expect(parseListUnsubscribe(headers)).toEqual([ 31 + "https://example.com/unsub", 32 + "mailto:unsub@example.com", 33 + ]); 34 + }); 35 + 36 + test("handles tab-folded continuation", () => { 37 + const headers = 38 + "List-Unsubscribe: <https://example.com/unsub>,\n\t<mailto:unsub@example.com>\n"; 39 + expect(parseListUnsubscribe(headers)).toEqual([ 40 + "https://example.com/unsub", 41 + "mailto:unsub@example.com", 42 + ]); 43 + }); 44 + 45 + test("returns null when header is missing", () => { 46 + const headers = "From: sender@example.com\r\nTo: recipient@example.com\r\n"; 47 + expect(parseListUnsubscribe(headers)).toBeNull(); 48 + }); 49 + 50 + test("returns null for empty headers", () => { 51 + expect(parseListUnsubscribe("")).toBeNull(); 52 + }); 53 + 54 + test("returns null when header has no angle-bracket URLs", () => { 55 + const headers = "List-Unsubscribe: not a valid url\r\n"; 56 + expect(parseListUnsubscribe(headers)).toBeNull(); 57 + }); 58 + 59 + test("is case-insensitive for header name", () => { 60 + const headers = "list-unsubscribe: <https://example.com/unsub>\r\n"; 61 + expect(parseListUnsubscribe(headers)).toEqual(["https://example.com/unsub"]); 62 + }); 63 + 64 + test("finds header among other headers", () => { 65 + const headers = [ 66 + "From: sender@example.com", 67 + "To: recipient@example.com", 68 + "List-Unsubscribe: <https://example.com/unsub>", 69 + "Date: Mon, 1 Jan 2024 00:00:00 +0000", 70 + ].join("\r\n"); 71 + expect(parseListUnsubscribe(headers)).toEqual(["https://example.com/unsub"]); 72 + }); 73 + });
+21
src/applescript.ts
··· 1 + import { execFile } from "node:child_process"; 2 + 3 + const TIMEOUT_MS = 15_000; 4 + const MAX_BUFFER = 10 * 1024 * 1024; // 10MB 5 + 6 + export function execAppleScript(script: string): Promise<string> { 7 + return new Promise((resolve, reject) => { 8 + execFile( 9 + "osascript", 10 + ["-e", script], 11 + { timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER }, 12 + (err, stdout, stderr) => { 13 + if (err) { 14 + reject(new Error(`AppleScript failed: ${err.message}\n${stderr}`)); 15 + } else { 16 + resolve(stdout); 17 + } 18 + }, 19 + ); 20 + }); 21 + }
+70
src/db.ts
··· 1 + import os from "node:os"; 2 + import path from "node:path"; 3 + import Database from "better-sqlite3"; 4 + import { execAppleScript } from "./applescript.js"; 5 + 6 + const DB_PATH = 7 + process.env.MAILGENIE_DB_PATH ?? 8 + path.join(os.homedir(), "Library/Mail/V10/MailData/Envelope Index"); 9 + 10 + let db: Database.Database; 11 + 12 + export function getDb(): Database.Database { 13 + if (!db) { 14 + db = new Database(DB_PATH, { readonly: true, fileMustExist: true }); 15 + db.pragma("journal_mode = WAL"); 16 + } 17 + return db; 18 + } 19 + 20 + export interface AccountInfo { 21 + uuid: string; 22 + name: string; 23 + } 24 + 25 + let accountCache: Map<string, string> | null = null; 26 + 27 + export async function getAccountMap(): Promise<Map<string, string>> { 28 + if (accountCache) return accountCache; 29 + 30 + const script = ` 31 + tell application "Mail" 32 + set acctNames to name of every account 33 + set acctIds to id of every account 34 + set output to "" 35 + repeat with i from 1 to count of acctNames 36 + set output to output & item i of acctIds & "\\t" & item i of acctNames & "\\n" 37 + end repeat 38 + return output 39 + end tell 40 + `; 41 + const result = await execAppleScript(script); 42 + accountCache = new Map(); 43 + for (const line of result.trim().split("\n")) { 44 + const [id, name] = line.split("\t"); 45 + if (id && name) { 46 + accountCache.set(id.trim(), name.trim()); 47 + } 48 + } 49 + return accountCache; 50 + } 51 + 52 + export function extractAccountUuid(mailboxUrl: string): string | null { 53 + // URLs look like: imap://UUID/path or imap://UUID@server/path 54 + const stripped = mailboxUrl.replace(/^[a-z]+:\/\//, ""); 55 + const firstSlash = stripped.indexOf("/"); 56 + if (firstSlash === -1) return null; 57 + let host = stripped.substring(0, firstSlash); 58 + // Remove @server part if present 59 + const atIdx = host.indexOf("@"); 60 + if (atIdx !== -1) host = host.substring(0, atIdx); 61 + return host; 62 + } 63 + 64 + export function extractMailboxPath(mailboxUrl: string): string { 65 + const stripped = mailboxUrl.replace(/^[a-z]+:\/\//, ""); 66 + const firstSlash = stripped.indexOf("/"); 67 + if (firstSlash === -1) return ""; 68 + const encoded = stripped.substring(firstSlash + 1); 69 + return decodeURIComponent(encoded); 70 + }
+66
src/index.ts
··· 1 + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 + import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 + 4 + import { getUnreadCount, getUnreadCountSchema } from "./tools/get-unread-count.js"; 5 + import { listAccounts, listAccountsSchema } from "./tools/list-accounts.js"; 6 + import { listMailboxes, listMailboxesSchema } from "./tools/list-mailboxes.js"; 7 + import { readMessage, readMessageSchema } from "./tools/read-message.js"; 8 + import { saveAttachment, saveAttachmentSchema } from "./tools/save-attachment.js"; 9 + import { searchMessages, searchMessagesSchema } from "./tools/search-messages.js"; 10 + 11 + const server = new McpServer({ 12 + name: "mailgenie", 13 + version: "1.0.0", 14 + }); 15 + 16 + server.tool( 17 + "list_accounts", 18 + "List all configured Mail.app accounts with message and unread counts", 19 + listAccountsSchema.shape, 20 + async () => listAccounts(), 21 + ); 22 + 23 + server.tool( 24 + "list_mailboxes", 25 + "List mailboxes (folders) with message and unread counts, optionally filtered by account", 26 + listMailboxesSchema.shape, 27 + async (params) => listMailboxes(params), 28 + ); 29 + 30 + server.tool( 31 + "get_unread_count", 32 + "Get unread message count, optionally filtered by account or mailbox", 33 + getUnreadCountSchema.shape, 34 + async (params) => getUnreadCount(params), 35 + ); 36 + 37 + server.tool( 38 + "search_messages", 39 + "Search messages by subject, sender, date range, read/unread status, flagged, mailbox, or account. Returns metadata and preview text.", 40 + searchMessagesSchema.shape, 41 + async (params) => searchMessages(params), 42 + ); 43 + 44 + server.tool( 45 + "read_message", 46 + "Read the full body of a specific message by its ID (from search results). Uses AppleScript to retrieve content from Mail.app. Includes list_unsubscribe URLs when the List-Unsubscribe header is present.", 47 + readMessageSchema.shape, 48 + async (params) => readMessage(params), 49 + ); 50 + 51 + server.tool( 52 + "save_attachment", 53 + "Save a message attachment to disk. Requires message ID and attachment filename (from read_message results). Saves to /tmp/mailgenie/.", 54 + saveAttachmentSchema.shape, 55 + async (params) => saveAttachment(params), 56 + ); 57 + 58 + async function main() { 59 + const transport = new StdioServerTransport(); 60 + await server.connect(transport); 61 + } 62 + 63 + main().catch((err) => { 64 + console.error("Fatal error:", err); 65 + process.exit(1); 66 + });
+14
src/parse-headers.ts
··· 1 + /** 2 + * Parse List-Unsubscribe URLs from raw email headers. 3 + * 4 + * Handles RFC 2822 header folding (continuation lines) and extracts 5 + * all angle-bracket-delimited URIs from the List-Unsubscribe header. 6 + */ 7 + export function parseListUnsubscribe(rawHeaders: string): string[] | null { 8 + // Unfold continuation lines (RFC 2822: CRLF followed by whitespace) 9 + const unfolded = rawHeaders.replace(/\r?\n[ \t]+/g, " "); 10 + const match = unfolded.match(/^List-Unsubscribe:\s*(.+)$/im); 11 + if (!match) return null; 12 + const urls = [...match[1].matchAll(/<([^>]+)>/g)].map((m) => m[1]); 13 + return urls.length > 0 ? urls : null; 14 + }
+68
src/tools/get-unread-count.ts
··· 1 + import { z } from "zod"; 2 + import { extractAccountUuid, extractMailboxPath, getAccountMap, getDb } from "../db.js"; 3 + 4 + export const getUnreadCountSchema = z.object({ 5 + account: z.string().optional().describe("Filter by account name or UUID"), 6 + mailbox: z.string().optional().describe("Filter by mailbox path (substring match)"), 7 + }); 8 + 9 + export async function getUnreadCount(params: z.infer<typeof getUnreadCountSchema>) { 10 + const db = getDb(); 11 + const accountMap = await getAccountMap(); 12 + 13 + const rows = db 14 + .prepare( 15 + `SELECT m.url, 16 + SUM(CASE WHEN msg.read = 0 THEN 1 ELSE 0 END) as unread_count 17 + FROM mailboxes m 18 + JOIN messages msg ON msg.mailbox = m.ROWID 19 + GROUP BY m.url 20 + HAVING unread_count > 0`, 21 + ) 22 + .all() as Array<{ url: string; unread_count: number }>; 23 + 24 + // Resolve account filter 25 + let filterUuid: string | undefined; 26 + if (params.account) { 27 + if (accountMap.has(params.account)) { 28 + filterUuid = params.account; 29 + } else { 30 + for (const [uuid, name] of accountMap) { 31 + if (name.toLowerCase().includes(params.account.toLowerCase())) { 32 + filterUuid = uuid; 33 + break; 34 + } 35 + } 36 + } 37 + } 38 + 39 + const breakdown = rows 40 + .map((row) => { 41 + const uuid = extractAccountUuid(row.url); 42 + const path = extractMailboxPath(row.url); 43 + return { 44 + account: uuid ? (accountMap.get(uuid) ?? uuid) : "unknown", 45 + account_uuid: uuid, 46 + path, 47 + unread_count: row.unread_count, 48 + }; 49 + }) 50 + .filter((r) => { 51 + if (filterUuid && r.account_uuid !== filterUuid) return false; 52 + if (params.mailbox && !r.path.toLowerCase().includes(params.mailbox.toLowerCase())) 53 + return false; 54 + return true; 55 + }) 56 + .sort((a, b) => b.unread_count - a.unread_count); 57 + 58 + const total = breakdown.reduce((sum, r) => sum + r.unread_count, 0); 59 + 60 + return { 61 + content: [ 62 + { 63 + type: "text" as const, 64 + text: JSON.stringify({ total_unread: total, breakdown }, null, 2), 65 + }, 66 + ], 67 + }; 68 + }
+53
src/tools/list-accounts.ts
··· 1 + import { z } from "zod"; 2 + import { extractAccountUuid, getAccountMap, getDb } from "../db.js"; 3 + 4 + export const listAccountsSchema = z.object({}); 5 + 6 + export async function listAccounts() { 7 + const db = getDb(); 8 + const accountMap = await getAccountMap(); 9 + 10 + const rows = db 11 + .prepare( 12 + `SELECT m.url, 13 + COUNT(*) as total_messages, 14 + SUM(CASE WHEN msg.read = 0 THEN 1 ELSE 0 END) as unread_count 15 + FROM mailboxes m 16 + JOIN messages msg ON msg.mailbox = m.ROWID 17 + GROUP BY m.url`, 18 + ) 19 + .all() as Array<{ url: string; total_messages: number; unread_count: number }>; 20 + 21 + // Group by account UUID 22 + const accounts = new Map< 23 + string, 24 + { name: string; uuid: string; total_messages: number; unread_count: number } 25 + >(); 26 + 27 + for (const row of rows) { 28 + const uuid = extractAccountUuid(row.url); 29 + if (!uuid) continue; 30 + 31 + const existing = accounts.get(uuid); 32 + if (existing) { 33 + existing.total_messages += row.total_messages; 34 + existing.unread_count += row.unread_count; 35 + } else { 36 + accounts.set(uuid, { 37 + name: accountMap.get(uuid) ?? uuid, 38 + uuid, 39 + total_messages: row.total_messages, 40 + unread_count: row.unread_count, 41 + }); 42 + } 43 + } 44 + 45 + return { 46 + content: [ 47 + { 48 + type: "text" as const, 49 + text: JSON.stringify([...accounts.values()], null, 2), 50 + }, 51 + ], 52 + }; 53 + }
+72
src/tools/list-mailboxes.ts
··· 1 + import { z } from "zod"; 2 + import { extractAccountUuid, extractMailboxPath, getAccountMap, getDb } from "../db.js"; 3 + 4 + export const listMailboxesSchema = z.object({ 5 + account: z.string().optional().describe("Filter by account name or UUID"), 6 + unread_only: z 7 + .boolean() 8 + .optional() 9 + .default(false) 10 + .describe("Only show mailboxes with unread messages"), 11 + }); 12 + 13 + export async function listMailboxes(params: z.infer<typeof listMailboxesSchema>) { 14 + const db = getDb(); 15 + const accountMap = await getAccountMap(); 16 + 17 + const rows = db 18 + .prepare( 19 + `SELECT m.url, 20 + COUNT(*) as total_messages, 21 + SUM(CASE WHEN msg.read = 0 THEN 1 ELSE 0 END) as unread_count 22 + FROM mailboxes m 23 + JOIN messages msg ON msg.mailbox = m.ROWID 24 + GROUP BY m.url`, 25 + ) 26 + .all() as Array<{ url: string; total_messages: number; unread_count: number }>; 27 + 28 + // Resolve account filter to UUID if it's a name 29 + let filterUuid: string | undefined; 30 + if (params.account) { 31 + // Check if it's already a UUID 32 + if (accountMap.has(params.account)) { 33 + filterUuid = params.account; 34 + } else { 35 + // Search by name 36 + for (const [uuid, name] of accountMap) { 37 + if (name.toLowerCase().includes(params.account.toLowerCase())) { 38 + filterUuid = uuid; 39 + break; 40 + } 41 + } 42 + } 43 + } 44 + 45 + const results = rows 46 + .map((row) => { 47 + const uuid = extractAccountUuid(row.url); 48 + const path = extractMailboxPath(row.url); 49 + return { 50 + account: uuid ? (accountMap.get(uuid) ?? uuid) : "unknown", 51 + account_uuid: uuid, 52 + path, 53 + total_messages: row.total_messages, 54 + unread_count: row.unread_count, 55 + }; 56 + }) 57 + .filter((r) => { 58 + if (filterUuid && r.account_uuid !== filterUuid) return false; 59 + if (params.unread_only && r.unread_count === 0) return false; 60 + return true; 61 + }) 62 + .sort((a, b) => b.total_messages - a.total_messages); 63 + 64 + return { 65 + content: [ 66 + { 67 + type: "text" as const, 68 + text: JSON.stringify(results, null, 2), 69 + }, 70 + ], 71 + }; 72 + }
+170
src/tools/read-message.ts
··· 1 + import { z } from "zod"; 2 + import { execAppleScript } from "../applescript.js"; 3 + import { extractAccountUuid, extractMailboxPath, getAccountMap, getDb } from "../db.js"; 4 + import { parseListUnsubscribe } from "../parse-headers.js"; 5 + 6 + export const readMessageSchema = z.object({ 7 + id: z.number().describe("Message ROWID from search results"), 8 + }); 9 + 10 + export async function readMessage(params: z.infer<typeof readMessageSchema>) { 11 + const db = getDb(); 12 + const accountMap = await getAccountMap(); 13 + 14 + const row = db 15 + .prepare( 16 + `SELECT msg.ROWID as id, 17 + sub.subject, 18 + addr.address as sender, 19 + datetime(msg.date_received, 'unixepoch') as date_received, 20 + msg.read, 21 + msg.flagged, 22 + mb.url as mailbox_url, 23 + COALESCE(summ.summary, '') as preview 24 + FROM messages msg 25 + LEFT JOIN subjects sub ON msg.subject = sub.ROWID 26 + LEFT JOIN addresses addr ON msg.sender = addr.ROWID 27 + LEFT JOIN mailboxes mb ON msg.mailbox = mb.ROWID 28 + LEFT JOIN summaries summ ON msg.summary = summ.ROWID 29 + WHERE msg.ROWID = ?`, 30 + ) 31 + .get(params.id) as 32 + | { 33 + id: number; 34 + subject: string | null; 35 + sender: string | null; 36 + date_received: string; 37 + read: number; 38 + flagged: number; 39 + mailbox_url: string | null; 40 + preview: string; 41 + } 42 + | undefined; 43 + 44 + if (!row) { 45 + return { 46 + content: [{ type: "text" as const, text: `Message with id ${params.id} not found.` }], 47 + isError: true, 48 + }; 49 + } 50 + 51 + const uuid = row.mailbox_url ? extractAccountUuid(row.mailbox_url) : null; 52 + const mailboxPath = row.mailbox_url ? extractMailboxPath(row.mailbox_url) : null; 53 + 54 + // Get attachment names from DB 55 + const attRows = db 56 + .prepare("SELECT name FROM attachments WHERE message = ?") 57 + .all(params.id) as Array<{ name: string }>; 58 + 59 + let body = "(could not retrieve message body)"; 60 + let attachments: Array<{ name: string; mime_type: string; file_size_bytes: number }> = []; 61 + let listUnsubscribe: string[] | null = null; 62 + 63 + if (uuid && mailboxPath) { 64 + const escapedPath = mailboxPath.replace(/"/g, '\\"'); 65 + const msgSelector = `every message of mailbox "${escapedPath}" of account id "${uuid}" whose id is ${params.id}`; 66 + 67 + const bodyPromise = execAppleScript(` 68 + tell application "Mail" 69 + set theMessages to (${msgSelector}) 70 + if (count of theMessages) > 0 then 71 + return content of item 1 of theMessages 72 + else 73 + return "MESSAGE_NOT_FOUND" 74 + end if 75 + end tell 76 + `); 77 + 78 + const headersPromise = execAppleScript(` 79 + tell application "Mail" 80 + set theMessages to (${msgSelector}) 81 + if (count of theMessages) > 0 then 82 + return all headers of item 1 of theMessages 83 + else 84 + return "" 85 + end if 86 + end tell 87 + `); 88 + 89 + const [bodyResult, headersResult] = await Promise.allSettled([bodyPromise, headersPromise]); 90 + 91 + if (bodyResult.status === "fulfilled" && bodyResult.value.trim() !== "MESSAGE_NOT_FOUND") { 92 + body = bodyResult.value; 93 + } else if (bodyResult.status === "rejected") { 94 + body = `(error retrieving body: ${bodyResult.reason instanceof Error ? bodyResult.reason.message : String(bodyResult.reason)})`; 95 + } 96 + 97 + if (headersResult.status === "fulfilled" && headersResult.value) { 98 + listUnsubscribe = parseListUnsubscribe(headersResult.value); 99 + } 100 + 101 + if (attRows.length > 0) { 102 + try { 103 + const script = ` 104 + tell application "Mail" 105 + set theMessages to (every message of mailbox "${escapedPath}" of account id "${uuid}" whose id is ${params.id}) 106 + if (count of theMessages) > 0 then 107 + set theMsg to item 1 of theMessages 108 + set attList to every mail attachment of theMsg 109 + set output to "" 110 + repeat with att in attList 111 + set attName to name of att 112 + set attSize to file size of att 113 + set attType to "unknown" 114 + try 115 + set attType to MIME type of att 116 + end try 117 + set output to output & attName & "\\t" & attSize & "\\t" & attType & "\\n" 118 + end repeat 119 + return output 120 + end if 121 + end tell 122 + `; 123 + const result = await execAppleScript(script); 124 + for (const line of result.trim().split("\n")) { 125 + if (!line) continue; 126 + const [name, size, mimeType] = line.split("\t"); 127 + if (name) { 128 + attachments.push({ 129 + name, 130 + mime_type: mimeType ?? "application/octet-stream", 131 + file_size_bytes: Number.parseInt(size, 10) || 0, 132 + }); 133 + } 134 + } 135 + } catch { 136 + // Fall back to DB-only names if AppleScript fails 137 + attachments = attRows.map((a) => ({ 138 + name: a.name, 139 + mime_type: "unknown", 140 + file_size_bytes: 0, 141 + })); 142 + } 143 + } 144 + } 145 + 146 + return { 147 + content: [ 148 + { 149 + type: "text" as const, 150 + text: JSON.stringify( 151 + { 152 + id: row.id, 153 + subject: row.subject ?? "(no subject)", 154 + sender: row.sender ?? "(unknown)", 155 + date: row.date_received, 156 + read: row.read === 1, 157 + flagged: row.flagged === 1, 158 + account: uuid ? (accountMap.get(uuid) ?? uuid) : "unknown", 159 + mailbox: mailboxPath ?? "", 160 + body, 161 + list_unsubscribe: listUnsubscribe, 162 + attachments, 163 + }, 164 + null, 165 + 2, 166 + ), 167 + }, 168 + ], 169 + }; 170 + }
+104
src/tools/save-attachment.ts
··· 1 + import { mkdirSync } from "node:fs"; 2 + import { z } from "zod"; 3 + import { execAppleScript } from "../applescript.js"; 4 + import { extractAccountUuid, extractMailboxPath, getAccountMap, getDb } from "../db.js"; 5 + 6 + export const saveAttachmentSchema = z.object({ 7 + id: z.number().describe("Message ROWID from search results"), 8 + attachment_name: z.string().describe("Filename of the attachment to save"), 9 + }); 10 + 11 + const SAVE_DIR = "/tmp/mailgenie"; 12 + 13 + export async function saveAttachment(params: z.infer<typeof saveAttachmentSchema>) { 14 + const db = getDb(); 15 + const accountMap = await getAccountMap(); 16 + 17 + const row = db 18 + .prepare( 19 + `SELECT msg.ROWID as id, mb.url as mailbox_url 20 + FROM messages msg 21 + LEFT JOIN mailboxes mb ON msg.mailbox = mb.ROWID 22 + WHERE msg.ROWID = ?`, 23 + ) 24 + .get(params.id) as { id: number; mailbox_url: string | null } | undefined; 25 + 26 + if (!row) { 27 + return { 28 + content: [{ type: "text" as const, text: `Message with id ${params.id} not found.` }], 29 + isError: true, 30 + }; 31 + } 32 + 33 + const uuid = row.mailbox_url ? extractAccountUuid(row.mailbox_url) : null; 34 + const mailboxPath = row.mailbox_url ? extractMailboxPath(row.mailbox_url) : null; 35 + 36 + if (!uuid || !mailboxPath) { 37 + return { 38 + content: [ 39 + { type: "text" as const, text: "Could not resolve mailbox/account for this message." }, 40 + ], 41 + isError: true, 42 + }; 43 + } 44 + 45 + mkdirSync(SAVE_DIR, { recursive: true }); 46 + 47 + const escapedPath = mailboxPath.replace(/"/g, '\\"'); 48 + const escapedName = params.attachment_name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 49 + const savePath = `${SAVE_DIR}/${params.attachment_name}`; 50 + const escapedSavePath = savePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 51 + 52 + const script = ` 53 + tell application "Mail" 54 + set theMessages to (every message of mailbox "${escapedPath}" of account id "${uuid}" whose id is ${params.id}) 55 + if (count of theMessages) > 0 then 56 + set theMsg to item 1 of theMessages 57 + set attList to every mail attachment of theMsg 58 + repeat with att in attList 59 + if name of att is "${escapedName}" then 60 + save att in POSIX file "${escapedSavePath}" 61 + return "${escapedSavePath}" 62 + end if 63 + end repeat 64 + return "ATTACHMENT_NOT_FOUND" 65 + else 66 + return "MESSAGE_NOT_FOUND" 67 + end if 68 + end tell 69 + `; 70 + 71 + try { 72 + const result = (await execAppleScript(script)).trim(); 73 + if (result === "MESSAGE_NOT_FOUND") { 74 + return { 75 + content: [{ type: "text" as const, text: `Message ${params.id} not found in Mail.app.` }], 76 + isError: true, 77 + }; 78 + } 79 + if (result === "ATTACHMENT_NOT_FOUND") { 80 + return { 81 + content: [ 82 + { 83 + type: "text" as const, 84 + text: `Attachment "${params.attachment_name}" not found on message ${params.id}.`, 85 + }, 86 + ], 87 + isError: true, 88 + }; 89 + } 90 + return { 91 + content: [{ type: "text" as const, text: JSON.stringify({ saved_to: result }, null, 2) }], 92 + }; 93 + } catch (err) { 94 + return { 95 + content: [ 96 + { 97 + type: "text" as const, 98 + text: `Error saving attachment: ${err instanceof Error ? err.message : String(err)}`, 99 + }, 100 + ], 101 + isError: true, 102 + }; 103 + } 104 + }
+159
src/tools/search-messages.ts
··· 1 + import { z } from "zod"; 2 + import { extractAccountUuid, extractMailboxPath, getAccountMap, getDb } from "../db.js"; 3 + 4 + export const searchMessagesSchema = z.object({ 5 + subject: z.string().optional().describe("Search in message subject (substring match)"), 6 + sender: z.string().optional().describe("Search by sender email or name (substring match)"), 7 + date_from: z.string().optional().describe("Start date (ISO 8601, e.g. 2024-01-15)"), 8 + date_to: z.string().optional().describe("End date (ISO 8601, e.g. 2024-01-31)"), 9 + read: z.boolean().optional().describe("Filter by read status"), 10 + flagged: z.boolean().optional().describe("Filter by flagged status"), 11 + mailbox: z.string().optional().describe("Filter by mailbox path (substring match)"), 12 + account: z.string().optional().describe("Filter by account name or UUID"), 13 + limit: z.number().optional().default(25).describe("Max results (default 25)"), 14 + offset: z.number().optional().default(0).describe("Offset for pagination"), 15 + }); 16 + 17 + function escapeLike(s: string): string { 18 + return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); 19 + } 20 + 21 + function isoToUnixTimestamp(iso: string): number { 22 + return Math.floor(new Date(iso).getTime() / 1000); 23 + } 24 + 25 + export async function searchMessages(params: z.infer<typeof searchMessagesSchema>) { 26 + const db = getDb(); 27 + const accountMap = await getAccountMap(); 28 + 29 + const conditions: string[] = []; 30 + const bindings: unknown[] = []; 31 + 32 + if (params.subject) { 33 + conditions.push("sub.subject LIKE ? ESCAPE '\\'"); 34 + bindings.push(`%${escapeLike(params.subject)}%`); 35 + } 36 + 37 + if (params.sender) { 38 + conditions.push("addr.address LIKE ? ESCAPE '\\'"); 39 + bindings.push(`%${escapeLike(params.sender)}%`); 40 + } 41 + 42 + if (params.date_from) { 43 + conditions.push("msg.date_received >= ?"); 44 + bindings.push(isoToUnixTimestamp(params.date_from)); 45 + } 46 + 47 + if (params.date_to) { 48 + conditions.push("msg.date_received <= ?"); 49 + bindings.push(isoToUnixTimestamp(`${params.date_to}T23:59:59Z`)); 50 + } 51 + 52 + if (params.read !== undefined) { 53 + conditions.push("msg.read = ?"); 54 + bindings.push(params.read ? 1 : 0); 55 + } 56 + 57 + if (params.flagged !== undefined) { 58 + conditions.push("msg.flagged = ?"); 59 + bindings.push(params.flagged ? 1 : 0); 60 + } 61 + 62 + // Resolve account filter to UUID 63 + let filterUuid: string | undefined; 64 + if (params.account) { 65 + if (accountMap.has(params.account)) { 66 + filterUuid = params.account; 67 + } else { 68 + for (const [uuid, name] of accountMap) { 69 + if (name.toLowerCase().includes(params.account.toLowerCase())) { 70 + filterUuid = uuid; 71 + break; 72 + } 73 + } 74 + } 75 + } 76 + 77 + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; 78 + 79 + const query = ` 80 + SELECT msg.ROWID as id, 81 + sub.subject, 82 + addr.address as sender, 83 + datetime(msg.date_received, 'unixepoch') as date_received, 84 + msg.read, 85 + msg.flagged, 86 + mb.url as mailbox_url, 87 + COALESCE(summ.summary, '') as preview, 88 + GROUP_CONCAT(att.name, '|||') as attachment_names 89 + FROM messages msg 90 + LEFT JOIN subjects sub ON msg.subject = sub.ROWID 91 + LEFT JOIN addresses addr ON msg.sender = addr.ROWID 92 + LEFT JOIN mailboxes mb ON msg.mailbox = mb.ROWID 93 + LEFT JOIN summaries summ ON msg.summary = summ.ROWID 94 + LEFT JOIN attachments att ON att.message = msg.ROWID 95 + ${whereClause} 96 + GROUP BY msg.ROWID 97 + ORDER BY msg.date_received DESC 98 + LIMIT ? OFFSET ? 99 + `; 100 + 101 + bindings.push(params.limit, params.offset); 102 + 103 + const rows = db.prepare(query).all(...bindings) as Array<{ 104 + id: number; 105 + subject: string | null; 106 + sender: string | null; 107 + date_received: string; 108 + read: number; 109 + flagged: number; 110 + mailbox_url: string | null; 111 + preview: string; 112 + attachment_names: string | null; 113 + }>; 114 + 115 + const results = rows 116 + .map((row) => { 117 + const uuid = row.mailbox_url ? extractAccountUuid(row.mailbox_url) : null; 118 + const mailboxPath = row.mailbox_url ? extractMailboxPath(row.mailbox_url) : ""; 119 + 120 + return { 121 + id: row.id, 122 + subject: row.subject ?? "(no subject)", 123 + sender: row.sender ?? "(unknown)", 124 + date: row.date_received, 125 + read: row.read === 1, 126 + flagged: row.flagged === 1, 127 + account: uuid ? (accountMap.get(uuid) ?? uuid) : "unknown", 128 + mailbox: mailboxPath, 129 + preview: row.preview, 130 + attachments: row.attachment_names ? row.attachment_names.split("|||") : [], 131 + }; 132 + }) 133 + .filter((r) => { 134 + if (filterUuid) { 135 + const rowUuid = rows.find((row) => row.id === r.id)?.mailbox_url; 136 + if (rowUuid && extractAccountUuid(rowUuid) !== filterUuid) return false; 137 + } 138 + if (params.mailbox && !r.mailbox.toLowerCase().includes(params.mailbox.toLowerCase())) 139 + return false; 140 + return true; 141 + }); 142 + 143 + return { 144 + content: [ 145 + { 146 + type: "text" as const, 147 + text: JSON.stringify( 148 + { 149 + count: results.length, 150 + offset: params.offset, 151 + messages: results, 152 + }, 153 + null, 154 + 2, 155 + ), 156 + }, 157 + ], 158 + }; 159 + }
+15
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "Node16", 5 + "moduleResolution": "Node16", 6 + "outDir": "dist", 7 + "rootDir": "src", 8 + "strict": true, 9 + "esModuleInterop": true, 10 + "skipLibCheck": true, 11 + "declaration": true 12 + }, 13 + "include": ["src"], 14 + "exclude": ["src/__tests__"] 15 + }