apple-tools-mcp 2.0.1 → 2.0.3
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 +54 -13
- package/bin/apple-tools-indexer.js +15 -0
- package/bin/apple-tools-mcp.js +7 -0
- package/index.js +26 -7
- package/indexer.js +4 -1
- package/lib/appleScript.js +90 -26
- package/lib/calendarWrite.js +39 -0
- package/lib/contactsWrite.js +191 -4
- package/lib/mailWrite.js +296 -26
- package/lib/messagesWrite.js +6 -2
- package/lib/permissions.js +360 -0
- package/lib/processMode.js +50 -2
- package/lib/shell.js +27 -0
- package/lib/writeRouting.js +76 -6
- package/lib/writeTools.js +9 -6
- package/package.json +7 -3
- package/scripts/postinstall.js +32 -0
- package/scripts/smoke-writes.js +4 -1
package/lib/contactsWrite.js
CHANGED
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
* writing that database directly corrupts iCloud sync.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
runAppleScript,
|
|
14
|
+
asString,
|
|
15
|
+
formatOsascriptDiagnostic,
|
|
16
|
+
CONTACTS_TCC_GUIDANCE,
|
|
17
|
+
CONTACTS_APP_NOT_RUNNING_GUIDANCE,
|
|
18
|
+
ATTRIBUTION_GUIDANCE
|
|
19
|
+
} from "./appleScript.js";
|
|
20
|
+
import { safeOpenApp } from "./shell.js";
|
|
13
21
|
import {
|
|
14
22
|
planWrite,
|
|
15
23
|
normalizeList,
|
|
@@ -75,6 +83,117 @@ function childLines(items, kind, personVar) {
|
|
|
75
83
|
.join("\n");
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
export const CONTACTS_READY_ATTEMPTS = 16;
|
|
87
|
+
export const CONTACTS_READY_INTERVAL_MS = 500;
|
|
88
|
+
export const CONTACTS_OPEN_RETRY_EVERY = 4;
|
|
89
|
+
/** Wall-clock cap so ensure + CRUD stay under the 90s write-bridge timeout. */
|
|
90
|
+
export const CONTACTS_READY_BUDGET_MS = 20000;
|
|
91
|
+
export const CONTACTS_LAUNCH_TIMEOUT_MS = 4000;
|
|
92
|
+
export const CONTACTS_READY_SCRIPT_TIMEOUT_MS = 2000;
|
|
93
|
+
|
|
94
|
+
export function buildContactsLaunchScript() {
|
|
95
|
+
// launch only — activate waits until Contacts is frontmost and a slow
|
|
96
|
+
// cold start can ETIMEDOUT, which must not abort the ready poll as TCC.
|
|
97
|
+
return `tell application "Contacts" to launch`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildContactsReadyScript() {
|
|
101
|
+
return `tell application "Contacts" to get name`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function contactsAppIsReady(output) {
|
|
105
|
+
const text = String(output || "").trim().toLowerCase();
|
|
106
|
+
return text === "contacts" || text === "ready";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function sleepMs(ms) {
|
|
110
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
111
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.min(ms, 5000));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Cold `tell application "Contacts"` under launchd often returns -600
|
|
116
|
+
* instead of auto-launching. Prefer `open -a Contacts` in *this* process
|
|
117
|
+
* (the write-bridge daemon when routed), then `launch` (not `activate`),
|
|
118
|
+
* then poll until a trivial AppleScript (`get name`) responds.
|
|
119
|
+
* A launch/ready ETIMEDOUT is a slow cold start, not Automation — keep polling.
|
|
120
|
+
*/
|
|
121
|
+
function isFatalContactsReadyKind(kind) {
|
|
122
|
+
return kind === "tcc" || kind === "attribution" || kind === "app_unavailable";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function tryOpenContacts(openApp) {
|
|
126
|
+
try {
|
|
127
|
+
openApp("Contacts");
|
|
128
|
+
return { ok: true };
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function ensureContactsAppReady({
|
|
135
|
+
run = runAppleScript,
|
|
136
|
+
sleep = sleepMs,
|
|
137
|
+
openApp = safeOpenApp,
|
|
138
|
+
attempts = CONTACTS_READY_ATTEMPTS,
|
|
139
|
+
intervalMs = CONTACTS_READY_INTERVAL_MS,
|
|
140
|
+
openRetryEvery = CONTACTS_OPEN_RETRY_EVERY,
|
|
141
|
+
budgetMs = CONTACTS_READY_BUDGET_MS,
|
|
142
|
+
launchTimeoutMs = CONTACTS_LAUNCH_TIMEOUT_MS,
|
|
143
|
+
readyTimeoutMs = CONTACTS_READY_SCRIPT_TIMEOUT_MS,
|
|
144
|
+
now = Date.now
|
|
145
|
+
} = {}) {
|
|
146
|
+
const started = now();
|
|
147
|
+
const remaining = () => budgetMs - (now() - started);
|
|
148
|
+
const slice = (maxMs) => Math.max(0, Math.min(maxMs, remaining()));
|
|
149
|
+
|
|
150
|
+
tryOpenContacts(openApp);
|
|
151
|
+
|
|
152
|
+
const launchBudget = slice(launchTimeoutMs);
|
|
153
|
+
const launch = launchBudget > 0
|
|
154
|
+
? run(buildContactsLaunchScript(), { timeout: launchBudget, appName: "Contacts" })
|
|
155
|
+
: { ok: false, kind: "app_not_running", error: "Contacts ensure budget exhausted before launch", output: "" };
|
|
156
|
+
if (!launch.ok && isFatalContactsReadyKind(launch.kind)) {
|
|
157
|
+
return launch;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tries = Math.max(1, attempts);
|
|
161
|
+
const retryEvery = Math.max(0, openRetryEvery);
|
|
162
|
+
let last = launch;
|
|
163
|
+
for (let i = 0; i < tries; i++) {
|
|
164
|
+
if (remaining() <= 0) break;
|
|
165
|
+
if (i > 0 && retryEvery > 0 && i % retryEvery === 0) {
|
|
166
|
+
tryOpenContacts(openApp);
|
|
167
|
+
}
|
|
168
|
+
const readyBudget = slice(readyTimeoutMs);
|
|
169
|
+
if (readyBudget <= 0) break;
|
|
170
|
+
const ready = run(buildContactsReadyScript(), { timeout: readyBudget, appName: "Contacts" });
|
|
171
|
+
last = ready;
|
|
172
|
+
if (ready.ok && contactsAppIsReady(ready.output)) {
|
|
173
|
+
return { ok: true, kind: null };
|
|
174
|
+
}
|
|
175
|
+
if (!ready.ok && isFatalContactsReadyKind(ready.kind)) {
|
|
176
|
+
return ready;
|
|
177
|
+
}
|
|
178
|
+
const pause = slice(intervalMs);
|
|
179
|
+
if (i < tries - 1 && pause > 0) sleep(pause);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (last && last.ok === false && isFatalContactsReadyKind(last.kind)) {
|
|
183
|
+
return last;
|
|
184
|
+
}
|
|
185
|
+
if (last && last.ok === false && last.kind === "app_not_running") {
|
|
186
|
+
return last;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
kind: "app_not_running",
|
|
192
|
+
error: (last && last.error) || (launch && launch.error) || "Contacts.app did not become ready after launch",
|
|
193
|
+
output: ""
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
78
197
|
function failure(action, summary, result, secrets = []) {
|
|
79
198
|
if (result.kind === "tcc" || result.kind === "timeout") {
|
|
80
199
|
return `${action} failed — attempted to ${summary}. ${CONTACTS_TCC_GUIDANCE}`;
|
|
@@ -83,6 +202,9 @@ function failure(action, summary, result, secrets = []) {
|
|
|
83
202
|
if (raw.includes("CONTACT_NOT_FOUND")) {
|
|
84
203
|
return `${action} failed — attempted to ${summary}. No contact with that id was found; use the Contact ID from contacts_search or contacts_lookup.`;
|
|
85
204
|
}
|
|
205
|
+
if (result.kind === "app_not_running") {
|
|
206
|
+
return `${action} failed — attempted to ${summary}. ${CONTACTS_APP_NOT_RUNNING_GUIDANCE} ${formatOsascriptDiagnostic(result)}`;
|
|
207
|
+
}
|
|
86
208
|
if (result.kind === "attribution") {
|
|
87
209
|
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
88
210
|
}
|
|
@@ -105,7 +227,7 @@ end tell
|
|
|
105
227
|
return newId`;
|
|
106
228
|
}
|
|
107
229
|
|
|
108
|
-
export function contactsAdd(args = {}) {
|
|
230
|
+
export function contactsAdd(args = {}, deps = {}) {
|
|
109
231
|
const action = "contacts_add";
|
|
110
232
|
|
|
111
233
|
const firstName = validateSubject(args.first_name);
|
|
@@ -140,6 +262,9 @@ export function contactsAdd(args = {}) {
|
|
|
140
262
|
if (organization.text) properties.organization = organization.text;
|
|
141
263
|
if (jobTitle.text) properties["job title"] = jobTitle.text;
|
|
142
264
|
|
|
265
|
+
const ready = ensureContactsAppReady(deps);
|
|
266
|
+
if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
|
|
267
|
+
|
|
143
268
|
const result = runAppleScript(
|
|
144
269
|
buildAddContactScript({ properties, emails: emails.emails, phones: phones.phones }),
|
|
145
270
|
{ timeout: 60000, appName: "Contacts" }
|
|
@@ -181,7 +306,7 @@ end tell
|
|
|
181
306
|
return editedName`;
|
|
182
307
|
}
|
|
183
308
|
|
|
184
|
-
export function contactsEdit(args = {}) {
|
|
309
|
+
export function contactsEdit(args = {}, deps = {}) {
|
|
185
310
|
const action = "contacts_edit";
|
|
186
311
|
|
|
187
312
|
const contactId = validateContactId(args.contact_id);
|
|
@@ -234,6 +359,9 @@ export function contactsEdit(args = {}) {
|
|
|
234
359
|
});
|
|
235
360
|
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
236
361
|
|
|
362
|
+
const ready = ensureContactsAppReady(deps);
|
|
363
|
+
if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
|
|
364
|
+
|
|
237
365
|
const result = runAppleScript(
|
|
238
366
|
buildEditContactScript({
|
|
239
367
|
contactId,
|
|
@@ -269,7 +397,7 @@ end tell
|
|
|
269
397
|
return removedName`;
|
|
270
398
|
}
|
|
271
399
|
|
|
272
|
-
export function contactsRemove(args = {}) {
|
|
400
|
+
export function contactsRemove(args = {}, deps = {}) {
|
|
273
401
|
const action = "contacts_remove";
|
|
274
402
|
|
|
275
403
|
const contactId = validateContactId(args.contact_id);
|
|
@@ -287,6 +415,9 @@ export function contactsRemove(args = {}) {
|
|
|
287
415
|
});
|
|
288
416
|
if (!plan.proceed) return { ok: true, message: plan.message, planned: true };
|
|
289
417
|
|
|
418
|
+
const ready = ensureContactsAppReady(deps);
|
|
419
|
+
if (!ready.ok) return { ok: false, message: failure(action, summary, ready) };
|
|
420
|
+
|
|
290
421
|
const result = runAppleScript(buildRemoveContactScript(contactId), { timeout: 60000, appName: "Contacts" });
|
|
291
422
|
if (!result.ok) return { ok: false, message: failure(action, summary, result) };
|
|
292
423
|
|
|
@@ -298,3 +429,59 @@ export function contactsRemove(args = {}) {
|
|
|
298
429
|
})
|
|
299
430
|
};
|
|
300
431
|
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* First-run / upgrade probe: the Contacts write verb that triggers
|
|
435
|
+
* Automation → Contacts. Creates a clearly named throwaway person and
|
|
436
|
+
* deletes it in the same script so a completed run leaves no junk.
|
|
437
|
+
*/
|
|
438
|
+
export const CONTACTS_AUTOMATION_PROBE_FIRST = "ATM";
|
|
439
|
+
export const CONTACTS_AUTOMATION_PROBE_LAST = "Permissions Probe";
|
|
440
|
+
|
|
441
|
+
export function buildContactsAutomationProbeScript() {
|
|
442
|
+
return `tell application "Contacts"
|
|
443
|
+
set probe to make new person with properties {first name:${asString(CONTACTS_AUTOMATION_PROBE_FIRST)}, last name:${asString(CONTACTS_AUTOMATION_PROBE_LAST)}}
|
|
444
|
+
delete probe
|
|
445
|
+
save
|
|
446
|
+
end tell
|
|
447
|
+
return "OK"`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function buildContactsProbeCleanupScript() {
|
|
451
|
+
return `tell application "Contacts"
|
|
452
|
+
set leftovers to (every person whose first name is ${asString(CONTACTS_AUTOMATION_PROBE_FIRST)} and last name is ${asString(CONTACTS_AUTOMATION_PROBE_LAST)})
|
|
453
|
+
repeat with p in leftovers
|
|
454
|
+
delete p
|
|
455
|
+
end repeat
|
|
456
|
+
save
|
|
457
|
+
end tell
|
|
458
|
+
return "OK"`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Live Contacts Apple Events check. Not a dry_run.
|
|
463
|
+
* @returns {{ ok: boolean, message: string, kind: string|null }}
|
|
464
|
+
*/
|
|
465
|
+
export function probeContactsAutomation(deps = {}) {
|
|
466
|
+
const action = "contacts_automation_probe";
|
|
467
|
+
const summary = "create and delete a throwaway Contacts person (Automation / AddressBook check)";
|
|
468
|
+
const ready = ensureContactsAppReady(deps);
|
|
469
|
+
if (!ready.ok) {
|
|
470
|
+
runAppleScript(buildContactsProbeCleanupScript(), { timeout: 30000, appName: "Contacts" });
|
|
471
|
+
return { ok: false, message: failure(action, summary, ready), kind: ready.kind };
|
|
472
|
+
}
|
|
473
|
+
const result = runAppleScript(buildContactsAutomationProbeScript(), { timeout: 60000, appName: "Contacts" });
|
|
474
|
+
// Best-effort cleanup if the probe created the person then failed mid-script.
|
|
475
|
+
runAppleScript(buildContactsProbeCleanupScript(), { timeout: 30000, appName: "Contacts" });
|
|
476
|
+
if (!result.ok) {
|
|
477
|
+
return { ok: false, message: failure(action, summary, result), kind: result.kind };
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
kind: null,
|
|
482
|
+
message: writeSuccessMessage(
|
|
483
|
+
action,
|
|
484
|
+
"Contacts Automation allowed (created and deleted a throwaway contact)"
|
|
485
|
+
)
|
|
486
|
+
};
|
|
487
|
+
}
|
package/lib/mailWrite.js
CHANGED
|
@@ -6,12 +6,25 @@
|
|
|
6
6
|
* property). `file_path` from mail_search / mail_recent is also accepted and
|
|
7
7
|
* is resolved to a Message-ID by reading the .emlx headers, so callers never
|
|
8
8
|
* have to invent an identifier.
|
|
9
|
+
*
|
|
10
|
+
* After a send/reply/forward hang, Sent (and Outbox) is checked before the
|
|
11
|
+
* tool reports failure. A message already in Sent is success — never a TCC
|
|
12
|
+
* fail. ETIMEDOUT / -1712 is a timeout (find/reply/open before send too);
|
|
13
|
+
* -1743 / -10004 is a hard deny.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import fs from "fs";
|
|
12
17
|
import path from "path";
|
|
13
18
|
import { validateEmailPath, unfoldRfc822Headers, safeMatch, stripHtmlTags } from "./validators.js";
|
|
14
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
runAppleScript,
|
|
21
|
+
asString,
|
|
22
|
+
MAIL_TCC_GUIDANCE,
|
|
23
|
+
MAIL_SEND_TIMEOUT_GUIDANCE,
|
|
24
|
+
MAIL_APP_NOT_RUNNING_GUIDANCE,
|
|
25
|
+
ATTRIBUTION_GUIDANCE,
|
|
26
|
+
isHardTccDenial
|
|
27
|
+
} from "./appleScript.js";
|
|
15
28
|
import {
|
|
16
29
|
planWrite,
|
|
17
30
|
validateEmailList,
|
|
@@ -123,10 +136,11 @@ export function probeMailAutomation() {
|
|
|
123
136
|
const summary = "compose a temporary outgoing message in Mail (Automation / Apple Events check; nothing is sent)";
|
|
124
137
|
const result = runAppleScript(buildMailAutomationProbeScript(), { timeout: 30000, appName: "Mail" });
|
|
125
138
|
if (!result.ok) {
|
|
126
|
-
return { ok: false, message: failure(action, summary, result, []) };
|
|
139
|
+
return { ok: false, message: failure(action, summary, result, []), kind: result.kind };
|
|
127
140
|
}
|
|
128
141
|
return {
|
|
129
142
|
ok: true,
|
|
143
|
+
kind: null,
|
|
130
144
|
message: writeSuccessMessage(
|
|
131
145
|
action,
|
|
132
146
|
"Mail Automation allowed (composed and discarded a temporary outgoing message; nothing was sent)"
|
|
@@ -171,13 +185,219 @@ function summarizeRecipients(to, cc, bcc) {
|
|
|
171
185
|
return parts.join("; ");
|
|
172
186
|
}
|
|
173
187
|
|
|
188
|
+
export const SENT_VERIFY_FOUND = "FOUND";
|
|
189
|
+
export const SENT_VERIFY_TIMEOUT_MS = 15000;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Hard Mail Automation deny: -1743 / -10004 / "not authorized…", or
|
|
193
|
+
* classify kind `tcc`. A spawnSync hang is `timeout`, not this.
|
|
194
|
+
*/
|
|
195
|
+
export function isMailHardTcc(result) {
|
|
196
|
+
if (!result) return false;
|
|
197
|
+
if (result.kind === "tcc") return true;
|
|
198
|
+
return isHardTccDenial(result.error);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Find/reply/send hang (ETIMEDOUT / -1712). Not a hard TCC deny.
|
|
203
|
+
*/
|
|
204
|
+
export function isMailSendTimeout(result) {
|
|
205
|
+
if (!result) return false;
|
|
206
|
+
if (result.kind !== "timeout") return false;
|
|
207
|
+
return !isHardTccDenial(result.error);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function mailBoxesPreamble() {
|
|
211
|
+
return ` set cutoff to (current date) - (10 * minutes)
|
|
212
|
+
set boxes to {}
|
|
213
|
+
try
|
|
214
|
+
set end of boxes to sent mailbox
|
|
215
|
+
end try
|
|
216
|
+
try
|
|
217
|
+
set end of boxes to outgoing mailbox
|
|
218
|
+
end try
|
|
219
|
+
repeat with acct in accounts
|
|
220
|
+
try
|
|
221
|
+
set end of boxes to sent mailbox of acct
|
|
222
|
+
end try
|
|
223
|
+
end repeat`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function appleScriptToList(addresses = []) {
|
|
227
|
+
return `{${addresses.map((address) => asString(address)).join(", ")}}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Look in Sent / Outbox for a reply of `messageId`.
|
|
232
|
+
* Proper Mail replies set In-Reply-To. The fallback `make new outgoing
|
|
233
|
+
* message` path does not, so also match exact "Re: " & original subject.
|
|
234
|
+
* Never treat the original itself (or a Re: that merely contains the
|
|
235
|
+
* original subject) as this send.
|
|
236
|
+
*/
|
|
237
|
+
export function buildFindSentByInReplyToScript(messageId) {
|
|
238
|
+
const idLit = asString(messageId);
|
|
239
|
+
const needleBare = asString(messageId);
|
|
240
|
+
const needleAngle = asString(`<${messageId}>`);
|
|
241
|
+
return `${findMessageHandler()}
|
|
242
|
+
|
|
243
|
+
set origSubject to ""
|
|
244
|
+
try
|
|
245
|
+
set origMsg to atmFindMessage(${idLit})
|
|
246
|
+
tell application "Mail" to set origSubject to subject of origMsg
|
|
247
|
+
end try
|
|
248
|
+
tell application "Mail"
|
|
249
|
+
${mailBoxesPreamble()}
|
|
250
|
+
repeat with boxRef in boxes
|
|
251
|
+
try
|
|
252
|
+
set recentMsgs to (messages of boxRef whose date sent > cutoff)
|
|
253
|
+
repeat with msg in recentMsgs
|
|
254
|
+
try
|
|
255
|
+
set candId to message id of msg
|
|
256
|
+
if candId is ${needleBare} then
|
|
257
|
+
-- The original, not the reply we just sent.
|
|
258
|
+
else
|
|
259
|
+
try
|
|
260
|
+
set src to source of msg
|
|
261
|
+
if src contains ("In-Reply-To: " & ${needleAngle}) then return "${SENT_VERIFY_FOUND}"
|
|
262
|
+
if src contains ("In-Reply-To: " & ${needleBare}) then return "${SENT_VERIFY_FOUND}"
|
|
263
|
+
end try
|
|
264
|
+
if origSubject is not "" then
|
|
265
|
+
set subj to subject of msg
|
|
266
|
+
if subj is ("Re: " & origSubject) then return "${SENT_VERIFY_FOUND}"
|
|
267
|
+
end if
|
|
268
|
+
end if
|
|
269
|
+
end try
|
|
270
|
+
end repeat
|
|
271
|
+
end try
|
|
272
|
+
end repeat
|
|
273
|
+
end tell
|
|
274
|
+
return "NOT_FOUND"`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Look in Sent / Outbox for a forward of `messageId` to `toAddresses`.
|
|
279
|
+
* Mail.app forwards do not set In-Reply-To / References — those headers are
|
|
280
|
+
* replies. Require the intended recipient plus either an exact Fwd:/Fw:
|
|
281
|
+
* subject or the original Message-ID in a forwarded body. Skip the original
|
|
282
|
+
* itself, In-Reply-To hits, and unrelated Fwd: mail that only shares a To.
|
|
283
|
+
*/
|
|
284
|
+
export function buildFindSentForwardScript(messageId, toAddresses = []) {
|
|
285
|
+
const idLit = asString(messageId);
|
|
286
|
+
const needleBare = asString(messageId);
|
|
287
|
+
const needleAngle = asString(`<${messageId}>`);
|
|
288
|
+
const toList = appleScriptToList(toAddresses);
|
|
289
|
+
return `${findMessageHandler()}
|
|
290
|
+
|
|
291
|
+
set origSubject to ""
|
|
292
|
+
try
|
|
293
|
+
set origMsg to atmFindMessage(${idLit})
|
|
294
|
+
tell application "Mail" to set origSubject to subject of origMsg
|
|
295
|
+
end try
|
|
296
|
+
tell application "Mail"
|
|
297
|
+
${mailBoxesPreamble()}
|
|
298
|
+
set wantedTos to ${toList}
|
|
299
|
+
repeat with boxRef in boxes
|
|
300
|
+
try
|
|
301
|
+
set recentMsgs to (messages of boxRef whose date sent > cutoff)
|
|
302
|
+
repeat with msg in recentMsgs
|
|
303
|
+
try
|
|
304
|
+
set candId to message id of msg
|
|
305
|
+
if candId is ${needleBare} then
|
|
306
|
+
-- The original, not the forward we just sent.
|
|
307
|
+
else
|
|
308
|
+
set src to source of msg
|
|
309
|
+
if src contains ("In-Reply-To: " & ${needleAngle}) or src contains ("In-Reply-To: " & ${needleBare}) then
|
|
310
|
+
-- A reply to the original is not this forward.
|
|
311
|
+
else
|
|
312
|
+
set hitTo to false
|
|
313
|
+
try
|
|
314
|
+
repeat with recip in (to recipients of msg)
|
|
315
|
+
set recipAddr to address of recip as string
|
|
316
|
+
repeat with wanted in wantedTos
|
|
317
|
+
if recipAddr is (wanted as string) then set hitTo to true
|
|
318
|
+
end repeat
|
|
319
|
+
end repeat
|
|
320
|
+
end try
|
|
321
|
+
if hitTo then
|
|
322
|
+
set subj to subject of msg
|
|
323
|
+
set exactFwd to false
|
|
324
|
+
if origSubject is not "" then
|
|
325
|
+
if subj is ("Fwd: " & origSubject) then set exactFwd to true
|
|
326
|
+
if subj is ("Fw: " & origSubject) then set exactFwd to true
|
|
327
|
+
if subj is ("FW: " & origSubject) then set exactFwd to true
|
|
328
|
+
if subj is ("Forward: " & origSubject) then set exactFwd to true
|
|
329
|
+
end if
|
|
330
|
+
set looksForward to exactFwd
|
|
331
|
+
if subj starts with "Fwd:" or subj starts with "Fw:" or subj starts with "FW:" or subj starts with "Forward:" then set looksForward to true
|
|
332
|
+
if src contains "Begin forwarded message" then set looksForward to true
|
|
333
|
+
set mentionsOrigId to false
|
|
334
|
+
if src contains ${needleAngle} then set mentionsOrigId to true
|
|
335
|
+
if src contains ${needleBare} then set mentionsOrigId to true
|
|
336
|
+
if exactFwd then return "${SENT_VERIFY_FOUND}"
|
|
337
|
+
if looksForward and mentionsOrigId then return "${SENT_VERIFY_FOUND}"
|
|
338
|
+
end if
|
|
339
|
+
end if
|
|
340
|
+
end if
|
|
341
|
+
end try
|
|
342
|
+
end repeat
|
|
343
|
+
end try
|
|
344
|
+
end repeat
|
|
345
|
+
end tell
|
|
346
|
+
return "NOT_FOUND"`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Look in Sent / Outbox for a compose whose subject matches exactly and
|
|
351
|
+
* was sent in the last 10 minutes.
|
|
352
|
+
*/
|
|
353
|
+
export function buildFindSentBySubjectScript(subject) {
|
|
354
|
+
const subj = asString(subject);
|
|
355
|
+
return `tell application "Mail"
|
|
356
|
+
${mailBoxesPreamble()}
|
|
357
|
+
repeat with boxRef in boxes
|
|
358
|
+
try
|
|
359
|
+
set hits to (messages of boxRef whose subject is ${subj} and date sent > cutoff)
|
|
360
|
+
if (count of hits) > 0 then return "${SENT_VERIFY_FOUND}"
|
|
361
|
+
end try
|
|
362
|
+
end repeat
|
|
363
|
+
end tell
|
|
364
|
+
return "NOT_FOUND"`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* After a send hang, ask Mail whether the message is already in Sent.
|
|
369
|
+
* Returns a hit object or null. Verify failure / timeout is not success.
|
|
370
|
+
*/
|
|
371
|
+
export function recoverIfInSent({ inReplyTo = null, subject = null, forwardTo = null } = {}) {
|
|
372
|
+
const script = forwardTo && inReplyTo
|
|
373
|
+
? buildFindSentForwardScript(inReplyTo, forwardTo)
|
|
374
|
+
: inReplyTo
|
|
375
|
+
? buildFindSentByInReplyToScript(inReplyTo)
|
|
376
|
+
: subject
|
|
377
|
+
? buildFindSentBySubjectScript(subject)
|
|
378
|
+
: null;
|
|
379
|
+
if (!script) return null;
|
|
380
|
+
const result = runAppleScript(script, { timeout: SENT_VERIFY_TIMEOUT_MS, appName: "Mail" });
|
|
381
|
+
if (!result.ok) return null;
|
|
382
|
+
const out = String(result.output || "").trim();
|
|
383
|
+
if (out === SENT_VERIFY_FOUND) return { found: true };
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
174
387
|
function failure(action, summary, result, secrets) {
|
|
175
|
-
if (result.kind === "tcc" || result
|
|
388
|
+
if (result.kind === "tcc" || isHardTccDenial(result && result.error)) {
|
|
176
389
|
return `${action} failed — attempted to ${summary}. ${MAIL_TCC_GUIDANCE}`;
|
|
177
390
|
}
|
|
391
|
+
if (result.kind === "timeout") {
|
|
392
|
+
// Allowed hang / ETIMEDOUT / -1712 is never TCC — including pre-send find/reply/open.
|
|
393
|
+
return `${action} failed — attempted to ${summary}. ${MAIL_SEND_TIMEOUT_GUIDANCE}`;
|
|
394
|
+
}
|
|
178
395
|
if (result.kind === "not_found") {
|
|
179
396
|
return `${action} failed — attempted to ${summary}. The message could not be found in Mail. Pass a message_id from a current mail_search result.`;
|
|
180
397
|
}
|
|
398
|
+
if (result.kind === "app_not_running") {
|
|
399
|
+
return `${action} failed — attempted to ${summary}. ${MAIL_APP_NOT_RUNNING_GUIDANCE}`;
|
|
400
|
+
}
|
|
181
401
|
if (result.kind === "attribution") {
|
|
182
402
|
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
183
403
|
}
|
|
@@ -187,6 +407,45 @@ function failure(action, summary, result, secrets) {
|
|
|
187
407
|
return writeErrorMessage(action, summary, new Error(result.error || "unknown error"), secrets);
|
|
188
408
|
}
|
|
189
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Finish a compose/reply/forward. A send hang is not labeled TCC; if Mail
|
|
412
|
+
* already delivered, return success. Hard -1743/-10004 stays a deny.
|
|
413
|
+
*/
|
|
414
|
+
function finalizeMailWrite({
|
|
415
|
+
action,
|
|
416
|
+
summary,
|
|
417
|
+
result,
|
|
418
|
+
secrets,
|
|
419
|
+
sendNow,
|
|
420
|
+
inReplyTo = null,
|
|
421
|
+
subject = null,
|
|
422
|
+
forwardTo = null,
|
|
423
|
+
successSummary,
|
|
424
|
+
details
|
|
425
|
+
}) {
|
|
426
|
+
if (result.ok) {
|
|
427
|
+
return { ok: true, message: writeSuccessMessage(action, successSummary, details) };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (sendNow && isMailSendTimeout(result)) {
|
|
431
|
+
const recovered = recoverIfInSent({ inReplyTo, subject, forwardTo });
|
|
432
|
+
if (recovered) {
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
recovered: true,
|
|
436
|
+
message: writeSuccessMessage(
|
|
437
|
+
action,
|
|
438
|
+
`${successSummary} (verified in Sent after AppleScript hang)`,
|
|
439
|
+
details
|
|
440
|
+
)
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return { ok: false, message: failure(action, summary, result, secrets) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { ok: false, message: failure(action, summary, result, secrets) };
|
|
447
|
+
}
|
|
448
|
+
|
|
190
449
|
/**
|
|
191
450
|
* Compose and send (or save as draft) a new email.
|
|
192
451
|
*/
|
|
@@ -240,19 +499,21 @@ export function mailCompose(args = {}, { draft = false } = {}) {
|
|
|
240
499
|
});
|
|
241
500
|
|
|
242
501
|
const result = runAppleScript(script, { timeout: 60000, appName: "Mail" });
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
502
|
+
return finalizeMailWrite({
|
|
503
|
+
action,
|
|
504
|
+
summary,
|
|
505
|
+
result,
|
|
506
|
+
secrets: [body.text, subject.text],
|
|
507
|
+
sendNow: !draft,
|
|
508
|
+
subject: draft ? null : subject.text,
|
|
509
|
+
successSummary: draft ? "draft saved to Drafts" : "sent",
|
|
510
|
+
details: {
|
|
250
511
|
to: to.addresses.join(", "),
|
|
251
512
|
cc: cc.addresses.join(", ") || undefined,
|
|
252
513
|
bcc: bcc.addresses.length ? `${bcc.addresses.length} recipient(s)` : undefined,
|
|
253
514
|
subject: truncate(subject.text, 150)
|
|
254
|
-
}
|
|
255
|
-
};
|
|
515
|
+
}
|
|
516
|
+
});
|
|
256
517
|
}
|
|
257
518
|
|
|
258
519
|
export function buildReplyScript({ messageId, body, replyAll, sendNow }) {
|
|
@@ -310,15 +571,19 @@ export function mailReply(args = {}) {
|
|
|
310
571
|
buildReplyScript({ messageId: resolved.messageId, body: body.text, replyAll, sendNow }),
|
|
311
572
|
{ timeout: 60000, appName: "Mail" }
|
|
312
573
|
);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
574
|
+
return finalizeMailWrite({
|
|
575
|
+
action,
|
|
576
|
+
summary,
|
|
577
|
+
result,
|
|
578
|
+
secrets: [body.text],
|
|
579
|
+
sendNow,
|
|
580
|
+
inReplyTo: sendNow ? resolved.messageId : null,
|
|
581
|
+
successSummary: sendNow ? "reply sent" : "reply saved to Drafts",
|
|
582
|
+
details: {
|
|
318
583
|
message_id: resolved.messageId,
|
|
319
584
|
reply_all: String(replyAll)
|
|
320
|
-
}
|
|
321
|
-
};
|
|
585
|
+
}
|
|
586
|
+
});
|
|
322
587
|
}
|
|
323
588
|
|
|
324
589
|
export function buildForwardScript({ messageId, to, body, sendNow }) {
|
|
@@ -369,15 +634,20 @@ export function mailForward(args = {}) {
|
|
|
369
634
|
buildForwardScript({ messageId: resolved.messageId, to: to.addresses, body: body.text, sendNow }),
|
|
370
635
|
{ timeout: 60000, appName: "Mail" }
|
|
371
636
|
);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
637
|
+
return finalizeMailWrite({
|
|
638
|
+
action,
|
|
639
|
+
summary,
|
|
640
|
+
result,
|
|
641
|
+
secrets: [body.text],
|
|
642
|
+
sendNow,
|
|
643
|
+
inReplyTo: sendNow ? resolved.messageId : null,
|
|
644
|
+
forwardTo: sendNow ? to.addresses : null,
|
|
645
|
+
successSummary: sendNow ? "forwarded" : "forward saved to Drafts",
|
|
646
|
+
details: {
|
|
377
647
|
message_id: resolved.messageId,
|
|
378
648
|
to: to.addresses.join(", ")
|
|
379
|
-
}
|
|
380
|
-
};
|
|
649
|
+
}
|
|
650
|
+
});
|
|
381
651
|
}
|
|
382
652
|
|
|
383
653
|
export function buildMarkScript({ messageId, read }) {
|
package/lib/messagesWrite.js
CHANGED
|
@@ -14,7 +14,7 @@ import fs from "fs";
|
|
|
14
14
|
import path from "path";
|
|
15
15
|
import { safeSqlite3Json } from "./shell.js";
|
|
16
16
|
import { escapeSQL } from "./validators.js";
|
|
17
|
-
import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
17
|
+
import { runAppleScript, asString, MESSAGES_TCC_GUIDANCE, MESSAGES_APP_NOT_RUNNING_GUIDANCE, ATTRIBUTION_GUIDANCE } from "./appleScript.js";
|
|
18
18
|
import {
|
|
19
19
|
planWrite,
|
|
20
20
|
normalizeList,
|
|
@@ -116,10 +116,11 @@ export function probeMessagesAutomation() {
|
|
|
116
116
|
const summary = "enumerate Messages accounts (Automation / Apple Events check; nothing is sent)";
|
|
117
117
|
const result = runAppleScript(buildMessagesAutomationProbeScript(), { timeout: 30000, appName: "Messages" });
|
|
118
118
|
if (!result.ok) {
|
|
119
|
-
return { ok: false, message: failure(action, summary, result, []) };
|
|
119
|
+
return { ok: false, message: failure(action, summary, result, []), kind: result.kind };
|
|
120
120
|
}
|
|
121
121
|
return {
|
|
122
122
|
ok: true,
|
|
123
|
+
kind: null,
|
|
123
124
|
message: writeSuccessMessage(
|
|
124
125
|
action,
|
|
125
126
|
"Messages Automation allowed (enumerated accounts; nothing was sent)"
|
|
@@ -194,6 +195,9 @@ function failure(action, summary, result, secrets) {
|
|
|
194
195
|
if (result.kind === "tcc" || result.kind === "timeout") {
|
|
195
196
|
return `${action} failed — attempted to ${summary}. ${MESSAGES_TCC_GUIDANCE}`;
|
|
196
197
|
}
|
|
198
|
+
if (result.kind === "app_not_running") {
|
|
199
|
+
return `${action} failed — attempted to ${summary}. ${MESSAGES_APP_NOT_RUNNING_GUIDANCE}`;
|
|
200
|
+
}
|
|
197
201
|
if (result.kind === "attribution") {
|
|
198
202
|
return `${action} failed — attempted to ${summary}. ${ATTRIBUTION_GUIDANCE}`;
|
|
199
203
|
}
|