appleMailManager.js 77 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010
  1. /**
  2. * Apple Mail Manager
  3. *
  4. * Handles all interactions with Apple Mail via AppleScript.
  5. * This is the core service layer for the MCP server.
  6. *
  7. * Architecture:
  8. * - Text escaping is handled by dedicated helper functions
  9. * - AppleScript generation uses template builders for consistency
  10. * - All public methods return typed results (no raw strings)
  11. * - Error handling is consistent across all operations
  12. *
  13. * @module services/appleMailManager
  14. */
  15. import { spawnSync } from "child_process";
  16. import { existsSync, writeFileSync } from "fs";
  17. import { isAbsolute, resolve } from "path";
  18. import { homedir } from "os";
  19. import { executeAppleScript } from "../utils/applescript.js";
  20. import { parseMimeAttachments, extractMimeAttachment } from "../utils/mimeParse.js";
  21. // =============================================================================
  22. // Text Processing Utilities
  23. // =============================================================================
  24. /**
  25. * Escapes text for safe embedding in AppleScript string literals.
  26. *
  27. * AppleScript strings use double quotes, so we need to escape:
  28. * 1. Backslashes (\) - escaped as \\
  29. * 2. Double quotes (") - escaped as \"
  30. *
  31. * @param text - Raw text to escape
  32. * @returns Text safe for AppleScript string embedding
  33. */
  34. function escapeForAppleScript(text) {
  35. if (!text)
  36. return "";
  37. return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
  38. }
  39. /**
  40. * Validates attachment file paths and builds AppleScript commands to attach them.
  41. *
  42. * @param attachments - Absolute file paths to attach
  43. * @returns AppleScript commands to add attachments, or empty string if none
  44. * @throws Error if any path is not absolute or does not exist
  45. */
  46. function buildAttachmentCommands(attachments) {
  47. if (!attachments || attachments.length === 0)
  48. return "";
  49. for (const filePath of attachments) {
  50. if (!isAbsolute(filePath)) {
  51. throw new Error(`Attachment path must be absolute: "${filePath}"`);
  52. }
  53. if (!existsSync(filePath)) {
  54. throw new Error(`Attachment file not found: "${filePath}"`);
  55. }
  56. }
  57. let commands = "";
  58. for (const filePath of attachments) {
  59. const safePath = escapeForAppleScript(filePath);
  60. commands += `make new attachment with properties {file name:POSIX file "${safePath}"} at after the last paragraph\n`;
  61. }
  62. return commands;
  63. }
  64. /**
  65. * AppleScript snippet that converts a date variable `d` into a
  66. * locale-independent numeric string: "YYYY-M-D-H-m-s".
  67. * Use: set d to date received of msg, then inline this snippet.
  68. */
  69. 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)`;
  70. /**
  71. * Parses a locale-independent date string "YYYY-M-D-H-m-s"
  72. * produced by the AppleScript snippet above.
  73. *
  74. * Falls back to the locale-dependent `as string` format for
  75. * backwards compatibility, and finally to current date.
  76. *
  77. * @param dateStr - Date string from AppleScript
  78. * @returns Parsed Date, or current date if parsing fails
  79. */
  80. function parseAppleScriptDate(dateStr) {
  81. // Try locale-independent numeric format first: "YYYY-M-D-H-m-s"
  82. const numParts = dateStr.split("-").map(Number);
  83. if (numParts.length === 6 && numParts.every((n) => !isNaN(n))) {
  84. return new Date(numParts[0], numParts[1] - 1, numParts[2], numParts[3], numParts[4], numParts[5]);
  85. }
  86. // Fallback: try legacy locale-dependent format
  87. const withoutPrefix = dateStr.replace(/^date\s+/, "");
  88. const normalized = withoutPrefix.replace(" at ", " ");
  89. const parsed = new Date(normalized);
  90. return isNaN(parsed.getTime()) ? new Date() : parsed;
  91. }
  92. /**
  93. * Builds an AppleScript command scoped to a specific account.
  94. */
  95. function buildAccountScopedScript(account, command) {
  96. return `
  97. tell application "Mail"
  98. tell account "${escapeForAppleScript(account)}"
  99. ${command}
  100. end tell
  101. end tell
  102. `;
  103. }
  104. /**
  105. * Builds an AppleScript command at the application level.
  106. */
  107. function buildAppLevelScript(command) {
  108. return `
  109. tell application "Mail"
  110. ${command}
  111. end tell
  112. `;
  113. }
  114. /**
  115. * Common mailbox name variations across different account types.
  116. * Maps normalized (lowercase) names to possible actual names.
  117. */
  118. const MAILBOX_ALIASES = {
  119. inbox: ["INBOX", "Inbox", "inbox"],
  120. sent: ["Sent", "Sent Items", "Sent Messages", "SENT", "sent"],
  121. drafts: ["Drafts", "DRAFTS", "drafts", "Draft"],
  122. trash: ["Trash", "Deleted Items", "Deleted Messages", "TRASH", "trash"],
  123. junk: ["Junk", "Junk Email", "Spam", "JUNK", "junk"],
  124. archive: ["Archive", "ARCHIVE", "archive", "All Mail"],
  125. };
  126. /**
  127. * Build the AppleScript `whose` clause for searchMessages from a filter set.
  128. *
  129. * - `query` is a subject-OR-sender substring match, parenthesized so it groups
  130. * correctly when ANDed with other filters.
  131. * - `from` and `subject` are substring matches (`sender`/`subject` contains).
  132. * - `isRead` / `isFlagged` are boolean status checks.
  133. * - Returns "" when no filters are set. Every interpolated value is escaped.
  134. *
  135. * Exported for unit testing: the bug this addresses (filters declared in the
  136. * tool schema but silently dropped) lived in this logic, so it gets direct
  137. * coverage independent of Mail.app.
  138. */
  139. export function buildSearchCondition(filters) {
  140. const { query, from, subject, isRead, isFlagged } = filters;
  141. const conditions = [];
  142. if (query) {
  143. const safeQuery = escapeForAppleScript(query);
  144. conditions.push(`(subject contains "${safeQuery}" or sender contains "${safeQuery}")`);
  145. }
  146. if (from) {
  147. conditions.push(`sender contains "${escapeForAppleScript(from)}"`);
  148. }
  149. if (subject) {
  150. conditions.push(`subject contains "${escapeForAppleScript(subject)}"`);
  151. }
  152. if (typeof isRead === "boolean") {
  153. conditions.push(`read status is ${isRead ? "true" : "false"}`);
  154. }
  155. if (typeof isFlagged === "boolean") {
  156. conditions.push(`flagged status is ${isFlagged ? "true" : "false"}`);
  157. }
  158. return conditions.length > 0 ? `whose ${conditions.join(" and ")}` : "";
  159. }
  160. export class AppleMailManager {
  161. /**
  162. * Default account used when no account is specified.
  163. */
  164. defaultAccount = null;
  165. /**
  166. * TTL cache for expensive AppleScript queries that rarely change.
  167. * Caches account list and per-account mailbox names to avoid
  168. * redundant AppleScript roundtrips on every tool call.
  169. */
  170. cache = {
  171. accounts: null,
  172. mailboxNames: new Map(),
  173. };
  174. /** Cache TTL in milliseconds (60 seconds). */
  175. CACHE_TTL_MS = 60_000;
  176. /**
  177. * Returns cached accounts or fetches fresh data if cache is expired/empty.
  178. */
  179. getCachedAccounts() {
  180. const now = Date.now();
  181. if (this.cache.accounts && now < this.cache.accounts.expiry) {
  182. return this.cache.accounts.data;
  183. }
  184. const accounts = this.fetchAccounts();
  185. this.cache.accounts = { data: accounts, expiry: now + this.CACHE_TTL_MS };
  186. return accounts;
  187. }
  188. /**
  189. * Returns cached mailbox names for an account, or fetches fresh.
  190. * This caches only the name list used by resolveMailbox(), not the
  191. * full Mailbox objects with counts (which change frequently).
  192. */
  193. getCachedMailboxNames(account) {
  194. const now = Date.now();
  195. const cached = this.cache.mailboxNames.get(account);
  196. if (cached && now < cached.expiry) {
  197. return cached.data;
  198. }
  199. const names = this.fetchMailboxNames(account);
  200. this.cache.mailboxNames.set(account, { data: names, expiry: now + this.CACHE_TTL_MS });
  201. return names;
  202. }
  203. /**
  204. * Invalidate all caches. Call after operations that change
  205. * mailbox structure (create/delete/rename mailbox).
  206. */
  207. invalidateCache() {
  208. this.cache.accounts = null;
  209. this.cache.mailboxNames.clear();
  210. }
  211. /**
  212. * Resolves the account to use for an operation.
  213. * Queries Mail.app's configured default send account, then falls back
  214. * to the first available account.
  215. */
  216. resolveAccount(account) {
  217. if (account)
  218. return account;
  219. if (this.defaultAccount)
  220. return this.defaultAccount;
  221. // Query Mail.app's default send account by inspecting a temporary outgoing message
  222. const defaultResult = executeAppleScript(buildAppLevelScript(`
  223. set newMsg to make new outgoing message
  224. set fromAddr to sender of newMsg
  225. delete newMsg
  226. return fromAddr
  227. `));
  228. if (defaultResult.success && defaultResult.output.trim()) {
  229. // sender returns "Name <email>" — match to account by email address
  230. const senderOutput = defaultResult.output.trim();
  231. const emailMatch = senderOutput.match(/<([^>]+)>/);
  232. const defaultEmail = emailMatch ? emailMatch[1] : senderOutput;
  233. const accounts = this.getCachedAccounts();
  234. const matchedAccount = accounts.find((a) => a.email.toLowerCase() === defaultEmail.toLowerCase());
  235. if (matchedAccount) {
  236. this.defaultAccount = matchedAccount.name;
  237. return this.defaultAccount;
  238. }
  239. }
  240. // Fall back to first available account
  241. const accounts = this.getCachedAccounts();
  242. if (accounts.length > 0) {
  243. this.defaultAccount = accounts[0].name;
  244. return this.defaultAccount;
  245. }
  246. return "iCloud"; // Last resort fallback
  247. }
  248. /**
  249. * Resolves a mailbox name to its actual name in the account.
  250. *
  251. * Different account types (IMAP, Exchange, iCloud) use different
  252. * mailbox naming conventions:
  253. * - IMAP/Gmail: "INBOX", "Sent", "Drafts"
  254. * - Exchange: "Inbox", "Sent Items", "Deleted Items"
  255. * - iCloud: "INBOX", "Sent", "Trash"
  256. *
  257. * This method tries to find a matching mailbox by:
  258. * 1. Exact match
  259. * 2. Case-insensitive match
  260. * 3. Known aliases (e.g., "Sent" -> "Sent Items")
  261. *
  262. * @param mailbox - Requested mailbox name
  263. * @param account - Account to search in
  264. * @returns Actual mailbox name, or original if not found
  265. */
  266. resolveMailbox(mailbox, account) {
  267. const actualMailboxes = this.getCachedMailboxNames(account);
  268. if (actualMailboxes.length === 0) {
  269. return mailbox; // Fall back to original
  270. }
  271. // 1. Try exact match
  272. if (actualMailboxes.includes(mailbox)) {
  273. return mailbox;
  274. }
  275. // 2. Try case-insensitive match
  276. const lowerMailbox = mailbox.toLowerCase();
  277. const caseMatch = actualMailboxes.find((mb) => mb.toLowerCase() === lowerMailbox);
  278. if (caseMatch) {
  279. return caseMatch;
  280. }
  281. // 3. Try known aliases
  282. const aliases = MAILBOX_ALIASES[lowerMailbox];
  283. if (aliases) {
  284. for (const alias of aliases) {
  285. if (actualMailboxes.includes(alias)) {
  286. return alias;
  287. }
  288. // Also try case-insensitive alias match
  289. const aliasMatch = actualMailboxes.find((mb) => mb.toLowerCase() === alias.toLowerCase());
  290. if (aliasMatch) {
  291. return aliasMatch;
  292. }
  293. }
  294. }
  295. // No match found, return original and let AppleScript handle the error
  296. return mailbox;
  297. }
  298. // ===========================================================================
  299. // Message Operations
  300. // ===========================================================================
  301. /**
  302. * Search for messages matching criteria.
  303. *
  304. * @param query - Text to search for in subject or sender
  305. * @param mailbox - Mailbox to search in (e.g., "INBOX")
  306. * @param account - Account to search in
  307. * @param limit - Maximum number of results
  308. * @returns Array of matching messages
  309. */
  310. searchMessages(query, mailbox, account, limit = 50, dateFrom, dateTo, from, subject, isRead, isFlagged) {
  311. // If no account specified, search across all accounts
  312. if (!account) {
  313. const accounts = this.listAccounts();
  314. const allMessages = [];
  315. for (const acct of accounts) {
  316. if (allMessages.length >= limit)
  317. break;
  318. const remaining = limit - allMessages.length;
  319. const msgs = this.searchMessages(query, mailbox, acct.name, remaining, dateFrom, dateTo, from, subject, isRead, isFlagged);
  320. allMessages.push(...msgs);
  321. }
  322. return allMessages.slice(0, limit);
  323. }
  324. const targetAccount = this.resolveAccount(account);
  325. // `query` is a subject-OR-sender substring match; from/subject/isRead/isFlagged
  326. // are additional AND filters. Date filtering stays post-fetch below — `whose`
  327. // date comparisons are unreliable in Mail.app AppleScript. See buildSearchCondition.
  328. const searchCondition = buildSearchCondition({ query, from, subject, isRead, isFlagged });
  329. // Build date filter AppleScript.
  330. // Note: dateFrom/dateTo are already validated by DATE_FILTER_SCHEMA (alphanumeric + safe
  331. // punctuation only), so escapeForAppleScript() below is belt-and-suspenders — it won't
  332. // alter valid date strings but guards against future schema changes.
  333. let dateFilter = "";
  334. if (dateFrom || dateTo) {
  335. const dateChecks = [];
  336. if (dateFrom) {
  337. dateChecks.push(`date received of msg >= date "${escapeForAppleScript(dateFrom)}"`);
  338. }
  339. if (dateTo) {
  340. dateChecks.push(`date received of msg <= date "${escapeForAppleScript(dateTo)}"`);
  341. }
  342. dateFilter = dateChecks.join(" and ");
  343. }
  344. let searchCommand;
  345. if (mailbox) {
  346. // Search a specific mailbox
  347. const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
  348. searchCommand = `
  349. set outputText to ""
  350. set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
  351. set allMessages to messages of theMailbox ${searchCondition}
  352. set msgCount to 0
  353. repeat with msg in allMessages
  354. if msgCount >= ${limit} then exit repeat
  355. try
  356. ${dateFilter ? `set msgDate to date received of msg\n if not (${dateFilter}) then\n -- skip message outside date range\n else` : ""}
  357. set msgId to id of msg as string
  358. set msgSubject to subject of msg
  359. set msgSender to sender of msg
  360. set d to date received of msg
  361. set msgDateStr to ${AS_DATE_TO_STRING}
  362. set msgRead to read status of msg as string
  363. set msgFlagged to flagged status of msg as string
  364. if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
  365. set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged
  366. set msgCount to msgCount + 1
  367. ${dateFilter ? "end if" : ""}
  368. end try
  369. end repeat
  370. return outputText
  371. `;
  372. }
  373. else {
  374. // Search ALL mailboxes — iterate every mailbox in the account, dedup by message ID
  375. searchCommand = `
  376. set outputText to ""
  377. set msgCount to 0
  378. set seenIds to {}
  379. repeat with mb in mailboxes
  380. if msgCount >= ${limit} then exit repeat
  381. try
  382. set allMessages to messages of mb ${searchCondition}
  383. repeat with msg in allMessages
  384. if msgCount >= ${limit} then exit repeat
  385. try
  386. set msgId to id of msg as string
  387. if seenIds does not contain msgId then
  388. set end of seenIds to msgId
  389. ${dateFilter ? `set msgDate to date received of msg\n if not (${dateFilter}) then\n -- skip message outside date range\n else` : ""}
  390. set msgSubject to subject of msg
  391. set msgSender to sender of msg
  392. set d to date received of msg
  393. set msgDateStr to ${AS_DATE_TO_STRING}
  394. set msgRead to read status of msg as string
  395. set msgFlagged to flagged status of msg as string
  396. if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
  397. set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDateStr & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb
  398. set msgCount to msgCount + 1
  399. ${dateFilter ? "end if" : ""}
  400. end if
  401. end try
  402. end repeat
  403. end try
  404. end repeat
  405. return outputText
  406. `;
  407. }
  408. const script = buildAccountScopedScript(targetAccount, searchCommand);
  409. const result = executeAppleScript(script, { timeoutMs: 60000 });
  410. if (!result.success) {
  411. console.error(`Failed to search messages: ${result.error}`);
  412. return [];
  413. }
  414. if (!result.output.trim())
  415. return [];
  416. return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
  417. }
  418. /**
  419. * Get a message by ID.
  420. *
  421. * Note: Mail.app message IDs are unique per mailbox. This method searches
  422. * all mailboxes in all accounts to find the message.
  423. */
  424. getMessageById(id) {
  425. const script = buildAppLevelScript(`
  426. try
  427. repeat with acct in accounts
  428. repeat with mb in mailboxes of acct
  429. try
  430. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  431. if (count of matchingMsgs) > 0 then
  432. set msg to item 1 of matchingMsgs
  433. set msgSubject to subject of msg
  434. set msgSender to sender of msg
  435. set d to date received of msg
  436. set msgDate to ${AS_DATE_TO_STRING}
  437. set msgRead to read status of msg as string
  438. set msgFlagged to flagged status of msg as string
  439. set msgJunk to junk mail status of msg as string
  440. set msgDeleted to deleted status of msg as string
  441. set msgMailbox to name of mb
  442. set msgAccount to name of acct
  443. set hasAtt to "false"
  444. try
  445. set attCount to count of mail attachments of msg
  446. if attCount > 0 then set hasAtt to "true"
  447. end try
  448. -- MIME-embedded attachments are invisible to AppleScript's
  449. -- attachment object. Fall back to scanning the raw source.
  450. -- This reads the full message source (can be MB-sized for
  451. -- messages with large bodies), so it's the slowest part of
  452. -- get-message for attachmentless messages. Accepted as the
  453. -- cost of correct hasAttachments in the detail view.
  454. if hasAtt is "false" then
  455. try
  456. set rawSrc to source of msg
  457. if rawSrc contains "Content-Disposition: attachment" then set hasAtt to "true"
  458. end try
  459. end if
  460. return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount & "|||" & hasAtt
  461. end if
  462. end try
  463. end repeat
  464. end repeat
  465. return ""
  466. on error errMsg
  467. return ""
  468. end try
  469. `);
  470. const result = executeAppleScript(script, { timeoutMs: 60000 }); // Longer timeout for search
  471. if (!result.success || !result.output.trim()) {
  472. console.error(`Failed to get message ${id}: ${result.error}`);
  473. return null;
  474. }
  475. const parts = result.output.split("|||");
  476. if (parts.length < 9)
  477. return null;
  478. return {
  479. id: id.toString(),
  480. subject: parts[0],
  481. sender: parts[1],
  482. recipients: [],
  483. dateReceived: parseAppleScriptDate(parts[2]),
  484. isRead: parts[3] === "true",
  485. isFlagged: parts[4] === "true",
  486. isJunk: parts[5] === "true",
  487. isDeleted: parts[6] === "true",
  488. mailbox: parts[7],
  489. account: parts[8],
  490. hasAttachments: parts.length > 9 ? parts[9] === "true" : false,
  491. };
  492. }
  493. /**
  494. * Get the content of a message.
  495. */
  496. getMessageContent(id) {
  497. const script = buildAppLevelScript(`
  498. try
  499. repeat with acct in accounts
  500. repeat with mb in mailboxes of acct
  501. try
  502. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  503. if (count of matchingMsgs) > 0 then
  504. set msg to item 1 of matchingMsgs
  505. set msgSubject to subject of msg
  506. set msgContent to content of msg
  507. set htmlContent to ""
  508. try
  509. set htmlContent to source of msg
  510. end try
  511. return msgSubject & "|||CONTENT|||" & msgContent & "|||HTML|||" & htmlContent
  512. end if
  513. end try
  514. end repeat
  515. end repeat
  516. return ""
  517. on error errMsg
  518. return ""
  519. end try
  520. `);
  521. const result = executeAppleScript(script, { timeoutMs: 60000 });
  522. if (!result.success || !result.output.trim()) {
  523. console.error(`Failed to get message content: ${result.error}`);
  524. return null;
  525. }
  526. const htmlSplit = result.output.split("|||HTML|||");
  527. const contentPart = htmlSplit[0];
  528. const htmlContent = htmlSplit.length > 1 ? htmlSplit[1] : undefined;
  529. const parts = contentPart.split("|||CONTENT|||");
  530. if (parts.length < 2)
  531. return null;
  532. return {
  533. id: id.toString(),
  534. subject: parts[0],
  535. plainText: parts[1],
  536. htmlContent: htmlContent || undefined,
  537. };
  538. }
  539. /**
  540. * Get the raw MIME source of a message.
  541. * Used as fallback for attachment extraction when AppleScript
  542. * mail attachments returns empty.
  543. *
  544. * Timeout is 2x the default (120s) because `source of msg` returns
  545. * the entire raw message including base64-encoded attachments —
  546. * a 20MB attachment can take several seconds over Exchange/IMAP.
  547. */
  548. getRawSource(id) {
  549. const script = buildAppLevelScript(`
  550. try
  551. repeat with acct in accounts
  552. repeat with mb in mailboxes of acct
  553. try
  554. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  555. if (count of matchingMsgs) > 0 then
  556. set msg to item 1 of matchingMsgs
  557. return source of msg
  558. end if
  559. end try
  560. end repeat
  561. end repeat
  562. return ""
  563. on error errMsg
  564. return ""
  565. end try
  566. `);
  567. const result = executeAppleScript(script, { timeoutMs: 120000 });
  568. if (!result.success || !result.output.trim()) {
  569. return null;
  570. }
  571. return result.output;
  572. }
  573. /**
  574. * List messages in a mailbox.
  575. *
  576. * @param mailbox - Mailbox to list from (default: INBOX)
  577. * @param account - Account to list from
  578. * @param limit - Maximum number of messages
  579. * @returns Array of messages
  580. */
  581. listMessages(mailbox, account, limit = 50, from, offset = 0) {
  582. // If no account specified, list across all accounts
  583. if (!account) {
  584. const accounts = this.listAccounts();
  585. const allMessages = [];
  586. for (const acct of accounts) {
  587. if (allMessages.length >= limit)
  588. break;
  589. const remaining = limit - allMessages.length;
  590. const msgs = this.listMessages(mailbox, acct.name, remaining, from, offset);
  591. allMessages.push(...msgs);
  592. }
  593. return allMessages.slice(0, limit);
  594. }
  595. const targetAccount = this.resolveAccount(account);
  596. const safeFrom = from ? escapeForAppleScript(from) : "";
  597. const fromFilter = from ? `whose sender contains "${safeFrom}"` : "";
  598. let listCommand;
  599. if (mailbox) {
  600. // List from a specific mailbox
  601. const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
  602. listCommand = `
  603. set outputText to ""
  604. set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
  605. set msgCount to 0
  606. set skipped to 0
  607. repeat with msg in messages of theMailbox ${fromFilter}
  608. if msgCount >= ${limit} then exit repeat
  609. try
  610. if skipped < ${offset} then
  611. set skipped to skipped + 1
  612. else
  613. set msgId to id of msg as string
  614. set msgSubject to subject of msg
  615. set msgSender to sender of msg
  616. set d to date received of msg
  617. set msgDate to ${AS_DATE_TO_STRING}
  618. set msgRead to read status of msg as string
  619. set msgFlagged to flagged status of msg as string
  620. set msgHasAtt to "false"
  621. try
  622. if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
  623. end try
  624. if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
  625. set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgHasAtt
  626. set msgCount to msgCount + 1
  627. end if
  628. end try
  629. end repeat
  630. return outputText
  631. `;
  632. }
  633. else {
  634. // List from ALL mailboxes — iterate every mailbox in the account, dedup by message ID
  635. listCommand = `
  636. set outputText to ""
  637. set msgCount to 0
  638. set skipped to 0
  639. set seenIds to {}
  640. repeat with mb in mailboxes
  641. if msgCount >= ${limit} then exit repeat
  642. try
  643. repeat with msg in messages of mb ${fromFilter}
  644. if msgCount >= ${limit} then exit repeat
  645. try
  646. set msgId to id of msg as string
  647. if seenIds does not contain msgId then
  648. set end of seenIds to msgId
  649. if skipped < ${offset} then
  650. set skipped to skipped + 1
  651. else
  652. set msgSubject to subject of msg
  653. set msgSender to sender of msg
  654. set d to date received of msg
  655. set msgDate to ${AS_DATE_TO_STRING}
  656. set msgRead to read status of msg as string
  657. set msgFlagged to flagged status of msg as string
  658. set msgHasAtt to "false"
  659. try
  660. if (count of mail attachments of msg) > 0 then set msgHasAtt to "true"
  661. end try
  662. if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
  663. set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & name of mb & "|||" & msgHasAtt
  664. set msgCount to msgCount + 1
  665. end if
  666. end if
  667. end try
  668. end repeat
  669. end try
  670. end repeat
  671. return outputText
  672. `;
  673. }
  674. const script = buildAccountScopedScript(targetAccount, listCommand);
  675. const result = executeAppleScript(script, { timeoutMs: 60000 });
  676. if (!result.success) {
  677. console.error(`Failed to list messages: ${result.error}`);
  678. return [];
  679. }
  680. if (!result.output.trim())
  681. return [];
  682. return this.parseMessageList(result.output, mailbox || "INBOX", targetAccount);
  683. }
  684. /**
  685. * Parse message list output from AppleScript.
  686. *
  687. * Two emission schemas, disambiguated by length:
  688. * 7 fields: single-mailbox — ...|hasAtt (mailbox from caller)
  689. * 8 fields: all-mailboxes — ...|mailbox|hasAtt
  690. *
  691. * `hasAttachments` here is the fast-path AppleScript count only; it will
  692. * false-negative for MIME-embedded attachments (a known AppleScript
  693. * limitation). Use getMessage or list-attachments for authoritative info.
  694. */
  695. parseMessageList(output, mailbox, account) {
  696. const items = output.split("|||ITEM|||");
  697. const messages = [];
  698. for (const item of items) {
  699. const parts = item.split("|||");
  700. if (parts.length < 6)
  701. continue;
  702. let msgMailbox = mailbox;
  703. let hasAttachments = false;
  704. if (parts.length >= 8) {
  705. msgMailbox = parts[6];
  706. hasAttachments = parts[7] === "true";
  707. }
  708. else if (parts.length === 7) {
  709. hasAttachments = parts[6] === "true";
  710. }
  711. messages.push({
  712. id: parts[0].trim(),
  713. subject: parts[1],
  714. sender: parts[2],
  715. recipients: [],
  716. dateReceived: parseAppleScriptDate(parts[3]),
  717. isRead: parts[4] === "true",
  718. isFlagged: parts[5] === "true",
  719. isJunk: false,
  720. isDeleted: false,
  721. mailbox: msgMailbox,
  722. account,
  723. hasAttachments,
  724. });
  725. }
  726. return messages;
  727. }
  728. /**
  729. * Send an email.
  730. *
  731. * @param to - Recipient email addresses
  732. * @param subject - Email subject
  733. * @param body - Email body (plain text)
  734. * @param cc - CC recipients
  735. * @param bcc - BCC recipients
  736. * @param account - Account to send from
  737. * @returns true if sent successfully
  738. */
  739. // ───────────────────────────────────────────────────────────────────
  740. // KNOWN BUG: outgoing emails sent via AppleScript on macOS 15+ get wrapped
  741. // in <blockquote type="cite"> under the Apple-Mail-URLShareWrapperClass
  742. // template, so they render to recipients as quoted/forwarded content.
  743. // Plain-text alternative gets `>` prefixes on every line.
  744. //
  745. // Reproduces with EVERY AppleScript message-creation pattern I tried:
  746. // • make new outgoing message with properties {content: ..., ...}
  747. // • make new outgoing message (no content) + `set content of newMessage`
  748. // • setting `default message format` to plain format first
  749. //
  750. // Apple radar FB11734014 (open since Ventura, no movement).
  751. // Discussion: https://forums.macrumors.com/threads/applescript-creating-a-
  752. // new-message-in-mail-app-is-causing-weird-formatting-issues.2385052/
  753. //
  754. // Workaround for callers who need clean emails today: use SMTP directly
  755. // (Python smtplib). See e.g. sweetrb/nhl-bracket-tracker's send_email.py.
  756. //
  757. // Proper fix is probably to abandon `make new outgoing message` and either:
  758. // 1. Build the .emlx file ourselves and drop it into a Drafts mailbox.
  759. // 2. Switch to smtplib-style direct send with Keychain-stored creds.
  760. // 3. Use Mail.app's NSSharingService rather than AppleScript.
  761. // Tracking issue: https://github.com/sweetrb/apple-mail-mcp/issues/12
  762. // ───────────────────────────────────────────────────────────────────
  763. sendEmail(to, subject, body, cc, bcc, account, attachments) {
  764. const safeSubject = escapeForAppleScript(subject);
  765. const safeBody = escapeForAppleScript(body);
  766. // Build recipient additions
  767. let recipientCommands = "";
  768. for (const addr of to) {
  769. recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  770. }
  771. if (cc) {
  772. for (const addr of cc) {
  773. recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  774. }
  775. }
  776. if (bcc) {
  777. for (const addr of bcc) {
  778. recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  779. }
  780. }
  781. const attachmentCommands = buildAttachmentCommands(attachments);
  782. let sendCommand;
  783. if (account) {
  784. const safeAccount = escapeForAppleScript(account);
  785. sendCommand = `
  786. set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
  787. tell newMessage
  788. ${recipientCommands}
  789. set sender to "${safeAccount}"
  790. ${attachmentCommands}
  791. end tell
  792. send newMessage
  793. return "sent"
  794. `;
  795. }
  796. else {
  797. sendCommand = `
  798. set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
  799. tell newMessage
  800. ${recipientCommands}
  801. ${attachmentCommands}
  802. end tell
  803. send newMessage
  804. return "sent"
  805. `;
  806. }
  807. const script = buildAppLevelScript(sendCommand);
  808. const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
  809. if (!result.success) {
  810. console.error(`Failed to send email: ${result.error}`);
  811. return false;
  812. }
  813. return result.output.includes("sent");
  814. }
  815. /**
  816. * Send individual personalized emails to a list of recipients (mail merge).
  817. *
  818. * Replaces {{placeholder}} tokens in subject and body with per-recipient values.
  819. * Each recipient receives their own individual email.
  820. *
  821. * @param recipients - List of recipient objects with email and variable values
  822. * @param subject - Email subject (may contain {{placeholders}})
  823. * @param body - Email body (may contain {{placeholders}})
  824. * @param account - Account to send from
  825. * @param delayMs - Delay between sends in milliseconds (default: 500, max: 10000)
  826. * @returns Array of per-recipient results
  827. */
  828. sendSerialEmail(recipients, subject, body, account, delayMs = 500) {
  829. const effectiveDelay = Math.min(Math.max(delayMs, 0), 10000);
  830. const results = [];
  831. for (let i = 0; i < recipients.length; i++) {
  832. const recipient = recipients[i];
  833. try {
  834. // Replace all {{Key}} placeholders with recipient's values
  835. let personalizedSubject = subject;
  836. let personalizedBody = body;
  837. for (const [key, value] of Object.entries(recipient.variables)) {
  838. const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  839. const placeholder = new RegExp(`\\{\\{${safeKey}\\}\\}`, "g");
  840. personalizedSubject = personalizedSubject.replace(placeholder, value);
  841. personalizedBody = personalizedBody.replace(placeholder, value);
  842. }
  843. const success = this.sendEmail([recipient.email], personalizedSubject, personalizedBody, undefined, undefined, account);
  844. results.push({
  845. email: recipient.email,
  846. success,
  847. error: success ? undefined : "Failed to send email",
  848. });
  849. }
  850. catch (error) {
  851. results.push({
  852. email: recipient.email,
  853. success: false,
  854. error: error instanceof Error ? error.message : "Unknown error",
  855. });
  856. }
  857. // Brief delay between sends to avoid overwhelming Mail.app
  858. if (effectiveDelay > 0 && i < recipients.length - 1) {
  859. spawnSync("sleep", [(effectiveDelay / 1000).toString()], { stdio: "ignore" });
  860. }
  861. }
  862. return results;
  863. }
  864. /**
  865. * Create a draft email (saved to Drafts folder, not sent).
  866. *
  867. * @param to - Recipient email addresses
  868. * @param subject - Email subject
  869. * @param body - Email body (plain text)
  870. * @param cc - CC recipients
  871. * @param bcc - BCC recipients
  872. * @param account - Account to create draft in
  873. * @returns true if draft created successfully
  874. */
  875. createDraft(to, subject, body, cc, bcc, account, attachments) {
  876. const safeSubject = escapeForAppleScript(subject);
  877. const safeBody = escapeForAppleScript(body);
  878. // Build recipient additions
  879. let recipientCommands = "";
  880. for (const addr of to) {
  881. recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  882. }
  883. if (cc) {
  884. for (const addr of cc) {
  885. recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  886. }
  887. }
  888. if (bcc) {
  889. for (const addr of bcc) {
  890. recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  891. }
  892. }
  893. const attachmentCommands = buildAttachmentCommands(attachments);
  894. let draftCommand;
  895. if (account) {
  896. const safeAccount = escapeForAppleScript(account);
  897. draftCommand = `
  898. set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
  899. tell newMessage
  900. ${recipientCommands}
  901. set sender to "${safeAccount}"
  902. ${attachmentCommands}
  903. end tell
  904. return "draft created"
  905. `;
  906. }
  907. else {
  908. draftCommand = `
  909. set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
  910. tell newMessage
  911. ${recipientCommands}
  912. ${attachmentCommands}
  913. end tell
  914. return "draft created"
  915. `;
  916. }
  917. const script = buildAppLevelScript(draftCommand);
  918. const result = executeAppleScript(script, { timeoutMs: 60000, maxRetries: 2 });
  919. if (!result.success) {
  920. console.error(`Failed to create draft: ${result.error}`);
  921. return false;
  922. }
  923. return result.output.includes("draft created");
  924. }
  925. /**
  926. * Reply to a message.
  927. *
  928. * @param id - Message ID to reply to
  929. * @param body - Reply body
  930. * @param replyAll - If true, reply to all recipients
  931. * @param send - If true, send immediately; if false, save as draft
  932. * @returns true if reply created/sent successfully
  933. */
  934. replyToMessage(id, body, replyAll = false, send = true) {
  935. const safeBody = escapeForAppleScript(body);
  936. const replyAllClause = replyAll ? " with reply to all" : "";
  937. const sendAction = send ? "send theReply" : "";
  938. const script = buildAppLevelScript(`
  939. try
  940. repeat with acct in accounts
  941. repeat with mb in mailboxes of acct
  942. try
  943. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  944. if (count of matchingMsgs) > 0 then
  945. set msg to item 1 of matchingMsgs
  946. set theReply to reply msg without opening window${replyAllClause}
  947. set content of theReply to "${safeBody}"
  948. ${sendAction}
  949. return "ok"
  950. end if
  951. end try
  952. end repeat
  953. end repeat
  954. return "error:Message not found"
  955. on error errMsg
  956. return "error:" & errMsg
  957. end try
  958. `);
  959. const result = executeAppleScript(script, { timeoutMs: 60000 });
  960. if (!result.success || result.output.startsWith("error:")) {
  961. console.error(`Failed to reply to message: ${result.error || result.output}`);
  962. return false;
  963. }
  964. return true;
  965. }
  966. /**
  967. * Forward a message.
  968. *
  969. * @param id - Message ID to forward
  970. * @param to - Recipients to forward to
  971. * @param body - Optional body to prepend
  972. * @param send - If true, send immediately; if false, save as draft
  973. * @returns true if forward created/sent successfully
  974. */
  975. forwardMessage(id, to, body, send = true) {
  976. const safeBody = body ? escapeForAppleScript(body) : "";
  977. const sendAction = send ? "send theForward" : "";
  978. // Build recipient additions
  979. let recipientCommands = "";
  980. for (const addr of to) {
  981. recipientCommands += `make new to recipient at end of to recipients of theForward with properties {address:"${escapeForAppleScript(addr)}"}\n`;
  982. }
  983. const script = buildAppLevelScript(`
  984. try
  985. repeat with acct in accounts
  986. repeat with mb in mailboxes of acct
  987. try
  988. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  989. if (count of matchingMsgs) > 0 then
  990. set msg to item 1 of matchingMsgs
  991. set theForward to forward msg without opening window
  992. ${recipientCommands}
  993. ${safeBody ? `set content of theForward to "${safeBody}"` : ""}
  994. ${sendAction}
  995. return "ok"
  996. end if
  997. end try
  998. end repeat
  999. end repeat
  1000. return "error:Message not found"
  1001. on error errMsg
  1002. return "error:" & errMsg
  1003. end try
  1004. `);
  1005. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1006. if (!result.success || result.output.startsWith("error:")) {
  1007. console.error(`Failed to forward message: ${result.error || result.output}`);
  1008. return false;
  1009. }
  1010. return true;
  1011. }
  1012. /**
  1013. * Helper to find and operate on a message by ID.
  1014. */
  1015. findMessageScript(id, operation) {
  1016. return buildAppLevelScript(`
  1017. try
  1018. repeat with acct in accounts
  1019. repeat with mb in mailboxes of acct
  1020. try
  1021. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  1022. if (count of matchingMsgs) > 0 then
  1023. set msg to item 1 of matchingMsgs
  1024. ${operation}
  1025. return "ok"
  1026. end if
  1027. end try
  1028. end repeat
  1029. end repeat
  1030. return "error:Message not found"
  1031. on error errMsg
  1032. return "error:" & errMsg
  1033. end try
  1034. `);
  1035. }
  1036. /**
  1037. * Mark a message as read.
  1038. */
  1039. markAsRead(id) {
  1040. const script = this.findMessageScript(id, "set read status of msg to true");
  1041. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1042. if (!result.success || result.output.startsWith("error:")) {
  1043. console.error(`Failed to mark message as read: ${result.error || result.output}`);
  1044. return false;
  1045. }
  1046. return true;
  1047. }
  1048. /**
  1049. * Mark a message as unread.
  1050. */
  1051. markAsUnread(id) {
  1052. const script = this.findMessageScript(id, "set read status of msg to false");
  1053. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1054. if (!result.success || result.output.startsWith("error:")) {
  1055. console.error(`Failed to mark message as unread: ${result.error || result.output}`);
  1056. return false;
  1057. }
  1058. return true;
  1059. }
  1060. /**
  1061. * Flag a message.
  1062. */
  1063. flagMessage(id) {
  1064. const script = this.findMessageScript(id, "set flagged status of msg to true");
  1065. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1066. if (!result.success || result.output.startsWith("error:")) {
  1067. console.error(`Failed to flag message: ${result.error || result.output}`);
  1068. return false;
  1069. }
  1070. return true;
  1071. }
  1072. /**
  1073. * Unflag a message.
  1074. */
  1075. unflagMessage(id) {
  1076. const script = this.findMessageScript(id, "set flagged status of msg to false");
  1077. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1078. if (!result.success || result.output.startsWith("error:")) {
  1079. console.error(`Failed to unflag message: ${result.error || result.output}`);
  1080. return false;
  1081. }
  1082. return true;
  1083. }
  1084. /**
  1085. * Delete a message.
  1086. */
  1087. deleteMessage(id) {
  1088. const script = this.findMessageScript(id, "delete msg");
  1089. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1090. if (!result.success || result.output.startsWith("error:")) {
  1091. console.error(`Failed to delete message: ${result.error || result.output}`);
  1092. return false;
  1093. }
  1094. return true;
  1095. }
  1096. /**
  1097. * Move a message to a different mailbox.
  1098. */
  1099. moveMessage(id, mailbox, account) {
  1100. const targetAccount = this.resolveAccount(account);
  1101. const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
  1102. const safeMailbox = escapeForAppleScript(targetMailbox);
  1103. const safeAccount = escapeForAppleScript(targetAccount);
  1104. const script = buildAppLevelScript(`
  1105. try
  1106. repeat with acct in accounts
  1107. repeat with mb in mailboxes of acct
  1108. try
  1109. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  1110. if (count of matchingMsgs) > 0 then
  1111. set msg to item 1 of matchingMsgs
  1112. set destMailbox to mailbox "${safeMailbox}" of account "${safeAccount}"
  1113. move msg to destMailbox
  1114. return "ok"
  1115. end if
  1116. end try
  1117. end repeat
  1118. end repeat
  1119. return "error:Message not found"
  1120. on error errMsg
  1121. return "error:" & errMsg
  1122. end try
  1123. `);
  1124. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1125. if (!result.success || result.output.startsWith("error:")) {
  1126. console.error(`Failed to move message: ${result.error || result.output}`);
  1127. return false;
  1128. }
  1129. return true;
  1130. }
  1131. // ===========================================================================
  1132. // Batch Operations
  1133. // ===========================================================================
  1134. /**
  1135. * Delete multiple messages at once.
  1136. *
  1137. * @param ids - Array of message IDs to delete
  1138. * @returns Array of results for each message
  1139. */
  1140. batchDeleteMessages(ids) {
  1141. const results = [];
  1142. for (const id of ids) {
  1143. const success = this.deleteMessage(id);
  1144. results.push({
  1145. id,
  1146. success,
  1147. error: success ? undefined : "Failed to delete message",
  1148. });
  1149. }
  1150. return results;
  1151. }
  1152. /**
  1153. * Move multiple messages to a mailbox at once.
  1154. *
  1155. * @param ids - Array of message IDs to move
  1156. * @param mailbox - Destination mailbox name
  1157. * @param account - Account containing the destination mailbox
  1158. * @returns Array of results for each message
  1159. */
  1160. batchMoveMessages(ids, mailbox, account) {
  1161. const results = [];
  1162. for (const id of ids) {
  1163. const success = this.moveMessage(id, mailbox, account);
  1164. results.push({
  1165. id,
  1166. success,
  1167. error: success ? undefined : "Failed to move message",
  1168. });
  1169. }
  1170. return results;
  1171. }
  1172. /**
  1173. * Mark multiple messages as read at once.
  1174. */
  1175. batchMarkAsRead(ids) {
  1176. const results = [];
  1177. for (const id of ids) {
  1178. const success = this.markAsRead(id);
  1179. results.push({ id, success, error: success ? undefined : "Failed to mark message as read" });
  1180. }
  1181. return results;
  1182. }
  1183. /**
  1184. * Mark multiple messages as unread at once.
  1185. */
  1186. batchMarkAsUnread(ids) {
  1187. const results = [];
  1188. for (const id of ids) {
  1189. const success = this.markAsUnread(id);
  1190. results.push({
  1191. id,
  1192. success,
  1193. error: success ? undefined : "Failed to mark message as unread",
  1194. });
  1195. }
  1196. return results;
  1197. }
  1198. /**
  1199. * Flag multiple messages at once.
  1200. */
  1201. batchFlagMessages(ids) {
  1202. const results = [];
  1203. for (const id of ids) {
  1204. const success = this.flagMessage(id);
  1205. results.push({ id, success, error: success ? undefined : "Failed to flag message" });
  1206. }
  1207. return results;
  1208. }
  1209. /**
  1210. * Unflag multiple messages at once.
  1211. */
  1212. batchUnflagMessages(ids) {
  1213. const results = [];
  1214. for (const id of ids) {
  1215. const success = this.unflagMessage(id);
  1216. results.push({ id, success, error: success ? undefined : "Failed to unflag message" });
  1217. }
  1218. return results;
  1219. }
  1220. /**
  1221. * List attachments for a message.
  1222. * Tries AppleScript first, falls back to MIME source parsing
  1223. * when AppleScript returns empty (known issue across all account types).
  1224. */
  1225. listAttachments(id) {
  1226. // Attempt 1: AppleScript mail attachments
  1227. const script = buildAppLevelScript(`
  1228. try
  1229. repeat with acct in accounts
  1230. repeat with mb in mailboxes of acct
  1231. try
  1232. set matchingMsgs to (messages of mb whose id is ${Number(id)})
  1233. if (count of matchingMsgs) > 0 then
  1234. set msg to item 1 of matchingMsgs
  1235. set outputText to ""
  1236. set attCount to 0
  1237. repeat with att in mail attachments of msg
  1238. set attName to name of att
  1239. set attType to MIME type of att
  1240. set attSize to file size of att as string
  1241. if attCount > 0 then set outputText to outputText & "|||ITEM|||"
  1242. set outputText to outputText & attName & "|||" & attType & "|||" & attSize
  1243. set attCount to attCount + 1
  1244. end repeat
  1245. return outputText
  1246. end if
  1247. end try
  1248. end repeat
  1249. end repeat
  1250. return ""
  1251. on error errMsg
  1252. return ""
  1253. end try
  1254. `);
  1255. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1256. if (result.success && result.output.trim()) {
  1257. const items = result.output.split("|||ITEM|||");
  1258. const attachments = [];
  1259. for (const item of items) {
  1260. const parts = item.split("|||");
  1261. if (parts.length < 3)
  1262. continue;
  1263. attachments.push({
  1264. id: `${id}-${parts[0]}`,
  1265. name: parts[0],
  1266. mimeType: parts[1],
  1267. size: parseInt(parts[2]) || 0,
  1268. });
  1269. }
  1270. if (attachments.length > 0)
  1271. return attachments;
  1272. }
  1273. // Attempt 2: MIME source fallback
  1274. const rawSource = this.getRawSource(id);
  1275. if (!rawSource)
  1276. return [];
  1277. const mimeAttachments = parseMimeAttachments(rawSource);
  1278. return mimeAttachments.map((att) => ({
  1279. id: `${id}-${att.name}`,
  1280. name: att.name,
  1281. mimeType: att.mimeType,
  1282. size: att.size,
  1283. }));
  1284. }
  1285. /**
  1286. * Save an attachment from a message to disk.
  1287. * Tries AppleScript first, falls back to MIME source extraction
  1288. * when AppleScript can't find the attachment.
  1289. */
  1290. saveAttachment(id, attachmentName, savePath) {
  1291. // Validate attachment name: block path separators, traversal, null bytes, and backslashes
  1292. if (/[/\\\0]/.test(attachmentName) || attachmentName.includes("..")) {
  1293. console.error(`Invalid attachment name: "${attachmentName}"`);
  1294. return false;
  1295. }
  1296. // Resolve the save path to prevent symlink / ".." traversal bypass
  1297. const resolvedPath = resolve(savePath);
  1298. const allowedPrefixes = [homedir(), "/tmp", "/private/tmp", "/Volumes"];
  1299. const isAllowed = allowedPrefixes.some((prefix) => resolvedPath.startsWith(prefix));
  1300. if (!isAllowed) {
  1301. console.error(`Save path "${savePath}" is outside allowed directories`);
  1302. return false;
  1303. }
  1304. const safeName = escapeForAppleScript(attachmentName);
  1305. const safePath = escapeForAppleScript(resolvedPath);
  1306. const numericId = Number(id);
  1307. // Attempt 1: AppleScript save
  1308. const script = buildAppLevelScript(`
  1309. try
  1310. repeat with acct in accounts
  1311. repeat with mb in mailboxes of acct
  1312. try
  1313. set matchingMsgs to (messages of mb whose id is ${numericId})
  1314. if (count of matchingMsgs) > 0 then
  1315. set msg to item 1 of matchingMsgs
  1316. repeat with att in mail attachments of msg
  1317. if name of att is "${safeName}" then
  1318. set savePath to POSIX file "${safePath}/${safeName}"
  1319. save att in savePath
  1320. return "ok"
  1321. end if
  1322. end repeat
  1323. return "error:Attachment not found"
  1324. end if
  1325. end try
  1326. end repeat
  1327. end repeat
  1328. return "error:Message not found"
  1329. on error errMsg
  1330. return "error:" & errMsg
  1331. end try
  1332. `);
  1333. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1334. if (result.success && result.output === "ok") {
  1335. return true;
  1336. }
  1337. // Attempt 2: MIME source fallback
  1338. const rawSource = this.getRawSource(id);
  1339. if (!rawSource) {
  1340. console.error(`Failed to save attachment: could not retrieve message source`);
  1341. return false;
  1342. }
  1343. const attachment = extractMimeAttachment(rawSource, attachmentName);
  1344. if (!attachment) {
  1345. console.error(`Failed to save attachment: "${attachmentName}" not found in MIME source`);
  1346. return false;
  1347. }
  1348. try {
  1349. const outPath = resolve(resolvedPath, attachmentName);
  1350. // Verify the resolved output path is still within allowed directories
  1351. const isOutAllowed = allowedPrefixes.some((prefix) => outPath.startsWith(prefix));
  1352. if (!isOutAllowed) {
  1353. console.error(`Output path "${outPath}" is outside allowed directories`);
  1354. return false;
  1355. }
  1356. writeFileSync(outPath, attachment.data);
  1357. return true;
  1358. }
  1359. catch (err) {
  1360. console.error(`Failed to write attachment to disk: ${err}`);
  1361. return false;
  1362. }
  1363. }
  1364. // ===========================================================================
  1365. // Mailbox Operations
  1366. // ===========================================================================
  1367. /**
  1368. * List all mailboxes for an account.
  1369. */
  1370. listMailboxes(account) {
  1371. const targetAccount = this.resolveAccount(account);
  1372. const listCommand = `
  1373. set mailboxList to {}
  1374. repeat with mb in mailboxes
  1375. set mbName to name of mb
  1376. set mbUnread to unread count of mb
  1377. set mbCount to count of messages of mb
  1378. set end of mailboxList to mbName & "|||" & mbUnread & "|||" & mbCount
  1379. end repeat
  1380. set AppleScript's text item delimiters to "|||ITEM|||"
  1381. return mailboxList as text
  1382. `;
  1383. const script = buildAccountScopedScript(targetAccount, listCommand);
  1384. const result = executeAppleScript(script);
  1385. if (!result.success) {
  1386. console.error(`Failed to list mailboxes: ${result.error}`);
  1387. return [];
  1388. }
  1389. if (!result.output.trim())
  1390. return [];
  1391. const items = result.output.split("|||ITEM|||");
  1392. const mailboxes = [];
  1393. for (const item of items) {
  1394. const parts = item.split("|||");
  1395. if (parts.length < 3)
  1396. continue;
  1397. mailboxes.push({
  1398. name: parts[0],
  1399. account: targetAccount,
  1400. unreadCount: parseInt(parts[1]) || 0,
  1401. messageCount: parseInt(parts[2]) || 0,
  1402. });
  1403. }
  1404. return mailboxes;
  1405. }
  1406. /**
  1407. * Get unread count for a mailbox.
  1408. */
  1409. getUnreadCount(mailbox, account) {
  1410. const targetAccount = this.resolveAccount(account);
  1411. let command;
  1412. if (mailbox) {
  1413. const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
  1414. const safeMailbox = escapeForAppleScript(targetMailbox);
  1415. command = `return unread count of mailbox "${safeMailbox}"`;
  1416. }
  1417. else {
  1418. // Get total unread across all mailboxes
  1419. command = `
  1420. set total to 0
  1421. repeat with mb in mailboxes
  1422. set total to total + (unread count of mb)
  1423. end repeat
  1424. return total
  1425. `;
  1426. }
  1427. const script = buildAccountScopedScript(targetAccount, command);
  1428. const result = executeAppleScript(script);
  1429. if (!result.success) {
  1430. console.error(`Failed to get unread count: ${result.error}`);
  1431. return 0;
  1432. }
  1433. return parseInt(result.output) || 0;
  1434. }
  1435. /**
  1436. * Create a new mailbox.
  1437. */
  1438. createMailbox(name, account) {
  1439. const targetAccount = this.resolveAccount(account);
  1440. const safeName = escapeForAppleScript(name);
  1441. const safeAccount = escapeForAppleScript(targetAccount);
  1442. const script = buildAppLevelScript(`
  1443. try
  1444. make new mailbox with properties {name:"${safeName}"} at account "${safeAccount}"
  1445. return "ok"
  1446. on error errMsg
  1447. return "error:" & errMsg
  1448. end try
  1449. `);
  1450. const result = executeAppleScript(script);
  1451. if (!result.success || result.output.startsWith("error:")) {
  1452. console.error(`Failed to create mailbox: ${result.error || result.output}`);
  1453. return false;
  1454. }
  1455. this.invalidateCache();
  1456. return true;
  1457. }
  1458. /**
  1459. * Delete a mailbox.
  1460. */
  1461. deleteMailbox(name, account) {
  1462. const targetAccount = this.resolveAccount(account);
  1463. const targetMailbox = this.resolveMailbox(name, targetAccount);
  1464. const safeName = escapeForAppleScript(targetMailbox);
  1465. const safeAccount = escapeForAppleScript(targetAccount);
  1466. const script = buildAppLevelScript(`
  1467. try
  1468. delete mailbox "${safeName}" of account "${safeAccount}"
  1469. return "ok"
  1470. on error errMsg
  1471. return "error:" & errMsg
  1472. end try
  1473. `);
  1474. const result = executeAppleScript(script);
  1475. if (!result.success || result.output.startsWith("error:")) {
  1476. console.error(`Failed to delete mailbox: ${result.error || result.output}`);
  1477. return false;
  1478. }
  1479. this.invalidateCache();
  1480. return true;
  1481. }
  1482. /**
  1483. * Rename a mailbox by creating a new one, moving messages, and deleting the old one.
  1484. */
  1485. renameMailbox(oldName, newName, account) {
  1486. const targetAccount = this.resolveAccount(account);
  1487. // Create the new mailbox
  1488. if (!this.createMailbox(newName, targetAccount)) {
  1489. return false;
  1490. }
  1491. // Move all messages from old to new
  1492. const resolvedOld = this.resolveMailbox(oldName, targetAccount);
  1493. const resolvedNew = this.resolveMailbox(newName, targetAccount);
  1494. const safeOld = escapeForAppleScript(resolvedOld);
  1495. const safeNew = escapeForAppleScript(resolvedNew);
  1496. const safeAccount = escapeForAppleScript(targetAccount);
  1497. const moveScript = buildAppLevelScript(`
  1498. try
  1499. set srcMailbox to mailbox "${safeOld}" of account "${safeAccount}"
  1500. set destMailbox to mailbox "${safeNew}" of account "${safeAccount}"
  1501. repeat with msg in messages of srcMailbox
  1502. move msg to destMailbox
  1503. end repeat
  1504. delete mailbox "${safeOld}" of account "${safeAccount}"
  1505. return "ok"
  1506. on error errMsg
  1507. return "error:" & errMsg
  1508. end try
  1509. `);
  1510. const result = executeAppleScript(moveScript, { timeoutMs: 60000 });
  1511. if (!result.success || result.output.startsWith("error:")) {
  1512. console.error(`Failed to rename mailbox: ${result.error || result.output}`);
  1513. return false;
  1514. }
  1515. this.invalidateCache();
  1516. return true;
  1517. }
  1518. // ===========================================================================
  1519. // Account Operations
  1520. // ===========================================================================
  1521. /**
  1522. * List all mail accounts (uses cache).
  1523. */
  1524. listAccounts() {
  1525. return this.getCachedAccounts();
  1526. }
  1527. /**
  1528. * Fetches account list directly from Mail.app via AppleScript.
  1529. * Used internally by the cache; prefer getCachedAccounts() or listAccounts().
  1530. */
  1531. fetchAccounts() {
  1532. const script = buildAppLevelScript(`
  1533. set accountList to {}
  1534. repeat with acct in accounts
  1535. set acctName to name of acct
  1536. set acctEmail to email addresses of acct
  1537. set acctEnabled to enabled of acct
  1538. set emailStr to ""
  1539. if (count of acctEmail) > 0 then
  1540. set emailStr to item 1 of acctEmail
  1541. end if
  1542. set end of accountList to acctName & "|||" & emailStr & "|||" & acctEnabled
  1543. end repeat
  1544. set AppleScript's text item delimiters to "|||ITEM|||"
  1545. return accountList as text
  1546. `);
  1547. const result = executeAppleScript(script);
  1548. if (!result.success) {
  1549. console.error(`Failed to list accounts: ${result.error}`);
  1550. return [];
  1551. }
  1552. if (!result.output.trim())
  1553. return [];
  1554. const items = result.output.split("|||ITEM|||");
  1555. const accounts = [];
  1556. for (const item of items) {
  1557. const parts = item.split("|||");
  1558. if (parts.length < 3)
  1559. continue;
  1560. accounts.push({
  1561. name: parts[0],
  1562. email: parts[1],
  1563. enabled: parts[2] === "true",
  1564. });
  1565. }
  1566. return accounts;
  1567. }
  1568. /**
  1569. * Fetches mailbox names for an account directly from Mail.app.
  1570. * Used internally by the cache; prefer getCachedMailboxNames().
  1571. */
  1572. fetchMailboxNames(account) {
  1573. const script = buildAccountScopedScript(account, `
  1574. set mbNames to {}
  1575. repeat with mb in mailboxes
  1576. set end of mbNames to name of mb
  1577. end repeat
  1578. return mbNames
  1579. `);
  1580. const result = executeAppleScript(script);
  1581. if (!result.success || !result.output) {
  1582. return [];
  1583. }
  1584. return result.output.split(", ").map((s) => s.trim());
  1585. }
  1586. // ===========================================================================
  1587. // Mail Rules
  1588. // ===========================================================================
  1589. /**
  1590. * List all mail rules.
  1591. */
  1592. listRules() {
  1593. const script = buildAppLevelScript(`
  1594. set ruleList to {}
  1595. repeat with r in rules
  1596. set ruleName to name of r
  1597. set ruleEnabled to enabled of r
  1598. set end of ruleList to ruleName & "|||" & (ruleEnabled as string)
  1599. end repeat
  1600. set AppleScript's text item delimiters to "|||ITEM|||"
  1601. return ruleList as text
  1602. `);
  1603. const result = executeAppleScript(script);
  1604. if (!result.success || !result.output.trim()) {
  1605. return [];
  1606. }
  1607. const items = result.output.split("|||ITEM|||");
  1608. const rules = [];
  1609. for (const item of items) {
  1610. const parts = item.split("|||");
  1611. if (parts.length < 2)
  1612. continue;
  1613. rules.push({
  1614. name: parts[0],
  1615. enabled: parts[1] === "true",
  1616. });
  1617. }
  1618. return rules;
  1619. }
  1620. /**
  1621. * Enable or disable a mail rule.
  1622. */
  1623. setRuleEnabled(ruleName, enabled) {
  1624. const safeName = escapeForAppleScript(ruleName);
  1625. const script = buildAppLevelScript(`
  1626. try
  1627. repeat with r in rules
  1628. if name of r is "${safeName}" then
  1629. set enabled of r to ${enabled}
  1630. return "ok"
  1631. end if
  1632. end repeat
  1633. return "error:Rule not found"
  1634. on error errMsg
  1635. return "error:" & errMsg
  1636. end try
  1637. `);
  1638. const result = executeAppleScript(script);
  1639. if (!result.success || result.output.startsWith("error:")) {
  1640. console.error(`Failed to set rule state: ${result.error || result.output}`);
  1641. return false;
  1642. }
  1643. return true;
  1644. }
  1645. // ===========================================================================
  1646. // Contacts Integration
  1647. // ===========================================================================
  1648. /**
  1649. * Search contacts by name or email.
  1650. */
  1651. searchContacts(query) {
  1652. const safeQuery = escapeForAppleScript(query);
  1653. const script = `
  1654. tell application "Contacts"
  1655. set matchedContacts to {}
  1656. set foundPeople to (every person whose name contains "${safeQuery}") & (every person whose value of emails contains "${safeQuery}")
  1657. -- Deduplicate by tracking IDs
  1658. set seenIds to {}
  1659. repeat with p in foundPeople
  1660. set pid to id of p
  1661. if seenIds does not contain pid then
  1662. set end of seenIds to pid
  1663. set pName to name of p
  1664. set pEmails to ""
  1665. repeat with e in emails of p
  1666. if pEmails is not "" then set pEmails to pEmails & ","
  1667. set pEmails to pEmails & (value of e)
  1668. end repeat
  1669. set pPhones to ""
  1670. repeat with ph in phones of p
  1671. if pPhones is not "" then set pPhones to pPhones & ","
  1672. set pPhones to pPhones & (value of ph)
  1673. end repeat
  1674. set end of matchedContacts to pName & "|||" & pEmails & "|||" & pPhones
  1675. end if
  1676. end repeat
  1677. set AppleScript's text item delimiters to "|||ITEM|||"
  1678. return matchedContacts as text
  1679. end tell
  1680. `;
  1681. const result = executeAppleScript(script);
  1682. if (!result.success || !result.output.trim()) {
  1683. return [];
  1684. }
  1685. const items = result.output.split("|||ITEM|||");
  1686. const contacts = [];
  1687. for (const item of items) {
  1688. const parts = item.split("|||");
  1689. if (parts.length < 3)
  1690. continue;
  1691. contacts.push({
  1692. name: parts[0],
  1693. emails: parts[1] ? parts[1].split(",").filter(Boolean) : [],
  1694. phones: parts[2] ? parts[2].split(",").filter(Boolean) : [],
  1695. });
  1696. }
  1697. return contacts;
  1698. }
  1699. // ===========================================================================
  1700. // Email Templates
  1701. // ===========================================================================
  1702. templates = new Map();
  1703. nextTemplateId = 1;
  1704. /**
  1705. * List all stored templates.
  1706. */
  1707. listTemplates() {
  1708. return Array.from(this.templates.values());
  1709. }
  1710. /**
  1711. * Get a template by ID.
  1712. */
  1713. getTemplate(id) {
  1714. return this.templates.get(id) || null;
  1715. }
  1716. /**
  1717. * Create or update a template.
  1718. */
  1719. saveTemplate(name, subject, body, to, cc, id) {
  1720. const templateId = id || `tmpl_${this.nextTemplateId++}`;
  1721. const template = { id: templateId, name, subject, body, to, cc };
  1722. this.templates.set(templateId, template);
  1723. return template;
  1724. }
  1725. /**
  1726. * Delete a template.
  1727. */
  1728. deleteTemplate(id) {
  1729. return this.templates.delete(id);
  1730. }
  1731. /**
  1732. * Use a template to create a draft.
  1733. */
  1734. useTemplate(id, overrides) {
  1735. const template = this.templates.get(id);
  1736. if (!template)
  1737. return false;
  1738. const to = overrides?.to || template.to || [];
  1739. const cc = overrides?.cc || template.cc;
  1740. const subject = overrides?.subject || template.subject;
  1741. const body = overrides?.body || template.body;
  1742. if (to.length === 0)
  1743. return false;
  1744. return this.createDraft(to, subject, body, cc);
  1745. }
  1746. // ===========================================================================
  1747. // Diagnostics
  1748. // ===========================================================================
  1749. /**
  1750. * Run health check on Mail.app connectivity.
  1751. */
  1752. healthCheck() {
  1753. const checks = [];
  1754. // Check 1: Mail.app is accessible
  1755. const mailCheck = executeAppleScript('tell application "Mail" to return "ok"');
  1756. if (mailCheck.success && mailCheck.output === "ok") {
  1757. checks.push({
  1758. name: "mail_app",
  1759. passed: true,
  1760. message: "Mail.app is accessible",
  1761. });
  1762. }
  1763. else {
  1764. const errorHint = mailCheck.error?.includes("not authorized")
  1765. ? " (check Automation permissions in System Preferences)"
  1766. : "";
  1767. checks.push({
  1768. name: "mail_app",
  1769. passed: false,
  1770. message: `Mail.app is not accessible${errorHint}`,
  1771. });
  1772. return { healthy: false, checks };
  1773. }
  1774. // Check 2: AppleScript permissions
  1775. const permCheck = executeAppleScript('tell application "Mail" to get name of account 1');
  1776. if (permCheck.success) {
  1777. checks.push({
  1778. name: "permissions",
  1779. passed: true,
  1780. message: "AppleScript automation permissions granted",
  1781. });
  1782. }
  1783. else {
  1784. const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
  1785. checks.push({
  1786. name: "permissions",
  1787. passed: !isPermError,
  1788. message: isPermError
  1789. ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
  1790. : `Permission check returned: ${permCheck.error}`,
  1791. });
  1792. if (isPermError) {
  1793. return { healthy: false, checks };
  1794. }
  1795. }
  1796. // Check 3: At least one account accessible
  1797. const accounts = this.listAccounts();
  1798. if (accounts.length > 0) {
  1799. const accountNames = accounts.map((a) => a.name).join(", ");
  1800. checks.push({
  1801. name: "accounts",
  1802. passed: true,
  1803. message: `Found ${accounts.length} account(s): ${accountNames}`,
  1804. });
  1805. }
  1806. else {
  1807. checks.push({
  1808. name: "accounts",
  1809. passed: false,
  1810. message: "No Mail accounts found. Set up an account in Mail.app first.",
  1811. });
  1812. return { healthy: false, checks };
  1813. }
  1814. // Check 4: Basic operations work
  1815. const mailboxes = this.listMailboxes(accounts[0].name);
  1816. checks.push({
  1817. name: "operations",
  1818. passed: true,
  1819. message: `Basic operations working (${mailboxes.length} mailbox(es) in ${accounts[0].name})`,
  1820. });
  1821. return {
  1822. healthy: checks.every((c) => c.passed),
  1823. checks,
  1824. };
  1825. }
  1826. /**
  1827. * Get mail statistics.
  1828. */
  1829. getMailStats() {
  1830. const accounts = this.listAccounts();
  1831. const accountStats = [];
  1832. let totalMessages = 0;
  1833. let totalUnread = 0;
  1834. for (const account of accounts) {
  1835. const mailboxes = this.listMailboxes(account.name);
  1836. let accountMessages = 0;
  1837. let accountUnread = 0;
  1838. const mailboxStats = mailboxes.map((mb) => {
  1839. accountMessages += mb.messageCount;
  1840. accountUnread += mb.unreadCount;
  1841. return {
  1842. name: mb.name,
  1843. messageCount: mb.messageCount,
  1844. unreadCount: mb.unreadCount,
  1845. };
  1846. });
  1847. totalMessages += accountMessages;
  1848. totalUnread += accountUnread;
  1849. accountStats.push({
  1850. name: account.name,
  1851. totalMessages: accountMessages,
  1852. unreadMessages: accountUnread,
  1853. mailboxCount: mailboxes.length,
  1854. mailboxes: mailboxStats,
  1855. });
  1856. }
  1857. // Get recently received stats
  1858. const recentlyReceived = this.getRecentlyReceivedStats();
  1859. return {
  1860. totalMessages,
  1861. totalUnread,
  1862. accounts: accountStats,
  1863. recentlyReceived,
  1864. };
  1865. }
  1866. /**
  1867. * Get counts of recently received messages.
  1868. *
  1869. * Only counts messages in INBOX for performance (scanning all mailboxes
  1870. * is too slow for large accounts).
  1871. *
  1872. * @returns Counts of messages received in last 24h, 7d, and 30d
  1873. */
  1874. getRecentlyReceivedStats() {
  1875. // Get message counts for different time periods
  1876. const now = new Date();
  1877. const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
  1878. const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
  1879. const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
  1880. // Format dates for AppleScript comparison
  1881. const formatDate = (d) => {
  1882. const months = [
  1883. "January",
  1884. "February",
  1885. "March",
  1886. "April",
  1887. "May",
  1888. "June",
  1889. "July",
  1890. "August",
  1891. "September",
  1892. "October",
  1893. "November",
  1894. "December",
  1895. ];
  1896. return `date "${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}"`;
  1897. };
  1898. // Only scan INBOX for performance - scanning all mailboxes is too slow
  1899. const script = buildAppLevelScript(`
  1900. set last24h to 0
  1901. set last7d to 0
  1902. set last30d to 0
  1903. set oneDayAgo to ${formatDate(oneDayAgo)}
  1904. set sevenDaysAgo to ${formatDate(sevenDaysAgo)}
  1905. set thirtyDaysAgo to ${formatDate(thirtyDaysAgo)}
  1906. repeat with acct in accounts
  1907. try
  1908. -- Try common inbox names
  1909. set inboxNames to {"INBOX", "Inbox", "inbox"}
  1910. repeat with inboxName in inboxNames
  1911. try
  1912. set theInbox to mailbox inboxName of acct
  1913. set last24h to last24h + (count of (messages of theInbox whose date received >= oneDayAgo))
  1914. set last7d to last7d + (count of (messages of theInbox whose date received >= sevenDaysAgo))
  1915. set last30d to last30d + (count of (messages of theInbox whose date received >= thirtyDaysAgo))
  1916. exit repeat
  1917. end try
  1918. end repeat
  1919. end try
  1920. end repeat
  1921. return (last24h as string) & "|||" & (last7d as string) & "|||" & (last30d as string)
  1922. `);
  1923. const result = executeAppleScript(script, { timeoutMs: 60000 });
  1924. if (!result.success || !result.output.trim()) {
  1925. console.error(`Failed to get recently received stats: ${result.error}`);
  1926. return { last24h: 0, last7d: 0, last30d: 0 };
  1927. }
  1928. const parts = result.output.split("|||");
  1929. if (parts.length < 3) {
  1930. return { last24h: 0, last7d: 0, last30d: 0 };
  1931. }
  1932. return {
  1933. last24h: parseInt(parts[0]) || 0,
  1934. last7d: parseInt(parts[1]) || 0,
  1935. last30d: parseInt(parts[2]) || 0,
  1936. };
  1937. }
  1938. /**
  1939. * Get sync status for Mail.app.
  1940. *
  1941. * Checks for sync activity indicators like:
  1942. * - Activity monitor status
  1943. * - Network activity status
  1944. * - Background refresh indicators
  1945. *
  1946. * @returns Sync status information
  1947. */
  1948. getSyncStatus() {
  1949. // Check for Mail.app background activity and sync status
  1950. // Mail.app doesn't expose sync status directly through AppleScript,
  1951. // so we check for recent changes and activity indicators
  1952. const script = buildAppLevelScript(`
  1953. set syncInfo to ""
  1954. -- Check if Mail.app is running
  1955. tell application "System Events"
  1956. set mailRunning to (name of processes) contains "Mail"
  1957. end tell
  1958. if not mailRunning then
  1959. return "not_running"
  1960. end if
  1961. -- Check for background activity by looking at message counts changing
  1962. -- This is a proxy for sync activity since Mail doesn't expose sync status
  1963. set accountCount to count of accounts
  1964. set totalMailboxes to 0
  1965. repeat with acct in accounts
  1966. set totalMailboxes to totalMailboxes + (count of mailboxes of acct)
  1967. end repeat
  1968. return "running|||" & accountCount & "|||" & totalMailboxes
  1969. `);
  1970. const result = executeAppleScript(script);
  1971. if (!result.success) {
  1972. return {
  1973. syncDetected: false,
  1974. pendingUpload: 0,
  1975. recentActivity: false,
  1976. secondsSinceLastChange: -1,
  1977. error: result.error,
  1978. };
  1979. }
  1980. if (result.output === "not_running") {
  1981. return {
  1982. syncDetected: false,
  1983. pendingUpload: 0,
  1984. recentActivity: false,
  1985. secondsSinceLastChange: -1,
  1986. error: "Mail.app is not running",
  1987. };
  1988. }
  1989. // Parse the response
  1990. const parts = result.output.split("|||");
  1991. const isRunning = parts[0] === "running";
  1992. const accountCount = parseInt(parts[1]) || 0;
  1993. // Mail.app is running with accounts configured - assume sync is active
  1994. // (Mail.app syncs automatically when running)
  1995. return {
  1996. syncDetected: isRunning && accountCount > 0,
  1997. pendingUpload: 0, // Not exposed by Mail.app
  1998. recentActivity: isRunning,
  1999. secondsSinceLastChange: 0,
  2000. };
  2001. }
  2002. }