appleMailManager.ts 75 KB

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