@l22-io/orchard-mcp 0.3.2 → 0.6.1

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 (43) hide show
  1. package/README.md +11 -8
  2. package/build/bridge.d.ts +13 -2
  3. package/build/bridge.js +140 -23
  4. package/build/bridge.js.map +1 -1
  5. package/build/index.js +11 -1
  6. package/build/index.js.map +1 -1
  7. package/build/tools/contacts.d.ts +2 -0
  8. package/build/tools/contacts.js +37 -0
  9. package/build/tools/contacts.js.map +1 -0
  10. package/build/tools/files.js +4 -1
  11. package/build/tools/files.js.map +1 -1
  12. package/build/tools/keynote.d.ts +2 -0
  13. package/build/tools/keynote.js +198 -0
  14. package/build/tools/keynote.js.map +1 -0
  15. package/build/tools/mail.js +69 -13
  16. package/build/tools/mail.js.map +1 -1
  17. package/build/tools/notes.d.ts +2 -0
  18. package/build/tools/notes.js +83 -0
  19. package/build/tools/notes.js.map +1 -0
  20. package/build/tools/numbers.d.ts +2 -0
  21. package/build/tools/numbers.js +167 -0
  22. package/build/tools/numbers.js.map +1 -0
  23. package/build/tools/pages.d.ts +2 -0
  24. package/build/tools/pages.js +143 -0
  25. package/build/tools/pages.js.map +1 -0
  26. package/package.json +12 -9
  27. package/scripts/postinstall.sh +77 -8
  28. package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
  29. package/swift/.build/AppleBridge.app.sha256 +1 -0
  30. package/swift/Package.resolved +14 -0
  31. package/swift/Package.swift +28 -0
  32. package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
  33. package/swift/Sources/AppleBridge/Calendar.swift +221 -0
  34. package/swift/Sources/AppleBridge/Contacts.swift +225 -0
  35. package/swift/Sources/AppleBridge/Doctor.swift +252 -0
  36. package/swift/Sources/AppleBridge/Files.swift +474 -0
  37. package/swift/Sources/AppleBridge/JSON.swift +57 -0
  38. package/swift/Sources/AppleBridge/Keynote.swift +599 -0
  39. package/swift/Sources/AppleBridge/Mail.swift +854 -0
  40. package/swift/Sources/AppleBridge/Notes.swift +263 -0
  41. package/swift/Sources/AppleBridge/Numbers.swift +601 -0
  42. package/swift/Sources/AppleBridge/Pages.swift +467 -0
  43. package/swift/Sources/AppleBridge/Reminders.swift +347 -0
@@ -0,0 +1,854 @@
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
+ // Refuse the catastrophic combination: body-content search across every
120
+ // mailbox of every account. AppleScript's `content contains` predicate
121
+ // forces Mail to load message bodies; iterating that across all
122
+ // mailboxes can lock Mail.app on Apple Event processing for many
123
+ // minutes (it cannot service quit / UI events while the script runs).
124
+ // See: orphaned osascript incident (PID 85905, ~11min before kill).
125
+ let searchesContent = (searchIn == "body" || searchIn == "all")
126
+ let allAccounts = (account == nil || account == "all")
127
+ let allMailboxes = (mailbox == "all")
128
+ if searchesContent && allAccounts && allMailboxes {
129
+ JSONOutput.error(
130
+ "Refusing body/all-fields search across every mailbox of every account — this can lock Mail.app for minutes. " +
131
+ "Narrow the scope: pass --account <name>, or pass --mailbox <name>, or pass --search-in subject (or --search-in sender)."
132
+ )
133
+ return
134
+ }
135
+
136
+ let searchQuery = escapeForAppleScript(query)
137
+ let effectiveOffset = offset ?? 0
138
+ let whereClause: String
139
+ switch searchIn {
140
+ case "subject":
141
+ whereClause = "whose subject contains searchQuery"
142
+ case "sender":
143
+ whereClause = "whose sender contains searchQuery"
144
+ case "body":
145
+ whereClause = "whose content contains searchQuery"
146
+ default:
147
+ whereClause = "whose subject contains searchQuery or sender contains searchQuery or content contains searchQuery"
148
+ }
149
+
150
+ let isAllMailboxes = mailbox == "all"
151
+ let isAllAccounts = account == "all"
152
+
153
+ let script: String
154
+ if isAllMailboxes {
155
+ // Case 3 & 4: iterate mailboxes
156
+ let accountLoop: String
157
+ if let acct = account, !isAllAccounts {
158
+ // Case 3: specific account, all mailboxes
159
+ accountLoop = "set acctList to every account whose name is \"\(escapeForAppleScript(acct))\""
160
+ } else {
161
+ // Case 4: all accounts, all mailboxes
162
+ accountLoop = "set acctList to every account"
163
+ }
164
+ script = """
165
+ tell application "Mail"
166
+ set resultList to {}
167
+ set searchQuery to "\(searchQuery)"
168
+ \(accountLoop)
169
+ set totalCount to 0
170
+ set skipped to 0
171
+ set collected to 0
172
+ repeat with acct in acctList
173
+ repeat with mbox in every mailbox of acct
174
+ try
175
+ set mboxMsgs to (every message of mbox \(whereClause))
176
+ set mboxCount to count of mboxMsgs
177
+ set totalCount to totalCount + mboxCount
178
+ repeat with j from 1 to mboxCount
179
+ if skipped < \(effectiveOffset) then
180
+ set skipped to skipped + 1
181
+ else if collected < \(limit) then
182
+ set msg to item j of mboxMsgs
183
+ set msgId to message id of msg
184
+ set msgSubject to subject of msg
185
+ set msgSender to sender of msg
186
+ set msgDate to date received of msg as «class isot» as string
187
+ set msgRead to read status of msg
188
+ set msgFlagged to flagged status of msg
189
+ set msgMbox to name of mbox
190
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgRead as string) & "|||" & (msgFlagged as string) & "|||" & ((count of mail attachments of msg) as string) & "|||" & msgMbox
191
+ set collected to collected + 1
192
+ end if
193
+ end repeat
194
+ end try
195
+ end repeat
196
+ end repeat
197
+ return my joinList(resultList, "^^^") & "###TOTAL:::" & (totalCount as string)
198
+ end tell
199
+
200
+ on joinList(theList, delim)
201
+ set oldDelim to AppleScript's text item delimiters
202
+ set AppleScript's text item delimiters to delim
203
+ set theResult to theList as string
204
+ set AppleScript's text item delimiters to oldDelim
205
+ return theResult
206
+ end joinList
207
+ """
208
+ } else {
209
+ // Cases 1, 2, 5: single mailbox search (existing logic with whereClause)
210
+ // When account is "all", leave accountFilter empty to search unified mailbox
211
+ let accountFilter: String
212
+ if let acct = account, acct != "all" {
213
+ accountFilter = "of account \"\(escapeForAppleScript(acct))\""
214
+ } else {
215
+ accountFilter = ""
216
+ }
217
+ let mailboxTarget: String
218
+ if let mbox = mailbox {
219
+ mailboxTarget = "mailbox \"\(escapeForAppleScript(mbox))\""
220
+ } else {
221
+ mailboxTarget = "inbox"
222
+ }
223
+
224
+ let fallbackBlock: String
225
+ if account != nil && account != "all" && mailbox == nil {
226
+ fallbackBlock = """
227
+ on error
228
+ try
229
+ set msgs to (every message of mailbox "All Mail" \(accountFilter) \(whereClause))
230
+ end try
231
+ """
232
+ } else {
233
+ fallbackBlock = ""
234
+ }
235
+
236
+ script = """
237
+ tell application "Mail"
238
+ set resultList to {}
239
+ set searchQuery to "\(searchQuery)"
240
+ set msgs to {}
241
+ try
242
+ set msgs to (every message of \(mailboxTarget) \(accountFilter) \(whereClause))
243
+ \(fallbackBlock)
244
+ end try
245
+ set msgCount to count of msgs
246
+ set startIdx to \(effectiveOffset) + 1
247
+ set endIdx to startIdx + \(limit) - 1
248
+ if endIdx > msgCount then set endIdx to msgCount
249
+ repeat with i from startIdx to endIdx
250
+ set msg to item i of msgs
251
+ set msgId to message id of msg
252
+ set msgSubject to subject of msg
253
+ set msgSender to sender of msg
254
+ set msgDate to date received of msg as «class isot» as string
255
+ set msgRead to read status of msg
256
+ set msgFlagged to flagged status of msg
257
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgRead as string) & "|||" & (msgFlagged as string) & "|||" & ((count of mail attachments of msg) as string)
258
+ end repeat
259
+ return my joinList(resultList, "^^^") & "###TOTAL:::" & (msgCount as string)
260
+ end tell
261
+
262
+ on joinList(theList, delim)
263
+ set oldDelim to AppleScript's text item delimiters
264
+ set AppleScript's text item delimiters to delim
265
+ set theResult to theList as string
266
+ set AppleScript's text item delimiters to oldDelim
267
+ return theResult
268
+ end joinList
269
+ """
270
+ }
271
+
272
+ guard let raw = runAppleScript(script) else { return }
273
+
274
+ // Split off total count metadata
275
+ let totalParts = raw.components(separatedBy: "###TOTAL:::")
276
+ let messagesRaw = totalParts[0]
277
+ let total = totalParts.count > 1 ? Int(totalParts[1].trimmingCharacters(in: .whitespaces)) ?? 0 : 0
278
+
279
+ let messages = parseMessageList(messagesRaw)
280
+
281
+ if let offset = offset {
282
+ // offset was explicitly provided — return pagination envelope
283
+ let envelope: [String: Any] = [
284
+ "messages": messages,
285
+ "total": total,
286
+ "offset": offset,
287
+ "limit": limit,
288
+ "hasMore": offset + limit < total
289
+ ]
290
+ JSONOutput.success(envelope)
291
+ } else {
292
+ // offset not provided — backwards-compatible flat array
293
+ JSONOutput.success(messages)
294
+ }
295
+ }
296
+
297
+ /// Get full message content by message ID.
298
+ static func readMessage(messageId: String, maxBodyLength: Int) {
299
+ let escapedId = escapeForAppleScript(messageId)
300
+
301
+ let script = """
302
+ tell application "Mail"
303
+ -- Find message: unified inbox covers normal accounts; per-account
304
+ -- fallback only for accounts where inbox keyword fails (Proton Bridge).
305
+ set targetMsg to missing value
306
+ try
307
+ set targetMsg to first message of inbox whose message id is "\(escapedId)"
308
+ end try
309
+ if targetMsg is missing value then
310
+ repeat with acct in every account
311
+ repeat with mbox in every mailbox of acct
312
+ try
313
+ set targetMsg to first message of mbox whose message id is "\(escapedId)"
314
+ exit repeat
315
+ end try
316
+ end repeat
317
+ if targetMsg is not missing value then exit repeat
318
+ end repeat
319
+ end if
320
+ if targetMsg is missing value then
321
+ return "ERROR_NOT_FOUND"
322
+ end if
323
+ set msgSubject to subject of targetMsg
324
+ set msgSender to sender of targetMsg
325
+ set msgDate to date received of targetMsg as «class isot» as string
326
+ set msgRead to read status of targetMsg
327
+ set msgFlagged to flagged status of targetMsg
328
+ set msgContent to content of targetMsg
329
+ set msgTo to address of every to recipient of targetMsg
330
+ set msgCc to address of every cc recipient of targetMsg
331
+ set attachList to {}
332
+ repeat with att in every mail attachment of targetMsg
333
+ set attName to name of att
334
+ try
335
+ set attMime to MIME type of att
336
+ on error
337
+ set attMime to "application/octet-stream"
338
+ end try
339
+ set end of attachList to attName & ":::" & attMime
340
+ end repeat
341
+ return msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (msgRead as string) & "|||" & (msgFlagged as string) & "|||" & msgContent & "|||" & (msgTo as string) & "|||" & (msgCc as string) & "|||" & (my joinList(attachList, "^^^"))
342
+ end tell
343
+
344
+ on joinList(theList, delim)
345
+ set oldDelim to AppleScript's text item delimiters
346
+ set AppleScript's text item delimiters to delim
347
+ set theResult to theList as string
348
+ set AppleScript's text item delimiters to oldDelim
349
+ return theResult
350
+ end joinList
351
+ """
352
+
353
+ guard let raw = runAppleScript(script) else { return }
354
+ if raw == "ERROR_NOT_FOUND" {
355
+ JSONOutput.error("Message not found. It may be in a mailbox not searched (try mail.search to locate it first).")
356
+ return
357
+ }
358
+ let parts = raw.components(separatedBy: "|||")
359
+ guard parts.count >= 6 else {
360
+ JSONOutput.error("Unexpected response format from Mail.app")
361
+ return
362
+ }
363
+
364
+ var body = parts[5]
365
+ if maxBodyLength > 0 && body.count > maxBodyLength {
366
+ body = String(body.prefix(maxBodyLength)) + "\n\n[truncated — \(body.count) chars total]"
367
+ }
368
+
369
+ var message: [String: Any] = [
370
+ "id": messageId,
371
+ "subject": parts[0],
372
+ "sender": parts[1],
373
+ "date": parts[2],
374
+ "read": parts[3] == "true",
375
+ "flagged": parts[4] == "true",
376
+ "body": body
377
+ ]
378
+ if parts.count > 6 { message["to"] = parts[6] }
379
+ if parts.count > 7 { message["cc"] = parts[7] }
380
+
381
+ if parts.count > 8 && !parts[8].isEmpty {
382
+ let attachStrings = parts[8].components(separatedBy: "^^^")
383
+ let attachments: [[String: Any]] = attachStrings.enumerated().compactMap { (idx, attStr) in
384
+ let fields = attStr.components(separatedBy: ":::")
385
+ guard fields.count >= 2 else { return nil }
386
+ return [
387
+ "index": idx,
388
+ "name": fields[0],
389
+ "mimeType": fields[1]
390
+ ]
391
+ }
392
+ message["attachments"] = attachments
393
+ message["attachmentCount"] = attachments.count
394
+ message["hasAttachments"] = !attachments.isEmpty
395
+ } else {
396
+ message["attachments"] = [] as [[String: Any]]
397
+ message["attachmentCount"] = 0
398
+ message["hasAttachments"] = false
399
+ }
400
+
401
+ JSONOutput.success(message)
402
+ }
403
+
404
+ /// List flagged messages across all accounts.
405
+ static func flagged(limit: Int, offset: Int?) {
406
+ let effectiveOffset = offset ?? 0
407
+
408
+ // Two script variants: without pagination (early-exit for performance)
409
+ // and with pagination (must count all flagged messages for total).
410
+ let script: String
411
+ if offset != nil {
412
+ // Pagination: iterate everything to compute totalCount
413
+ script = """
414
+ tell application "Mail"
415
+ set resultList to {}
416
+ set totalCount to 0
417
+ set skipped to 0
418
+ set collected to 0
419
+ repeat with acct in every account
420
+ try
421
+ repeat with mbox in every mailbox of acct
422
+ set flaggedMsgs to (every message of mbox whose flagged status is true)
423
+ set totalCount to totalCount + (count of flaggedMsgs)
424
+ repeat with msg in flaggedMsgs
425
+ if skipped < \(effectiveOffset) then
426
+ set skipped to skipped + 1
427
+ else if collected < \(limit) then
428
+ set msgId to message id of msg
429
+ set msgSubject to subject of msg
430
+ set msgSender to sender of msg
431
+ set msgDate to date received of msg as «class isot» as string
432
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (name of acct) & "|||" & ((count of mail attachments of msg) as string)
433
+ set collected to collected + 1
434
+ end if
435
+ end repeat
436
+ end repeat
437
+ end try
438
+ end repeat
439
+ return my joinList(resultList, "^^^") & "###TOTAL:::" & (totalCount as string)
440
+ end tell
441
+
442
+ on joinList(theList, delim)
443
+ set oldDelim to AppleScript's text item delimiters
444
+ set AppleScript's text item delimiters to delim
445
+ set theResult to theList as string
446
+ set AppleScript's text item delimiters to oldDelim
447
+ return theResult
448
+ end joinList
449
+ """
450
+ } else {
451
+ // No pagination: early-exit once limit is reached
452
+ script = """
453
+ tell application "Mail"
454
+ set resultList to {}
455
+ repeat with acct in every account
456
+ try
457
+ repeat with mbox in every mailbox of acct
458
+ set flaggedMsgs to (every message of mbox whose flagged status is true)
459
+ repeat with msg in flaggedMsgs
460
+ set msgId to message id of msg
461
+ set msgSubject to subject of msg
462
+ set msgSender to sender of msg
463
+ set msgDate to date received of msg as «class isot» as string
464
+ set end of resultList to msgId & "|||" & msgSubject & "|||" & msgSender & "|||" & msgDate & "|||" & (name of acct) & "|||" & ((count of mail attachments of msg) as string)
465
+ if (count of resultList) >= \(limit) then exit repeat
466
+ end repeat
467
+ if (count of resultList) >= \(limit) then exit repeat
468
+ end repeat
469
+ end try
470
+ if (count of resultList) >= \(limit) then exit repeat
471
+ end repeat
472
+ return my joinList(resultList, "^^^")
473
+ end tell
474
+
475
+ on joinList(theList, delim)
476
+ set oldDelim to AppleScript's text item delimiters
477
+ set AppleScript's text item delimiters to delim
478
+ set theResult to theList as string
479
+ set AppleScript's text item delimiters to oldDelim
480
+ return theResult
481
+ end joinList
482
+ """
483
+ }
484
+
485
+ guard let raw = runAppleScript(script) else { return }
486
+ let totalParts = raw.components(separatedBy: "###TOTAL:::")
487
+ let messagesRaw = totalParts[0]
488
+ let total = totalParts.count > 1 ? Int(totalParts[1].trimmingCharacters(in: .whitespaces)) ?? 0 : 0
489
+ let messages = parseFlaggedList(messagesRaw)
490
+
491
+ if let offset = offset {
492
+ let envelope: [String: Any] = [
493
+ "messages": messages,
494
+ "total": total,
495
+ "offset": offset,
496
+ "limit": limit,
497
+ "hasMore": offset + limit < total
498
+ ]
499
+ JSONOutput.success(envelope)
500
+ } else {
501
+ JSONOutput.success(messages)
502
+ }
503
+ }
504
+
505
+ /// Create a draft email in Mail.app. Opens the compose window for user review.
506
+ static func createDraft(to: [String], cc: [String]?, bcc: [String]?, subject: String, body: String, account: String?) {
507
+ var recipientLines = ""
508
+ for addr in to {
509
+ recipientLines += " make new to recipient with properties {address:\"\(escapeForAppleScript(addr))\"}\n"
510
+ }
511
+ if let ccAddrs = cc {
512
+ for addr in ccAddrs {
513
+ recipientLines += " make new cc recipient with properties {address:\"\(escapeForAppleScript(addr))\"}\n"
514
+ }
515
+ }
516
+ if let bccAddrs = bcc {
517
+ for addr in bccAddrs {
518
+ recipientLines += " make new bcc recipient with properties {address:\"\(escapeForAppleScript(addr))\"}\n"
519
+ }
520
+ }
521
+
522
+ let senderLine: String
523
+ if let acct = account {
524
+ senderLine = " set sender of newMsg to \"\(escapeForAppleScript(acct))\""
525
+ } else {
526
+ senderLine = ""
527
+ }
528
+
529
+ let script = """
530
+ tell application "Mail"
531
+ set newMsg to make new outgoing message with properties {subject:"\(escapeForAppleScript(subject))", content:"\(escapeForAppleScript(body))", visible:true}
532
+ tell newMsg
533
+ \(recipientLines) end tell
534
+ \(senderLine)
535
+ end tell
536
+ """
537
+
538
+ guard runAppleScript(script) != nil else { return }
539
+
540
+ var result: [String: Any] = [
541
+ "subject": subject,
542
+ "to": to
543
+ ]
544
+ if let cc = cc { result["cc"] = cc }
545
+ if let bcc = bcc { result["bcc"] = bcc }
546
+ if let account = account { result["account"] = account }
547
+ JSONOutput.success(result)
548
+ }
549
+
550
+ /// Save a specific attachment from a message to disk.
551
+ static func saveAttachment(messageId: String, index: Int, outputDir: String) {
552
+ guard index >= 0 else {
553
+ JSONOutput.error("Attachment index must be non-negative. Got \(index).")
554
+ return
555
+ }
556
+ let escapedId = escapeForAppleScript(messageId)
557
+ guard let resolvedDir = FilesBridge.validatePath(outputDir, mustExist: false) else {
558
+ JSONOutput.error("Output path is outside home directory: \(outputDir)")
559
+ return
560
+ }
561
+
562
+ // Create output directory if needed
563
+ let fm = FileManager.default
564
+ if !fm.fileExists(atPath: resolvedDir) {
565
+ do {
566
+ try fm.createDirectory(atPath: resolvedDir, withIntermediateDirectories: true)
567
+ } catch {
568
+ JSONOutput.error("Failed to create output directory: \(error.localizedDescription)")
569
+ return
570
+ }
571
+ }
572
+
573
+ let escapedDir = escapeForAppleScript(resolvedDir)
574
+ // AppleScript index is 1-based
575
+ let asIndex = index + 1
576
+
577
+ let script = """
578
+ tell application "Mail"
579
+ -- Find message: unified inbox covers normal accounts; per-account
580
+ -- fallback only for accounts where inbox keyword fails (Proton Bridge).
581
+ set targetMsg to missing value
582
+ try
583
+ set targetMsg to first message of inbox whose message id is "\(escapedId)"
584
+ end try
585
+ if targetMsg is missing value then
586
+ repeat with acct in every account
587
+ repeat with mbox in every mailbox of acct
588
+ try
589
+ set targetMsg to first message of mbox whose message id is "\(escapedId)"
590
+ exit repeat
591
+ end try
592
+ end repeat
593
+ if targetMsg is not missing value then exit repeat
594
+ end repeat
595
+ end if
596
+ if targetMsg is missing value then
597
+ return "ERROR:::Message not found"
598
+ end if
599
+ set attList to every mail attachment of targetMsg
600
+ if (count of attList) < \(asIndex) then
601
+ return "ERROR:::Attachment index out of range. Message has " & ((count of attList) as string) & " attachments."
602
+ end if
603
+ set att to item \(asIndex) of attList
604
+ set attName to name of att
605
+ try
606
+ set attMime to MIME type of att
607
+ on error
608
+ set attMime to "application/octet-stream"
609
+ end try
610
+ set fullPath to "\(escapedDir)/" & attName
611
+ save att in (POSIX file fullPath)
612
+ return attName & ":::" & attMime & ":::" & fullPath
613
+ end tell
614
+ """
615
+
616
+ guard let raw = runAppleScript(script) else { return }
617
+
618
+ if raw.hasPrefix("ERROR:::") {
619
+ let errorMsg = String(raw.dropFirst("ERROR:::".count))
620
+ JSONOutput.error(errorMsg)
621
+ return
622
+ }
623
+
624
+ let fields = raw.components(separatedBy: ":::")
625
+ guard fields.count >= 3 else {
626
+ JSONOutput.error("Unexpected response format from Mail.app")
627
+ return
628
+ }
629
+
630
+ let result: [String: Any] = [
631
+ "name": fields[0],
632
+ "mimeType": fields[1],
633
+ "path": fields[2]
634
+ ]
635
+ JSONOutput.success(result)
636
+ }
637
+
638
+ // MARK: - AppleScript Execution
639
+
640
+ /// Default osascript timeout: long enough for legitimate per-account or
641
+ /// per-mailbox searches on large accounts, short enough that a hung
642
+ /// script returns control to the caller before Mail.app appears frozen
643
+ /// from the user's perspective. The scope guard in `search()` already
644
+ /// rejects the worst combinations; this is belt-and-braces.
645
+ private static let defaultAppleScriptTimeout: TimeInterval = 90
646
+
647
+ private static func runAppleScript(_ script: String, timeoutSeconds: TimeInterval = defaultAppleScriptTimeout) -> String? {
648
+ let task = Process()
649
+ task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
650
+ task.arguments = ["-e", script]
651
+
652
+ let outPipe = Pipe()
653
+ let errPipe = Pipe()
654
+ task.standardOutput = outPipe
655
+ task.standardError = errPipe
656
+
657
+ do {
658
+ try task.run()
659
+
660
+ // Watchdog: terminate osascript if it exceeds the timeout. SIGTERM
661
+ // first so the script gets a chance to clean up; SIGKILL after a
662
+ // short grace period if it ignores SIGTERM (Apple Events held by
663
+ // Mail.app can keep an osascript subprocess unresponsive to TERM).
664
+ let pid = task.processIdentifier
665
+ let didTimeOut = TimeoutFlag()
666
+ let watchdog = DispatchWorkItem {
667
+ guard task.isRunning else { return }
668
+ didTimeOut.set()
669
+ task.terminate()
670
+ DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
671
+ if task.isRunning { kill(pid, SIGKILL) }
672
+ }
673
+ }
674
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: watchdog)
675
+
676
+ task.waitUntilExit()
677
+ watchdog.cancel()
678
+
679
+ if didTimeOut.value {
680
+ JSONOutput.error(
681
+ "Mail AppleScript exceeded \(Int(timeoutSeconds))s timeout — killed to free Mail.app. " +
682
+ "Narrow the search scope (specific --account or --mailbox) or use --search-in subject."
683
+ )
684
+ return nil
685
+ }
686
+
687
+ if task.terminationStatus != 0 {
688
+ let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
689
+ let errStr = String(data: errData, encoding: .utf8)?
690
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error"
691
+
692
+ if errStr.contains("-1743") || errStr.contains("not allowed") {
693
+ JSONOutput.error("Mail automation permission denied. Grant access in System Settings > Privacy & Security > Automation > apple-bridge > Mail.")
694
+ } else if errStr.contains("-600") || errStr.contains("not running") {
695
+ JSONOutput.error("Mail.app is not running. Open Mail.app and try again.")
696
+ } else {
697
+ JSONOutput.error("AppleScript error: \(errStr)")
698
+ }
699
+ return nil
700
+ }
701
+
702
+ let data = outPipe.fileHandleForReading.readDataToEndOfFile()
703
+ return String(data: data, encoding: .utf8)?
704
+ .trimmingCharacters(in: .whitespacesAndNewlines)
705
+ } catch {
706
+ JSONOutput.error("Failed to run osascript: \(error.localizedDescription)")
707
+ return nil
708
+ }
709
+ }
710
+
711
+ /// Tiny actor-like flag so the watchdog closure can signal the main
712
+ /// thread that it fired, without racing with `task.terminationReason`
713
+ /// (which is only set after the kernel reaps the child).
714
+ private final class TimeoutFlag {
715
+ private let lock = NSLock()
716
+ private var fired = false
717
+ func set() { lock.lock(); fired = true; lock.unlock() }
718
+ var value: Bool { lock.lock(); defer { lock.unlock() }; return fired }
719
+ }
720
+
721
+ private static func escapeForAppleScript(_ str: String) -> String {
722
+ return str
723
+ .replacingOccurrences(of: "\\", with: "\\\\")
724
+ .replacingOccurrences(of: "\"", with: "\\\"")
725
+ .replacingOccurrences(of: "\r\n", with: "\" & (ASCII character 13) & (ASCII character 10) & \"")
726
+ .replacingOccurrences(of: "\n", with: "\" & (ASCII character 10) & \"")
727
+ .replacingOccurrences(of: "\r", with: "\" & (ASCII character 13) & \"")
728
+ .replacingOccurrences(of: "\t", with: "\" & (ASCII character 9) & \"")
729
+ }
730
+
731
+ // MARK: - Parsers
732
+
733
+ private static func parseAccountList(_ raw: String) -> [[String: Any]] {
734
+ guard !raw.isEmpty else { return [] }
735
+
736
+ let accountChunks = raw.components(separatedBy: "###")
737
+ return accountChunks.compactMap { chunk -> [String: Any]? in
738
+ let parts = chunk.components(separatedBy: "|||")
739
+ guard parts.count >= 2 else { return nil }
740
+
741
+ var account: [String: Any] = [
742
+ "name": parts[0].trimmingCharacters(in: .whitespaces),
743
+ "email": parts[1].trimmingCharacters(in: .whitespaces)
744
+ ]
745
+
746
+ if parts.count > 2 && !parts[2].isEmpty {
747
+ let mboxStrings = parts[2].components(separatedBy: "^^^")
748
+ let mailboxes: [[String: Any]] = mboxStrings.compactMap { mboxStr in
749
+ let fields = mboxStr.components(separatedBy: "::")
750
+ guard fields.count >= 2 else { return nil }
751
+ return [
752
+ "name": fields[0].trimmingCharacters(in: .whitespaces),
753
+ "unreadCount": Int(fields[1].trimmingCharacters(in: .whitespaces)) ?? 0
754
+ ]
755
+ }
756
+ account["mailboxes"] = mailboxes
757
+ }
758
+
759
+ return account
760
+ }
761
+ }
762
+
763
+ private static func parseUnreadSummary(_ raw: String, limit: Int) -> [[String: Any]] {
764
+ guard !raw.isEmpty else { return [] }
765
+
766
+ let accountChunks = raw.components(separatedBy: "###")
767
+ return accountChunks.compactMap { chunk -> [String: Any]? in
768
+ let parts = chunk.components(separatedBy: ":::")
769
+ guard parts.count >= 2 else { return nil }
770
+
771
+ var account: [String: Any] = [
772
+ "account": parts[0].trimmingCharacters(in: .whitespaces),
773
+ "unreadCount": Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0
774
+ ]
775
+
776
+ if parts.count > 2 && !parts[2].isEmpty {
777
+ let msgStrings = parts[2].components(separatedBy: "^^^")
778
+ let messages: [[String: Any]] = msgStrings.compactMap { msgStr in
779
+ let fields = msgStr.components(separatedBy: "|||")
780
+ guard fields.count >= 4 else { return nil }
781
+ var msg: [String: Any] = [
782
+ "id": fields[0],
783
+ "subject": fields[1],
784
+ "sender": fields[2],
785
+ "date": fields[3]
786
+ ]
787
+ if fields.count > 4 {
788
+ msg["flagged"] = fields[4] == "true"
789
+ }
790
+ if fields.count > 5 {
791
+ let count = Int(fields[5].trimmingCharacters(in: .whitespaces)) ?? 0
792
+ msg["attachmentCount"] = count
793
+ msg["hasAttachments"] = count > 0
794
+ }
795
+ return msg
796
+ }
797
+ account["recentUnread"] = messages
798
+ }
799
+
800
+ return account
801
+ }
802
+ }
803
+
804
+ private static func parseMessageList(_ raw: String) -> [[String: Any]] {
805
+ guard !raw.isEmpty else { return [] }
806
+
807
+ let msgStrings = raw.components(separatedBy: "^^^")
808
+ return msgStrings.compactMap { msgStr -> [String: Any]? in
809
+ let fields = msgStr.components(separatedBy: "|||")
810
+ guard fields.count >= 4 else { return nil }
811
+ var msg: [String: Any] = [
812
+ "id": fields[0],
813
+ "subject": fields[1],
814
+ "sender": fields[2],
815
+ "date": fields[3]
816
+ ]
817
+ if fields.count > 4 { msg["read"] = fields[4] == "true" }
818
+ if fields.count > 5 { msg["flagged"] = fields[5] == "true" }
819
+ if fields.count > 6 {
820
+ let count = Int(fields[6].trimmingCharacters(in: .whitespaces)) ?? 0
821
+ msg["attachmentCount"] = count
822
+ msg["hasAttachments"] = count > 0
823
+ }
824
+ if fields.count > 7 {
825
+ msg["mailbox"] = fields[7].trimmingCharacters(in: .whitespaces)
826
+ }
827
+ return msg
828
+ }
829
+ }
830
+
831
+ private static func parseFlaggedList(_ raw: String) -> [[String: Any]] {
832
+ guard !raw.isEmpty else { return [] }
833
+
834
+ let msgStrings = raw.components(separatedBy: "^^^")
835
+ return msgStrings.compactMap { msgStr -> [String: Any]? in
836
+ let fields = msgStr.components(separatedBy: "|||")
837
+ guard fields.count >= 4 else { return nil }
838
+ var msg: [String: Any] = [
839
+ "id": fields[0],
840
+ "subject": fields[1],
841
+ "sender": fields[2],
842
+ "date": fields[3],
843
+ "flagged": true
844
+ ]
845
+ if fields.count > 4 { msg["account"] = fields[4] }
846
+ if fields.count > 5 {
847
+ let count = Int(fields[5].trimmingCharacters(in: .whitespaces)) ?? 0
848
+ msg["attachmentCount"] = count
849
+ msg["hasAttachments"] = count > 0
850
+ }
851
+ return msg
852
+ }
853
+ }
854
+ }