appleMailManager.js 81 KB

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