@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.
- package/README.md +11 -8
- package/build/bridge.d.ts +13 -2
- package/build/bridge.js +140 -23
- package/build/bridge.js.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +37 -0
- package/build/tools/contacts.js.map +1 -0
- package/build/tools/files.js +4 -1
- package/build/tools/files.js.map +1 -1
- package/build/tools/keynote.d.ts +2 -0
- package/build/tools/keynote.js +198 -0
- package/build/tools/keynote.js.map +1 -0
- package/build/tools/mail.js +69 -13
- package/build/tools/mail.js.map +1 -1
- package/build/tools/notes.d.ts +2 -0
- package/build/tools/notes.js +83 -0
- package/build/tools/notes.js.map +1 -0
- package/build/tools/numbers.d.ts +2 -0
- package/build/tools/numbers.js +167 -0
- package/build/tools/numbers.js.map +1 -0
- package/build/tools/pages.d.ts +2 -0
- package/build/tools/pages.js +143 -0
- package/build/tools/pages.js.map +1 -0
- package/package.json +12 -9
- package/scripts/postinstall.sh +77 -8
- package/swift/.build/AppleBridge.app/Contents/MacOS/apple-bridge +0 -0
- package/swift/.build/AppleBridge.app.sha256 +1 -0
- package/swift/Package.resolved +14 -0
- package/swift/Package.swift +28 -0
- package/swift/Sources/AppleBridge/AppleBridge.swift +846 -0
- package/swift/Sources/AppleBridge/Calendar.swift +221 -0
- package/swift/Sources/AppleBridge/Contacts.swift +225 -0
- package/swift/Sources/AppleBridge/Doctor.swift +252 -0
- package/swift/Sources/AppleBridge/Files.swift +474 -0
- package/swift/Sources/AppleBridge/JSON.swift +57 -0
- package/swift/Sources/AppleBridge/Keynote.swift +599 -0
- package/swift/Sources/AppleBridge/Mail.swift +854 -0
- package/swift/Sources/AppleBridge/Notes.swift +263 -0
- package/swift/Sources/AppleBridge/Numbers.swift +601 -0
- package/swift/Sources/AppleBridge/Pages.swift +467 -0
- 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
|
+
}
|