@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.
- package/README.md +125 -0
- package/package.json +42 -0
- package/src/imessage/def.js +41 -0
- package/src/imessage/index.js +9 -0
- package/src/imessage/mac/data.js +144 -0
- package/src/imessage/mac/reply.js +68 -0
- package/src/imessage/mac/service.js +141 -0
- package/src/imessage/tools.js +44 -0
- package/src/index.js +7 -0
- package/src/mail/def.js +317 -0
- package/src/mail/index.js +165 -0
- package/src/mail/manage/index.js +10 -0
- package/src/mail/manage/mac/index.js +275 -0
- package/src/mail/read/index.js +1 -0
- package/src/mail/read/mac/accounts.js +53 -0
- package/src/mail/read/mac/index.js +170 -0
- package/src/mail/read/mac/permission.js +29 -0
- package/src/mail/read/mac/sync.js +98 -0
- package/src/mail/read/mac/transform.js +55 -0
- package/src/mail/send/index.js +1 -0
- package/src/mail/send/mac/index.js +93 -0
- package/src/mail/shared/index.js +6 -0
- package/src/mail/shared/mac/index.js +48 -0
- package/src/mail/tools.js +41 -0
- package/src/screen/capture/index.js +1 -0
- package/src/screen/capture/mac/index.js +109 -0
- package/src/screen/control/index.js +15 -0
- package/src/screen/control/mac/accessibility.js +25 -0
- package/src/screen/control/mac/apps.js +62 -0
- package/src/screen/control/mac/exec.js +66 -0
- package/src/screen/control/mac/helpers.js +5 -0
- package/src/screen/control/mac/index.js +10 -0
- package/src/screen/control/mac/keyboard.js +34 -0
- package/src/screen/control/mac/keycodes.js +87 -0
- package/src/screen/control/mac/mouse.js +59 -0
- package/src/screen/control/mac/python-keyboard.js +66 -0
- package/src/screen/control/mac/python-mouse.js +66 -0
- package/src/screen/control/mac/python.js +2 -0
- package/src/screen/control/mac/ui-scan.js +45 -0
- package/src/screen/def.js +304 -0
- package/src/screen/index.js +17 -0
- package/src/screen/queue.js +54 -0
- package/src/screen/tools.js +50 -0
- 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'
|