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.
@@ -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 { homedir } from 'os';
4
- import { join } from 'path';
3
+ import { dataFile } from './data-paths.js';
5
4
 
6
- const LOG_FILE = join(homedir(), '.icloud-mcp-session.json');
5
+ function logFile() {
6
+ return dataFile('.icloud-mcp-session.json');
7
+ }
7
8
 
8
9
  export function logRead() {
9
- if (!existsSync(LOG_FILE)) return { steps: [], startedAt: null };
10
+ const file = logFile();
11
+ if (!existsSync(file)) return { steps: [], startedAt: null };
10
12
  try {
11
- return JSON.parse(readFileSync(LOG_FILE, 'utf8'));
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(LOG_FILE, JSON.stringify(log, null, 2));
23
+ writeFileSync(logFile(), JSON.stringify(log, null, 2));
22
24
  return log;
23
25
  }
24
26
 
25
27
  export function logClear() {
26
- writeFileSync(LOG_FILE, JSON.stringify({ steps: [], startedAt: null }, null, 2));
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:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`;
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
- // Who to reply to: prefer Reply-To over From
105
- const replyTarget = originalReplyTo ?? originalFrom;
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) mailOptions.cc = normalizeAddresses(opts.cc);
182
- if (opts.bcc) mailOptions.bcc = normalizeAddresses(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
- if (err.message?.includes('NONEXISTENT') || err.message?.includes('does not exist') || err.message?.includes('NO ')) {
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';