| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010 |
- /**
- * Apple Mail Manager
- *
- * Handles all interactions with Apple Mail via AppleScript.
- * This is the core service layer for the MCP server.
- *
- * Architecture:
- * - Text escaping is handled by dedicated helper functions
- * - AppleScript generation uses template builders for consistency
- * - All public methods return typed results (no raw strings)
- * - Error handling is consistent across all operations
- *
- * @module services/appleMailManager
- */
- import { spawnSync } from "child_process";
- import { existsSync, writeFileSync } from "fs";
- import { isAbsolute, resolve } from "path";
- import { homedir } from "os";
- import { executeAppleScript } from "../utils/applescript.js";
- import { parseMimeAttachments, extractMimeAttachment } from "../utils/mimeParse.js";
- // =============================================================================
- // Text Processing Utilities
- // =============================================================================
- /**
- * Escapes text for safe embedding in AppleScript string literals.
- *
- * AppleScript strings use double quotes, so we need to escape:
- * 1. Backslashes (\) - escaped as \\
- * 2. Double quotes (") - escaped as \"
- *
- * @param text - Raw text to escape
- * @returns Text safe for AppleScript string embedding
- */
- function escapeForAppleScript(text) {
- if (!text)
- return "";
- return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
- }
- /**
- * Validates attachment file paths and builds AppleScript commands to attach them.
- *
- * @param attachments - Absolute file paths to attach
- * @returns AppleScript commands to add attachments, or empty string if none
- * @throws Error if any path is not absolute or does not exist
- */
- function buildAttachmentCommands(attachments) {
- if (!attachments || attachments.length === 0)
- return "";
- for (const filePath of attachments) {
- if (!isAbsolute(filePath)) {
- throw new Error(`Attachment path must be absolute: "${filePath}"`);
- }
- if (!existsSync(filePath)) {
- throw new Error(`Attachment file not found: "${filePath}"`);
- }
- }
- let commands = "";
- for (const filePath of attachments) {
- const safePath = escapeForAppleScript(filePath);
- commands += `make new attachment with properties {file name:POSIX file "${safePath}"} at after the last paragraph\n`;
- }
- return commands;
- }
- /**
- * AppleScript snippet that converts a date variable `d` into a
- * locale-independent numeric string: "YYYY-M-D-H-m-s".
- * Use: set d to date received of msg, then inline this snippet.
- */
- const AS_DATE_TO_STRING = `((year of d) as string) & "-" & ((month of d as integer) as string) & "-" & ((day of d) as string) & "-" & ((hours of d) as string) & "-" & ((minutes of d) as string) & "-" & ((seconds of d) as string)`;
- /**
- * Parses a locale-independent date string "YYYY-M-D-H-m-s"
- * produced by the AppleScript snippet above.
- *
- * Falls back to the locale-dependent `as string` format for
- * backwards compatibility, and finally to current date.
- *
- * @param dateStr - Date string from AppleScript
- * @returns Parsed Date, or current date if parsing fails
- */
- function parseAppleScriptDate(dateStr) {
- // Try locale-independent numeric format first: "YYYY-M-D-H-m-s"
- const numParts = dateStr.split("-").map(Number);
- if (numParts.length === 6 && numParts.every((n) => !isNaN(n))) {
- return new Date(numParts[0], numParts[1] - 1, numParts[2], numParts[3], numParts[4], numParts[5]);
- }
- // Fallback: try legacy locale-dependent format
- const withoutPrefix = dateStr.replace(/^date\s+/, "");
- const normalized = withoutPrefix.replace(" at ", " ");
- const parsed = new Date(normalized);
- return isNaN(parsed.getTime()) ? new Date() : parsed;
- }
- /**
- * Builds an AppleScript command scoped to a specific account.
- */
- function buildAccountScopedScript(account, command) {
- return `
- tell application "Mail"
- tell account "${escapeForAppleScript(account)}"
- ${command}
- end tell
- end tell
- `;
- }
- /**
- * Builds an AppleScript command at the application level.
- */
- function buildAppLevelScript(command) {
- return `
- tell application "Mail"
- ${command}
- end tell
- `;
- }
- /**
- * Common mailbox name variations across different account types.
- * Maps normalized (lowercase) names to possible actual names.
- */
- const MAILBOX_ALIASES = {
- inbox: ["INBOX", "Inbox", "inbox"],
- sent: ["Sent", "Sent Items", "Sent Messages", "SENT", "sent"],
- drafts: ["Drafts", "DRAFTS", "drafts", "Draft"],
- trash: ["Trash", "Deleted Items", "Deleted Messages", "TRASH", "trash"],
- junk: ["Junk", "Junk Email", "Spam", "JUNK", "junk"],
- archive: ["Archive", "ARCHIVE", "archive", "All Mail"],
- };
- /**
- * Build the AppleScript `whose` clause for searchMessages from a filter set.
- *
- * - `query` is a subject-OR-sender substring match, parenthesized so it groups
- * correctly when ANDed with other filters.
- * - `from` and `subject` are substring matches (`sender`/`subject` contains).
- * - `isRead` / `isFlagged` are boolean status checks.
- * - Returns "" when no filters are set. Every interpolated value is escaped.
- *
- * Exported for unit testing: the bug this addresses (filters declared in the
- * tool schema but silently dropped) lived in this logic, so it gets direct
- * coverage independent of Mail.app.
- */
- export function buildSearchCondition(filters) {
- const { query, from, subject, isRead, isFlagged } = filters;
- const conditions = [];
- if (query) {
- const safeQuery = escapeForAppleScript(query);
- conditions.push(`(subject contains "${safeQuery}" or sender contains "${safeQuery}")`);
- }
- if (from) {
- conditions.push(`sender contains "${escapeForAppleScript(from)}"`);
- }
- if (subject) {
- conditions.push(`subject contains "${escapeForAppleScript(subject)}"`);
- }
- if (typeof isRead === "boolean") {
- conditions.push(`read status is ${isRead ? "true" : "false"}`);
- }
- if (typeof isFlagged === "boolean") {
- conditions.push(`flagged status is ${isFlagged ? "true" : "false"}`);
- }
- return conditions.length > 0 ? `whose ${conditions.join(" and ")}` : "";
- }
- export class AppleMailManager {
- /**
- * Default account used when no account is specified.
- */
- defaultAccount = null;
- /**
- * TTL cache for expensive AppleScript queries that rarely change.
- * Caches account list and per-account mailbox names to avoid
- * redundant AppleScript roundtrips on every tool call.
- */
- cache = {
- accounts: null,
- mailboxNames: new Map(),
- };
- /** Cache TTL in milliseconds (60 seconds). */
- CACHE_TTL_MS = 60_000;
- /**
- * Returns cached accounts or fetches fresh data if cache is expired/empty.
- */
- getCachedAccounts() {
- const now = Date.now();
- if (this.cache.accounts && now < this.cache.accounts.expiry) {
- return this.cache.accounts.data;
- }
- const accounts = this.fetchAccounts();
- this.cache.accounts = { data: accounts, expiry: now + this.CACHE_TTL_MS };
- return accounts;
- }
- /**
- * Returns cached mailbox names for an account, or fetches fresh.
- * This caches only the name list used by resolveMailbox(), not the
- * full Mailbox objects with counts (which change frequently).
- */
- getCachedMailboxNames(account) {
- const now = Date.now();
- const cached = this.cache.mailboxNames.get(account);
- if (cached && now < cached.expiry) {
- return cached.data;
- }
- const names = this.fetchMailboxNames(account);
- this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
- return names;
- }
- /**
- * Invalidate all caches. Call after operations that change
- * mailbox structure (create/delete/rename mailbox).
- */
- invalidateCache() {
- this.cache.accounts = null;
- this.cache.mailboxNames.clear();
- }
- /**
- * Resolves the account to use for an operation.
- * Queries Mail.app's configured default send account, then falls back
- * to the first available account.
- */
- resolveAccount(account) {
- if (account)
- return account;
- if (this.defaultAccount)
- return this.defaultAccount;
- // Query Mail.app's default send account by inspecting a temporary outgoing message
- const defaultResult = executeAppleScript(buildAppLevelScript(`
- set newMsg to make new outgoing message
- set fromAddr to sender of newMsg
- delete newMsg
- return fromAddr
- `));
- if (defaultResult.success && defaultResult.output.trim()) {
- // sender returns "Name <email>" — match to account by email address
- const senderOutput = defaultResult.output.trim();
- const emailMatch = senderOutput.match(/<([^>]+)>/);
- const defaultEmail = emailMatch ? emailMatch[1] : senderOutput;
- const accounts = this.getCachedAccounts();
- const matchedAccount = accounts.find((a) => a.email.toLowerCase() === defaultEmail.toLowerCase());
- if (matchedAccount) {
- this.defaultAccount = matchedAccount.name;
- return this.defaultAccount;
- }
- }
- // Fall back to first available account
- const accounts = this.getCachedAccounts();
- if (accounts.length > 0) {
- this.defaultAccount = accounts[0].name;
- return this.defaultAccount;
- }
- return "iCloud"; // Last resort fallback
- }
- /**
- * Resolves a mailbox name to its actual name in the account.
- *
- * Different account types (IMAP, Exchange, iCloud) use different
- * mailbox naming conventions:
- * - IMAP/Gmail: "INBOX", "Sent", "Drafts"
- * - Exchange: "Inbox", "Sent Items", "Deleted Items"
- * - iCloud: "INBOX", "Sent", "Trash"
- *
- * This method tries to find a matching mailbox by:
- * 1. Exact match
- * 2. Case-insensitive match
- * 3. Known aliases (e.g., "Sent" -> "Sent Items")
- *
- * @param mailbox - Requested mailbox name
- * @param account - Account to search in
- * @returns Actual mailbox name, or original if not found
- */
- resolveMailbox(mailbox, account) {
- const actualMailboxes = this.getCachedMailboxNames(account);
- if (actualMailboxes.length === 0) {
- return mailbox; // Fall back to original
- }
- // 1. Try exact match
- if (actualMailboxes.includes(mailbox)) {
- return mailbox;
- }
- // 2. Try case-insensitive match
- const lowerMailbox = mailbox.toLowerCase();
- const caseMatch = actualMailboxes.find((mb) => mb.toLowerCase() === lowerMailbox);
- if (caseMatch) {
- return caseMatch;
- }
- // 3. Try known aliases
- const aliases = MAILBOX_ALIASES[lowerMailbox];
- if (aliases) {
- for (const alias of aliases) {
- if (actualMailboxes.includes(alias)) {
- return alias;
- }
- // Also try case-insensitive alias match
- const aliasMatch = actualMailboxes.find((mb) => mb.toLowerCase() === alias.toLowerCase());
- if (aliasMatch) {
- return aliasMatch;
- }
- }
- }
- // No match found, return original and let AppleScript handle the error
- return mailbox;
- }
- // ===========================================================================
- // Message Operations
- // ===========================================================================
- /**
- * Search for messages matching criteria.
- *
- * @param query - Text to search for in subject or sender
- * @param mailbox - Mailbox to search in (e.g., "INBOX")
- * @param account - Account to search in
- * @param limit - Maximum number of results
- * @returns Array of matching messages
- */
- searchMessages(query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged) {
- // If no account specified, search across all accounts
- if (!account) {
- const accounts = this.listAccounts();
- const allMessages = [];
- for (const acct of accounts) {
- if (allMessages.length >= limit)
- break;
- const remaining = limit - allMessages.length;
- const msgs = this.searchMessages(query, mailbox, acct.name, remaining, dateFrom, dateTo, from, subject, isRead, isFlagged);
- allMessages.push(...msgs);
- }
- return allMessages.slice(0, limit);
- }
- const targetAccount = this.resolveAccount(account);
- // `query` is a subject-OR-sender substring match; from/subject/isRead/isFlagged
- // are additional AND filters. Date filtering stays post-fetch below — `whose`
- // date comparisons are unreliable in Mail.app AppleScript. See buildSearchCondition.
- const searchCondition = buildSearchCondition({ query, from, subject, isRead, isFlagged });
- // Build date filter AppleScript.
- // Note: dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA (alphanumeric + safe
- // punctuation only), so escapeForAppleScript() below is belt-and-suspenders — it won't
- // alter valid date strings but guards against future schema changes.
- let dateFilter = "";
- if (dateFrom || dateTo) {
- const dateChecks = [];
- if (dateFrom) {
- dateChecks.push(`date received of msg >= date "${escapeForAppleScript(dateFrom)}"`);
- }
- if (dateTo) {
- dateChecks.push(`date received of msg <= date "${escapeForAppleScript(dateTo)}"`);
- }
- dateFilter = dateChecks.join(" and ");
- }
- let searchCommand;
- if (mailbox) {
- // Search a specific mailbox
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
- searchCommand = `
- set outputText to ""
- set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
- set allMessages to messages of theMailbox ${searchCondition}
- set msgCount to 0
- repeat with msg in allMessages
- if msgCount >= ${limit} then exit repeat
- try
- ${dateFilter ? `set msgDate to date received of msg\n if not (${dateFilter}) then\n -- skip message outside date range\n else` : ""}
- set msgId to id of msg as string
- set msgSubject to subject of msg
- set msgSender to sender of msg
- set d to date received of msg
- set msgDateStr to ${AS_DATE_TO_STRING}
- set msgRead to read status of msg as string
- set msgFlagged to flagged status of msg as string
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged
- set msgCount to msgCount + 1
- ${dateFilter ? "end if" : ""}
- end try
- end repeat
- return outputText
- `;
- }
- else {
- // Search ALL mailboxes — iterate every mailbox in the account, dedup by message ID
- searchCommand = `
- set outputText to ""
- set msgCount to 0
- set seenIds to {}
- repeat with mb in mailboxes
- if msgCount >= ${limit} then exit repeat
- try
- set allMessages to messages of mb ${searchCondition}
- repeat with msg in allMessages
- if msgCount >= ${limit} then exit repeat
- try
- set msgId to id of msg as string
- if seenIds does not contain msgId then
- set end of seenIds to msgId
- ${dateFilter ? `set msgDate to date received of msg\n if not (${dateFilter}) then\n -- skip message outside date range\n else` : ""}
- set msgSubject to subject of msg
- set msgSender to sender of msg
- set d to date received of msg
- set msgDateStr to ${AS_DATE_TO_STRING}
- set msgRead to read status of msg as string
- set msgFlagged to flagged status of msg as string
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb
- set msgCount to msgCount + 1
- ${dateFilter ? "end if" : ""}
- end if
- end try
- end repeat
- end try
- end repeat
- return outputText
- `;
- }
- const script = buildAccountScopedScript(targetAccount, searchCommand);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success) {
- console.error(`Failed to search messages: ${result.error}`);
- return [];
- }
- if (!result.output.trim())
- return [];
- return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
- }
- /**
- * Get a message by ID.
- *
- * Note: Mail.app message IDs are unique per mailbox. This method searches
- * all mailboxes in all accounts to find the message.
- */
- getMessageById(id) {
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- set msgSubject to subject of msg
- set msgSender to sender of msg
- set d to date received of msg
- set msgDate to ${AS_DATE_TO_STRING}
- set msgRead to read status of msg as string
- set msgFlagged to flagged status of msg as string
- set msgJunk to junk mail status of msg as string
- set msgDeleted to deleted status of msg as string
- set msgMailbox to name of mb
- set msgAccount to name of acct
- set hasAtt to "false"
- try
- set attCount to count of mail attachments of msg
- if attCount > 0 then set hasAtt to "true"
- end try
- -- MIME-embedded attachments are invisible to AppleScript's
- -- attachment object. Fall back to scanning the raw source.
- -- This reads the full message source (can be MB-sized for
- -- messages with large bodies), so it's the slowest part of
- -- get-message for attachmentless messages. Accepted as the
- -- cost of correct hasAttachments in the detail view.
- if hasAtt is "false" then
- try
- set rawSrc to source of msg
- if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
- end try
- end if
- return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount & "|||" & hasAtt
- end if
- end try
- end repeat
- end repeat
- return ""
- on error errMsg
- return ""
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 }); // Longer timeout for search
- if (!result.success || !result.output.trim()) {
- console.error(`Failed to get message ${id}: ${result.error}`);
- return null;
- }
- const parts = result.output.split("|||");
- if (parts.length < 9)
- return null;
- return {
- id: id.toString(),
- subject: parts[0],
- sender: parts[1],
- recipients: [],
- dateReceived: parseAppleScriptDate(parts[2]),
- isRead: parts[3] === "true",
- isFlagged: parts[4] === "true",
- isJunk: parts[5] === "true",
- isDeleted: parts[6] === "true",
- mailbox: parts[7],
- account: parts[8],
- hasAttachments: parts.length > 9 ? parts[9] === "true" : false,
- };
- }
- /**
- * Get the content of a message.
- */
- getMessageContent(id) {
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- set msgSubject to subject of msg
- set msgContent to content of msg
- set htmlContent to ""
- try
- set htmlContent to source of msg
- end try
- return msgSubject & "|||CONTENT|||" & msgContent & "|||HTML|||" & htmlContent
- end if
- end try
- end repeat
- end repeat
- return ""
- on error errMsg
- return ""
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || !result.output.trim()) {
- console.error(`Failed to get message content: ${result.error}`);
- return null;
- }
- const htmlSplit = result.output.split("|||HTML|||");
- const contentPart = htmlSplit[0];
- const htmlContent = htmlSplit.length > 1 ? htmlSplit[1] : undefined;
- const parts = contentPart.split("|||CONTENT|||");
- if (parts.length < 2)
- return null;
- return {
- id: id.toString(),
- subject: parts[0],
- plainText: parts[1],
- htmlContent: htmlContent || undefined,
- };
- }
- /**
- * Get the raw MIME source of a message.
- * Used as fallback for attachment extraction when AppleScript
- * mail attachments returns empty.
- *
- * Timeout is 2x the default (120s) because `source of msg` returns
- * the entire raw message including base64-encoded attachments —
- * a 20MB attachment can take several seconds over Exchange/IMAP.
- */
- getRawSource(id) {
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- return source of msg
- end if
- end try
- end repeat
- end repeat
- return ""
- on error errMsg
- return ""
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 120000 });
- if (!result.success || !result.output.trim()) {
- return null;
- }
- return result.output;
- }
- /**
- * List messages in a mailbox.
- *
- * @param mailbox - Mailbox to list from (default: INBOX)
- * @param account - Account to list from
- * @param limit - Maximum number of messages
- * @returns Array of messages
- */
- listMessages(mailbox, account, limit = 50, from, offset = 0) {
- // If no account specified, list across all accounts
- if (!account) {
- const accounts = this.listAccounts();
- const allMessages = [];
- for (const acct of accounts) {
- if (allMessages.length >= limit)
- break;
- const remaining = limit - allMessages.length;
- const msgs = this.listMessages(mailbox, acct.name, remaining, from, offset);
- allMessages.push(...msgs);
- }
- return allMessages.slice(0, limit);
- }
- const targetAccount = this.resolveAccount(account);
- const safeFrom = from ? escapeForAppleScript(from) : "";
- const fromFilter = from ? `whose sender contains "${safeFrom}"` : "";
- let listCommand;
- if (mailbox) {
- // List from a specific mailbox
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
- listCommand = `
- set outputText to ""
- set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
- set msgCount to 0
- set skipped to 0
- repeat with msg in messages of theMailbox ${fromFilter}
- if msgCount >= ${limit} then exit repeat
- try
- if skipped < ${offset} then
- set skipped to skipped + 1
- else
- set msgId to id of msg as string
- set msgSubject to subject of msg
- set msgSender to sender of msg
- set d to date received of msg
- set msgDate to ${AS_DATE_TO_STRING}
- set msgRead to read status of msg as string
- set msgFlagged to flagged status of msg as string
- set msgHasAtt to "false"
- try
- if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
- end try
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgHasAtt
- set msgCount to msgCount + 1
- end if
- end try
- end repeat
- return outputText
- `;
- }
- else {
- // List from ALL mailboxes — iterate every mailbox in the account, dedup by message ID
- listCommand = `
- set outputText to ""
- set msgCount to 0
- set skipped to 0
- set seenIds to {}
- repeat with mb in mailboxes
- if msgCount >= ${limit} then exit repeat
- try
- repeat with msg in messages of mb ${fromFilter}
- if msgCount >= ${limit} then exit repeat
- try
- set msgId to id of msg as string
- if seenIds does not contain msgId then
- set end of seenIds to msgId
- if skipped < ${offset} then
- set skipped to skipped + 1
- else
- set msgSubject to subject of msg
- set msgSender to sender of msg
- set d to date received of msg
- set msgDate to ${AS_DATE_TO_STRING}
- set msgRead to read status of msg as string
- set msgFlagged to flagged status of msg as string
- set msgHasAtt to "false"
- try
- if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
- end try
- if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
- set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb & "|||" & msgHasAtt
- set msgCount to msgCount + 1
- end if
- end if
- end try
- end repeat
- end try
- end repeat
- return outputText
- `;
- }
- const script = buildAccountScopedScript(targetAccount, listCommand);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success) {
- console.error(`Failed to list messages: ${result.error}`);
- return [];
- }
- if (!result.output.trim())
- return [];
- return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
- }
- /**
- * Parse message list output from AppleScript.
- *
- * Two emission schemas, disambiguated by length:
- * 7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
- * 8 fields: all-mailboxes — ...|mailbox|hasAtt
- *
- * `hasAttachments` here is the fast-path AppleScript count only; it will
- * false-negative for MIME-embedded attachments (a known AppleScript
- * limitation). Use getMessage or list-attachments for authoritative info.
- */
- parseMessageList(output, mailbox, account) {
- const items = output.split("|||ITEM|||");
- const messages = [];
- for (const item of items) {
- const parts = item.split("|||");
- if (parts.length < 6)
- continue;
- let msgMailbox = mailbox;
- let hasAttachments = false;
- if (parts.length >= 8) {
- msgMailbox = parts[6];
- hasAttachments = parts[7] === "true";
- }
- else if (parts.length === 7) {
- hasAttachments = parts[6] === "true";
- }
- messages.push({
- id: parts[0].trim(),
- subject: parts[1],
- sender: parts[2],
- recipients: [],
- dateReceived: parseAppleScriptDate(parts[3]),
- isRead: parts[4] === "true",
- isFlagged: parts[5] === "true",
- isJunk: false,
- isDeleted: false,
- mailbox: msgMailbox,
- account,
- hasAttachments,
- });
- }
- return messages;
- }
- /**
- * Send an email.
- *
- * @param to - Recipient email addresses
- * @param subject - Email subject
- * @param body - Email body (plain text)
- * @param cc - CC recipients
- * @param bcc - BCC recipients
- * @param account - Account to send from
- * @returns true if sent successfully
- */
- // ───────────────────────────────────────────────────────────────────
- // KNOWN BUG: outgoing emails sent via AppleScript on macOS 15+ get wrapped
- // in <blockquote type="cite"> under the Apple-Mail-URLShareWrapperClass
- // template, so they render to recipients as quoted/forwarded content.
- // Plain-text alternative gets `>` prefixes on every line.
- //
- // Reproduces with EVERY AppleScript message-creation pattern I tried:
- // • make new outgoing message with properties {content: ..., ...}
- // • make new outgoing message (no content) + `set content of newMessage`
- // • setting `default message format` to plain format first
- //
- // Apple radar FB11734014 (open since Ventura, no movement).
- // Discussion: https://forums.macrumors.com/threads/applescript-creating-a-
- // new-message-in-mail-app-is-causing-weird-formatting-issues.2385052/
- //
- // Workaround for callers who need clean emails today: use SMTP directly
- // (Python smtplib). See e.g. sweetrb/nhl-bracket-tracker's send_email.py.
- //
- // Proper fix is probably to abandon `make new outgoing message` and either:
- // 1. Build the .emlx file ourselves and drop it into a Drafts mailbox.
- // 2. Switch to smtplib-style direct send with Keychain-stored creds.
- // 3. Use Mail.app's NSSharingService rather than AppleScript.
- // Tracking issue: https://github.com/sweetrb/apple-mail-mcp/issues/12
- // ───────────────────────────────────────────────────────────────────
- sendEmail(to, subject, body, cc, bcc, account, attachments) {
- const safeSubject = escapeForAppleScript(subject);
- const safeBody = escapeForAppleScript(body);
- // Build recipient additions
- let recipientCommands = "";
- for (const addr of to) {
- recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- if (cc) {
- for (const addr of cc) {
- recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- }
- if (bcc) {
- for (const addr of bcc) {
- recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- }
- const attachmentCommands = buildAttachmentCommands(attachments);
- let sendCommand;
- if (account) {
- const safeAccount = escapeForAppleScript(account);
- sendCommand = `
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
- tell newMessage
- ${recipientCommands}
- set sender to "${safeAccount}"
- ${attachmentCommands}
- end tell
- send newMessage
- return "sent"
- `;
- }
- else {
- sendCommand = `
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
- tell newMessage
- ${recipientCommands}
- ${attachmentCommands}
- end tell
- send newMessage
- return "sent"
- `;
- }
- const script = buildAppLevelScript(sendCommand);
- const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
- if (!result.success) {
- console.error(`Failed to send email: ${result.error}`);
- return false;
- }
- return result.output.includes("sent");
- }
- /**
- * Send individual personalized emails to a list of recipients (mail merge).
- *
- * Replaces {{placeholder}} tokens in subject and body with per-recipient values.
- * Each recipient receives their own individual email.
- *
- * @param recipients - List of recipient objects with email and variable values
- * @param subject - Email subject (may contain {{placeholders}})
- * @param body - Email body (may contain {{placeholders}})
- * @param account - Account to send from
- * @param delayMs - Delay between sends in milliseconds (default: 500, max: 10000)
- * @returns Array of per-recipient results
- */
- sendSerialEmail(recipients, subject, body, account, delayMs = 500) {
- const effectiveDelay = Math.min(Math.max(delayMs, 0), 10000);
- const results = [];
- for (let i = 0; i < recipients.length; i++) {
- const recipient = recipients[i];
- try {
- // Replace all {{Key}} placeholders with recipient's values
- let personalizedSubject = subject;
- let personalizedBody = body;
- for (const [key, value] of Object.entries(recipient.variables)) {
- const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
- const placeholder = new RegExp(`\\{\\{${safeKey}\\}\\}`, "g");
- personalizedSubject = personalizedSubject.replace(placeholder, value);
- personalizedBody = personalizedBody.replace(placeholder, value);
- }
- const success = this.sendEmail([recipient.email], personalizedSubject, personalizedBody, undefined, undefined, account);
- results.push({
- email: recipient.email,
- success,
- error: success ? undefined : "Failed to send email",
- });
- }
- catch (error) {
- results.push({
- email: recipient.email,
- success: false,
- error: error instanceof Error ? error.message : "Unknown error",
- });
- }
- // Brief delay between sends to avoid overwhelming Mail.app
- if (effectiveDelay > 0 && i < recipients.length - 1) {
- spawnSync("sleep", [(effectiveDelay / 1000).toString()], { stdio: "ignore" });
- }
- }
- return results;
- }
- /**
- * Create a draft email (saved to Drafts folder, not sent).
- *
- * @param to - Recipient email addresses
- * @param subject - Email subject
- * @param body - Email body (plain text)
- * @param cc - CC recipients
- * @param bcc - BCC recipients
- * @param account - Account to create draft in
- * @returns true if draft created successfully
- */
- createDraft(to, subject, body, cc, bcc, account, attachments) {
- const safeSubject = escapeForAppleScript(subject);
- const safeBody = escapeForAppleScript(body);
- // Build recipient additions
- let recipientCommands = "";
- for (const addr of to) {
- recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- if (cc) {
- for (const addr of cc) {
- recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- }
- if (bcc) {
- for (const addr of bcc) {
- recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- }
- const attachmentCommands = buildAttachmentCommands(attachments);
- let draftCommand;
- if (account) {
- const safeAccount = escapeForAppleScript(account);
- draftCommand = `
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
- tell newMessage
- ${recipientCommands}
- set sender to "${safeAccount}"
- ${attachmentCommands}
- end tell
- return "draft created"
- `;
- }
- else {
- draftCommand = `
- set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
- tell newMessage
- ${recipientCommands}
- ${attachmentCommands}
- end tell
- return "draft created"
- `;
- }
- const script = buildAppLevelScript(draftCommand);
- const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
- if (!result.success) {
- console.error(`Failed to create draft: ${result.error}`);
- return false;
- }
- return result.output.includes("draft created");
- }
- /**
- * Reply to a message.
- *
- * @param id - Message ID to reply to
- * @param body - Reply body
- * @param replyAll - If true, reply to all recipients
- * @param send - If true, send immediately; if false, save as draft
- * @returns true if reply created/sent successfully
- */
- replyToMessage(id, body, replyAll = false, send = true) {
- const safeBody = escapeForAppleScript(body);
- const replyAllClause = replyAll ? " with reply to all" : "";
- const sendAction = send ? "send theReply" : "";
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- set theReply to reply msg without opening window${replyAllClause}
- set content of theReply to "${safeBody}"
- ${sendAction}
- return "ok"
- end if
- end try
- end repeat
- end repeat
- return "error:Message not found"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to reply to message: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Forward a message.
- *
- * @param id - Message ID to forward
- * @param to - Recipients to forward to
- * @param body - Optional body to prepend
- * @param send - If true, send immediately; if false, save as draft
- * @returns true if forward created/sent successfully
- */
- forwardMessage(id, to, body, send = true) {
- const safeBody = body ? escapeForAppleScript(body) : "";
- const sendAction = send ? "send theForward" : "";
- // Build recipient additions
- let recipientCommands = "";
- for (const addr of to) {
- recipientCommands += `make new to recipient at end of to recipients of theForward with properties {address:"${escapeForAppleScript(addr)}"}\n`;
- }
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- set theForward to forward msg without opening window
- ${recipientCommands}
- ${safeBody ? `set content of theForward to "${safeBody}"` : ""}
- ${sendAction}
- return "ok"
- end if
- end try
- end repeat
- end repeat
- return "error:Message not found"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to forward message: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Helper to find and operate on a message by ID.
- */
- findMessageScript(id, operation) {
- return buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- ${operation}
- return "ok"
- end if
- end try
- end repeat
- end repeat
- return "error:Message not found"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- }
- /**
- * Mark a message as read.
- */
- markAsRead(id) {
- const script = this.findMessageScript(id, "set read status of msg to true");
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to mark message as read: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Mark a message as unread.
- */
- markAsUnread(id) {
- const script = this.findMessageScript(id, "set read status of msg to false");
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to mark message as unread: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Flag a message.
- */
- flagMessage(id) {
- const script = this.findMessageScript(id, "set flagged status of msg to true");
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to flag message: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Unflag a message.
- */
- unflagMessage(id) {
- const script = this.findMessageScript(id, "set flagged status of msg to false");
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to unflag message: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Delete a message.
- */
- deleteMessage(id) {
- const script = this.findMessageScript(id, "delete msg");
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to delete message: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- /**
- * Move a message to a different mailbox.
- */
- moveMessage(id, mailbox, account) {
- const targetAccount = this.resolveAccount(account);
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
- const safeMailbox = escapeForAppleScript(targetMailbox);
- const safeAccount = escapeForAppleScript(targetAccount);
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- set destMailbox to mailbox "${safeMailbox}" of account "${safeAccount}"
- move msg to destMailbox
- return "ok"
- end if
- end try
- end repeat
- end repeat
- return "error:Message not found"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to move message: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- // ===========================================================================
- // Batch Operations
- // ===========================================================================
- /**
- * Delete multiple messages at once.
- *
- * @param ids - Array of message IDs to delete
- * @returns Array of results for each message
- */
- batchDeleteMessages(ids) {
- const results = [];
- for (const id of ids) {
- const success = this.deleteMessage(id);
- results.push({
- id,
- success,
- error: success ? undefined : "Failed to delete message",
- });
- }
- return results;
- }
- /**
- * Move multiple messages to a mailbox at once.
- *
- * @param ids - Array of message IDs to move
- * @param mailbox - Destination mailbox name
- * @param account - Account containing the destination mailbox
- * @returns Array of results for each message
- */
- batchMoveMessages(ids, mailbox, account) {
- const results = [];
- for (const id of ids) {
- const success = this.moveMessage(id, mailbox, account);
- results.push({
- id,
- success,
- error: success ? undefined : "Failed to move message",
- });
- }
- return results;
- }
- /**
- * Mark multiple messages as read at once.
- */
- batchMarkAsRead(ids) {
- const results = [];
- for (const id of ids) {
- const success = this.markAsRead(id);
- results.push({ id, success, error: success ? undefined : "Failed to mark message as read" });
- }
- return results;
- }
- /**
- * Mark multiple messages as unread at once.
- */
- batchMarkAsUnread(ids) {
- const results = [];
- for (const id of ids) {
- const success = this.markAsUnread(id);
- results.push({
- id,
- success,
- error: success ? undefined : "Failed to mark message as unread",
- });
- }
- return results;
- }
- /**
- * Flag multiple messages at once.
- */
- batchFlagMessages(ids) {
- const results = [];
- for (const id of ids) {
- const success = this.flagMessage(id);
- results.push({ id, success, error: success ? undefined : "Failed to flag message" });
- }
- return results;
- }
- /**
- * Unflag multiple messages at once.
- */
- batchUnflagMessages(ids) {
- const results = [];
- for (const id of ids) {
- const success = this.unflagMessage(id);
- results.push({ id, success, error: success ? undefined : "Failed to unflag message" });
- }
- return results;
- }
- /**
- * List attachments for a message.
- * Tries AppleScript first, falls back to MIME source parsing
- * when AppleScript returns empty (known issue across all account types).
- */
- listAttachments(id) {
- // Attempt 1: AppleScript mail attachments
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- set outputText to ""
- set attCount to 0
- repeat with att in mail attachments of msg
- set attName to name of att
- set attType to MIME type of att
- set attSize to file size of att as string
- if attCount > 0 then set outputText to outputText & "|||ITEM|||"
- set outputText to outputText & attName & "|||" & attType & "|||" & attSize
- set attCount to attCount + 1
- end repeat
- return outputText
- end if
- end try
- end repeat
- end repeat
- return ""
- on error errMsg
- return ""
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (result.success && result.output.trim()) {
- const items = result.output.split("|||ITEM|||");
- const attachments = [];
- for (const item of items) {
- const parts = item.split("|||");
- if (parts.length < 3)
- continue;
- attachments.push({
- id: `${id}-${parts[0]}`,
- name: parts[0],
- mimeType: parts[1],
- size: parseInt(parts[2]) || 0,
- });
- }
- if (attachments.length > 0)
- return attachments;
- }
- // Attempt 2: MIME source fallback
- const rawSource = this.getRawSource(id);
- if (!rawSource)
- return [];
- const mimeAttachments = parseMimeAttachments(rawSource);
- return mimeAttachments.map((att) => ({
- id: `${id}-${att.name}`,
- name: att.name,
- mimeType: att.mimeType,
- size: att.size,
- }));
- }
- /**
- * Save an attachment from a message to disk.
- * Tries AppleScript first, falls back to MIME source extraction
- * when AppleScript can't find the attachment.
- */
- saveAttachment(id, attachmentName, savePath) {
- // Validate attachment name: block path separators, traversal, null bytes, and backslashes
- if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
- console.error(`Invalid attachment name: "${attachmentName}"`);
- return false;
- }
- // Resolve the save path to prevent symlink / ".." traversal bypass
- const resolvedPath = resolve(savePath);
- const allowedPrefixes = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
- const isAllowed = allowedPrefixes.some((prefix) => resolvedPath.startsWith(prefix));
- if (!isAllowed) {
- console.error(`Save path "${savePath}" is outside allowed directories`);
- return false;
- }
- const safeName = escapeForAppleScript(attachmentName);
- const safePath = escapeForAppleScript(resolvedPath);
- const numericId = Number(id);
- // Attempt 1: AppleScript save
- const script = buildAppLevelScript(`
- try
- repeat with acct in accounts
- repeat with mb in mailboxes of acct
- try
- set matchingMsgs to (messages of mb whose id is ${numericId})
- if (count of matchingMsgs) > 0 then
- set msg to item 1 of matchingMsgs
- repeat with att in mail attachments of msg
- if name of att is "${safeName}" then
- set savePath to POSIX file "${safePath}/${safeName}"
- save att in savePath
- return "ok"
- end if
- end repeat
- return "error:Attachment not found"
- end if
- end try
- end repeat
- end repeat
- return "error:Message not found"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (result.success && result.output === "ok") {
- return true;
- }
- // Attempt 2: MIME source fallback
- const rawSource = this.getRawSource(id);
- if (!rawSource) {
- console.error(`Failed to save attachment: could not retrieve message source`);
- return false;
- }
- const attachment = extractMimeAttachment(rawSource, attachmentName);
- if (!attachment) {
- console.error(`Failed to save attachment: "${attachmentName}" not found in MIME source`);
- return false;
- }
- try {
- const outPath = resolve(resolvedPath, attachmentName);
- // Verify the resolved output path is still within allowed directories
- const isOutAllowed = allowedPrefixes.some((prefix) => outPath.startsWith(prefix));
- if (!isOutAllowed) {
- console.error(`Output path "${outPath}" is outside allowed directories`);
- return false;
- }
- writeFileSync(outPath, attachment.data);
- return true;
- }
- catch (err) {
- console.error(`Failed to write attachment to disk: ${err}`);
- return false;
- }
- }
- // ===========================================================================
- // Mailbox Operations
- // ===========================================================================
- /**
- * List all mailboxes for an account.
- */
- listMailboxes(account) {
- const targetAccount = this.resolveAccount(account);
- const listCommand = `
- set mailboxList to {}
- repeat with mb in mailboxes
- set mbName to name of mb
- set mbUnread to unread count of mb
- set mbCount to count of messages of mb
- set end of mailboxList to mbName & "|||" & mbUnread & "|||" & mbCount
- end repeat
- set AppleScript's text item delimiters to "|||ITEM|||"
- return mailboxList as text
- `;
- const script = buildAccountScopedScript(targetAccount, listCommand);
- const result = executeAppleScript(script);
- if (!result.success) {
- console.error(`Failed to list mailboxes: ${result.error}`);
- return [];
- }
- if (!result.output.trim())
- return [];
- const items = result.output.split("|||ITEM|||");
- const mailboxes = [];
- for (const item of items) {
- const parts = item.split("|||");
- if (parts.length < 3)
- continue;
- mailboxes.push({
- name: parts[0],
- account: targetAccount,
- unreadCount: parseInt(parts[1]) || 0,
- messageCount: parseInt(parts[2]) || 0,
- });
- }
- return mailboxes;
- }
- /**
- * Get unread count for a mailbox.
- */
- getUnreadCount(mailbox, account) {
- const targetAccount = this.resolveAccount(account);
- let command;
- if (mailbox) {
- const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
- const safeMailbox = escapeForAppleScript(targetMailbox);
- command = `return unread count of mailbox "${safeMailbox}"`;
- }
- else {
- // Get total unread across all mailboxes
- command = `
- set total to 0
- repeat with mb in mailboxes
- set total to total + (unread count of mb)
- end repeat
- return total
- `;
- }
- const script = buildAccountScopedScript(targetAccount, command);
- const result = executeAppleScript(script);
- if (!result.success) {
- console.error(`Failed to get unread count: ${result.error}`);
- return 0;
- }
- return parseInt(result.output) || 0;
- }
- /**
- * Create a new mailbox.
- */
- createMailbox(name, account) {
- const targetAccount = this.resolveAccount(account);
- const safeName = escapeForAppleScript(name);
- const safeAccount = escapeForAppleScript(targetAccount);
- const script = buildAppLevelScript(`
- try
- make new mailbox with properties {name:"${safeName}"} at account "${safeAccount}"
- return "ok"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script);
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to create mailbox: ${result.error || result.output}`);
- return false;
- }
- this.invalidateCache();
- return true;
- }
- /**
- * Delete a mailbox.
- */
- deleteMailbox(name, account) {
- const targetAccount = this.resolveAccount(account);
- const targetMailbox = this.resolveMailbox(name, targetAccount);
- const safeName = escapeForAppleScript(targetMailbox);
- const safeAccount = escapeForAppleScript(targetAccount);
- const script = buildAppLevelScript(`
- try
- delete mailbox "${safeName}" of account "${safeAccount}"
- return "ok"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script);
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to delete mailbox: ${result.error || result.output}`);
- return false;
- }
- this.invalidateCache();
- return true;
- }
- /**
- * Rename a mailbox by creating a new one, moving messages, and deleting the old one.
- */
- renameMailbox(oldName, newName, account) {
- const targetAccount = this.resolveAccount(account);
- // Create the new mailbox
- if (!this.createMailbox(newName, targetAccount)) {
- return false;
- }
- // Move all messages from old to new
- const resolvedOld = this.resolveMailbox(oldName, targetAccount);
- const resolvedNew = this.resolveMailbox(newName, targetAccount);
- const safeOld = escapeForAppleScript(resolvedOld);
- const safeNew = escapeForAppleScript(resolvedNew);
- const safeAccount = escapeForAppleScript(targetAccount);
- const moveScript = buildAppLevelScript(`
- try
- set srcMailbox to mailbox "${safeOld}" of account "${safeAccount}"
- set destMailbox to mailbox "${safeNew}" of account "${safeAccount}"
- repeat with msg in messages of srcMailbox
- move msg to destMailbox
- end repeat
- delete mailbox "${safeOld}" of account "${safeAccount}"
- return "ok"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(moveScript, { timeoutMs: 60000 });
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to rename mailbox: ${result.error || result.output}`);
- return false;
- }
- this.invalidateCache();
- return true;
- }
- // ===========================================================================
- // Account Operations
- // ===========================================================================
- /**
- * List all mail accounts (uses cache).
- */
- listAccounts() {
- return this.getCachedAccounts();
- }
- /**
- * Fetches account list directly from Mail.app via AppleScript.
- * Used internally by the cache; prefer getCachedAccounts() or listAccounts().
- */
- fetchAccounts() {
- const script = buildAppLevelScript(`
- set accountList to {}
- repeat with acct in accounts
- set acctName to name of acct
- set acctEmail to email addresses of acct
- set acctEnabled to enabled of acct
- set emailStr to ""
- if (count of acctEmail) > 0 then
- set emailStr to item 1 of acctEmail
- end if
- set end of accountList to acctName & "|||" & emailStr & "|||" & acctEnabled
- end repeat
- set AppleScript's text item delimiters to "|||ITEM|||"
- return accountList as text
- `);
- const result = executeAppleScript(script);
- if (!result.success) {
- console.error(`Failed to list accounts: ${result.error}`);
- return [];
- }
- if (!result.output.trim())
- return [];
- const items = result.output.split("|||ITEM|||");
- const accounts = [];
- for (const item of items) {
- const parts = item.split("|||");
- if (parts.length < 3)
- continue;
- accounts.push({
- name: parts[0],
- email: parts[1],
- enabled: parts[2] === "true",
- });
- }
- return accounts;
- }
- /**
- * Fetches mailbox names for an account directly from Mail.app.
- * Used internally by the cache; prefer getCachedMailboxNames().
- */
- fetchMailboxNames(account) {
- const script = buildAccountScopedScript(account, `
- set mbNames to {}
- repeat with mb in mailboxes
- set end of mbNames to name of mb
- end repeat
- return mbNames
- `);
- const result = executeAppleScript(script);
- if (!result.success || !result.output) {
- return [];
- }
- return result.output.split(", ").map((s) => s.trim());
- }
- // ===========================================================================
- // Mail Rules
- // ===========================================================================
- /**
- * List all mail rules.
- */
- listRules() {
- const script = buildAppLevelScript(`
- set ruleList to {}
- repeat with r in rules
- set ruleName to name of r
- set ruleEnabled to enabled of r
- set end of ruleList to ruleName & "|||" & (ruleEnabled as string)
- end repeat
- set AppleScript's text item delimiters to "|||ITEM|||"
- return ruleList as text
- `);
- const result = executeAppleScript(script);
- if (!result.success || !result.output.trim()) {
- return [];
- }
- const items = result.output.split("|||ITEM|||");
- const rules = [];
- for (const item of items) {
- const parts = item.split("|||");
- if (parts.length < 2)
- continue;
- rules.push({
- name: parts[0],
- enabled: parts[1] === "true",
- });
- }
- return rules;
- }
- /**
- * Enable or disable a mail rule.
- */
- setRuleEnabled(ruleName, enabled) {
- const safeName = escapeForAppleScript(ruleName);
- const script = buildAppLevelScript(`
- try
- repeat with r in rules
- if name of r is "${safeName}" then
- set enabled of r to ${enabled}
- return "ok"
- end if
- end repeat
- return "error:Rule not found"
- on error errMsg
- return "error:" & errMsg
- end try
- `);
- const result = executeAppleScript(script);
- if (!result.success || result.output.startsWith("error:")) {
- console.error(`Failed to set rule state: ${result.error || result.output}`);
- return false;
- }
- return true;
- }
- // ===========================================================================
- // Contacts Integration
- // ===========================================================================
- /**
- * Search contacts by name or email.
- */
- searchContacts(query) {
- const safeQuery = escapeForAppleScript(query);
- const script = `
- tell application "Contacts"
- set matchedContacts to {}
- set foundPeople to (every person whose name contains "${safeQuery}") & (every person whose value of emails contains "${safeQuery}")
- -- Deduplicate by tracking IDs
- set seenIds to {}
- repeat with p in foundPeople
- set pid to id of p
- if seenIds does not contain pid then
- set end of seenIds to pid
- set pName to name of p
- set pEmails to ""
- repeat with e in emails of p
- if pEmails is not "" then set pEmails to pEmails & ","
- set pEmails to pEmails & (value of e)
- end repeat
- set pPhones to ""
- repeat with ph in phones of p
- if pPhones is not "" then set pPhones to pPhones & ","
- set pPhones to pPhones & (value of ph)
- end repeat
- set end of matchedContacts to pName & "|||" & pEmails & "|||" & pPhones
- end if
- end repeat
- set AppleScript's text item delimiters to "|||ITEM|||"
- return matchedContacts as text
- end tell
- `;
- const result = executeAppleScript(script);
- if (!result.success || !result.output.trim()) {
- return [];
- }
- const items = result.output.split("|||ITEM|||");
- const contacts = [];
- for (const item of items) {
- const parts = item.split("|||");
- if (parts.length < 3)
- continue;
- contacts.push({
- name: parts[0],
- emails: parts[1] ? parts[1].split(",").filter(Boolean) : [],
- phones: parts[2] ? parts[2].split(",").filter(Boolean) : [],
- });
- }
- return contacts;
- }
- // ===========================================================================
- // Email Templates
- // ===========================================================================
- templates = new Map();
- nextTemplateId = 1;
- /**
- * List all stored templates.
- */
- listTemplates() {
- return Array.from(this.templates.values());
- }
- /**
- * Get a template by ID.
- */
- getTemplate(id) {
- return this.templates.get(id) || null;
- }
- /**
- * Create or update a template.
- */
- saveTemplate(name, subject, body, to, cc, id) {
- const templateId = id || `tmpl_${this.nextTemplateId++}`;
- const template = { id: templateId, name, subject, body, to, cc };
- this.templates.set(templateId, template);
- return template;
- }
- /**
- * Delete a template.
- */
- deleteTemplate(id) {
- return this.templates.delete(id);
- }
- /**
- * Use a template to create a draft.
- */
- useTemplate(id, overrides) {
- const template = this.templates.get(id);
- if (!template)
- return false;
- const to = overrides?.to || template.to || [];
- const cc = overrides?.cc || template.cc;
- const subject = overrides?.subject || template.subject;
- const body = overrides?.body || template.body;
- if (to.length === 0)
- return false;
- return this.createDraft(to, subject, body, cc);
- }
- // ===========================================================================
- // Diagnostics
- // ===========================================================================
- /**
- * Run health check on Mail.app connectivity.
- */
- healthCheck() {
- const checks = [];
- // Check 1: Mail.app is accessible
- const mailCheck = executeAppleScript('tell application "Mail" to return "ok"');
- if (mailCheck.success && mailCheck.output === "ok") {
- checks.push({
- name: "mail_app",
- passed: true,
- message: "Mail.app is accessible",
- });
- }
- else {
- const errorHint = mailCheck.error?.includes("not authorized")
- ? " (check Automation permissions in System Preferences)"
- : "";
- checks.push({
- name: "mail_app",
- passed: false,
- message: `Mail.app is not accessible${errorHint}`,
- });
- return { healthy: false, checks };
- }
- // Check 2: AppleScript permissions
- const permCheck = executeAppleScript('tell application "Mail" to get name of account 1');
- if (permCheck.success) {
- checks.push({
- name: "permissions",
- passed: true,
- message: "AppleScript automation permissions granted",
- });
- }
- else {
- const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
- checks.push({
- name: "permissions",
- passed: !isPermError,
- message: isPermError
- ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
- : `Permission check returned: ${permCheck.error}`,
- });
- if (isPermError) {
- return { healthy: false, checks };
- }
- }
- // Check 3: At least one account accessible
- const accounts = this.listAccounts();
- if (accounts.length > 0) {
- const accountNames = accounts.map((a) => a.name).join(", ");
- checks.push({
- name: "accounts",
- passed: true,
- message: `Found ${accounts.length} account(s): ${accountNames}`,
- });
- }
- else {
- checks.push({
- name: "accounts",
- passed: false,
- message: "No Mail accounts found. Set up an account in Mail.app first.",
- });
- return { healthy: false, checks };
- }
- // Check 4: Basic operations work
- const mailboxes = this.listMailboxes(accounts[0].name);
- checks.push({
- name: "operations",
- passed: true,
- message: `Basic operations working (${mailboxes.length} mailbox(es) in ${accounts[0].name})`,
- });
- return {
- healthy: checks.every((c) => c.passed),
- checks,
- };
- }
- /**
- * Get mail statistics.
- */
- getMailStats() {
- const accounts = this.listAccounts();
- const accountStats = [];
- let totalMessages = 0;
- let totalUnread = 0;
- for (const account of accounts) {
- const mailboxes = this.listMailboxes(account.name);
- let accountMessages = 0;
- let accountUnread = 0;
- const mailboxStats = mailboxes.map((mb) => {
- accountMessages += mb.messageCount;
- accountUnread += mb.unreadCount;
- return {
- name: mb.name,
- messageCount: mb.messageCount,
- unreadCount: mb.unreadCount,
- };
- });
- totalMessages += accountMessages;
- totalUnread += accountUnread;
- accountStats.push({
- name: account.name,
- totalMessages: accountMessages,
- unreadMessages: accountUnread,
- mailboxCount: mailboxes.length,
- mailboxes: mailboxStats,
- });
- }
- // Get recently received stats
- const recentlyReceived = this.getRecentlyReceivedStats();
- return {
- totalMessages,
- totalUnread,
- accounts: accountStats,
- recentlyReceived,
- };
- }
- /**
- * Get counts of recently received messages.
- *
- * Only counts messages in INBOX for performance (scanning all mailboxes
- * is too slow for large accounts).
- *
- * @returns Counts of messages received in last 24h, 7d, and 30d
- */
- getRecentlyReceivedStats() {
- // Get message counts for different time periods
- const now = new Date();
- const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
- const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
- const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
- // Format dates for AppleScript comparison
- const formatDate = (d) => {
- const months = [
- "January",
- "February",
- "March",
- "April",
- "May",
- "June",
- "July",
- "August",
- "September",
- "October",
- "November",
- "December",
- ];
- return `date "${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}"`;
- };
- // Only scan INBOX for performance - scanning all mailboxes is too slow
- const script = buildAppLevelScript(`
- set last24h to 0
- set last7d to 0
- set last30d to 0
- set oneDayAgo to ${formatDate(oneDayAgo)}
- set sevenDaysAgo to ${formatDate(sevenDaysAgo)}
- set thirtyDaysAgo to ${formatDate(thirtyDaysAgo)}
- repeat with acct in accounts
- try
- -- Try common inbox names
- set inboxNames to {"INBOX", "Inbox", "inbox"}
- repeat with inboxName in inboxNames
- try
- set theInbox to mailbox inboxName of acct
- set last24h to last24h + (count of (messages of theInbox whose date received >= oneDayAgo))
- set last7d to last7d + (count of (messages of theInbox whose date received >= sevenDaysAgo))
- set last30d to last30d + (count of (messages of theInbox whose date received >= thirtyDaysAgo))
- exit repeat
- end try
- end repeat
- end try
- end repeat
- return (last24h as string) & "|||" & (last7d as string) & "|||" & (last30d as string)
- `);
- const result = executeAppleScript(script, { timeoutMs: 60000 });
- if (!result.success || !result.output.trim()) {
- console.error(`Failed to get recently received stats: ${result.error}`);
- return { last24h: 0, last7d: 0, last30d: 0 };
- }
- const parts = result.output.split("|||");
- if (parts.length < 3) {
- return { last24h: 0, last7d: 0, last30d: 0 };
- }
- return {
- last24h: parseInt(parts[0]) || 0,
- last7d: parseInt(parts[1]) || 0,
- last30d: parseInt(parts[2]) || 0,
- };
- }
- /**
- * Get sync status for Mail.app.
- *
- * Checks for sync activity indicators like:
- * - Activity monitor status
- * - Network activity status
- * - Background refresh indicators
- *
- * @returns Sync status information
- */
- getSyncStatus() {
- // Check for Mail.app background activity and sync status
- // Mail.app doesn't expose sync status directly through AppleScript,
- // so we check for recent changes and activity indicators
- const script = buildAppLevelScript(`
- set syncInfo to ""
- -- Check if Mail.app is running
- tell application "System Events"
- set mailRunning to (name of processes) contains "Mail"
- end tell
- if not mailRunning then
- return "not_running"
- end if
- -- Check for background activity by looking at message counts changing
- -- This is a proxy for sync activity since Mail doesn't expose sync status
- set accountCount to count of accounts
- set totalMailboxes to 0
- repeat with acct in accounts
- set totalMailboxes to totalMailboxes + (count of mailboxes of acct)
- end repeat
- return "running|||" & accountCount & "|||" & totalMailboxes
- `);
- const result = executeAppleScript(script);
- if (!result.success) {
- return {
- syncDetected: false,
- pendingUpload: 0,
- recentActivity: false,
- secondsSinceLastChange: -1,
- error: result.error,
- };
- }
- if (result.output === "not_running") {
- return {
- syncDetected: false,
- pendingUpload: 0,
- recentActivity: false,
- secondsSinceLastChange: -1,
- error: "Mail.app is not running",
- };
- }
- // Parse the response
- const parts = result.output.split("|||");
- const isRunning = parts[0] === "running";
- const accountCount = parseInt(parts[1]) || 0;
- // Mail.app is running with accounts configured - assume sync is active
- // (Mail.app syncs automatically when running)
- return {
- syncDetected: isRunning && accountCount > 0,
- pendingUpload: 0, // Not exposed by Mail.app
- recentActivity: isRunning,
- secondsSinceLastChange: 0,
- };
- }
- }
|