| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994 |
- /**
- * 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"],
- };
- // =============================================================================
- // Apple Mail Manager Class
- // =============================================================================
- /**
- * Manager class for Apple Mail operations.
- *
- * Provides methods for:
- * - Reading and searching messages
- * - Sending emails
- * - Managing mailboxes
- * - Listing accounts
- *
- * All operations are synchronous since they rely on AppleScript
- * execution via osascript. Error handling is consistent: methods
- * return null/false/empty-array on failure rather than throwing.
- */
- 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) {
- // 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);
- allMessages.push(...msgs);
- }
- return allMessages.slice(0, limit);
- }
- const targetAccount = this.resolveAccount(account);
- // Build the search condition
- let searchCondition = "";
- if (query) {
- const safeQuery = escapeForAppleScript(query);
- searchCondition = `whose subject contains "${safeQuery}" or sender contains "${safeQuery}"`;
- }
- // 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,
- };
- }
- }
|