@vox-ai-app/integrations 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +125 -0
  2. package/package.json +42 -0
  3. package/src/imessage/def.js +41 -0
  4. package/src/imessage/index.js +9 -0
  5. package/src/imessage/mac/data.js +144 -0
  6. package/src/imessage/mac/reply.js +68 -0
  7. package/src/imessage/mac/service.js +141 -0
  8. package/src/imessage/tools.js +44 -0
  9. package/src/index.js +7 -0
  10. package/src/mail/def.js +317 -0
  11. package/src/mail/index.js +165 -0
  12. package/src/mail/manage/index.js +10 -0
  13. package/src/mail/manage/mac/index.js +275 -0
  14. package/src/mail/read/index.js +1 -0
  15. package/src/mail/read/mac/accounts.js +53 -0
  16. package/src/mail/read/mac/index.js +170 -0
  17. package/src/mail/read/mac/permission.js +29 -0
  18. package/src/mail/read/mac/sync.js +98 -0
  19. package/src/mail/read/mac/transform.js +55 -0
  20. package/src/mail/send/index.js +1 -0
  21. package/src/mail/send/mac/index.js +93 -0
  22. package/src/mail/shared/index.js +6 -0
  23. package/src/mail/shared/mac/index.js +48 -0
  24. package/src/mail/tools.js +41 -0
  25. package/src/screen/capture/index.js +1 -0
  26. package/src/screen/capture/mac/index.js +109 -0
  27. package/src/screen/control/index.js +15 -0
  28. package/src/screen/control/mac/accessibility.js +25 -0
  29. package/src/screen/control/mac/apps.js +62 -0
  30. package/src/screen/control/mac/exec.js +66 -0
  31. package/src/screen/control/mac/helpers.js +5 -0
  32. package/src/screen/control/mac/index.js +10 -0
  33. package/src/screen/control/mac/keyboard.js +34 -0
  34. package/src/screen/control/mac/keycodes.js +87 -0
  35. package/src/screen/control/mac/mouse.js +59 -0
  36. package/src/screen/control/mac/python-keyboard.js +66 -0
  37. package/src/screen/control/mac/python-mouse.js +66 -0
  38. package/src/screen/control/mac/python.js +2 -0
  39. package/src/screen/control/mac/ui-scan.js +45 -0
  40. package/src/screen/def.js +304 -0
  41. package/src/screen/index.js +17 -0
  42. package/src/screen/queue.js +54 -0
  43. package/src/screen/tools.js +50 -0
  44. package/src/tools.js +6 -0
@@ -0,0 +1,275 @@
1
+ import { resolveLocalPath } from '@vox-ai-app/tools'
2
+ import {
3
+ execAbortable,
4
+ esc,
5
+ EXEC_TIMEOUT,
6
+ writeTempScript,
7
+ cleanupTemp
8
+ } from '@vox-ai-app/tools/exec'
9
+ import { ensureAppleMailConfigured } from '../../shared/index.js'
10
+
11
+ const runAs = async (script, signal) => {
12
+ await ensureAppleMailConfigured()
13
+ const scriptFile = await writeTempScript(script, 'scpt')
14
+ try {
15
+ const { stdout } = await execAbortable(
16
+ `osascript "${scriptFile}"`,
17
+ { timeout: EXEC_TIMEOUT },
18
+ signal
19
+ )
20
+ const out = stdout.trim()
21
+ if (out.startsWith('ERROR:')) throw new Error(out.slice(6))
22
+ return out
23
+ } finally {
24
+ await cleanupTemp(scriptFile)
25
+ }
26
+ }
27
+
28
+ const findMessageById = (messageId) => [
29
+ `set targetId to ${Number(messageId)}`,
30
+ 'set theMsg to missing value',
31
+ 'set theMb to missing value',
32
+ 'set allBoxes to {}',
33
+ 'repeat with acct in every account',
34
+ ' try',
35
+ ' set end of allBoxes to mailbox "INBOX" of acct',
36
+ ' end try',
37
+ ' try',
38
+ ' repeat with mb in every mailbox of acct',
39
+ ' set end of allBoxes to mb',
40
+ ' end repeat',
41
+ ' end try',
42
+ 'end repeat',
43
+ 'repeat with mb in allBoxes',
44
+ ' try',
45
+ ' set theMsg to first message of mb whose id is targetId',
46
+ ' set theMb to mb',
47
+ ' exit repeat',
48
+ ' end try',
49
+ 'end repeat',
50
+ 'if theMsg is missing value then return "ERROR:message not found"'
51
+ ]
52
+ export const replyToEmailMac = async (
53
+ { messageId, body, replyAll = false, account },
54
+ { signal } = {}
55
+ ) => {
56
+ const bodyEsc = esc(body)
57
+ const replyCmd = replyAll ? 'reply theMsg with reply to all' : 'reply theMsg'
58
+ const senderLines = account
59
+ ? [
60
+ ` set acct to first account whose name contains "${esc(account)}"`,
61
+ ' set addressesList to email addresses of acct',
62
+ ' set senderAddr to item 1 of addressesList',
63
+ ' set sender of replyMsg to senderAddr'
64
+ ]
65
+ : []
66
+ const script = [
67
+ 'tell application "Mail"',
68
+ ...findMessageById(messageId),
69
+ ` set replyMsg to ${replyCmd}`,
70
+ ...senderLines,
71
+ ' tell replyMsg',
72
+ ` set content to "${bodyEsc}"`,
73
+ ' end tell',
74
+ ' send replyMsg',
75
+ ' return "sent"',
76
+ 'end tell'
77
+ ].join('\n')
78
+ await runAs(script, signal)
79
+ return {
80
+ status: 'sent',
81
+ messageId,
82
+ replyAll
83
+ }
84
+ }
85
+ export const forwardEmailMac = async ({ messageId, to, body = '', account }, { signal } = {}) => {
86
+ const bodyEsc = esc(body)
87
+ const senderLines = account
88
+ ? [
89
+ ` set acct to first account whose name contains "${esc(account)}"`,
90
+ ' set addressesList to email addresses of acct',
91
+ ' set senderAddr to item 1 of addressesList',
92
+ ' set sender of fwdMsg to senderAddr'
93
+ ]
94
+ : []
95
+ const script = [
96
+ 'tell application "Mail"',
97
+ ...findMessageById(messageId),
98
+ ' set fwdMsg to forward theMsg with opening window',
99
+ ...senderLines,
100
+ ' tell fwdMsg',
101
+ ...(Array.isArray(to) ? to : [to]).map(
102
+ (addr) => ` make new to recipient with properties {address:"${esc(addr)}"}`
103
+ ),
104
+ bodyEsc ? ` set content to "${bodyEsc}" & return & return & (content of fwdMsg)` : '',
105
+ ' end tell',
106
+ ' send fwdMsg',
107
+ ' return "sent"',
108
+ 'end tell'
109
+ ]
110
+ .filter(Boolean)
111
+ .join('\n')
112
+ await runAs(script, signal)
113
+ return {
114
+ status: 'sent',
115
+ messageId,
116
+ to
117
+ }
118
+ }
119
+ export const markEmailReadMac = async ({ messageId, read = true }, { signal } = {}) => {
120
+ const script = [
121
+ 'tell application "Mail"',
122
+ ...findMessageById(messageId),
123
+ ` set read status of theMsg to ${read}`,
124
+ ' return "done"',
125
+ 'end tell'
126
+ ].join('\n')
127
+ await runAs(script, signal)
128
+ return {
129
+ status: 'done',
130
+ messageId,
131
+ read
132
+ }
133
+ }
134
+ export const flagEmailMac = async ({ messageId, flagged = true }, { signal } = {}) => {
135
+ const script = [
136
+ 'tell application "Mail"',
137
+ ...findMessageById(messageId),
138
+ ` set flagged status of theMsg to ${flagged}`,
139
+ ' return "done"',
140
+ 'end tell'
141
+ ].join('\n')
142
+ await runAs(script, signal)
143
+ return {
144
+ status: 'done',
145
+ messageId,
146
+ flagged
147
+ }
148
+ }
149
+ export const deleteEmailMac = async ({ messageId }, { signal } = {}) => {
150
+ const script = [
151
+ 'tell application "Mail"',
152
+ ...findMessageById(messageId),
153
+ ' delete theMsg',
154
+ ' return "deleted"',
155
+ 'end tell'
156
+ ].join('\n')
157
+ await runAs(script, signal)
158
+ return {
159
+ status: 'deleted',
160
+ messageId
161
+ }
162
+ }
163
+ export const moveEmailMac = async ({ messageId, targetFolder }, { signal } = {}) => {
164
+ const folderEsc = esc(targetFolder)
165
+ const script = [
166
+ 'tell application "Mail"',
167
+ ...findMessageById(messageId),
168
+ ' set targetBox to missing value',
169
+ ' repeat with acct in every account',
170
+ ' repeat with mb in mailboxes of acct',
171
+ ` if name of mb contains "${folderEsc}" then`,
172
+ ' set targetBox to mb',
173
+ ' exit repeat',
174
+ ' end if',
175
+ ' end repeat',
176
+ ' if targetBox is not missing value then exit repeat',
177
+ ' end repeat',
178
+ ' if targetBox is missing value then',
179
+ ' repeat with mb in every mailbox',
180
+ ` if name of mb contains "${folderEsc}" then`,
181
+ ' set targetBox to mb',
182
+ ' exit repeat',
183
+ ' end if',
184
+ ' end repeat',
185
+ ' end if',
186
+ ' if targetBox is missing value then return "ERROR:target folder not found"',
187
+ ' move theMsg to targetBox',
188
+ ' return "moved"',
189
+ 'end tell'
190
+ ].join('\n')
191
+ await runAs(script, signal)
192
+ return {
193
+ status: 'moved',
194
+ messageId,
195
+ targetFolder
196
+ }
197
+ }
198
+ export const createDraftMac = async (
199
+ { to, subject, body, cc, bcc, attachments, account },
200
+ { signal } = {}
201
+ ) => {
202
+ const lines = ['tell application "Mail"']
203
+ if (account) {
204
+ lines.push(
205
+ ` set acct to first account whose name contains "${esc(account)}"`,
206
+ ' set addressesList to email addresses of acct',
207
+ ' set senderAddr to item 1 of addressesList',
208
+ ` set msg to make new outgoing message with properties {subject:"${esc(subject)}", content:"${esc(body).replace(/\n/g, '\\n')}", visible:true, sender:senderAddr}`
209
+ )
210
+ } else {
211
+ lines.push(
212
+ ` set msg to make new outgoing message with properties {subject:"${esc(subject)}", content:"${esc(body).replace(/\n/g, '\\n')}", visible:true}`
213
+ )
214
+ }
215
+ lines.push(' tell msg')
216
+ const toList = Array.isArray(to) ? to : [to]
217
+ for (const addr of toList) {
218
+ lines.push(` make new to recipient with properties {address:"${esc(addr)}"}`)
219
+ }
220
+ if (cc) {
221
+ const ccList = Array.isArray(cc) ? cc : [cc]
222
+ for (const addr of ccList) {
223
+ lines.push(` make new cc recipient with properties {address:"${esc(addr)}"}`)
224
+ }
225
+ }
226
+ if (bcc) {
227
+ const bccList = Array.isArray(bcc) ? bcc : [bcc]
228
+ for (const addr of bccList) {
229
+ lines.push(` make new bcc recipient with properties {address:"${esc(addr)}"}`)
230
+ }
231
+ }
232
+ if (attachments?.length) {
233
+ for (const p of attachments) {
234
+ const abs = resolveLocalPath(p)
235
+ lines.push(
236
+ ` make new attachment with properties {file name:(POSIX file "${esc(abs)}")} at after the last paragraph`
237
+ )
238
+ }
239
+ }
240
+ lines.push(' end tell', ' return "draft created"', 'end tell')
241
+ await runAs(lines.join('\n'), signal)
242
+ return {
243
+ status: 'draft_created',
244
+ to,
245
+ subject
246
+ }
247
+ }
248
+ export const saveAttachmentMac = async (
249
+ { messageId, attachmentName, savePath },
250
+ { signal } = {}
251
+ ) => {
252
+ const saveDir = resolveLocalPath(savePath || '~/Downloads')
253
+ const attNameEsc = esc(attachmentName)
254
+ const script = [
255
+ 'tell application "Mail"',
256
+ ...findMessageById(messageId),
257
+ ' set attFound to false',
258
+ ' repeat with att in mail attachments of theMsg',
259
+ ` if name of att contains "${attNameEsc}" then`,
260
+ ` save att in POSIX file "${esc(saveDir)}/${attNameEsc}"`,
261
+ ' set attFound to true',
262
+ ' exit repeat',
263
+ ' end if',
264
+ ' end repeat',
265
+ ' if not attFound then return "ERROR:attachment not found"',
266
+ ' return "saved"',
267
+ 'end tell'
268
+ ].join('\n')
269
+ await runAs(script, signal)
270
+ return {
271
+ status: 'saved',
272
+ attachmentName,
273
+ path: `${saveDir}/${attachmentName}`
274
+ }
275
+ }
@@ -0,0 +1 @@
1
+ export { readEmailsMac, getEmailBodyMac, openMailPermissionSettings } from './mac/index.js'
@@ -0,0 +1,53 @@
1
+ import { execAbortable, writeTempScript, cleanupTemp } from '@vox-ai-app/tools/exec'
2
+ import { ensureAppleMailConfigured } from '../../shared/index.js'
3
+
4
+ const ACCOUNT_CACHE_TTL = 60_000
5
+ let _accountCache = null
6
+
7
+ export const uuidFromUrl = (url) => {
8
+ const m = url && url.match(/\/\/([0-9A-F-]{36})\//i)
9
+ return m ? m[1].toUpperCase() : null
10
+ }
11
+
12
+ export const getAccountMap = async (signal) => {
13
+ if (_accountCache && Date.now() - _accountCache.ts < ACCOUNT_CACHE_TTL) {
14
+ return _accountCache.map
15
+ }
16
+ await ensureAppleMailConfigured(signal)
17
+ const script = `tell application "Mail"
18
+ set output to ""
19
+ repeat with a in every account
20
+ set output to output & (id of a) & tab & (name of a) & tab & (email addresses of a) & linefeed
21
+ end repeat
22
+ return output
23
+ end tell`
24
+ const scriptFile = await writeTempScript(script, 'scpt')
25
+ try {
26
+ const { stdout } = await execAbortable(`osascript "${scriptFile}"`, { timeout: 15_000 }, signal)
27
+ const map = {}
28
+ String(stdout)
29
+ .split('\n')
30
+ .map((l) => l.trim())
31
+ .filter(Boolean)
32
+ .forEach((line) => {
33
+ const parts = line.split('\t')
34
+ if (parts.length >= 2) {
35
+ const uuid = parts[0].trim().toUpperCase()
36
+ const name = parts[1].trim()
37
+ const email = (parts[2] || '').trim().toLowerCase()
38
+ map[uuid] = { name, email }
39
+ }
40
+ })
41
+ _accountCache = { map, ts: Date.now() }
42
+ return map
43
+ } finally {
44
+ await cleanupTemp(scriptFile)
45
+ }
46
+ }
47
+
48
+ export const findAccount = (accountMap, query) => {
49
+ const q = query.toLowerCase()
50
+ return Object.entries(accountMap).find(
51
+ ([, { name, email }]) => name.toLowerCase().includes(q) || email.includes(q)
52
+ )
53
+ }
@@ -0,0 +1,170 @@
1
+ import Database from 'better-sqlite3'
2
+ import {
3
+ execAbortable,
4
+ esc,
5
+ EXEC_TIMEOUT,
6
+ writeTempScript,
7
+ cleanupTemp
8
+ } from '@vox-ai-app/tools/exec'
9
+ import { ensureAppleMailConfigured } from '../../shared/index.js'
10
+ import { openMailPermissionSettings, checkMailAccess, throwFdaError } from './permission.js'
11
+ import { getAccountMap, findAccount } from './accounts.js'
12
+ import { ensureFresh } from './sync.js'
13
+ import { escapeLike, rowToEmail, parseBodyOutput } from './transform.js'
14
+
15
+ export { openMailPermissionSettings }
16
+
17
+ const DB = `${process.env.HOME}/Library/Mail/V10/MailData/Envelope Index`
18
+
19
+ const sqlite = (sql, params = []) => {
20
+ const db = new Database(DB, { readonly: true, fileMustExist: true })
21
+ try {
22
+ return db.prepare(sql).all(...params)
23
+ } finally {
24
+ db.close()
25
+ }
26
+ }
27
+
28
+ export const readEmailsMac = async (
29
+ { folder = 'inbox', limit = 20, offset = 0, unreadOnly = false, search = '', account = '' },
30
+ { signal } = {}
31
+ ) => {
32
+ if (!checkMailAccess()) throwFdaError()
33
+ const [accountMap] = await Promise.all([getAccountMap(signal), ensureFresh(sqlite, signal)])
34
+ const conditions = ['AND m.deleted = 0']
35
+ const params = []
36
+ if (folder.toLowerCase() === 'inbox') {
37
+ conditions.push(`AND (
38
+ (mb.url LIKE '%/INBOX' OR mb.url LIKE '%/Inbox')
39
+ OR
40
+ (mb.url LIKE '%5BGmail%5D/All%20Mail' AND EXISTS (
41
+ SELECT 1 FROM labels l
42
+ JOIN mailboxes lmb ON l.mailbox_id = lmb.ROWID
43
+ WHERE l.message_id = m.ROWID
44
+ AND (lmb.url LIKE '%/INBOX' OR lmb.url LIKE '%/Inbox')
45
+ ))
46
+ )`)
47
+ } else {
48
+ conditions.push(`AND mb.url LIKE ? ESCAPE '\\'`)
49
+ params.push(`%/${escapeLike(folder)}`)
50
+ }
51
+ if (unreadOnly) conditions.push('AND m.read = 0')
52
+ if (search) {
53
+ const s = escapeLike(search)
54
+ conditions.push(
55
+ `AND (s.subject LIKE ? ESCAPE '\\' OR a.address LIKE ? ESCAPE '\\' OR a.comment LIKE ? ESCAPE '\\')`
56
+ )
57
+ params.push(`%${s}%`, `%${s}%`, `%${s}%`)
58
+ }
59
+ if (account) {
60
+ const entry = findAccount(accountMap, account)
61
+ if (!entry) throw new Error(`No mail account matching "${account}" found.`)
62
+ conditions.push(`AND mb.url LIKE ?`)
63
+ params.push(`%${entry[0]}%`)
64
+ }
65
+ const sql = `
66
+ SELECT m.ROWID, s.subject, a.address, COALESCE(a.comment, '') as comment, m.date_received, m.read, m.flagged, mb.url
67
+ FROM messages m
68
+ JOIN subjects s ON m.subject = s.ROWID
69
+ JOIN addresses a ON m.sender = a.ROWID
70
+ JOIN mailboxes mb ON m.mailbox = mb.ROWID
71
+ WHERE 1=1
72
+ ${conditions.join('\n ')}
73
+ ORDER BY m.date_received DESC
74
+ LIMIT ? OFFSET ?`
75
+ params.push(Number(limit), Number(offset))
76
+ const rows = sqlite(sql, params)
77
+ return rows.map((row) => rowToEmail(row, accountMap)).filter((r) => r.sender && r.subject)
78
+ }
79
+
80
+ export const getEmailBodyMac = async (
81
+ { sender = '', subject = '', messageId = '' } = {},
82
+ { signal } = {}
83
+ ) => {
84
+ if (!checkMailAccess()) throwFdaError()
85
+ await ensureAppleMailConfigured(signal)
86
+ const script = messageId
87
+ ? `tell application "Mail"
88
+ set targetId to ${Number(messageId)}
89
+ repeat with acct in every account
90
+ set mb to missing value
91
+ try
92
+ set mb to mailbox "INBOX" of acct
93
+ end try
94
+ if mb is missing value then
95
+ try
96
+ set mb to mailbox "Inbox" of acct
97
+ end try
98
+ end if
99
+ if mb is not missing value then
100
+ try
101
+ set m to first message of mb whose id is targetId
102
+ set attNames to ""
103
+ repeat with att in mail attachments of m
104
+ set attNames to attNames & name of att & ","
105
+ end repeat
106
+ return (id of m as string) & "\\n---BODY:" & content of m & "\\n---ATTACHMENTS:" & (count of mail attachments of m) & ":" & attNames
107
+ end try
108
+ end if
109
+ end repeat
110
+ repeat with acct in every account
111
+ repeat with mb in every mailbox of acct
112
+ try
113
+ set m to first message of mb whose id is targetId
114
+ set attNames to ""
115
+ repeat with att in mail attachments of m
116
+ set attNames to attNames & name of att & ","
117
+ end repeat
118
+ return (id of m as string) & "\\n---BODY:" & content of m & "\\n---ATTACHMENTS:" & (count of mail attachments of m) & ":" & attNames
119
+ end try
120
+ end repeat
121
+ end repeat
122
+ return "NOT_FOUND"
123
+ end tell`
124
+ : `tell application "Mail"
125
+ set sQ to "${esc(sender)}"
126
+ set subQ to "${esc(subject)}"
127
+ repeat with acct in every account
128
+ set mb to missing value
129
+ try
130
+ set mb to mailbox "INBOX" of acct
131
+ end try
132
+ if mb is missing value then
133
+ try
134
+ set mb to mailbox "Inbox" of acct
135
+ end try
136
+ end if
137
+ if mb is not missing value then
138
+ try
139
+ set n to 0
140
+ repeat with m in (messages of mb)
141
+ set ok to true
142
+ if sQ is not "" and sender of m does not contain sQ then set ok to false
143
+ if ok and subQ is not "" and subject of m does not contain subQ then set ok to false
144
+ if ok then
145
+ set attNames to ""
146
+ repeat with att in mail attachments of m
147
+ set attNames to attNames & name of att & ","
148
+ end repeat
149
+ return (id of m as string) & "\\n---BODY:" & content of m & "\\n---ATTACHMENTS:" & (count of mail attachments of m) & ":" & attNames
150
+ end if
151
+ set n to n + 1
152
+ if n >= 50 then exit repeat
153
+ end repeat
154
+ end try
155
+ end if
156
+ end repeat
157
+ return "NOT_FOUND"
158
+ end tell`
159
+ const scriptFile = await writeTempScript(script, 'scpt')
160
+ try {
161
+ const { stdout } = await execAbortable(
162
+ `osascript "${scriptFile}"`,
163
+ { timeout: EXEC_TIMEOUT },
164
+ signal
165
+ )
166
+ return parseBodyOutput(stdout.trim())
167
+ } finally {
168
+ await cleanupTemp(scriptFile)
169
+ }
170
+ }
@@ -0,0 +1,29 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { shell } from 'electron'
3
+
4
+ const MAIL_DIR = `${process.env.HOME}/Library/Mail/`
5
+
6
+ const openFdaSettings = () => {
7
+ shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles')
8
+ }
9
+
10
+ export const openMailPermissionSettings = openFdaSettings
11
+
12
+ export const checkMailAccess = () => {
13
+ try {
14
+ readdirSync(MAIL_DIR)
15
+ return true
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ export const throwFdaError = () => {
22
+ openFdaSettings()
23
+ throw Object.assign(
24
+ new Error(
25
+ 'Vox needs Full Disk Access to read your emails. Opening System Settings → Privacy & Security → Full Disk Access — please enable it for Vox and try again.'
26
+ ),
27
+ { code: 'MAIL_FDA_REQUIRED' }
28
+ )
29
+ }
@@ -0,0 +1,98 @@
1
+ import { execAsync, execAbortable, writeTempScript, cleanupTemp } from '@vox-ai-app/tools/exec'
2
+
3
+ const SYNC_RETRY_DELAY = 2_000
4
+ const SYNC_MAX_RETRIES = 3
5
+ const FRESH_CACHE_TTL = 10_000
6
+ let _freshCache = null
7
+
8
+ const getLatestIds = async (signal) => {
9
+ const script = `tell application "Mail"
10
+ set output to ""
11
+ repeat with acct in every account
12
+ set mb to missing value
13
+ try
14
+ set mb to mailbox "INBOX" of acct
15
+ end try
16
+ if mb is missing value then
17
+ try
18
+ set mb to mailbox "Inbox" of acct
19
+ end try
20
+ end if
21
+ if mb is not missing value then
22
+ try
23
+ set m to message 1 of mb
24
+ set output to output & (id of acct) & tab & (id of m) & linefeed
25
+ end try
26
+ end if
27
+ end repeat
28
+ return output
29
+ end tell`
30
+ const scriptFile = await writeTempScript(script, 'scpt')
31
+ try {
32
+ const { stdout } = await execAbortable(`osascript "${scriptFile}"`, { timeout: 15_000 }, signal)
33
+ return String(stdout)
34
+ .split('\n')
35
+ .map((l) => l.trim())
36
+ .filter(Boolean)
37
+ .map((line) => {
38
+ const [, msgId] = line.split('\t')
39
+ return msgId ? Number(msgId) : null
40
+ })
41
+ .filter(Boolean)
42
+ } finally {
43
+ await cleanupTemp(scriptFile)
44
+ }
45
+ }
46
+
47
+ const triggerSync = () => {
48
+ const script = `tell application "Mail"
49
+ repeat with acct in every account
50
+ try
51
+ synchronize acct
52
+ end try
53
+ end repeat
54
+ end tell`
55
+ writeTempScript(script, 'scpt')
56
+ .then((f) => execAsync(`osascript "${f}"`).finally(() => cleanupTemp(f)))
57
+ .catch(() => {})
58
+ }
59
+
60
+ export const checkIdsInDb = (sqlite, ids) => {
61
+ if (!ids.length) return true
62
+ const placeholders = ids.map(() => '?').join(',')
63
+ const row = sqlite(`SELECT COUNT(*) as count FROM messages WHERE ROWID IN (${placeholders})`, ids)
64
+ return row[0]?.count === ids.length
65
+ }
66
+
67
+ export const ensureFresh = async (sqlite, signal) => {
68
+ if (_freshCache && Date.now() - _freshCache.ts < FRESH_CACHE_TTL) return
69
+ let ids
70
+ try {
71
+ ids = await getLatestIds(signal)
72
+ } catch {
73
+ _freshCache = { ts: Date.now() }
74
+ return
75
+ }
76
+ try {
77
+ if (checkIdsInDb(sqlite, ids)) {
78
+ _freshCache = { ts: Date.now() }
79
+ return
80
+ }
81
+ } catch {
82
+ _freshCache = { ts: Date.now() }
83
+ return
84
+ }
85
+ for (let i = 0; i < SYNC_MAX_RETRIES; i++) {
86
+ triggerSync()
87
+ await new Promise((r) => setTimeout(r, SYNC_RETRY_DELAY))
88
+ try {
89
+ if (checkIdsInDb(sqlite, ids)) {
90
+ _freshCache = { ts: Date.now() }
91
+ return
92
+ }
93
+ } catch {
94
+ break
95
+ }
96
+ }
97
+ _freshCache = { ts: Date.now() }
98
+ }
@@ -0,0 +1,55 @@
1
+ import { uuidFromUrl } from './accounts.js'
2
+
3
+ export const escapeLike = (s) => s.replace(/%/g, '\\%').replace(/_/g, '\\_')
4
+
5
+ export const localISODate = (ms) => {
6
+ const d = new Date(ms)
7
+ const off = -d.getTimezoneOffset()
8
+ const sign = off >= 0 ? '+' : '-'
9
+ const pad = (n) => String(Math.abs(n)).padStart(2, '0')
10
+ const local = new Date(ms - d.getTimezoneOffset() * 60_000)
11
+ return (
12
+ local.toISOString().slice(0, 19) +
13
+ `${sign}${pad(Math.floor(Math.abs(off) / 60))}:${pad(Math.abs(off) % 60)}`
14
+ )
15
+ }
16
+
17
+ export const rowToEmail = (row, accountMap) => {
18
+ const uuid = uuidFromUrl(row.url)
19
+ const acct = (uuid && accountMap[uuid]) || null
20
+ const senderEmail = row.address || ''
21
+ const senderName = row.comment || ''
22
+ const sender = senderName ? `${senderName} <${senderEmail}>` : senderEmail
23
+ const dateTs = row.date_received
24
+ return {
25
+ id: String(row.ROWID),
26
+ subject: row.subject,
27
+ sender,
28
+ date: dateTs ? localISODate(dateTs * 1000) : null,
29
+ read: row.read === 1,
30
+ flagged: row.flagged === 1,
31
+ account: acct ? acct.name : uuid || ''
32
+ }
33
+ }
34
+
35
+ export const parseBodyOutput = (raw) => {
36
+ if (!raw || raw === 'NOT_FOUND') return { found: false, body: null, attachments: [] }
37
+ const bodyMarker = raw.indexOf('\n---BODY:')
38
+ const attMarker = raw.lastIndexOf('\n---ATTACHMENTS:')
39
+ let id
40
+ let body = raw
41
+ let attachments = []
42
+ if (bodyMarker !== -1) {
43
+ id = raw.slice(0, bodyMarker)
44
+ body = attMarker !== -1 ? raw.slice(bodyMarker + 9, attMarker) : raw.slice(bodyMarker + 9)
45
+ }
46
+ if (attMarker !== -1) {
47
+ const [, ...nameParts] = raw.slice(attMarker + 16).split(':')
48
+ attachments = nameParts
49
+ .join(':')
50
+ .split(',')
51
+ .map((n) => n.trim())
52
+ .filter(Boolean)
53
+ }
54
+ return { found: true, id, body, attachments }
55
+ }
@@ -0,0 +1 @@
1
+ export { sendEmailMac, searchContactsMac } from './mac/index.js'