@l22-io/orchard-mcp 0.3.2 → 0.6.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.
Files changed (42) hide show
  1. package/README.md +11 -8
  2. package/build/bridge.js +18 -0
  3. package/build/bridge.js.map +1 -1
  4. package/build/index.js +11 -1
  5. package/build/index.js.map +1 -1
  6. package/build/tools/contacts.d.ts +2 -0
  7. package/build/tools/contacts.js +37 -0
  8. package/build/tools/contacts.js.map +1 -0
  9. package/build/tools/files.js +4 -1
  10. package/build/tools/files.js.map +1 -1
  11. package/build/tools/keynote.d.ts +2 -0
  12. package/build/tools/keynote.js +198 -0
  13. package/build/tools/keynote.js.map +1 -0
  14. package/build/tools/mail.js +57 -10
  15. package/build/tools/mail.js.map +1 -1
  16. package/build/tools/notes.d.ts +2 -0
  17. package/build/tools/notes.js +83 -0
  18. package/build/tools/notes.js.map +1 -0
  19. package/build/tools/numbers.d.ts +2 -0
  20. package/build/tools/numbers.js +167 -0
  21. package/build/tools/numbers.js.map +1 -0
  22. package/build/tools/pages.d.ts +2 -0
  23. package/build/tools/pages.js +143 -0
  24. package/build/tools/pages.js.map +1 -0
  25. package/package.json +11 -8
  26. package/scripts/postinstall.sh +77 -8
  27. package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
  28. package/swift/.build/AppleBridge.app.sha256 +1 -0
  29. package/swift/Package.resolved +14 -0
  30. package/swift/Package.swift +28 -0
  31. package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
  32. package/swift/Sources/AppleBridge/Calendar.swift +221 -0
  33. package/swift/Sources/AppleBridge/Contacts.swift +225 -0
  34. package/swift/Sources/AppleBridge/Doctor.swift +252 -0
  35. package/swift/Sources/AppleBridge/Files.swift +474 -0
  36. package/swift/Sources/AppleBridge/JSON.swift +57 -0
  37. package/swift/Sources/AppleBridge/Keynote.swift +599 -0
  38. package/swift/Sources/AppleBridge/Mail.swift +794 -0
  39. package/swift/Sources/AppleBridge/Notes.swift +263 -0
  40. package/swift/Sources/AppleBridge/Numbers.swift +601 -0
  41. package/swift/Sources/AppleBridge/Pages.swift +467 -0
  42. package/swift/Sources/AppleBridge/Reminders.swift +347 -0
@@ -0,0 +1,794 @@
1
+ import Foundation
2
+
3
+ // Reason: No native Mail framework exists for reading messages. AppleScript via
4
+ // osascript is the only supported approach that doesn't require Full Disk Access.
5
+ // Same pattern as Doctor.swift checkMailAccess().
6
+
7
+ enum MailBridge {
8
+
9
+ // MARK: - Public API
10
+
11
+ /// List all mail accounts with their mailboxes and unread counts.
12
+ static func listAccounts() {
13
+ let script = """
14
+ tell application "Mail"
15
+ set resultList to {}
16
+ repeat with acct in every account
17
+ set acctName to name of acct
18
+ try
19
+ set acctEmail to my joinList(email addresses of acct, ",")
20
+ on error
21
+ set acctEmail to ""
22
+ end try
23
+ set mboxList to my listMailboxes(acct, "")
24
+ set end of resultList to acctName & "|||" & acctEmail & "|||" & (my joinList(mboxList, "^^^"))
25
+ end repeat
26
+ return my joinList(resultList, "###")
27
+ end tell
28
+
29
+ on listMailboxes(parentMbox, prefix)
30
+ set mboxList to {}
31
+ tell application "Mail"
32
+ set childBoxes to every mailbox of parentMbox
33
+ repeat with mbox in childBoxes
34
+ set fullName to prefix & name of mbox
35
+ set mboxUnread to unread count of mbox
36
+ set end of mboxList to fullName & "::" & (mboxUnread as string)
37
+ end repeat
38
+ end tell
39
+ repeat with i from 1 to count of childBoxes
40
+ set mbox to item i of childBoxes
41
+ tell application "Mail"
42
+ set mboxName to prefix & name of mbox
43
+ end tell
44
+ set subList to my listMailboxes(mbox, mboxName & "/")
45
+ set mboxList to mboxList & subList
46
+ end repeat
47
+ return mboxList
48
+ end listMailboxes
49
+
50
+ on joinList(theList, delim)
51
+ set oldDelim to AppleScript's text item delimiters
52
+ set AppleScript's text item delimiters to delim
53
+ set theResult to theList as string
54
+ set AppleScript's text item delimiters to oldDelim
55
+ return theResult
56
+ end joinList
57
+ """
58
+
59
+ guard let raw = runAppleScript(script) else { return }
60
+ let accounts = parseAccountList(raw)
61
+ JSONOutput.success(accounts)
62
+ }
63
+
64
+ /// Unread summary: count per account + recent unread subjects/senders.
65
+ static func unreadSummary(limit: Int) {
66
+ let script = """
67
+ tell application "Mail"
68
+ set resultList to {}
69
+ repeat with acct in every account
70
+ set acctName to name of acct
71
+ set msgs to {}
72
+ set msgCount to 0
73
+ -- Try inbox keyword first; fall back to All Mail only for accounts
74
+ -- where inbox keyword fails (e.g. Proton Bridge).
75
+ try
76
+ set msgs to (every message of inbox of acct whose read status is false)
77
+ set msgCount to count of msgs
78
+ on error
79
+ try
80
+ set msgs to (every message of mailbox "All Mail" of acct whose read status is false)
81
+ set msgCount to count of msgs
82
+ end try
83
+ end try
84
+ set maxItems to \(limit)
85
+ if msgCount < maxItems then set maxItems to msgCount
86
+ set msgList to {}
87
+ repeat with i from 1 to maxItems
88
+ try
89
+ set msg to item i of msgs
90
+ set msgSubject to subject of msg
91
+ set msgSender to sender of msg
92
+ set msgDate to date received of msg as «class isot» as string
93
+ set msgFlagged to flagged status of msg
94
+ set msgId to message id of msg
95
+ set end of msgList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgFlagged as string) & "|||" & ((count of mail attachments of msg) as string)
96
+ end try
97
+ end repeat
98
+ set end of resultList to acctName & ":::" & (msgCount as string) & ":::" & (my joinList(msgList, "^^^"))
99
+ end repeat
100
+ return my joinList(resultList, "###")
101
+ end tell
102
+
103
+ on joinList(theList, delim)
104
+ set oldDelim to AppleScript's text item delimiters
105
+ set AppleScript's text item delimiters to delim
106
+ set theResult to theList as string
107
+ set AppleScript's text item delimiters to oldDelim
108
+ return theResult
109
+ end joinList
110
+ """
111
+
112
+ guard let raw = runAppleScript(script) else { return }
113
+ let accounts = parseUnreadSummary(raw, limit: limit)
114
+ JSONOutput.success(accounts)
115
+ }
116
+
117
+ /// Search messages by subject, sender, body, or all fields across accounts.
118
+ static func search(query: String, account: String?, mailbox: String?, limit: Int, searchIn: String, offset: Int?) {
119
+ let searchQuery = escapeForAppleScript(query)
120
+ let effectiveOffset = offset ?? 0
121
+ let whereClause: String
122
+ switch searchIn {
123
+ case "subject":
124
+ whereClause = "whose subject contains searchQuery"
125
+ case "sender":
126
+ whereClause = "whose sender contains searchQuery"
127
+ case "body":
128
+ whereClause = "whose content contains searchQuery"
129
+ default:
130
+ whereClause = "whose subject contains searchQuery or sender contains searchQuery or content contains searchQuery"
131
+ }
132
+
133
+ let isAllMailboxes = mailbox == "all"
134
+ let isAllAccounts = account == "all"
135
+
136
+ let script: String
137
+ if isAllMailboxes {
138
+ // Case 3 & 4: iterate mailboxes
139
+ let accountLoop: String
140
+ if let acct = account, !isAllAccounts {
141
+ // Case 3: specific account, all mailboxes
142
+ accountLoop = "set acctList to every account whose name is \"\(escapeForAppleScript(acct))\""
143
+ } else {
144
+ // Case 4: all accounts, all mailboxes
145
+ accountLoop = "set acctList to every account"
146
+ }
147
+ script = """
148
+ tell application "Mail"
149
+ set resultList to {}
150
+ set searchQuery to "\(searchQuery)"
151
+ \(accountLoop)
152
+ set totalCount to 0
153
+ set skipped to 0
154
+ set collected to 0
155
+ repeat with acct in acctList
156
+ repeat with mbox in every mailbox of acct
157
+ try
158
+ set mboxMsgs to (every message of mbox \(whereClause))
159
+ set mboxCount to count of mboxMsgs
160
+ set totalCount to totalCount + mboxCount
161
+ repeat with j from 1 to mboxCount
162
+ if skipped < \(effectiveOffset) then
163
+ set skipped to skipped + 1
164
+ else if collected < \(limit) then
165
+ set msg to item j of mboxMsgs
166
+ set msgId to message id of msg
167
+ set msgSubject to subject of msg
168
+ set msgSender to sender of msg
169
+ set msgDate to date received of msg as «class isot» as string
170
+ set msgRead to read status of msg
171
+ set msgFlagged to flagged status of msg
172
+ set msgMbox to name of mbox
173
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgRead as string) & "|||" & (msgFlagged as string) & "|||" & ((count of mail attachments of msg) as string) & "|||" & msgMbox
174
+ set collected to collected + 1
175
+ end if
176
+ end repeat
177
+ end try
178
+ end repeat
179
+ end repeat
180
+ return my joinList(resultList, "^^^") & "###TOTAL:::" & (totalCount as string)
181
+ end tell
182
+
183
+ on joinList(theList, delim)
184
+ set oldDelim to AppleScript's text item delimiters
185
+ set AppleScript's text item delimiters to delim
186
+ set theResult to theList as string
187
+ set AppleScript's text item delimiters to oldDelim
188
+ return theResult
189
+ end joinList
190
+ """
191
+ } else {
192
+ // Cases 1, 2, 5: single mailbox search (existing logic with whereClause)
193
+ // When account is "all", leave accountFilter empty to search unified mailbox
194
+ let accountFilter: String
195
+ if let acct = account, acct != "all" {
196
+ accountFilter = "of account \"\(escapeForAppleScript(acct))\""
197
+ } else {
198
+ accountFilter = ""
199
+ }
200
+ let mailboxTarget: String
201
+ if let mbox = mailbox {
202
+ mailboxTarget = "mailbox \"\(escapeForAppleScript(mbox))\""
203
+ } else {
204
+ mailboxTarget = "inbox"
205
+ }
206
+
207
+ let fallbackBlock: String
208
+ if account != nil && account != "all" && mailbox == nil {
209
+ fallbackBlock = """
210
+ on error
211
+ try
212
+ set msgs to (every message of mailbox "All Mail" \(accountFilter) \(whereClause))
213
+ end try
214
+ """
215
+ } else {
216
+ fallbackBlock = ""
217
+ }
218
+
219
+ script = """
220
+ tell application "Mail"
221
+ set resultList to {}
222
+ set searchQuery to "\(searchQuery)"
223
+ set msgs to {}
224
+ try
225
+ set msgs to (every message of \(mailboxTarget) \(accountFilter) \(whereClause))
226
+ \(fallbackBlock)
227
+ end try
228
+ set msgCount to count of msgs
229
+ set startIdx to \(effectiveOffset) + 1
230
+ set endIdx to startIdx + \(limit) - 1
231
+ if endIdx > msgCount then set endIdx to msgCount
232
+ repeat with i from startIdx to endIdx
233
+ set msg to item i of msgs
234
+ set msgId to message id of msg
235
+ set msgSubject to subject of msg
236
+ set msgSender to sender of msg
237
+ set msgDate to date received of msg as «class isot» as string
238
+ set msgRead to read status of msg
239
+ set msgFlagged to flagged status of msg
240
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgRead as string) & "|||" & (msgFlagged as string) & "|||" & ((count of mail attachments of msg) as string)
241
+ end repeat
242
+ return my joinList(resultList, "^^^") & "###TOTAL:::" & (msgCount as string)
243
+ end tell
244
+
245
+ on joinList(theList, delim)
246
+ set oldDelim to AppleScript's text item delimiters
247
+ set AppleScript's text item delimiters to delim
248
+ set theResult to theList as string
249
+ set AppleScript's text item delimiters to oldDelim
250
+ return theResult
251
+ end joinList
252
+ """
253
+ }
254
+
255
+ guard let raw = runAppleScript(script) else { return }
256
+
257
+ // Split off total count metadata
258
+ let totalParts = raw.components(separatedBy: "###TOTAL:::")
259
+ let messagesRaw = totalParts[0]
260
+ let total = totalParts.count > 1 ? Int(totalParts[1].trimmingCharacters(in: .whitespaces)) ?? 0 : 0
261
+
262
+ let messages = parseMessageList(messagesRaw)
263
+
264
+ if let offset = offset {
265
+ // offset was explicitly provided — return pagination envelope
266
+ let envelope: [String: Any] = [
267
+ "messages": messages,
268
+ "total": total,
269
+ "offset": offset,
270
+ "limit": limit,
271
+ "hasMore": offset + limit < total
272
+ ]
273
+ JSONOutput.success(envelope)
274
+ } else {
275
+ // offset not provided — backwards-compatible flat array
276
+ JSONOutput.success(messages)
277
+ }
278
+ }
279
+
280
+ /// Get full message content by message ID.
281
+ static func readMessage(messageId: String, maxBodyLength: Int) {
282
+ let escapedId = escapeForAppleScript(messageId)
283
+
284
+ let script = """
285
+ tell application "Mail"
286
+ -- Find message: unified inbox covers normal accounts; per-account
287
+ -- fallback only for accounts where inbox keyword fails (Proton Bridge).
288
+ set targetMsg to missing value
289
+ try
290
+ set targetMsg to first message of inbox whose message id is "\(escapedId)"
291
+ end try
292
+ if targetMsg is missing value then
293
+ repeat with acct in every account
294
+ repeat with mbox in every mailbox of acct
295
+ try
296
+ set targetMsg to first message of mbox whose message id is "\(escapedId)"
297
+ exit repeat
298
+ end try
299
+ end repeat
300
+ if targetMsg is not missing value then exit repeat
301
+ end repeat
302
+ end if
303
+ if targetMsg is missing value then
304
+ return "ERROR_NOT_FOUND"
305
+ end if
306
+ set msgSubject to subject of targetMsg
307
+ set msgSender to sender of targetMsg
308
+ set msgDate to date received of targetMsg as «class isot» as string
309
+ set msgRead to read status of targetMsg
310
+ set msgFlagged to flagged status of targetMsg
311
+ set msgContent to content of targetMsg
312
+ set msgTo to address of every to recipient of targetMsg
313
+ set msgCc to address of every cc recipient of targetMsg
314
+ set attachList to {}
315
+ repeat with att in every mail attachment of targetMsg
316
+ set attName to name of att
317
+ try
318
+ set attMime to MIME type of att
319
+ on error
320
+ set attMime to "application/octet-stream"
321
+ end try
322
+ set end of attachList to attName & ":::" & attMime
323
+ end repeat
324
+ return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgRead as string) & "|||" & (msgFlagged as string) & "|||" & msgContent & "|||" & (msgTo as string) & "|||" & (msgCc as string) & "|||" & (my joinList(attachList, "^^^"))
325
+ end tell
326
+
327
+ on joinList(theList, delim)
328
+ set oldDelim to AppleScript's text item delimiters
329
+ set AppleScript's text item delimiters to delim
330
+ set theResult to theList as string
331
+ set AppleScript's text item delimiters to oldDelim
332
+ return theResult
333
+ end joinList
334
+ """
335
+
336
+ guard let raw = runAppleScript(script) else { return }
337
+ if raw == "ERROR_NOT_FOUND" {
338
+ JSONOutput.error("Message not found. It may be in a mailbox not searched (try mail.search to locate it first).")
339
+ return
340
+ }
341
+ let parts = raw.components(separatedBy: "|||")
342
+ guard parts.count >= 6 else {
343
+ JSONOutput.error("Unexpected response format from Mail.app")
344
+ return
345
+ }
346
+
347
+ var body = parts[5]
348
+ if maxBodyLength > 0 && body.count > maxBodyLength {
349
+ body = String(body.prefix(maxBodyLength)) + "\n\n[truncated — \(body.count) chars total]"
350
+ }
351
+
352
+ var message: [String: Any] = [
353
+ "id": messageId,
354
+ "subject": parts[0],
355
+ "sender": parts[1],
356
+ "date": parts[2],
357
+ "read": parts[3] == "true",
358
+ "flagged": parts[4] == "true",
359
+ "body": body
360
+ ]
361
+ if parts.count > 6 { message["to"] = parts[6] }
362
+ if parts.count > 7 { message["cc"] = parts[7] }
363
+
364
+ if parts.count > 8 && !parts[8].isEmpty {
365
+ let attachStrings = parts[8].components(separatedBy: "^^^")
366
+ let attachments: [[String: Any]] = attachStrings.enumerated().compactMap { (idx, attStr) in
367
+ let fields = attStr.components(separatedBy: ":::")
368
+ guard fields.count >= 2 else { return nil }
369
+ return [
370
+ "index": idx,
371
+ "name": fields[0],
372
+ "mimeType": fields[1]
373
+ ]
374
+ }
375
+ message["attachments"] = attachments
376
+ message["attachmentCount"] = attachments.count
377
+ message["hasAttachments"] = !attachments.isEmpty
378
+ } else {
379
+ message["attachments"] = [] as [[String: Any]]
380
+ message["attachmentCount"] = 0
381
+ message["hasAttachments"] = false
382
+ }
383
+
384
+ JSONOutput.success(message)
385
+ }
386
+
387
+ /// List flagged messages across all accounts.
388
+ static func flagged(limit: Int, offset: Int?) {
389
+ let effectiveOffset = offset ?? 0
390
+
391
+ // Two script variants: without pagination (early-exit for performance)
392
+ // and with pagination (must count all flagged messages for total).
393
+ let script: String
394
+ if offset != nil {
395
+ // Pagination: iterate everything to compute totalCount
396
+ script = """
397
+ tell application "Mail"
398
+ set resultList to {}
399
+ set totalCount to 0
400
+ set skipped to 0
401
+ set collected to 0
402
+ repeat with acct in every account
403
+ try
404
+ repeat with mbox in every mailbox of acct
405
+ set flaggedMsgs to (every message of mbox whose flagged status is true)
406
+ set totalCount to totalCount + (count of flaggedMsgs)
407
+ repeat with msg in flaggedMsgs
408
+ if skipped < \(effectiveOffset) then
409
+ set skipped to skipped + 1
410
+ else if collected < \(limit) then
411
+ set msgId to message id of msg
412
+ set msgSubject to subject of msg
413
+ set msgSender to sender of msg
414
+ set msgDate to date received of msg as «class isot» as string
415
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (name of acct) & "|||" & ((count of mail attachments of msg) as string)
416
+ set collected to collected + 1
417
+ end if
418
+ end repeat
419
+ end repeat
420
+ end try
421
+ end repeat
422
+ return my joinList(resultList, "^^^") & "###TOTAL:::" & (totalCount as string)
423
+ end tell
424
+
425
+ on joinList(theList, delim)
426
+ set oldDelim to AppleScript's text item delimiters
427
+ set AppleScript's text item delimiters to delim
428
+ set theResult to theList as string
429
+ set AppleScript's text item delimiters to oldDelim
430
+ return theResult
431
+ end joinList
432
+ """
433
+ } else {
434
+ // No pagination: early-exit once limit is reached
435
+ script = """
436
+ tell application "Mail"
437
+ set resultList to {}
438
+ repeat with acct in every account
439
+ try
440
+ repeat with mbox in every mailbox of acct
441
+ set flaggedMsgs to (every message of mbox whose flagged status is true)
442
+ repeat with msg in flaggedMsgs
443
+ set msgId to message id of msg
444
+ set msgSubject to subject of msg
445
+ set msgSender to sender of msg
446
+ set msgDate to date received of msg as «class isot» as string
447
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (name of acct) & "|||" & ((count of mail attachments of msg) as string)
448
+ if (count of resultList) >= \(limit) then exit repeat
449
+ end repeat
450
+ if (count of resultList) >= \(limit) then exit repeat
451
+ end repeat
452
+ end try
453
+ if (count of resultList) >= \(limit) then exit repeat
454
+ end repeat
455
+ return my joinList(resultList, "^^^")
456
+ end tell
457
+
458
+ on joinList(theList, delim)
459
+ set oldDelim to AppleScript's text item delimiters
460
+ set AppleScript's text item delimiters to delim
461
+ set theResult to theList as string
462
+ set AppleScript's text item delimiters to oldDelim
463
+ return theResult
464
+ end joinList
465
+ """
466
+ }
467
+
468
+ guard let raw = runAppleScript(script) else { return }
469
+ let totalParts = raw.components(separatedBy: "###TOTAL:::")
470
+ let messagesRaw = totalParts[0]
471
+ let total = totalParts.count > 1 ? Int(totalParts[1].trimmingCharacters(in: .whitespaces)) ?? 0 : 0
472
+ let messages = parseFlaggedList(messagesRaw)
473
+
474
+ if let offset = offset {
475
+ let envelope: [String: Any] = [
476
+ "messages": messages,
477
+ "total": total,
478
+ "offset": offset,
479
+ "limit": limit,
480
+ "hasMore": offset + limit < total
481
+ ]
482
+ JSONOutput.success(envelope)
483
+ } else {
484
+ JSONOutput.success(messages)
485
+ }
486
+ }
487
+
488
+ /// Create a draft email in Mail.app. Opens the compose window for user review.
489
+ static func createDraft(to: [String], cc: [String]?, bcc: [String]?, subject: String, body: String, account: String?) {
490
+ var recipientLines = ""
491
+ for addr in to {
492
+ recipientLines += " make new to recipient with properties {address:\"\(escapeForAppleScript(addr))\"}\n"
493
+ }
494
+ if let ccAddrs = cc {
495
+ for addr in ccAddrs {
496
+ recipientLines += " make new cc recipient with properties {address:\"\(escapeForAppleScript(addr))\"}\n"
497
+ }
498
+ }
499
+ if let bccAddrs = bcc {
500
+ for addr in bccAddrs {
501
+ recipientLines += " make new bcc recipient with properties {address:\"\(escapeForAppleScript(addr))\"}\n"
502
+ }
503
+ }
504
+
505
+ let senderLine: String
506
+ if let acct = account {
507
+ senderLine = " set sender of newMsg to \"\(escapeForAppleScript(acct))\""
508
+ } else {
509
+ senderLine = ""
510
+ }
511
+
512
+ let script = """
513
+ tell application "Mail"
514
+ set newMsg to make new outgoing message with properties {subject:"\(escapeForAppleScript(subject))", content:"\(escapeForAppleScript(body))", visible:true}
515
+ tell newMsg
516
+ \(recipientLines) end tell
517
+ \(senderLine)
518
+ end tell
519
+ """
520
+
521
+ guard runAppleScript(script) != nil else { return }
522
+
523
+ var result: [String: Any] = [
524
+ "subject": subject,
525
+ "to": to
526
+ ]
527
+ if let cc = cc { result["cc"] = cc }
528
+ if let bcc = bcc { result["bcc"] = bcc }
529
+ if let account = account { result["account"] = account }
530
+ JSONOutput.success(result)
531
+ }
532
+
533
+ /// Save a specific attachment from a message to disk.
534
+ static func saveAttachment(messageId: String, index: Int, outputDir: String) {
535
+ guard index >= 0 else {
536
+ JSONOutput.error("Attachment index must be non-negative. Got \(index).")
537
+ return
538
+ }
539
+ let escapedId = escapeForAppleScript(messageId)
540
+ guard let resolvedDir = FilesBridge.validatePath(outputDir, mustExist: false) else {
541
+ JSONOutput.error("Output path is outside home directory: \(outputDir)")
542
+ return
543
+ }
544
+
545
+ // Create output directory if needed
546
+ let fm = FileManager.default
547
+ if !fm.fileExists(atPath: resolvedDir) {
548
+ do {
549
+ try fm.createDirectory(atPath: resolvedDir, withIntermediateDirectories: true)
550
+ } catch {
551
+ JSONOutput.error("Failed to create output directory: \(error.localizedDescription)")
552
+ return
553
+ }
554
+ }
555
+
556
+ let escapedDir = escapeForAppleScript(resolvedDir)
557
+ // AppleScript index is 1-based
558
+ let asIndex = index + 1
559
+
560
+ let script = """
561
+ tell application "Mail"
562
+ -- Find message: unified inbox covers normal accounts; per-account
563
+ -- fallback only for accounts where inbox keyword fails (Proton Bridge).
564
+ set targetMsg to missing value
565
+ try
566
+ set targetMsg to first message of inbox whose message id is "\(escapedId)"
567
+ end try
568
+ if targetMsg is missing value then
569
+ repeat with acct in every account
570
+ repeat with mbox in every mailbox of acct
571
+ try
572
+ set targetMsg to first message of mbox whose message id is "\(escapedId)"
573
+ exit repeat
574
+ end try
575
+ end repeat
576
+ if targetMsg is not missing value then exit repeat
577
+ end repeat
578
+ end if
579
+ if targetMsg is missing value then
580
+ return "ERROR:::Message not found"
581
+ end if
582
+ set attList to every mail attachment of targetMsg
583
+ if (count of attList) < \(asIndex) then
584
+ return "ERROR:::Attachment index out of range. Message has " & ((count of attList) as string) & " attachments."
585
+ end if
586
+ set att to item \(asIndex) of attList
587
+ set attName to name of att
588
+ try
589
+ set attMime to MIME type of att
590
+ on error
591
+ set attMime to "application/octet-stream"
592
+ end try
593
+ set fullPath to "\(escapedDir)/" & attName
594
+ save att in (POSIX file fullPath)
595
+ return attName & ":::" & attMime & ":::" & fullPath
596
+ end tell
597
+ """
598
+
599
+ guard let raw = runAppleScript(script) else { return }
600
+
601
+ if raw.hasPrefix("ERROR:::") {
602
+ let errorMsg = String(raw.dropFirst("ERROR:::".count))
603
+ JSONOutput.error(errorMsg)
604
+ return
605
+ }
606
+
607
+ let fields = raw.components(separatedBy: ":::")
608
+ guard fields.count >= 3 else {
609
+ JSONOutput.error("Unexpected response format from Mail.app")
610
+ return
611
+ }
612
+
613
+ let result: [String: Any] = [
614
+ "name": fields[0],
615
+ "mimeType": fields[1],
616
+ "path": fields[2]
617
+ ]
618
+ JSONOutput.success(result)
619
+ }
620
+
621
+ // MARK: - AppleScript Execution
622
+
623
+ private static func runAppleScript(_ script: String) -> String? {
624
+ let task = Process()
625
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
626
+ task.arguments = ["-e", script]
627
+
628
+ let outPipe = Pipe()
629
+ let errPipe = Pipe()
630
+ task.standardOutput = outPipe
631
+ task.standardError = errPipe
632
+
633
+ do {
634
+ try task.run()
635
+ task.waitUntilExit()
636
+
637
+ if task.terminationStatus != 0 {
638
+ let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
639
+ let errStr = String(data: errData, encoding: .utf8)?
640
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error"
641
+
642
+ if errStr.contains("-1743") || errStr.contains("not allowed") {
643
+ JSONOutput.error("Mail automation permission denied. Grant access in System Settings > Privacy & Security > Automation > apple-bridge > Mail.")
644
+ } else if errStr.contains("-600") || errStr.contains("not running") {
645
+ JSONOutput.error("Mail.app is not running. Open Mail.app and try again.")
646
+ } else {
647
+ JSONOutput.error("AppleScript error: \(errStr)")
648
+ }
649
+ return nil
650
+ }
651
+
652
+ let data = outPipe.fileHandleForReading.readDataToEndOfFile()
653
+ return String(data: data, encoding: .utf8)?
654
+ .trimmingCharacters(in: .whitespacesAndNewlines)
655
+ } catch {
656
+ JSONOutput.error("Failed to run osascript: \(error.localizedDescription)")
657
+ return nil
658
+ }
659
+ }
660
+
661
+ private static func escapeForAppleScript(_ str: String) -> String {
662
+ return str
663
+ .replacingOccurrences(of: "\\", with: "\\\\")
664
+ .replacingOccurrences(of: "\"", with: "\\\"")
665
+ .replacingOccurrences(of: "\r\n", with: "\" & (ASCII character 13) & (ASCII character 10) & \"")
666
+ .replacingOccurrences(of: "\n", with: "\" & (ASCII character 10) & \"")
667
+ .replacingOccurrences(of: "\r", with: "\" & (ASCII character 13) & \"")
668
+ .replacingOccurrences(of: "\t", with: "\" & (ASCII character 9) & \"")
669
+ }
670
+
671
+ // MARK: - Parsers
672
+
673
+ private static func parseAccountList(_ raw: String) -> [[String: Any]] {
674
+ guard !raw.isEmpty else { return [] }
675
+
676
+ let accountChunks = raw.components(separatedBy: "###")
677
+ return accountChunks.compactMap { chunk -> [String: Any]? in
678
+ let parts = chunk.components(separatedBy: "|||")
679
+ guard parts.count >= 2 else { return nil }
680
+
681
+ var account: [String: Any] = [
682
+ "name": parts[0].trimmingCharacters(in: .whitespaces),
683
+ "email": parts[1].trimmingCharacters(in: .whitespaces)
684
+ ]
685
+
686
+ if parts.count > 2 && !parts[2].isEmpty {
687
+ let mboxStrings = parts[2].components(separatedBy: "^^^")
688
+ let mailboxes: [[String: Any]] = mboxStrings.compactMap { mboxStr in
689
+ let fields = mboxStr.components(separatedBy: "::")
690
+ guard fields.count >= 2 else { return nil }
691
+ return [
692
+ "name": fields[0].trimmingCharacters(in: .whitespaces),
693
+ "unreadCount": Int(fields[1].trimmingCharacters(in: .whitespaces)) ?? 0
694
+ ]
695
+ }
696
+ account["mailboxes"] = mailboxes
697
+ }
698
+
699
+ return account
700
+ }
701
+ }
702
+
703
+ private static func parseUnreadSummary(_ raw: String, limit: Int) -> [[String: Any]] {
704
+ guard !raw.isEmpty else { return [] }
705
+
706
+ let accountChunks = raw.components(separatedBy: "###")
707
+ return accountChunks.compactMap { chunk -> [String: Any]? in
708
+ let parts = chunk.components(separatedBy: ":::")
709
+ guard parts.count >= 2 else { return nil }
710
+
711
+ var account: [String: Any] = [
712
+ "account": parts[0].trimmingCharacters(in: .whitespaces),
713
+ "unreadCount": Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0
714
+ ]
715
+
716
+ if parts.count > 2 && !parts[2].isEmpty {
717
+ let msgStrings = parts[2].components(separatedBy: "^^^")
718
+ let messages: [[String: Any]] = msgStrings.compactMap { msgStr in
719
+ let fields = msgStr.components(separatedBy: "|||")
720
+ guard fields.count >= 4 else { return nil }
721
+ var msg: [String: Any] = [
722
+ "id": fields[0],
723
+ "subject": fields[1],
724
+ "sender": fields[2],
725
+ "date": fields[3]
726
+ ]
727
+ if fields.count > 4 {
728
+ msg["flagged"] = fields[4] == "true"
729
+ }
730
+ if fields.count > 5 {
731
+ let count = Int(fields[5].trimmingCharacters(in: .whitespaces)) ?? 0
732
+ msg["attachmentCount"] = count
733
+ msg["hasAttachments"] = count > 0
734
+ }
735
+ return msg
736
+ }
737
+ account["recentUnread"] = messages
738
+ }
739
+
740
+ return account
741
+ }
742
+ }
743
+
744
+ private static func parseMessageList(_ raw: String) -> [[String: Any]] {
745
+ guard !raw.isEmpty else { return [] }
746
+
747
+ let msgStrings = raw.components(separatedBy: "^^^")
748
+ return msgStrings.compactMap { msgStr -> [String: Any]? in
749
+ let fields = msgStr.components(separatedBy: "|||")
750
+ guard fields.count >= 4 else { return nil }
751
+ var msg: [String: Any] = [
752
+ "id": fields[0],
753
+ "subject": fields[1],
754
+ "sender": fields[2],
755
+ "date": fields[3]
756
+ ]
757
+ if fields.count > 4 { msg["read"] = fields[4] == "true" }
758
+ if fields.count > 5 { msg["flagged"] = fields[5] == "true" }
759
+ if fields.count > 6 {
760
+ let count = Int(fields[6].trimmingCharacters(in: .whitespaces)) ?? 0
761
+ msg["attachmentCount"] = count
762
+ msg["hasAttachments"] = count > 0
763
+ }
764
+ if fields.count > 7 {
765
+ msg["mailbox"] = fields[7].trimmingCharacters(in: .whitespaces)
766
+ }
767
+ return msg
768
+ }
769
+ }
770
+
771
+ private static func parseFlaggedList(_ raw: String) -> [[String: Any]] {
772
+ guard !raw.isEmpty else { return [] }
773
+
774
+ let msgStrings = raw.components(separatedBy: "^^^")
775
+ return msgStrings.compactMap { msgStr -> [String: Any]? in
776
+ let fields = msgStr.components(separatedBy: "|||")
777
+ guard fields.count >= 4 else { return nil }
778
+ var msg: [String: Any] = [
779
+ "id": fields[0],
780
+ "subject": fields[1],
781
+ "sender": fields[2],
782
+ "date": fields[3],
783
+ "flagged": true
784
+ ]
785
+ if fields.count > 4 { msg["account"] = fields[4] }
786
+ if fields.count > 5 {
787
+ let count = Int(fields[5].trimmingCharacters(in: .whitespaces)) ?? 0
788
+ msg["attachmentCount"] = count
789
+ msg["hasAttachments"] = count > 0
790
+ }
791
+ return msg
792
+ }
793
+ }
794
+ }