apple-mail-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1208 @@
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 { executeAppleScript } from "../utils/applescript.js";
16
+ // =============================================================================
17
+ // Text Processing Utilities
18
+ // =============================================================================
19
+ /**
20
+ * Escapes text for safe embedding in AppleScript string literals.
21
+ *
22
+ * AppleScript strings use double quotes, so we need to escape:
23
+ * 1. Backslashes (\) - escaped as \\
24
+ * 2. Double quotes (") - escaped as \"
25
+ *
26
+ * @param text - Raw text to escape
27
+ * @returns Text safe for AppleScript string embedding
28
+ */
29
+ function escapeForAppleScript(text) {
30
+ if (!text)
31
+ return "";
32
+ return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
33
+ }
34
+ /**
35
+ * Parses AppleScript date representation to JavaScript Date.
36
+ *
37
+ * AppleScript returns dates in a verbose format like:
38
+ * "date Saturday, December 27, 2025 at 3:44:02 PM"
39
+ *
40
+ * @param appleScriptDate - Date string from AppleScript
41
+ * @returns Parsed Date, or current date if parsing fails
42
+ */
43
+ function parseAppleScriptDate(appleScriptDate) {
44
+ const withoutPrefix = appleScriptDate.replace(/^date\s+/, "");
45
+ const normalized = withoutPrefix.replace(" at ", " ");
46
+ const parsed = new Date(normalized);
47
+ return isNaN(parsed.getTime()) ? new Date() : parsed;
48
+ }
49
+ /**
50
+ * Builds an AppleScript command scoped to a specific account.
51
+ */
52
+ function buildAccountScopedScript(account, command) {
53
+ return `
54
+ tell application "Mail"
55
+ tell account "${escapeForAppleScript(account)}"
56
+ ${command}
57
+ end tell
58
+ end tell
59
+ `;
60
+ }
61
+ /**
62
+ * Builds an AppleScript command at the application level.
63
+ */
64
+ function buildAppLevelScript(command) {
65
+ return `
66
+ tell application "Mail"
67
+ ${command}
68
+ end tell
69
+ `;
70
+ }
71
+ /**
72
+ * Common mailbox name variations across different account types.
73
+ * Maps normalized (lowercase) names to possible actual names.
74
+ */
75
+ const MAILBOX_ALIASES = {
76
+ inbox: ["INBOX", "Inbox", "inbox"],
77
+ sent: ["Sent", "Sent Items", "Sent Messages", "SENT", "sent"],
78
+ drafts: ["Drafts", "DRAFTS", "drafts", "Draft"],
79
+ trash: ["Trash", "Deleted Items", "Deleted Messages", "TRASH", "trash"],
80
+ junk: ["Junk", "Junk Email", "Spam", "JUNK", "junk"],
81
+ archive: ["Archive", "ARCHIVE", "archive", "All Mail"],
82
+ };
83
+ // =============================================================================
84
+ // Apple Mail Manager Class
85
+ // =============================================================================
86
+ /**
87
+ * Manager class for Apple Mail operations.
88
+ *
89
+ * Provides methods for:
90
+ * - Reading and searching messages
91
+ * - Sending emails
92
+ * - Managing mailboxes
93
+ * - Listing accounts
94
+ *
95
+ * All operations are synchronous since they rely on AppleScript
96
+ * execution via osascript. Error handling is consistent: methods
97
+ * return null/false/empty-array on failure rather than throwing.
98
+ */
99
+ export class AppleMailManager {
100
+ /**
101
+ * Default account used when no account is specified.
102
+ */
103
+ defaultAccount = null;
104
+ /**
105
+ * Resolves the account to use for an operation.
106
+ * Falls back to first available account if not specified.
107
+ */
108
+ resolveAccount(account) {
109
+ if (account)
110
+ return account;
111
+ if (this.defaultAccount)
112
+ return this.defaultAccount;
113
+ // Get first account as default
114
+ const accounts = this.listAccounts();
115
+ if (accounts.length > 0) {
116
+ this.defaultAccount = accounts[0].name;
117
+ return this.defaultAccount;
118
+ }
119
+ return "iCloud"; // Last resort fallback
120
+ }
121
+ /**
122
+ * Resolves a mailbox name to its actual name in the account.
123
+ *
124
+ * Different account types (IMAP, Exchange, iCloud) use different
125
+ * mailbox naming conventions:
126
+ * - IMAP/Gmail: "INBOX", "Sent", "Drafts"
127
+ * - Exchange: "Inbox", "Sent Items", "Deleted Items"
128
+ * - iCloud: "INBOX", "Sent", "Trash"
129
+ *
130
+ * This method tries to find a matching mailbox by:
131
+ * 1. Exact match
132
+ * 2. Case-insensitive match
133
+ * 3. Known aliases (e.g., "Sent" -> "Sent Items")
134
+ *
135
+ * @param mailbox - Requested mailbox name
136
+ * @param account - Account to search in
137
+ * @returns Actual mailbox name, or original if not found
138
+ */
139
+ resolveMailbox(mailbox, account) {
140
+ // Get actual mailbox names from the account
141
+ const script = buildAccountScopedScript(account, `
142
+ set mbNames to {}
143
+ repeat with mb in mailboxes
144
+ set end of mbNames to name of mb
145
+ end repeat
146
+ return mbNames
147
+ `);
148
+ const result = executeAppleScript(script);
149
+ if (!result.success || !result.output) {
150
+ return mailbox; // Fall back to original
151
+ }
152
+ // Parse the mailbox names (AppleScript returns comma-separated list)
153
+ const actualMailboxes = result.output.split(", ").map((s) => s.trim());
154
+ // 1. Try exact match
155
+ if (actualMailboxes.includes(mailbox)) {
156
+ return mailbox;
157
+ }
158
+ // 2. Try case-insensitive match
159
+ const lowerMailbox = mailbox.toLowerCase();
160
+ const caseMatch = actualMailboxes.find((mb) => mb.toLowerCase() === lowerMailbox);
161
+ if (caseMatch) {
162
+ return caseMatch;
163
+ }
164
+ // 3. Try known aliases
165
+ const aliases = MAILBOX_ALIASES[lowerMailbox];
166
+ if (aliases) {
167
+ for (const alias of aliases) {
168
+ if (actualMailboxes.includes(alias)) {
169
+ return alias;
170
+ }
171
+ // Also try case-insensitive alias match
172
+ const aliasMatch = actualMailboxes.find((mb) => mb.toLowerCase() === alias.toLowerCase());
173
+ if (aliasMatch) {
174
+ return aliasMatch;
175
+ }
176
+ }
177
+ }
178
+ // No match found, return original and let AppleScript handle the error
179
+ return mailbox;
180
+ }
181
+ // ===========================================================================
182
+ // Message Operations
183
+ // ===========================================================================
184
+ /**
185
+ * Search for messages matching criteria.
186
+ *
187
+ * @param query - Text to search for in subject or sender
188
+ * @param mailbox - Mailbox to search in (e.g., "INBOX")
189
+ * @param account - Account to search in
190
+ * @param limit - Maximum number of results
191
+ * @returns Array of matching messages
192
+ */
193
+ searchMessages(query, mailbox, account, limit = 50) {
194
+ const targetAccount = this.resolveAccount(account);
195
+ const requestedMailbox = mailbox || "INBOX";
196
+ const targetMailbox = this.resolveMailbox(requestedMailbox, targetAccount);
197
+ // Build the search condition
198
+ let searchCondition = "";
199
+ if (query) {
200
+ const safeQuery = escapeForAppleScript(query);
201
+ searchCondition = `whose subject contains "${safeQuery}" or sender contains "${safeQuery}"`;
202
+ }
203
+ const searchCommand = `
204
+ set outputText to ""
205
+ set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
206
+ set allMessages to messages of theMailbox ${searchCondition}
207
+ set msgCount to 0
208
+ repeat with msg in allMessages
209
+ if msgCount >= ${limit} then exit repeat
210
+ try
211
+ set msgId to id of msg as string
212
+ set msgSubject to subject of msg
213
+ set msgSender to sender of msg
214
+ set msgDate to date received of msg as string
215
+ set msgRead to read status of msg as string
216
+ set msgFlagged to flagged status of msg as string
217
+ if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
218
+ set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged
219
+ set msgCount to msgCount + 1
220
+ end try
221
+ end repeat
222
+ return outputText
223
+ `;
224
+ const script = buildAccountScopedScript(targetAccount, searchCommand);
225
+ const result = executeAppleScript(script);
226
+ if (!result.success) {
227
+ console.error(`Failed to search messages: ${result.error}`);
228
+ return [];
229
+ }
230
+ if (!result.output.trim())
231
+ return [];
232
+ return this.parseMessageList(result.output, targetMailbox, targetAccount);
233
+ }
234
+ /**
235
+ * Get a message by ID.
236
+ *
237
+ * Note: Mail.app message IDs are unique per mailbox. This method searches
238
+ * all mailboxes in all accounts to find the message.
239
+ */
240
+ getMessageById(id) {
241
+ const script = buildAppLevelScript(`
242
+ try
243
+ repeat with acct in accounts
244
+ repeat with mb in mailboxes of acct
245
+ try
246
+ set matchingMsgs to (messages of mb whose id is ${id})
247
+ if (count of matchingMsgs) > 0 then
248
+ set msg to item 1 of matchingMsgs
249
+ set msgSubject to subject of msg
250
+ set msgSender to sender of msg
251
+ set msgDate to date received of msg as string
252
+ set msgRead to read status of msg as string
253
+ set msgFlagged to flagged status of msg as string
254
+ set msgJunk to junk mail status of msg as string
255
+ set msgDeleted to deleted status of msg as string
256
+ set msgMailbox to name of mb
257
+ set msgAccount to name of acct
258
+ return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged & "|||" & msgJunk & "|||" & msgDeleted & "|||" & msgMailbox & "|||" & msgAccount
259
+ end if
260
+ end try
261
+ end repeat
262
+ end repeat
263
+ return ""
264
+ on error errMsg
265
+ return ""
266
+ end try
267
+ `);
268
+ const result = executeAppleScript(script, { timeoutMs: 60000 }); // Longer timeout for search
269
+ if (!result.success || !result.output.trim()) {
270
+ console.error(`Failed to get message ${id}: ${result.error}`);
271
+ return null;
272
+ }
273
+ const parts = result.output.split("|||");
274
+ if (parts.length < 9)
275
+ return null;
276
+ return {
277
+ id: id.toString(),
278
+ subject: parts[0],
279
+ sender: parts[1],
280
+ recipients: [],
281
+ dateReceived: parseAppleScriptDate(parts[2]),
282
+ isRead: parts[3] === "true",
283
+ isFlagged: parts[4] === "true",
284
+ isJunk: parts[5] === "true",
285
+ isDeleted: parts[6] === "true",
286
+ mailbox: parts[7],
287
+ account: parts[8],
288
+ hasAttachments: false,
289
+ };
290
+ }
291
+ /**
292
+ * Get the content of a message.
293
+ */
294
+ getMessageContent(id) {
295
+ const script = buildAppLevelScript(`
296
+ try
297
+ repeat with acct in accounts
298
+ repeat with mb in mailboxes of acct
299
+ try
300
+ set matchingMsgs to (messages of mb whose id is ${id})
301
+ if (count of matchingMsgs) > 0 then
302
+ set msg to item 1 of matchingMsgs
303
+ set msgSubject to subject of msg
304
+ set msgContent to content of msg
305
+ return msgSubject & "|||CONTENT|||" & msgContent
306
+ end if
307
+ end try
308
+ end repeat
309
+ end repeat
310
+ return ""
311
+ on error errMsg
312
+ return ""
313
+ end try
314
+ `);
315
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
316
+ if (!result.success || !result.output.trim()) {
317
+ console.error(`Failed to get message content: ${result.error}`);
318
+ return null;
319
+ }
320
+ const parts = result.output.split("|||CONTENT|||");
321
+ if (parts.length < 2)
322
+ return null;
323
+ return {
324
+ id: id.toString(),
325
+ subject: parts[0],
326
+ plainText: parts[1],
327
+ };
328
+ }
329
+ /**
330
+ * List messages in a mailbox.
331
+ *
332
+ * @param mailbox - Mailbox to list from (default: INBOX)
333
+ * @param account - Account to list from
334
+ * @param limit - Maximum number of messages
335
+ * @returns Array of messages
336
+ */
337
+ listMessages(mailbox, account, limit = 50) {
338
+ const targetAccount = this.resolveAccount(account);
339
+ const requestedMailbox = mailbox || "INBOX";
340
+ const targetMailbox = this.resolveMailbox(requestedMailbox, targetAccount);
341
+ const listCommand = `
342
+ set outputText to ""
343
+ set theMailbox to mailbox "${escapeForAppleScript(targetMailbox)}"
344
+ set msgCount to 0
345
+ repeat with msg in messages of theMailbox
346
+ if msgCount >= ${limit} then exit repeat
347
+ try
348
+ set msgId to id of msg as string
349
+ set msgSubject to subject of msg
350
+ set msgSender to sender of msg
351
+ set msgDate to date received of msg as string
352
+ set msgRead to read status of msg as string
353
+ set msgFlagged to flagged status of msg as string
354
+ if msgCount > 0 then set outputText to outputText & "|||ITEM|||"
355
+ set outputText to outputText & msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & msgRead & "|||" & msgFlagged
356
+ set msgCount to msgCount + 1
357
+ end try
358
+ end repeat
359
+ return outputText
360
+ `;
361
+ const script = buildAccountScopedScript(targetAccount, listCommand);
362
+ const result = executeAppleScript(script);
363
+ if (!result.success) {
364
+ console.error(`Failed to list messages: ${result.error}`);
365
+ return [];
366
+ }
367
+ if (!result.output.trim())
368
+ return [];
369
+ return this.parseMessageList(result.output, targetMailbox, targetAccount);
370
+ }
371
+ /**
372
+ * Parse message list output from AppleScript.
373
+ */
374
+ parseMessageList(output, mailbox, account) {
375
+ const items = output.split("|||ITEM|||");
376
+ const messages = [];
377
+ for (const item of items) {
378
+ const parts = item.split("|||");
379
+ if (parts.length < 6)
380
+ continue;
381
+ messages.push({
382
+ id: parts[0].trim(),
383
+ subject: parts[1],
384
+ sender: parts[2],
385
+ recipients: [],
386
+ dateReceived: parseAppleScriptDate(parts[3]),
387
+ isRead: parts[4] === "true",
388
+ isFlagged: parts[5] === "true",
389
+ isJunk: false,
390
+ isDeleted: false,
391
+ mailbox,
392
+ account,
393
+ hasAttachments: false,
394
+ });
395
+ }
396
+ return messages;
397
+ }
398
+ /**
399
+ * Send an email.
400
+ *
401
+ * @param to - Recipient email addresses
402
+ * @param subject - Email subject
403
+ * @param body - Email body (plain text)
404
+ * @param cc - CC recipients
405
+ * @param bcc - BCC recipients
406
+ * @param account - Account to send from
407
+ * @returns true if sent successfully
408
+ */
409
+ sendEmail(to, subject, body, cc, bcc, account) {
410
+ const safeSubject = escapeForAppleScript(subject);
411
+ const safeBody = escapeForAppleScript(body);
412
+ // Build recipient additions
413
+ let recipientCommands = "";
414
+ for (const addr of to) {
415
+ recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
416
+ }
417
+ if (cc) {
418
+ for (const addr of cc) {
419
+ recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
420
+ }
421
+ }
422
+ if (bcc) {
423
+ for (const addr of bcc) {
424
+ recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
425
+ }
426
+ }
427
+ let sendCommand;
428
+ if (account) {
429
+ const safeAccount = escapeForAppleScript(account);
430
+ sendCommand = `
431
+ set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
432
+ tell newMessage
433
+ ${recipientCommands}
434
+ set sender to "${safeAccount}"
435
+ end tell
436
+ send newMessage
437
+ return "sent"
438
+ `;
439
+ }
440
+ else {
441
+ sendCommand = `
442
+ set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:true}
443
+ tell newMessage
444
+ ${recipientCommands}
445
+ end tell
446
+ send newMessage
447
+ return "sent"
448
+ `;
449
+ }
450
+ const script = buildAppLevelScript(sendCommand);
451
+ const result = executeAppleScript(script);
452
+ if (!result.success) {
453
+ console.error(`Failed to send email: ${result.error}`);
454
+ return false;
455
+ }
456
+ return result.output.includes("sent");
457
+ }
458
+ /**
459
+ * Create a draft email (saved to Drafts folder, not sent).
460
+ *
461
+ * @param to - Recipient email addresses
462
+ * @param subject - Email subject
463
+ * @param body - Email body (plain text)
464
+ * @param cc - CC recipients
465
+ * @param bcc - BCC recipients
466
+ * @param account - Account to create draft in
467
+ * @returns true if draft created successfully
468
+ */
469
+ createDraft(to, subject, body, cc, bcc, account) {
470
+ const safeSubject = escapeForAppleScript(subject);
471
+ const safeBody = escapeForAppleScript(body);
472
+ // Build recipient additions
473
+ let recipientCommands = "";
474
+ for (const addr of to) {
475
+ recipientCommands += `make new to recipient at end of to recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
476
+ }
477
+ if (cc) {
478
+ for (const addr of cc) {
479
+ recipientCommands += `make new cc recipient at end of cc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
480
+ }
481
+ }
482
+ if (bcc) {
483
+ for (const addr of bcc) {
484
+ recipientCommands += `make new bcc recipient at end of bcc recipients with properties {address:"${escapeForAppleScript(addr)}"}\n`;
485
+ }
486
+ }
487
+ let draftCommand;
488
+ if (account) {
489
+ const safeAccount = escapeForAppleScript(account);
490
+ draftCommand = `
491
+ set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
492
+ tell newMessage
493
+ ${recipientCommands}
494
+ set sender to "${safeAccount}"
495
+ end tell
496
+ return "draft created"
497
+ `;
498
+ }
499
+ else {
500
+ draftCommand = `
501
+ set newMessage to make new outgoing message with properties {subject:"${safeSubject}", content:"${safeBody}", visible:false}
502
+ tell newMessage
503
+ ${recipientCommands}
504
+ end tell
505
+ return "draft created"
506
+ `;
507
+ }
508
+ const script = buildAppLevelScript(draftCommand);
509
+ const result = executeAppleScript(script);
510
+ if (!result.success) {
511
+ console.error(`Failed to create draft: ${result.error}`);
512
+ return false;
513
+ }
514
+ return result.output.includes("draft created");
515
+ }
516
+ /**
517
+ * Reply to a message.
518
+ *
519
+ * @param id - Message ID to reply to
520
+ * @param body - Reply body
521
+ * @param replyAll - If true, reply to all recipients
522
+ * @param send - If true, send immediately; if false, save as draft
523
+ * @returns true if reply created/sent successfully
524
+ */
525
+ replyToMessage(id, body, replyAll = false, send = true) {
526
+ const safeBody = escapeForAppleScript(body);
527
+ const replyAllClause = replyAll ? " with reply to all" : "";
528
+ const sendAction = send ? "send theReply" : "";
529
+ const script = buildAppLevelScript(`
530
+ try
531
+ repeat with acct in accounts
532
+ repeat with mb in mailboxes of acct
533
+ try
534
+ set matchingMsgs to (messages of mb whose id is ${id})
535
+ if (count of matchingMsgs) > 0 then
536
+ set msg to item 1 of matchingMsgs
537
+ set theReply to reply msg with opening window${replyAllClause}
538
+ set content of theReply to "${safeBody}" & return & return & content of theReply
539
+ ${sendAction}
540
+ return "ok"
541
+ end if
542
+ end try
543
+ end repeat
544
+ end repeat
545
+ return "error:Message not found"
546
+ on error errMsg
547
+ return "error:" & errMsg
548
+ end try
549
+ `);
550
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
551
+ if (!result.success || result.output.startsWith("error:")) {
552
+ console.error(`Failed to reply to message: ${result.error || result.output}`);
553
+ return false;
554
+ }
555
+ return true;
556
+ }
557
+ /**
558
+ * Forward a message.
559
+ *
560
+ * @param id - Message ID to forward
561
+ * @param to - Recipients to forward to
562
+ * @param body - Optional body to prepend
563
+ * @param send - If true, send immediately; if false, save as draft
564
+ * @returns true if forward created/sent successfully
565
+ */
566
+ forwardMessage(id, to, body, send = true) {
567
+ const safeBody = body ? escapeForAppleScript(body) : "";
568
+ const sendAction = send ? "send theForward" : "";
569
+ // Build recipient additions
570
+ let recipientCommands = "";
571
+ for (const addr of to) {
572
+ recipientCommands += `make new to recipient at end of to recipients of theForward with properties {address:"${escapeForAppleScript(addr)}"}\n`;
573
+ }
574
+ const script = buildAppLevelScript(`
575
+ try
576
+ repeat with acct in accounts
577
+ repeat with mb in mailboxes of acct
578
+ try
579
+ set matchingMsgs to (messages of mb whose id is ${id})
580
+ if (count of matchingMsgs) > 0 then
581
+ set msg to item 1 of matchingMsgs
582
+ set theForward to forward msg with opening window
583
+ ${recipientCommands}
584
+ ${safeBody ? `set content of theForward to "${safeBody}" & return & return & content of theForward` : ""}
585
+ ${sendAction}
586
+ return "ok"
587
+ end if
588
+ end try
589
+ end repeat
590
+ end repeat
591
+ return "error:Message not found"
592
+ on error errMsg
593
+ return "error:" & errMsg
594
+ end try
595
+ `);
596
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
597
+ if (!result.success || result.output.startsWith("error:")) {
598
+ console.error(`Failed to forward message: ${result.error || result.output}`);
599
+ return false;
600
+ }
601
+ return true;
602
+ }
603
+ /**
604
+ * Helper to find and operate on a message by ID.
605
+ */
606
+ findMessageScript(id, operation) {
607
+ return buildAppLevelScript(`
608
+ try
609
+ repeat with acct in accounts
610
+ repeat with mb in mailboxes of acct
611
+ try
612
+ set matchingMsgs to (messages of mb whose id is ${id})
613
+ if (count of matchingMsgs) > 0 then
614
+ set msg to item 1 of matchingMsgs
615
+ ${operation}
616
+ return "ok"
617
+ end if
618
+ end try
619
+ end repeat
620
+ end repeat
621
+ return "error:Message not found"
622
+ on error errMsg
623
+ return "error:" & errMsg
624
+ end try
625
+ `);
626
+ }
627
+ /**
628
+ * Mark a message as read.
629
+ */
630
+ markAsRead(id) {
631
+ const script = this.findMessageScript(id, "set read status of msg to true");
632
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
633
+ if (!result.success || result.output.startsWith("error:")) {
634
+ console.error(`Failed to mark message as read: ${result.error || result.output}`);
635
+ return false;
636
+ }
637
+ return true;
638
+ }
639
+ /**
640
+ * Mark a message as unread.
641
+ */
642
+ markAsUnread(id) {
643
+ const script = this.findMessageScript(id, "set read status of msg to false");
644
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
645
+ if (!result.success || result.output.startsWith("error:")) {
646
+ console.error(`Failed to mark message as unread: ${result.error || result.output}`);
647
+ return false;
648
+ }
649
+ return true;
650
+ }
651
+ /**
652
+ * Flag a message.
653
+ */
654
+ flagMessage(id) {
655
+ const script = this.findMessageScript(id, "set flagged status of msg to true");
656
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
657
+ if (!result.success || result.output.startsWith("error:")) {
658
+ console.error(`Failed to flag message: ${result.error || result.output}`);
659
+ return false;
660
+ }
661
+ return true;
662
+ }
663
+ /**
664
+ * Unflag a message.
665
+ */
666
+ unflagMessage(id) {
667
+ const script = this.findMessageScript(id, "set flagged status of msg to false");
668
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
669
+ if (!result.success || result.output.startsWith("error:")) {
670
+ console.error(`Failed to unflag message: ${result.error || result.output}`);
671
+ return false;
672
+ }
673
+ return true;
674
+ }
675
+ /**
676
+ * Delete a message.
677
+ */
678
+ deleteMessage(id) {
679
+ const script = this.findMessageScript(id, "delete msg");
680
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
681
+ if (!result.success || result.output.startsWith("error:")) {
682
+ console.error(`Failed to delete message: ${result.error || result.output}`);
683
+ return false;
684
+ }
685
+ return true;
686
+ }
687
+ /**
688
+ * Move a message to a different mailbox.
689
+ */
690
+ moveMessage(id, mailbox, account) {
691
+ const targetAccount = this.resolveAccount(account);
692
+ const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
693
+ const safeMailbox = escapeForAppleScript(targetMailbox);
694
+ const safeAccount = escapeForAppleScript(targetAccount);
695
+ const script = buildAppLevelScript(`
696
+ try
697
+ repeat with acct in accounts
698
+ repeat with mb in mailboxes of acct
699
+ try
700
+ set matchingMsgs to (messages of mb whose id is ${id})
701
+ if (count of matchingMsgs) > 0 then
702
+ set msg to item 1 of matchingMsgs
703
+ set destMailbox to mailbox "${safeMailbox}" of account "${safeAccount}"
704
+ move msg to destMailbox
705
+ return "ok"
706
+ end if
707
+ end try
708
+ end repeat
709
+ end repeat
710
+ return "error:Message not found"
711
+ on error errMsg
712
+ return "error:" & errMsg
713
+ end try
714
+ `);
715
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
716
+ if (!result.success || result.output.startsWith("error:")) {
717
+ console.error(`Failed to move message: ${result.error || result.output}`);
718
+ return false;
719
+ }
720
+ return true;
721
+ }
722
+ // ===========================================================================
723
+ // Batch Operations
724
+ // ===========================================================================
725
+ /**
726
+ * Delete multiple messages at once.
727
+ *
728
+ * @param ids - Array of message IDs to delete
729
+ * @returns Array of results for each message
730
+ */
731
+ batchDeleteMessages(ids) {
732
+ const results = [];
733
+ for (const id of ids) {
734
+ const success = this.deleteMessage(id);
735
+ results.push({
736
+ id,
737
+ success,
738
+ error: success ? undefined : "Failed to delete message",
739
+ });
740
+ }
741
+ return results;
742
+ }
743
+ /**
744
+ * Move multiple messages to a mailbox at once.
745
+ *
746
+ * @param ids - Array of message IDs to move
747
+ * @param mailbox - Destination mailbox name
748
+ * @param account - Account containing the destination mailbox
749
+ * @returns Array of results for each message
750
+ */
751
+ batchMoveMessages(ids, mailbox, account) {
752
+ const results = [];
753
+ for (const id of ids) {
754
+ const success = this.moveMessage(id, mailbox, account);
755
+ results.push({
756
+ id,
757
+ success,
758
+ error: success ? undefined : "Failed to move message",
759
+ });
760
+ }
761
+ return results;
762
+ }
763
+ /**
764
+ * Mark multiple messages as read at once.
765
+ *
766
+ * @param ids - Array of message IDs to mark as read
767
+ * @returns Array of results for each message
768
+ */
769
+ batchMarkAsRead(ids) {
770
+ const results = [];
771
+ for (const id of ids) {
772
+ const success = this.markAsRead(id);
773
+ results.push({
774
+ id,
775
+ success,
776
+ error: success ? undefined : "Failed to mark message as read",
777
+ });
778
+ }
779
+ return results;
780
+ }
781
+ /**
782
+ * List attachments for a message.
783
+ */
784
+ listAttachments(id) {
785
+ const script = buildAppLevelScript(`
786
+ try
787
+ repeat with acct in accounts
788
+ repeat with mb in mailboxes of acct
789
+ try
790
+ set matchingMsgs to (messages of mb whose id is ${id})
791
+ if (count of matchingMsgs) > 0 then
792
+ set msg to item 1 of matchingMsgs
793
+ set outputText to ""
794
+ set attCount to 0
795
+ repeat with att in mail attachments of msg
796
+ set attName to name of att
797
+ set attType to MIME type of att
798
+ set attSize to file size of att as string
799
+ if attCount > 0 then set outputText to outputText & "|||ITEM|||"
800
+ set outputText to outputText & attName & "|||" & attType & "|||" & attSize
801
+ set attCount to attCount + 1
802
+ end repeat
803
+ return outputText
804
+ end if
805
+ end try
806
+ end repeat
807
+ end repeat
808
+ return ""
809
+ on error errMsg
810
+ return ""
811
+ end try
812
+ `);
813
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
814
+ if (!result.success || !result.output.trim()) {
815
+ return [];
816
+ }
817
+ const items = result.output.split("|||ITEM|||");
818
+ const attachments = [];
819
+ for (const item of items) {
820
+ const parts = item.split("|||");
821
+ if (parts.length < 3)
822
+ continue;
823
+ attachments.push({
824
+ id: `${id}-${parts[0]}`,
825
+ name: parts[0],
826
+ mimeType: parts[1],
827
+ size: parseInt(parts[2]) || 0,
828
+ });
829
+ }
830
+ return attachments;
831
+ }
832
+ // ===========================================================================
833
+ // Mailbox Operations
834
+ // ===========================================================================
835
+ /**
836
+ * List all mailboxes for an account.
837
+ */
838
+ listMailboxes(account) {
839
+ const targetAccount = this.resolveAccount(account);
840
+ const listCommand = `
841
+ set mailboxList to {}
842
+ repeat with mb in mailboxes
843
+ set mbName to name of mb
844
+ set mbUnread to unread count of mb
845
+ set mbCount to count of messages of mb
846
+ set end of mailboxList to mbName & "|||" & mbUnread & "|||" & mbCount
847
+ end repeat
848
+ set AppleScript's text item delimiters to "|||ITEM|||"
849
+ return mailboxList as text
850
+ `;
851
+ const script = buildAccountScopedScript(targetAccount, listCommand);
852
+ const result = executeAppleScript(script);
853
+ if (!result.success) {
854
+ console.error(`Failed to list mailboxes: ${result.error}`);
855
+ return [];
856
+ }
857
+ if (!result.output.trim())
858
+ return [];
859
+ const items = result.output.split("|||ITEM|||");
860
+ const mailboxes = [];
861
+ for (const item of items) {
862
+ const parts = item.split("|||");
863
+ if (parts.length < 3)
864
+ continue;
865
+ mailboxes.push({
866
+ name: parts[0],
867
+ account: targetAccount,
868
+ unreadCount: parseInt(parts[1]) || 0,
869
+ messageCount: parseInt(parts[2]) || 0,
870
+ });
871
+ }
872
+ return mailboxes;
873
+ }
874
+ /**
875
+ * Get unread count for a mailbox.
876
+ */
877
+ getUnreadCount(mailbox, account) {
878
+ const targetAccount = this.resolveAccount(account);
879
+ let command;
880
+ if (mailbox) {
881
+ const targetMailbox = this.resolveMailbox(mailbox, targetAccount);
882
+ const safeMailbox = escapeForAppleScript(targetMailbox);
883
+ command = `return unread count of mailbox "${safeMailbox}"`;
884
+ }
885
+ else {
886
+ // Get total unread across all mailboxes
887
+ command = `
888
+ set total to 0
889
+ repeat with mb in mailboxes
890
+ set total to total + (unread count of mb)
891
+ end repeat
892
+ return total
893
+ `;
894
+ }
895
+ const script = buildAccountScopedScript(targetAccount, command);
896
+ const result = executeAppleScript(script);
897
+ if (!result.success) {
898
+ console.error(`Failed to get unread count: ${result.error}`);
899
+ return 0;
900
+ }
901
+ return parseInt(result.output) || 0;
902
+ }
903
+ // ===========================================================================
904
+ // Account Operations
905
+ // ===========================================================================
906
+ /**
907
+ * List all mail accounts.
908
+ */
909
+ listAccounts() {
910
+ const script = buildAppLevelScript(`
911
+ set accountList to {}
912
+ repeat with acct in accounts
913
+ set acctName to name of acct
914
+ set acctEmail to email addresses of acct
915
+ set acctEnabled to enabled of acct
916
+ set emailStr to ""
917
+ if (count of acctEmail) > 0 then
918
+ set emailStr to item 1 of acctEmail
919
+ end if
920
+ set end of accountList to acctName & "|||" & emailStr & "|||" & acctEnabled
921
+ end repeat
922
+ set AppleScript's text item delimiters to "|||ITEM|||"
923
+ return accountList as text
924
+ `);
925
+ const result = executeAppleScript(script);
926
+ if (!result.success) {
927
+ console.error(`Failed to list accounts: ${result.error}`);
928
+ return [];
929
+ }
930
+ if (!result.output.trim())
931
+ return [];
932
+ const items = result.output.split("|||ITEM|||");
933
+ const accounts = [];
934
+ for (const item of items) {
935
+ const parts = item.split("|||");
936
+ if (parts.length < 3)
937
+ continue;
938
+ accounts.push({
939
+ name: parts[0],
940
+ email: parts[1],
941
+ enabled: parts[2] === "true",
942
+ });
943
+ }
944
+ return accounts;
945
+ }
946
+ // ===========================================================================
947
+ // Diagnostics
948
+ // ===========================================================================
949
+ /**
950
+ * Run health check on Mail.app connectivity.
951
+ */
952
+ healthCheck() {
953
+ const checks = [];
954
+ // Check 1: Mail.app is accessible
955
+ const mailCheck = executeAppleScript('tell application "Mail" to return "ok"');
956
+ if (mailCheck.success && mailCheck.output === "ok") {
957
+ checks.push({
958
+ name: "mail_app",
959
+ passed: true,
960
+ message: "Mail.app is accessible",
961
+ });
962
+ }
963
+ else {
964
+ const errorHint = mailCheck.error?.includes("not authorized")
965
+ ? " (check Automation permissions in System Preferences)"
966
+ : "";
967
+ checks.push({
968
+ name: "mail_app",
969
+ passed: false,
970
+ message: `Mail.app is not accessible${errorHint}`,
971
+ });
972
+ return { healthy: false, checks };
973
+ }
974
+ // Check 2: AppleScript permissions
975
+ const permCheck = executeAppleScript('tell application "Mail" to get name of account 1');
976
+ if (permCheck.success) {
977
+ checks.push({
978
+ name: "permissions",
979
+ passed: true,
980
+ message: "AppleScript automation permissions granted",
981
+ });
982
+ }
983
+ else {
984
+ const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
985
+ checks.push({
986
+ name: "permissions",
987
+ passed: !isPermError,
988
+ message: isPermError
989
+ ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
990
+ : `Permission check returned: ${permCheck.error}`,
991
+ });
992
+ if (isPermError) {
993
+ return { healthy: false, checks };
994
+ }
995
+ }
996
+ // Check 3: At least one account accessible
997
+ const accounts = this.listAccounts();
998
+ if (accounts.length > 0) {
999
+ const accountNames = accounts.map((a) => a.name).join(", ");
1000
+ checks.push({
1001
+ name: "accounts",
1002
+ passed: true,
1003
+ message: `Found ${accounts.length} account(s): ${accountNames}`,
1004
+ });
1005
+ }
1006
+ else {
1007
+ checks.push({
1008
+ name: "accounts",
1009
+ passed: false,
1010
+ message: "No Mail accounts found. Set up an account in Mail.app first.",
1011
+ });
1012
+ return { healthy: false, checks };
1013
+ }
1014
+ // Check 4: Basic operations work
1015
+ const mailboxes = this.listMailboxes(accounts[0].name);
1016
+ checks.push({
1017
+ name: "operations",
1018
+ passed: true,
1019
+ message: `Basic operations working (${mailboxes.length} mailbox(es) in ${accounts[0].name})`,
1020
+ });
1021
+ return {
1022
+ healthy: checks.every((c) => c.passed),
1023
+ checks,
1024
+ };
1025
+ }
1026
+ /**
1027
+ * Get mail statistics.
1028
+ */
1029
+ getMailStats() {
1030
+ const accounts = this.listAccounts();
1031
+ const accountStats = [];
1032
+ let totalMessages = 0;
1033
+ let totalUnread = 0;
1034
+ for (const account of accounts) {
1035
+ const mailboxes = this.listMailboxes(account.name);
1036
+ let accountMessages = 0;
1037
+ let accountUnread = 0;
1038
+ const mailboxStats = mailboxes.map((mb) => {
1039
+ accountMessages += mb.messageCount;
1040
+ accountUnread += mb.unreadCount;
1041
+ return {
1042
+ name: mb.name,
1043
+ messageCount: mb.messageCount,
1044
+ unreadCount: mb.unreadCount,
1045
+ };
1046
+ });
1047
+ totalMessages += accountMessages;
1048
+ totalUnread += accountUnread;
1049
+ accountStats.push({
1050
+ name: account.name,
1051
+ totalMessages: accountMessages,
1052
+ unreadMessages: accountUnread,
1053
+ mailboxCount: mailboxes.length,
1054
+ mailboxes: mailboxStats,
1055
+ });
1056
+ }
1057
+ // Get recently received stats
1058
+ const recentlyReceived = this.getRecentlyReceivedStats();
1059
+ return {
1060
+ totalMessages,
1061
+ totalUnread,
1062
+ accounts: accountStats,
1063
+ recentlyReceived,
1064
+ };
1065
+ }
1066
+ /**
1067
+ * Get counts of recently received messages.
1068
+ *
1069
+ * Only counts messages in INBOX for performance (scanning all mailboxes
1070
+ * is too slow for large accounts).
1071
+ *
1072
+ * @returns Counts of messages received in last 24h, 7d, and 30d
1073
+ */
1074
+ getRecentlyReceivedStats() {
1075
+ // Get message counts for different time periods
1076
+ const now = new Date();
1077
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1078
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
1079
+ const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
1080
+ // Format dates for AppleScript comparison
1081
+ const formatDate = (d) => {
1082
+ const months = [
1083
+ "January",
1084
+ "February",
1085
+ "March",
1086
+ "April",
1087
+ "May",
1088
+ "June",
1089
+ "July",
1090
+ "August",
1091
+ "September",
1092
+ "October",
1093
+ "November",
1094
+ "December",
1095
+ ];
1096
+ return `date "${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}"`;
1097
+ };
1098
+ // Only scan INBOX for performance - scanning all mailboxes is too slow
1099
+ const script = buildAppLevelScript(`
1100
+ set last24h to 0
1101
+ set last7d to 0
1102
+ set last30d to 0
1103
+ set oneDayAgo to ${formatDate(oneDayAgo)}
1104
+ set sevenDaysAgo to ${formatDate(sevenDaysAgo)}
1105
+ set thirtyDaysAgo to ${formatDate(thirtyDaysAgo)}
1106
+
1107
+ repeat with acct in accounts
1108
+ try
1109
+ -- Try common inbox names
1110
+ set inboxNames to {"INBOX", "Inbox", "inbox"}
1111
+ repeat with inboxName in inboxNames
1112
+ try
1113
+ set theInbox to mailbox inboxName of acct
1114
+ set last24h to last24h + (count of (messages of theInbox whose date received >= oneDayAgo))
1115
+ set last7d to last7d + (count of (messages of theInbox whose date received >= sevenDaysAgo))
1116
+ set last30d to last30d + (count of (messages of theInbox whose date received >= thirtyDaysAgo))
1117
+ exit repeat
1118
+ end try
1119
+ end repeat
1120
+ end try
1121
+ end repeat
1122
+
1123
+ return (last24h as string) & "|||" & (last7d as string) & "|||" & (last30d as string)
1124
+ `);
1125
+ const result = executeAppleScript(script, { timeoutMs: 60000 });
1126
+ if (!result.success || !result.output.trim()) {
1127
+ console.error(`Failed to get recently received stats: ${result.error}`);
1128
+ return { last24h: 0, last7d: 0, last30d: 0 };
1129
+ }
1130
+ const parts = result.output.split("|||");
1131
+ if (parts.length < 3) {
1132
+ return { last24h: 0, last7d: 0, last30d: 0 };
1133
+ }
1134
+ return {
1135
+ last24h: parseInt(parts[0]) || 0,
1136
+ last7d: parseInt(parts[1]) || 0,
1137
+ last30d: parseInt(parts[2]) || 0,
1138
+ };
1139
+ }
1140
+ /**
1141
+ * Get sync status for Mail.app.
1142
+ *
1143
+ * Checks for sync activity indicators like:
1144
+ * - Activity monitor status
1145
+ * - Network activity status
1146
+ * - Background refresh indicators
1147
+ *
1148
+ * @returns Sync status information
1149
+ */
1150
+ getSyncStatus() {
1151
+ // Check for Mail.app background activity and sync status
1152
+ // Mail.app doesn't expose sync status directly through AppleScript,
1153
+ // so we check for recent changes and activity indicators
1154
+ const script = buildAppLevelScript(`
1155
+ set syncInfo to ""
1156
+
1157
+ -- Check if Mail.app is running
1158
+ tell application "System Events"
1159
+ set mailRunning to (name of processes) contains "Mail"
1160
+ end tell
1161
+
1162
+ if not mailRunning then
1163
+ return "not_running"
1164
+ end if
1165
+
1166
+ -- Check for background activity by looking at message counts changing
1167
+ -- This is a proxy for sync activity since Mail doesn't expose sync status
1168
+ set accountCount to count of accounts
1169
+ set totalMailboxes to 0
1170
+ repeat with acct in accounts
1171
+ set totalMailboxes to totalMailboxes + (count of mailboxes of acct)
1172
+ end repeat
1173
+
1174
+ return "running|||" & accountCount & "|||" & totalMailboxes
1175
+ `);
1176
+ const result = executeAppleScript(script);
1177
+ if (!result.success) {
1178
+ return {
1179
+ syncDetected: false,
1180
+ pendingUpload: 0,
1181
+ recentActivity: false,
1182
+ secondsSinceLastChange: -1,
1183
+ error: result.error,
1184
+ };
1185
+ }
1186
+ if (result.output === "not_running") {
1187
+ return {
1188
+ syncDetected: false,
1189
+ pendingUpload: 0,
1190
+ recentActivity: false,
1191
+ secondsSinceLastChange: -1,
1192
+ error: "Mail.app is not running",
1193
+ };
1194
+ }
1195
+ // Parse the response
1196
+ const parts = result.output.split("|||");
1197
+ const isRunning = parts[0] === "running";
1198
+ const accountCount = parseInt(parts[1]) || 0;
1199
+ // Mail.app is running with accounts configured - assume sync is active
1200
+ // (Mail.app syncs automatically when running)
1201
+ return {
1202
+ syncDetected: isRunning && accountCount > 0,
1203
+ pendingUpload: 0, // Not exposed by Mail.app
1204
+ recentActivity: isRunning,
1205
+ secondsSinceLastChange: 0,
1206
+ };
1207
+ }
1208
+ }