icloud-mcp 2.5.0 → 2.7.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/LICENSE +21 -0
- package/README.md +235 -341
- package/index.js +30 -1138
- package/lib/caldav.js +628 -3
- package/lib/carddav.js +28 -2
- package/lib/data-paths.js +13 -0
- package/lib/digest.js +23 -8
- package/lib/imap.js +298 -85
- package/lib/reminders.js +316 -0
- package/lib/session.js +9 -7
- package/lib/smtp.js +53 -21
- package/lib/tools/calendar.js +333 -0
- package/lib/tools/contacts.js +135 -0
- package/lib/tools/mail.js +903 -0
- package/lib/tools/reminders.js +165 -0
- package/package.json +26 -7
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/settings.local.json +0 -35
- package/.mcp.json.example +0 -12
- package/run-digest.js +0 -57
package/lib/reminders.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// ─── lib/reminders.js — iCloud Reminders via JXA (JavaScript for Automation) ──
|
|
2
|
+
//
|
|
3
|
+
// Uses `osascript -l JavaScript` to talk directly to Reminders.app, bypassing
|
|
4
|
+
// the broken CalDAV VTODO endpoint (Apple moved modern Reminders to CloudKit,
|
|
5
|
+
// which is not exposed via standard CalDAV).
|
|
6
|
+
//
|
|
7
|
+
// PERMISSION: The first time Node.js calls osascript on Reminders, macOS will
|
|
8
|
+
// show an Automation permission dialog. Run `node -e "require('./lib/reminders.js')"
|
|
9
|
+
// manually once to grant permission, then it persists for headless digest runs.
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
|
+
import { writeFileSync, unlinkSync } from 'fs';
|
|
13
|
+
import { tmpdir } from 'os';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
|
|
17
|
+
const PRIORITY_FROM_JXA = { 0: 'none', 1: 'high', 2: 'high', 3: 'high', 4: 'high', 5: 'medium', 6: 'low', 7: 'low', 8: 'low', 9: 'low' };
|
|
18
|
+
const PRIORITY_TO_JXA = { none: 0, high: 1, medium: 5, low: 9 };
|
|
19
|
+
|
|
20
|
+
// ─── JXA runner ───────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
// Reminders.app can stall for 30s+ right after a burst of changes while it syncs.
|
|
23
|
+
const JXA_TIMEOUT_MS = 90_000;
|
|
24
|
+
|
|
25
|
+
function runJxaImpl(script) {
|
|
26
|
+
const tmpFile = join(tmpdir(), `jxa-${randomUUID()}.js`);
|
|
27
|
+
writeFileSync(tmpFile, script, 'utf8');
|
|
28
|
+
try {
|
|
29
|
+
const output = execFileSync('osascript', ['-l', 'JavaScript', tmpFile], {
|
|
30
|
+
timeout: JXA_TIMEOUT_MS,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
});
|
|
33
|
+
return output.trim();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// execFileSync kills the script at the timeout. Say so plainly: the generic
|
|
36
|
+
// ETIMEDOUT text would otherwise be reported as an iCloud network timeout.
|
|
37
|
+
if (err.code === 'ETIMEDOUT' || err.signal === 'SIGTERM') {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Reminders script timed out after ${JXA_TIMEOUT_MS / 1000}s. Reminders.app is slow to respond, often while it syncs. ` +
|
|
40
|
+
'If this was a create, update, rename, or delete, it may still have gone through: check before retrying.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const stderr = (err.stderr || '').toString();
|
|
44
|
+
if (stderr.includes('not allowed') || stderr.includes('Authorization') || stderr.includes('assistive access')) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'Reminders access not authorized. Run this once in Terminal to grant permission:\n' +
|
|
47
|
+
' osascript -l JavaScript -e \'Application("Reminders").lists.length\'\n' +
|
|
48
|
+
'Then click Allow in the dialog.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const msg = stderr.replace(/\n/g, ' ').trim() || err.message;
|
|
52
|
+
throw new Error(`JXA error: ${msg}`);
|
|
53
|
+
} finally {
|
|
54
|
+
try { unlinkSync(tmpFile); } catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// JXA returns a bare value for one match and sometimes null for none. Callers index these as arrays.
|
|
59
|
+
export function asArray(value) {
|
|
60
|
+
if (value == null) return [];
|
|
61
|
+
return Array.isArray(value) ? value : [value];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Shared helper embedded in JXA scripts — finds a reminder within a list by ID.
|
|
65
|
+
const JXA_FIND_HELPER = `
|
|
66
|
+
${asArray.toString()}
|
|
67
|
+
// Uses a whose() query: one Apple Event, instead of one per reminder in the list.
|
|
68
|
+
function findReminder(list, id) {
|
|
69
|
+
const matches = asArray(list.reminders.whose({ id: id })());
|
|
70
|
+
if (matches.length === 0) throw new Error('Reminder not found: ' + id);
|
|
71
|
+
return matches[0];
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
let jxaRunner = runJxaImpl;
|
|
76
|
+
|
|
77
|
+
export function setJxaRunnerForTests(fn) {
|
|
78
|
+
jxaRunner = fn || runJxaImpl;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runJxa(script) {
|
|
82
|
+
return jxaRunner(script);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export function listReminderLists() {
|
|
88
|
+
const result = runJxa(`
|
|
89
|
+
const app = Application('Reminders');
|
|
90
|
+
const lists = app.lists().map(l => ({
|
|
91
|
+
id: l.id(),
|
|
92
|
+
name: l.name(),
|
|
93
|
+
count: l.reminders.length,
|
|
94
|
+
}));
|
|
95
|
+
JSON.stringify({ lists, count: lists.length });
|
|
96
|
+
`);
|
|
97
|
+
return JSON.parse(result);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function listReminders(listName = null, includeCompleted = false, limit = 50) {
|
|
101
|
+
const result = runJxa(`
|
|
102
|
+
${asArray.toString()}
|
|
103
|
+
const app = Application('Reminders');
|
|
104
|
+
const listName = ${JSON.stringify(listName)};
|
|
105
|
+
const includeCompleted = ${JSON.stringify(includeCompleted)};
|
|
106
|
+
const limit = ${JSON.stringify(limit)};
|
|
107
|
+
const priorityMap = ${JSON.stringify(PRIORITY_FROM_JXA)};
|
|
108
|
+
|
|
109
|
+
const lists = listName ? [app.lists.byName(listName)] : app.lists();
|
|
110
|
+
const reminders = [];
|
|
111
|
+
|
|
112
|
+
// Read each property for the whole list in one Apple Event. Reading them per
|
|
113
|
+
// reminder costs one round trip each and blew the 30s timeout on a 38-item list.
|
|
114
|
+
// A one-item collection comes back as a single value, and an empty one as null.
|
|
115
|
+
const iso = (d) => (d ? d.toISOString() : null);
|
|
116
|
+
for (const list of lists) {
|
|
117
|
+
const spec = includeCompleted ? list.reminders : list.reminders.whose({ completed: false });
|
|
118
|
+
const ids = asArray(spec.id());
|
|
119
|
+
if (ids.length === 0) continue;
|
|
120
|
+
const names = asArray(spec.name());
|
|
121
|
+
const bodies = asArray(spec.body());
|
|
122
|
+
const completed = asArray(spec.completed());
|
|
123
|
+
const dues = asArray(spec.dueDate());
|
|
124
|
+
const completedAts = asArray(spec.completionDate());
|
|
125
|
+
const priorities = asArray(spec.priority());
|
|
126
|
+
const createdAts = asArray(spec.creationDate());
|
|
127
|
+
const modifiedAts = asArray(spec.modificationDate());
|
|
128
|
+
const columns = [names, bodies, completed, dues, completedAts, priorities, createdAts, modifiedAts];
|
|
129
|
+
if (columns.some((col) => col.length !== ids.length)) {
|
|
130
|
+
throw new Error('Reminder property batches differ in length');
|
|
131
|
+
}
|
|
132
|
+
const thisListName = list.name();
|
|
133
|
+
const thisListId = list.id();
|
|
134
|
+
for (let i = 0; i < ids.length && reminders.length < limit; i++) {
|
|
135
|
+
reminders.push({
|
|
136
|
+
id: ids[i],
|
|
137
|
+
title: names[i],
|
|
138
|
+
notes: bodies[i] || null,
|
|
139
|
+
completed: completed[i],
|
|
140
|
+
due: iso(dues[i]),
|
|
141
|
+
completedAt: iso(completedAts[i]),
|
|
142
|
+
priority: priorityMap[String(priorities[i])] || 'none',
|
|
143
|
+
listName: thisListName,
|
|
144
|
+
listId: thisListId,
|
|
145
|
+
createdAt: iso(createdAts[i]),
|
|
146
|
+
modifiedAt: iso(modifiedAts[i]),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (reminders.length >= limit) break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
JSON.stringify({ reminders, count: reminders.length, listName, includeCompleted });
|
|
153
|
+
`);
|
|
154
|
+
return JSON.parse(result);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function getReminder(listName, reminderId) {
|
|
158
|
+
const result = runJxa(`
|
|
159
|
+
${JXA_FIND_HELPER}
|
|
160
|
+
const app = Application('Reminders');
|
|
161
|
+
const list = app.lists.byName(${JSON.stringify(listName)});
|
|
162
|
+
const r = findReminder(list, ${JSON.stringify(reminderId)});
|
|
163
|
+
const priorityMap = ${JSON.stringify(PRIORITY_FROM_JXA)};
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
id: r.id(),
|
|
166
|
+
title: r.name(),
|
|
167
|
+
notes: r.body() || null,
|
|
168
|
+
completed: r.completed(),
|
|
169
|
+
due: r.dueDate() ? r.dueDate().toISOString() : null,
|
|
170
|
+
completedAt: r.completionDate() ? r.completionDate().toISOString() : null,
|
|
171
|
+
priority: priorityMap[String(r.priority())] || 'none',
|
|
172
|
+
listName: list.name(),
|
|
173
|
+
listId: list.id(),
|
|
174
|
+
createdAt: r.creationDate() ? r.creationDate().toISOString() : null,
|
|
175
|
+
modifiedAt: r.modificationDate() ? r.modificationDate().toISOString() : null,
|
|
176
|
+
});
|
|
177
|
+
`);
|
|
178
|
+
return JSON.parse(result);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createReminder(listName, fields) {
|
|
182
|
+
const priorityVal = PRIORITY_TO_JXA[fields.priority] ?? 0;
|
|
183
|
+
const result = runJxa(`
|
|
184
|
+
const app = Application('Reminders');
|
|
185
|
+
const list = app.lists.byName(${JSON.stringify(listName)});
|
|
186
|
+
const props = { name: ${JSON.stringify(fields.title || '(No title)')} };
|
|
187
|
+
${fields.notes !== undefined ? `props.body = ${JSON.stringify(fields.notes)};` : ''}
|
|
188
|
+
${fields.due !== undefined ? `props.dueDate = new Date(${JSON.stringify(fields.due)});` : ''}
|
|
189
|
+
${priorityVal ? `props.priority = ${priorityVal};` : ''}
|
|
190
|
+
const r = app.make({ new: 'reminder', at: list, withProperties: props });
|
|
191
|
+
JSON.stringify({ created: true, id: r.id(), listName: list.name() });
|
|
192
|
+
`);
|
|
193
|
+
return JSON.parse(result);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function updateReminder(listName, reminderId, fields) {
|
|
197
|
+
const priorityVal = fields.priority !== undefined ? (PRIORITY_TO_JXA[fields.priority] ?? 0) : null;
|
|
198
|
+
const result = runJxa(`
|
|
199
|
+
${JXA_FIND_HELPER}
|
|
200
|
+
const app = Application('Reminders');
|
|
201
|
+
const list = app.lists.byName(${JSON.stringify(listName)});
|
|
202
|
+
const r = findReminder(list, ${JSON.stringify(reminderId)});
|
|
203
|
+
${fields.title !== undefined ? `r.name = ${JSON.stringify(fields.title)};` : ''}
|
|
204
|
+
${fields.notes !== undefined ? `r.body = ${JSON.stringify(fields.notes)};` : ''}
|
|
205
|
+
${fields.due !== undefined ? `r.dueDate = new Date(${JSON.stringify(fields.due)});` : ''}
|
|
206
|
+
${priorityVal !== null ? `r.priority = ${priorityVal};` : ''}
|
|
207
|
+
JSON.stringify({ updated: true, id: r.id() });
|
|
208
|
+
`);
|
|
209
|
+
return JSON.parse(result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function completeReminder(listName, reminderId) {
|
|
213
|
+
const result = runJxa(`
|
|
214
|
+
${JXA_FIND_HELPER}
|
|
215
|
+
const app = Application('Reminders');
|
|
216
|
+
const list = app.lists.byName(${JSON.stringify(listName)});
|
|
217
|
+
const r = findReminder(list, ${JSON.stringify(reminderId)});
|
|
218
|
+
r.completed = true;
|
|
219
|
+
JSON.stringify({ updated: true, id: r.id(), completed: true });
|
|
220
|
+
`);
|
|
221
|
+
return JSON.parse(result);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function deleteReminder(listName, reminderId, dryRun = false) {
|
|
225
|
+
if (dryRun) {
|
|
226
|
+
const existing = getReminder(listName, reminderId);
|
|
227
|
+
return {
|
|
228
|
+
dryRun: true,
|
|
229
|
+
wouldDelete: true,
|
|
230
|
+
changes: [{
|
|
231
|
+
action: 'delete_reminder',
|
|
232
|
+
listName,
|
|
233
|
+
reminderId,
|
|
234
|
+
title: existing.title,
|
|
235
|
+
notes: existing.notes,
|
|
236
|
+
due: existing.due,
|
|
237
|
+
}],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const result = runJxa(`
|
|
241
|
+
${JXA_FIND_HELPER}
|
|
242
|
+
const app = Application('Reminders');
|
|
243
|
+
const list = app.lists.byName(${JSON.stringify(listName)});
|
|
244
|
+
const r = findReminder(list, ${JSON.stringify(reminderId)});
|
|
245
|
+
app.delete(r);
|
|
246
|
+
JSON.stringify({ deleted: true, id: ${JSON.stringify(reminderId)} });
|
|
247
|
+
`);
|
|
248
|
+
return JSON.parse(result);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function createReminderList(name) {
|
|
252
|
+
const result = runJxa(`
|
|
253
|
+
const app = Application('Reminders');
|
|
254
|
+
const name = ${JSON.stringify(name)};
|
|
255
|
+
if (app.lists().some(l => l.name() === name)) throw new Error('A list named "' + name + '" already exists');
|
|
256
|
+
const list = app.make({ new: 'list', withProperties: { name } });
|
|
257
|
+
JSON.stringify({ created: true, id: list.id(), name: list.name() });
|
|
258
|
+
`);
|
|
259
|
+
return JSON.parse(result);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Refuses to delete a list that still has reminders in it, the same rule
|
|
263
|
+
// delete_mailbox follows. Delete or move the reminders first.
|
|
264
|
+
export function deleteReminderList(name, dryRun = false) {
|
|
265
|
+
const result = runJxa(`
|
|
266
|
+
const app = Application('Reminders');
|
|
267
|
+
const name = ${JSON.stringify(name)};
|
|
268
|
+
const dryRun = ${JSON.stringify(dryRun)};
|
|
269
|
+
const matches = app.lists().filter(l => l.name() === name);
|
|
270
|
+
if (matches.length === 0) throw new Error('Reminder list not found: ' + name);
|
|
271
|
+
if (matches.length > 1) throw new Error('More than one list is named "' + name + '"; refusing to guess which to delete');
|
|
272
|
+
const list = matches[0];
|
|
273
|
+
const id = list.id();
|
|
274
|
+
const reminderCount = list.reminders.length;
|
|
275
|
+
let out;
|
|
276
|
+
if (dryRun) {
|
|
277
|
+
out = {
|
|
278
|
+
dryRun: true,
|
|
279
|
+
wouldDelete: reminderCount === 0,
|
|
280
|
+
changes: reminderCount === 0 ? [{ action: 'delete_reminder_list', name, id, reminderCount }] : [],
|
|
281
|
+
reminderCount,
|
|
282
|
+
};
|
|
283
|
+
} else {
|
|
284
|
+
if (reminderCount > 0) throw new Error('List "' + name + '" still has ' + reminderCount + ' reminder(s); delete them first');
|
|
285
|
+
app.delete(list);
|
|
286
|
+
out = { deleted: true, name, id };
|
|
287
|
+
}
|
|
288
|
+
JSON.stringify(out);
|
|
289
|
+
`);
|
|
290
|
+
return JSON.parse(result);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function renameReminderList(oldName, newName, dryRun = false) {
|
|
294
|
+
const result = runJxa(`
|
|
295
|
+
const app = Application('Reminders');
|
|
296
|
+
const oldName = ${JSON.stringify(oldName)};
|
|
297
|
+
const newName = ${JSON.stringify(newName)};
|
|
298
|
+
const dryRun = ${JSON.stringify(dryRun)};
|
|
299
|
+
const all = app.lists();
|
|
300
|
+
const matches = all.filter(l => l.name() === oldName);
|
|
301
|
+
if (matches.length === 0) throw new Error('Reminder list not found: ' + oldName);
|
|
302
|
+
if (matches.length > 1) throw new Error('More than one list is named "' + oldName + '"; refusing to guess which to rename');
|
|
303
|
+
if (all.some(l => l.name() === newName)) throw new Error('A list named "' + newName + '" already exists');
|
|
304
|
+
const list = matches[0];
|
|
305
|
+
const id = list.id();
|
|
306
|
+
let out;
|
|
307
|
+
if (dryRun) {
|
|
308
|
+
out = { dryRun: true, changes: [{ action: 'rename_reminder_list', id, from: oldName, to: newName }] };
|
|
309
|
+
} else {
|
|
310
|
+
list.name = newName;
|
|
311
|
+
out = { renamed: true, id, from: oldName, to: list.name() };
|
|
312
|
+
}
|
|
313
|
+
JSON.stringify(out);
|
|
314
|
+
`);
|
|
315
|
+
return JSON.parse(result);
|
|
316
|
+
}
|
package/lib/session.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
// ─── Session Log ──────────────────────────────────────────────────────────────
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
-
import {
|
|
4
|
-
import { join } from 'path';
|
|
3
|
+
import { dataFile } from './data-paths.js';
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
function logFile() {
|
|
6
|
+
return dataFile('.icloud-mcp-session.json');
|
|
7
|
+
}
|
|
7
8
|
|
|
8
9
|
export function logRead() {
|
|
9
|
-
|
|
10
|
+
const file = logFile();
|
|
11
|
+
if (!existsSync(file)) return { steps: [], startedAt: null };
|
|
10
12
|
try {
|
|
11
|
-
return JSON.parse(readFileSync(
|
|
13
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
12
14
|
} catch {
|
|
13
15
|
return { steps: [], startedAt: null };
|
|
14
16
|
}
|
|
@@ -18,11 +20,11 @@ export function logWrite(step) {
|
|
|
18
20
|
const log = logRead();
|
|
19
21
|
if (!log.startedAt) log.startedAt = new Date().toISOString();
|
|
20
22
|
log.steps.push({ time: new Date().toISOString(), step });
|
|
21
|
-
writeFileSync(
|
|
23
|
+
writeFileSync(logFile(), JSON.stringify(log, null, 2));
|
|
22
24
|
return log;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function logClear() {
|
|
26
|
-
writeFileSync(
|
|
28
|
+
writeFileSync(logFile(), JSON.stringify({ steps: [], startedAt: null }, null, 2));
|
|
27
29
|
return { cleared: true };
|
|
28
30
|
}
|
package/lib/smtp.js
CHANGED
|
@@ -28,6 +28,35 @@ function normalizeAddresses(val) {
|
|
|
28
28
|
return Array.isArray(val) ? val.join(', ') : val;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Compare mailbox addresses ignoring case, surrounding whitespace, and a display name.
|
|
32
|
+
export function normalizeMailboxAddress(value) {
|
|
33
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
34
|
+
const angled = text.match(/<([^>]+)>/);
|
|
35
|
+
return (angled ? angled[1] : text).trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Recipients replyToEmail will put in To.
|
|
39
|
+
// Reply-all drops the account when anyone else is on the message (same as before).
|
|
40
|
+
// A message whose only party is the account still replies to the account, so a
|
|
41
|
+
// self-seed is not sent with an empty To.
|
|
42
|
+
export function planReplyAddresses(email, account, replyAll = false) {
|
|
43
|
+
const user = normalizeMailboxAddress(account);
|
|
44
|
+
const replyTarget = email?.headers?.replyTo || email?.from || '';
|
|
45
|
+
if (!replyAll) return replyTarget ? [replyTarget] : [];
|
|
46
|
+
|
|
47
|
+
const parties = [replyTarget, ...(email?.headers?.to || []), ...(email?.headers?.cc || [])].filter(Boolean);
|
|
48
|
+
const others = [];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
for (const party of parties) {
|
|
51
|
+
const key = normalizeMailboxAddress(party);
|
|
52
|
+
if (!key || key === user || seen.has(key)) continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
others.push(party);
|
|
55
|
+
}
|
|
56
|
+
if (others.length === 0) return user ? [account] : [];
|
|
57
|
+
return others;
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
// Convert HTML to a readable plain-text fallback for multipart/alternative
|
|
32
61
|
function htmlToText(html) {
|
|
33
62
|
return html
|
|
@@ -90,30 +119,19 @@ export async function replyToEmail(email, body, opts = {}, creds = null) {
|
|
|
90
119
|
|
|
91
120
|
const originalSubject = email.subject ?? '';
|
|
92
121
|
const originalMessageId = email.headers?.messageId ?? null;
|
|
93
|
-
const originalFrom = email.from ?? '';
|
|
94
|
-
const originalReplyTo = email.headers?.replyTo ?? null;
|
|
95
122
|
const existingRefs = email.headers?.references ?? [];
|
|
96
123
|
|
|
97
|
-
const subject = /^re
|
|
124
|
+
const subject = /^(re|in re):\s/i.test(originalSubject) || /^(re|in re):\s/i.test(originalSubject.replace(/^\[EXTERNAL\]\s*/i, ''))
|
|
125
|
+
? originalSubject
|
|
126
|
+
: `Re: ${originalSubject}`;
|
|
98
127
|
|
|
99
128
|
// Build RFC 2822 References chain: existing refs + original message-id
|
|
100
129
|
const references = [...existingRefs, ...(originalMessageId ? [originalMessageId] : [])]
|
|
101
130
|
.filter(Boolean)
|
|
102
131
|
.join(' ');
|
|
103
132
|
|
|
104
|
-
//
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
let toAddresses;
|
|
108
|
-
if (opts.replyAll) {
|
|
109
|
-
const originalTo = email.headers?.to ?? [];
|
|
110
|
-
const originalCc = email.headers?.cc ?? [];
|
|
111
|
-
// Reply-all: reply target + original To/Cc, excluding ourselves
|
|
112
|
-
toAddresses = [replyTarget, ...originalTo, ...originalCc]
|
|
113
|
-
.filter(a => a && a !== user);
|
|
114
|
-
} else {
|
|
115
|
-
toAddresses = [replyTarget].filter(Boolean);
|
|
116
|
-
}
|
|
133
|
+
// Prefer Reply-To over From. A self-only thread replies to the account.
|
|
134
|
+
const toAddresses = planReplyAddresses(email, user, Boolean(opts.replyAll));
|
|
117
135
|
|
|
118
136
|
const mailOptions = {
|
|
119
137
|
from: user,
|
|
@@ -170,6 +188,15 @@ export async function forwardEmail(email, to, note = '', opts = {}, creds = null
|
|
|
170
188
|
};
|
|
171
189
|
}
|
|
172
190
|
|
|
191
|
+
// ImapFlow emits 'error' on socket timeouts. With no listener, Node treats it as
|
|
192
|
+
// an uncaught exception and the server exits.
|
|
193
|
+
export function attachImapErrorHandler(client) {
|
|
194
|
+
client.on('error', (err) => {
|
|
195
|
+
process.stderr.write(`[imap] connection error: ${err?.message ?? err}\n`);
|
|
196
|
+
});
|
|
197
|
+
return client;
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
// ─── save_draft ───────────────────────────────────────────────────────────────
|
|
174
201
|
// Builds the raw MIME message without sending, then APPENDs to Drafts via IMAP.
|
|
175
202
|
|
|
@@ -178,15 +205,19 @@ export async function saveDraft(to, subject, body, opts = {}, creds = null) {
|
|
|
178
205
|
|
|
179
206
|
const mailOptions = { from: user, to: normalizeAddresses(to), subject };
|
|
180
207
|
applyBody(mailOptions, body, opts.html);
|
|
181
|
-
if (opts.cc)
|
|
182
|
-
if (opts.bcc)
|
|
208
|
+
if (opts.cc) mailOptions.cc = normalizeAddresses(opts.cc);
|
|
209
|
+
if (opts.bcc) mailOptions.bcc = normalizeAddresses(opts.bcc);
|
|
210
|
+
if (opts.inReplyTo) mailOptions.inReplyTo = opts.inReplyTo;
|
|
211
|
+
if (opts.references) mailOptions.references = Array.isArray(opts.references)
|
|
212
|
+
? opts.references.join(' ')
|
|
213
|
+
: opts.references;
|
|
183
214
|
|
|
184
215
|
// Use nodemailer stream transport to produce raw MIME bytes without sending
|
|
185
216
|
const streamTransport = nodemailer.createTransport({ streamTransport: true, buffer: true });
|
|
186
217
|
const { message: rawMessage } = await streamTransport.sendMail(mailOptions);
|
|
187
218
|
|
|
188
219
|
// APPEND the raw message to the Drafts folder via IMAP
|
|
189
|
-
const client = new ImapFlow({
|
|
220
|
+
const client = attachImapErrorHandler(new ImapFlow({
|
|
190
221
|
host: imapHost,
|
|
191
222
|
port: 993,
|
|
192
223
|
secure: true,
|
|
@@ -195,7 +226,7 @@ export async function saveDraft(to, subject, body, opts = {}, creds = null) {
|
|
|
195
226
|
connectionTimeout: 15_000,
|
|
196
227
|
greetingTimeout: 15_000,
|
|
197
228
|
socketTimeout: 60_000,
|
|
198
|
-
});
|
|
229
|
+
}));
|
|
199
230
|
|
|
200
231
|
await client.connect();
|
|
201
232
|
|
|
@@ -204,7 +235,8 @@ export async function saveDraft(to, subject, body, opts = {}, creds = null) {
|
|
|
204
235
|
await client.append(draftMailbox, rawMessage, ['\\Draft', '\\Seen']);
|
|
205
236
|
} catch (err) {
|
|
206
237
|
// Folder might have a different name — scan the list for the \Drafts attribute
|
|
207
|
-
|
|
238
|
+
const r = err.responseText ?? err.message ?? '';
|
|
239
|
+
if (r.includes('NONEXISTENT') || r.includes('does not exist') || r.includes("doesn't exist") || r.includes('TRYCREATE') || r.includes('NO ') || r.includes('Failure')) {
|
|
208
240
|
const mailboxes = await client.list();
|
|
209
241
|
const draftMb = mailboxes.find(mb => mb.flags?.has('\\Drafts'));
|
|
210
242
|
draftMailbox = draftMb?.path ?? 'Drafts';
|