appleMailManager.js 76 KB

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