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/lib/imap.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import { ImapFlow } from 'imapflow';
2
2
  import { readFileSync, writeFileSync, existsSync } from 'fs';
3
- import { homedir } from 'os';
4
- import { join } from 'path';
5
3
  import {
6
4
  decodeTransferEncoding, decodeCharset, stripHtml, extractRawHeader,
7
5
  findTextPart, findAttachments, estimateEmailSize, stripSubjectPrefixes
8
6
  } from './mime.js';
7
+ import { dataFile } from './data-paths.js';
9
8
 
10
- const MANIFEST_FILE = join(homedir(), '.icloud-mcp-move-manifest.json');
11
- const RULES_FILE = join(homedir(), '.icloud-mcp-rules.json');
9
+ function manifestFile() {
10
+ return dataFile('.icloud-mcp-move-manifest.json');
11
+ }
12
+ function rulesFile() {
13
+ return dataFile('.icloud-mcp-rules.json');
14
+ }
12
15
  const MAX_HISTORY = 5;
13
16
 
14
17
  // ─── Connection helpers ───────────────────────────────────────────────────────
@@ -20,7 +23,7 @@ function createClient(creds) {
20
23
  const host = creds?.host || 'imap.mail.me.com';
21
24
  const user = creds?.user || process.env.IMAP_USER;
22
25
  const pass = creds?.pass || process.env.IMAP_PASSWORD;
23
- return new ImapFlow({
26
+ const client = new ImapFlow({
24
27
  host,
25
28
  port: 993,
26
29
  secure: true,
@@ -30,6 +33,14 @@ function createClient(creds) {
30
33
  greetingTimeout: 15_000, // 15s to receive IMAP greeting after connect
31
34
  socketTimeout: 60_000, // 60s of inactivity before socket is killed
32
35
  });
36
+ // ImapFlow emits 'error' on socket timeouts and resets. With no listener, Node
37
+ // throws it as an uncaught exception and the whole server exits mid-operation.
38
+ // The connection closes either way and pending commands reject, so the tool
39
+ // call that owns this client still gets an error to report.
40
+ client.on('error', (err) => {
41
+ process.stderr.write(`[imap] connection error: ${err.message}\n`);
42
+ });
43
+ return client;
33
44
  }
34
45
 
35
46
  // ─── Managed client helpers ───────────────────────────────────────────────────
@@ -45,7 +56,14 @@ let _lastConnectTime = 0;
45
56
  let _connectGate = Promise.resolve();
46
57
  const MIN_CONNECT_INTERVAL = 10; // ms between connection initiations
47
58
 
59
+ // Test seam. Production leaves this null, so clients are created exactly as before.
60
+ let imapClientFactory = null;
61
+ export function setImapClientFactory(factory) {
62
+ imapClientFactory = factory;
63
+ }
64
+
48
65
  export function createRateLimitedClient(creds) {
66
+ if (imapClientFactory) return imapClientFactory(creds);
49
67
  const client = createClient(creds);
50
68
  const originalConnect = client.connect.bind(client);
51
69
  client.connect = async () => {
@@ -85,16 +103,17 @@ const ATTACHMENT_SCAN_LIMIT = 500; // max UIDs to scan client-side for hasAttach
85
103
  const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB cap for get_attachment downloads
86
104
 
87
105
  function readManifest() {
88
- if (!existsSync(MANIFEST_FILE)) return { current: null, history: [] };
106
+ const file = manifestFile();
107
+ if (!existsSync(file)) return { current: null, history: [] };
89
108
  try {
90
- return JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
109
+ return JSON.parse(readFileSync(file, 'utf8'));
91
110
  } catch {
92
111
  return { current: null, history: [] };
93
112
  }
94
113
  }
95
114
 
96
115
  function writeManifest(data) {
97
- writeFileSync(MANIFEST_FILE, JSON.stringify(data, null, 2));
116
+ writeFileSync(manifestFile(), JSON.stringify(data, null, 2));
98
117
  }
99
118
 
100
119
  function updateManifest(updater) {
@@ -732,9 +751,9 @@ export async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = fa
732
751
  if (msg) {
733
752
  emails.push({
734
753
  uid,
735
- subject: msg.envelope.subject,
736
- from: msg.envelope.from?.[0]?.address,
737
- date: msg.envelope.date,
754
+ subject: msg.envelope?.subject,
755
+ from: msg.envelope?.from?.[0]?.address,
756
+ date: msg.envelope?.date,
738
757
  flagged: msg.flags.has('\\Flagged'),
739
758
  seen: msg.flags.has('\\Seen')
740
759
  });
@@ -751,9 +770,9 @@ export async function fetchEmails(mailbox = 'INBOX', limit = 10, onlyUnread = fa
751
770
  for await (const msg of client.fetch(range, { envelope: true, flags: true })) {
752
771
  emails.push({
753
772
  uid: msg.uid,
754
- subject: msg.envelope.subject,
755
- from: msg.envelope.from?.[0]?.address,
756
- date: msg.envelope.date,
773
+ subject: msg.envelope?.subject,
774
+ from: msg.envelope?.from?.[0]?.address,
775
+ date: msg.envelope?.date,
757
776
  flagged: msg.flags.has('\\Flagged'),
758
777
  seen: msg.flags.has('\\Seen')
759
778
  });
@@ -786,7 +805,7 @@ export async function getTopSenders(mailbox = 'INBOX', sampleSize = 500, maxResu
786
805
  let count = 0;
787
806
 
788
807
  for await (const msg of client.fetch(range, { envelope: true })) {
789
- const address = msg.envelope.from?.[0]?.address;
808
+ const address = msg.envelope?.from?.[0]?.address;
790
809
  if (address) {
791
810
  senderCounts[address] = (senderCounts[address] || 0) + 1;
792
811
  const domain = address.split('@')[1];
@@ -815,7 +834,7 @@ export async function getUnreadSenders(mailbox = 'INBOX', sampleSize = 500, maxR
815
834
  }
816
835
 
817
836
  for await (const msg of client.fetch(recentUids, { envelope: true }, { uid: true })) {
818
- const address = msg.envelope.from?.[0]?.address;
837
+ const address = msg.envelope?.from?.[0]?.address;
819
838
  if (address) senderCounts[address] = (senderCounts[address] || 0) + 1;
820
839
  }
821
840
 
@@ -836,9 +855,9 @@ export async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10, c
836
855
  if (msg) {
837
856
  emails.push({
838
857
  uid,
839
- subject: msg.envelope.subject,
840
- from: msg.envelope.from?.[0]?.address,
841
- date: msg.envelope.date,
858
+ subject: msg.envelope?.subject,
859
+ from: msg.envelope?.from?.[0]?.address,
860
+ date: msg.envelope?.date,
842
861
  flagged: msg.flags.has('\\Flagged'),
843
862
  seen: msg.flags.has('\\Seen')
844
863
  });
@@ -848,11 +867,22 @@ export async function getEmailsBySender(sender, mailbox = 'INBOX', limit = 10, c
848
867
  return { total, showing: emails.length, emails };
849
868
  }
850
869
 
851
- export async function bulkDeleteBySender(sender, mailbox = 'INBOX', creds = null) {
870
+ export async function bulkDeleteBySender(sender, mailbox = 'INBOX', creds = null, dryRun = false) {
852
871
  const client = createRateLimitedClient(creds);
853
872
  await client.connect();
854
873
  await client.mailboxOpen(mailbox);
855
874
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
875
+ if (dryRun) {
876
+ await client.logout();
877
+ return {
878
+ dryRun: true,
879
+ wouldDelete: uids.length,
880
+ sender,
881
+ mailbox,
882
+ uids,
883
+ changes: uids.map(uid => ({ action: 'delete', uid, mailbox, sender })),
884
+ };
885
+ }
856
886
  if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
857
887
  let deleted = 0;
858
888
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
@@ -864,7 +894,7 @@ export async function bulkDeleteBySender(sender, mailbox = 'INBOX', creds = null
864
894
  return { deleted, sender };
865
895
  }
866
896
 
867
- export async function markOlderThanRead(days, mailbox = 'INBOX', creds = null) {
897
+ export async function markOlderThanRead(days, mailbox = 'INBOX', creds = null, dryRun = false) {
868
898
  const client = createRateLimitedClient(creds);
869
899
  await client.connect();
870
900
  await client.mailboxOpen(mailbox);
@@ -872,6 +902,17 @@ export async function markOlderThanRead(days, mailbox = 'INBOX', creds = null) {
872
902
  date.setDate(date.getDate() - days);
873
903
  const raw = await client.search({ before: date, seen: false }, { uid: true });
874
904
  const uids = Array.isArray(raw) ? raw : [];
905
+ if (dryRun) {
906
+ await client.logout();
907
+ return {
908
+ dryRun: true,
909
+ wouldMarkRead: uids.length,
910
+ olderThan: date.toISOString(),
911
+ mailbox,
912
+ uids,
913
+ changes: uids.map(uid => ({ action: 'mark_read', uid, mailbox })),
914
+ };
915
+ }
875
916
  if (uids.length === 0) { await client.logout(); return { marked: 0, olderThan: date.toISOString() }; }
876
917
  await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
877
918
  await client.logout();
@@ -889,18 +930,39 @@ export async function bulkMoveBySender(sender, targetMailbox, sourceMailbox = 'I
889
930
  await client.mailboxOpen(sourceMailbox);
890
931
  const uids = (await client.search({ from: sender }, { uid: true })) ?? [];
891
932
  await client.logout();
892
- if (dryRun) return { dryRun: true, wouldMove: uids.length, sender, sourceMailbox, targetMailbox };
933
+ if (dryRun) {
934
+ return {
935
+ dryRun: true,
936
+ wouldMove: uids.length,
937
+ sender,
938
+ sourceMailbox,
939
+ targetMailbox,
940
+ uids,
941
+ changes: uids.map(uid => ({ action: 'move', uid, sender, sourceMailbox, targetMailbox })),
942
+ };
943
+ }
893
944
  if (uids.length === 0) return { moved: 0 };
894
945
  await ensureMailbox(targetMailbox, creds);
895
946
  const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox, creds);
896
947
  return { ...result, sender, targetMailbox };
897
948
  }
898
949
 
899
- export async function bulkDeleteBySubject(subject, mailbox = 'INBOX', creds = null) {
950
+ export async function bulkDeleteBySubject(subject, mailbox = 'INBOX', creds = null, dryRun = false) {
900
951
  const client = createRateLimitedClient(creds);
901
952
  await client.connect();
902
953
  await client.mailboxOpen(mailbox);
903
954
  const uids = (await client.search({ subject }, { uid: true })) ?? [];
955
+ if (dryRun) {
956
+ await client.logout();
957
+ return {
958
+ dryRun: true,
959
+ wouldDelete: uids.length,
960
+ subject,
961
+ mailbox,
962
+ uids,
963
+ changes: uids.map(uid => ({ action: 'delete', uid, mailbox, subject })),
964
+ };
965
+ }
904
966
  if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
905
967
  let deleted = 0;
906
968
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
@@ -912,13 +974,24 @@ export async function bulkDeleteBySubject(subject, mailbox = 'INBOX', creds = nu
912
974
  return { deleted, subject };
913
975
  }
914
976
 
915
- export async function deleteOlderThan(days, mailbox = 'INBOX', creds = null) {
977
+ export async function deleteOlderThan(days, mailbox = 'INBOX', creds = null, dryRun = false) {
916
978
  const client = createRateLimitedClient(creds);
917
979
  await client.connect();
918
980
  await client.mailboxOpen(mailbox);
919
981
  const date = new Date();
920
982
  date.setDate(date.getDate() - days);
921
983
  const uids = (await client.search({ before: date }, { uid: true })) ?? [];
984
+ if (dryRun) {
985
+ await client.logout();
986
+ return {
987
+ dryRun: true,
988
+ wouldDelete: uids.length,
989
+ olderThan: date.toISOString(),
990
+ mailbox,
991
+ uids,
992
+ changes: uids.map(uid => ({ action: 'delete', uid, mailbox })),
993
+ };
994
+ }
922
995
  if (uids.length === 0) { await client.logout(); return { deleted: 0 }; }
923
996
  let deleted = 0;
924
997
  for (let i = 0; i < uids.length; i += CHUNK_SIZE) {
@@ -943,9 +1016,9 @@ export async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX'
943
1016
  if (msg) {
944
1017
  emails.push({
945
1018
  uid,
946
- subject: msg.envelope.subject,
947
- from: msg.envelope.from?.[0]?.address,
948
- date: msg.envelope.date,
1019
+ subject: msg.envelope?.subject,
1020
+ from: msg.envelope?.from?.[0]?.address,
1021
+ date: msg.envelope?.date,
949
1022
  flagged: msg.flags.has('\\Flagged'),
950
1023
  seen: msg.flags.has('\\Seen')
951
1024
  });
@@ -955,36 +1028,71 @@ export async function getEmailsByDateRange(startDate, endDate, mailbox = 'INBOX'
955
1028
  return { total, showing: emails.length, emails };
956
1029
  }
957
1030
 
958
- export async function bulkMarkRead(mailbox = 'INBOX', sender = null, creds = null) {
1031
+ export async function bulkMarkRead(mailbox = 'INBOX', sender = null, creds = null, dryRun = false) {
959
1032
  const client = createRateLimitedClient(creds);
960
1033
  await client.connect();
961
1034
  await client.mailboxOpen(mailbox);
962
1035
  const query = sender ? { from: sender, seen: false } : { seen: false };
963
1036
  const uids = (await client.search(query, { uid: true })) ?? [];
1037
+ if (dryRun) {
1038
+ await client.logout();
1039
+ return {
1040
+ dryRun: true,
1041
+ wouldMarkRead: uids.length,
1042
+ sender: sender || 'all',
1043
+ mailbox,
1044
+ uids,
1045
+ changes: uids.map(uid => ({ action: 'mark_read', uid, mailbox, sender: sender || 'all' })),
1046
+ };
1047
+ }
964
1048
  if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
965
1049
  await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
966
1050
  await client.logout();
967
1051
  return { marked: uids.length, sender: sender || 'all' };
968
1052
  }
969
1053
 
970
- export async function bulkMarkUnread(mailbox = 'INBOX', sender = null, creds = null) {
1054
+ export async function bulkMarkUnread(mailbox = 'INBOX', sender = null, creds = null, dryRun = false) {
971
1055
  const client = createRateLimitedClient(creds);
972
1056
  await client.connect();
973
1057
  await client.mailboxOpen(mailbox);
974
1058
  const query = sender ? { from: sender, seen: true } : { seen: true };
975
1059
  const uids = (await client.search(query, { uid: true })) ?? [];
1060
+ if (dryRun) {
1061
+ await client.logout();
1062
+ return {
1063
+ dryRun: true,
1064
+ wouldMarkUnread: uids.length,
1065
+ sender: sender || 'all',
1066
+ mailbox,
1067
+ uids,
1068
+ changes: uids.map(uid => ({ action: 'mark_unread', uid, mailbox, sender: sender || 'all' })),
1069
+ };
1070
+ }
976
1071
  if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
977
1072
  await client.messageFlagsRemove(uids, ['\\Seen'], { uid: true });
978
1073
  await client.logout();
979
1074
  return { marked: uids.length, sender: sender || 'all' };
980
1075
  }
981
1076
 
982
- export async function bulkFlag(filters, flagged, mailbox = 'INBOX', creds = null) {
1077
+ export async function bulkFlag(filters, flagged, mailbox = 'INBOX', creds = null, dryRun = false) {
983
1078
  const client = createRateLimitedClient(creds);
984
1079
  await client.connect();
985
1080
  await client.mailboxOpen(mailbox);
986
1081
  const query = buildQuery(filters);
987
1082
  const uids = (await client.search(query, { uid: true })) ?? [];
1083
+ if (dryRun) {
1084
+ await client.logout();
1085
+ const action = flagged ? 'flag' : 'unflag';
1086
+ return {
1087
+ dryRun: true,
1088
+ wouldAffect: uids.length,
1089
+ [flagged ? 'wouldFlag' : 'wouldUnflag']: uids.length,
1090
+ filters,
1091
+ mailbox,
1092
+ uids,
1093
+ changes: uids.map(uid => ({ action, uid, mailbox })),
1094
+ };
1095
+ }
988
1096
  if (uids.length === 0) { await client.logout(); return { flagged: 0 }; }
989
1097
  if (flagged) {
990
1098
  await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
@@ -995,12 +1103,25 @@ export async function bulkFlag(filters, flagged, mailbox = 'INBOX', creds = null
995
1103
  return { [flagged ? 'flagged' : 'unflagged']: uids.length, filters };
996
1104
  }
997
1105
 
998
- export async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX', creds = null) {
1106
+ export async function bulkFlagBySender(sender, flagged, mailbox = 'INBOX', creds = null, dryRun = false) {
999
1107
  const client = createRateLimitedClient(creds);
1000
1108
  await client.connect();
1001
1109
  await client.mailboxOpen(mailbox);
1002
1110
  const raw = await client.search({ from: sender }, { uid: true });
1003
1111
  const uids = Array.isArray(raw) ? raw : [];
1112
+ if (dryRun) {
1113
+ await client.logout();
1114
+ const action = flagged ? 'flag' : 'unflag';
1115
+ return {
1116
+ dryRun: true,
1117
+ wouldAffect: uids.length,
1118
+ [flagged ? 'wouldFlag' : 'wouldUnflag']: uids.length,
1119
+ sender,
1120
+ mailbox,
1121
+ uids,
1122
+ changes: uids.map(uid => ({ action, uid, mailbox, sender })),
1123
+ };
1124
+ }
1004
1125
  if (uids.length === 0) { await client.logout(); return { [flagged ? 'flagged' : 'unflagged']: 0, sender }; }
1005
1126
  if (flagged) {
1006
1127
  await client.messageFlagsAdd(uids, ['\\Flagged'], { uid: true });
@@ -1042,7 +1163,13 @@ export async function emptyTrash(dryRun = false, creds = null) {
1042
1163
 
1043
1164
  if (dryRun) {
1044
1165
  await safeClose(client);
1045
- return { dryRun: true, wouldDelete: uids.length, mailbox };
1166
+ return {
1167
+ dryRun: true,
1168
+ wouldDelete: uids.length,
1169
+ mailbox,
1170
+ uids,
1171
+ changes: uids.map(uid => ({ action: 'delete', uid, mailbox })),
1172
+ };
1046
1173
  }
1047
1174
 
1048
1175
  if (uids.length === 0) {
@@ -1069,7 +1196,17 @@ export async function archiveOlderThan(days, targetMailbox, sourceMailbox = 'INB
1069
1196
  const raw = await client.search({ before: date }, { uid: true });
1070
1197
  const uids = Array.isArray(raw) ? raw : [];
1071
1198
  await client.logout();
1072
- if (dryRun) return { dryRun: true, wouldMove: uids.length, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1199
+ if (dryRun) {
1200
+ return {
1201
+ dryRun: true,
1202
+ wouldMove: uids.length,
1203
+ olderThan: date.toISOString(),
1204
+ sourceMailbox,
1205
+ targetMailbox,
1206
+ uids,
1207
+ changes: uids.map(uid => ({ action: 'move', uid, sourceMailbox, targetMailbox })),
1208
+ };
1209
+ }
1073
1210
  if (uids.length === 0) return { moved: 0, olderThan: date.toISOString(), sourceMailbox, targetMailbox };
1074
1211
  await ensureMailbox(targetMailbox, creds);
1075
1212
  const result = await safeMoveEmails(uids, sourceMailbox, targetMailbox, creds);
@@ -1251,10 +1388,20 @@ export async function renameMailbox(oldName, newName, creds = null) {
1251
1388
  return { renamed: { from: oldName, to: newName } };
1252
1389
  }
1253
1390
 
1254
- export async function deleteMailbox(name, creds = null) {
1391
+ export async function deleteMailbox(name, creds = null, dryRun = false) {
1255
1392
  const client = createRateLimitedClient(creds);
1256
1393
  await client.connect();
1257
1394
  try {
1395
+ if (dryRun) {
1396
+ const status = await client.status(name, { messages: true });
1397
+ return {
1398
+ dryRun: true,
1399
+ wouldDelete: true,
1400
+ mailbox: name,
1401
+ messageCount: status.messages,
1402
+ changes: [{ action: 'delete_mailbox', mailbox: name, messageCount: status.messages }],
1403
+ };
1404
+ }
1258
1405
  await Promise.race([
1259
1406
  client.mailboxDelete(name),
1260
1407
  new Promise((_, reject) =>
@@ -1357,9 +1504,9 @@ export async function getEmailContent(uid, mailbox = 'INBOX', maxChars = 8000, i
1357
1504
  const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1358
1505
  const result = {
1359
1506
  uid: meta.uid,
1360
- subject: meta.envelope.subject,
1361
- from: meta.envelope.from?.[0]?.address,
1362
- date: meta.envelope.date,
1507
+ subject: meta.envelope?.subject,
1508
+ from: meta.envelope?.from?.[0]?.address,
1509
+ date: meta.envelope?.date,
1363
1510
  flags: [...meta.flags],
1364
1511
  attachments: {
1365
1512
  count: attachments.length,
@@ -1373,11 +1520,11 @@ export async function getEmailContent(uid, mailbox = 'INBOX', maxChars = 8000, i
1373
1520
  const rawRefs = extractRawHeader(meta.headers, 'references');
1374
1521
  const rawUnsub = extractRawHeader(meta.headers, 'list-unsubscribe');
1375
1522
  result.headers = {
1376
- to: meta.envelope.to?.map(a => a.address) ?? [],
1377
- cc: meta.envelope.cc?.map(a => a.address) ?? [],
1378
- replyTo: meta.envelope.replyTo?.[0]?.address ?? null,
1379
- messageId: meta.envelope.messageId ?? null,
1380
- inReplyTo: meta.envelope.inReplyTo ?? null,
1523
+ to: meta.envelope?.to?.map(a => a.address) ?? [],
1524
+ cc: meta.envelope?.cc?.map(a => a.address) ?? [],
1525
+ replyTo: meta.envelope?.replyTo?.[0]?.address ?? null,
1526
+ messageId: meta.envelope?.messageId ?? null,
1527
+ inReplyTo: meta.envelope?.inReplyTo ?? null,
1381
1528
  references: rawRefs ? rawRefs.split(/\s+/).filter(s => s.startsWith('<')) : [],
1382
1529
  listUnsubscribe: rawUnsub || null
1383
1530
  };
@@ -1396,7 +1543,7 @@ export async function listAttachments(uid, mailbox = 'INBOX', creds = null) {
1396
1543
  const attachments = meta.bodyStructure ? findAttachments(meta.bodyStructure) : [];
1397
1544
  return {
1398
1545
  uid: meta.uid,
1399
- subject: meta.envelope.subject,
1546
+ subject: meta.envelope?.subject,
1400
1547
  attachmentCount: attachments.length,
1401
1548
  attachments
1402
1549
  };
@@ -1555,10 +1702,26 @@ export async function markAsRead(uid, seen, mailbox = 'INBOX', creds = null) {
1555
1702
  return true;
1556
1703
  }
1557
1704
 
1558
- export async function deleteEmail(uid, mailbox = 'INBOX', creds = null) {
1705
+ export async function deleteEmail(uid, mailbox = 'INBOX', creds = null, dryRun = false) {
1559
1706
  const client = createRateLimitedClient(creds);
1560
1707
  await client.connect();
1561
1708
  await client.mailboxOpen(mailbox);
1709
+ if (dryRun) {
1710
+ const msg = await client.fetchOne(uid, { envelope: true }, { uid: true });
1711
+ await client.logout();
1712
+ if (!msg) throw new Error(`Email UID ${uid} not found`);
1713
+ return {
1714
+ dryRun: true,
1715
+ wouldDelete: 1,
1716
+ changes: [{
1717
+ action: 'delete',
1718
+ uid,
1719
+ mailbox,
1720
+ subject: msg.envelope?.subject ?? null,
1721
+ from: msg.envelope?.from?.[0]?.address ?? null,
1722
+ }],
1723
+ };
1724
+ }
1562
1725
  await client.messageDelete(uid, { uid: true });
1563
1726
  await client.logout();
1564
1727
  return true;
@@ -1609,8 +1772,13 @@ export async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters
1609
1772
 
1610
1773
  const extraQuery = buildQuery(filters);
1611
1774
  const hasExtra = Object.keys(extraQuery).length > 0 && !extraQuery.all;
1775
+ // Both sides can carry an OR (keyword search and the domain filter). A query
1776
+ // object holds only one 'or' key, so fold the filter's OR into each text branch.
1777
+ const merged = textQuery?.or && extraQuery.or
1778
+ ? { ...textQuery, ...extraQuery, or: textQuery.or.map(part => ({ ...part, or: extraQuery.or })) }
1779
+ : { ...textQuery, ...extraQuery };
1612
1780
  const finalQuery = textQuery
1613
- ? (hasExtra ? { ...textQuery, ...extraQuery } : textQuery)
1781
+ ? (hasExtra ? merged : textQuery)
1614
1782
  : (hasExtra ? extraQuery : { all: true });
1615
1783
 
1616
1784
  let uids = (await client.search(finalQuery, { uid: true })) ?? [];
@@ -1631,9 +1799,9 @@ export async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters
1631
1799
  if (msg) {
1632
1800
  emails.push({
1633
1801
  uid,
1634
- subject: msg.envelope.subject,
1635
- from: msg.envelope.from?.[0]?.address,
1636
- date: msg.envelope.date,
1802
+ subject: msg.envelope?.subject,
1803
+ from: msg.envelope?.from?.[0]?.address,
1804
+ date: msg.envelope?.date,
1637
1805
  flagged: msg.flags.has('\\Flagged'),
1638
1806
  seen: msg.flags.has('\\Seen')
1639
1807
  });
@@ -1668,10 +1836,27 @@ export async function searchEmails(query, mailbox = 'INBOX', limit = 10, filters
1668
1836
  return { total: uids.length, showing: emails.length, emails };
1669
1837
  }
1670
1838
 
1671
- export async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX', creds = null) {
1839
+ export async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX', creds = null, dryRun = false) {
1672
1840
  const client = createRateLimitedClient(creds);
1673
1841
  await client.connect();
1674
1842
  await client.mailboxOpen(sourceMailbox);
1843
+ if (dryRun) {
1844
+ const msg = await client.fetchOne(uid, { envelope: true }, { uid: true });
1845
+ await client.logout();
1846
+ if (!msg) throw new Error(`Email UID ${uid} not found`);
1847
+ return {
1848
+ dryRun: true,
1849
+ wouldMove: 1,
1850
+ changes: [{
1851
+ action: 'move',
1852
+ uid,
1853
+ sourceMailbox,
1854
+ targetMailbox,
1855
+ subject: msg.envelope?.subject ?? null,
1856
+ from: msg.envelope?.from?.[0]?.address ?? null,
1857
+ }],
1858
+ };
1859
+ }
1675
1860
  await client.messageMove(uid, targetMailbox, { uid: true });
1676
1861
  await client.logout();
1677
1862
  return true;
@@ -1680,7 +1865,13 @@ export async function moveEmail(uid, targetMailbox, sourceMailbox = 'INBOX', cre
1680
1865
  function buildQuery(filters) {
1681
1866
  const query = {};
1682
1867
  if (filters.sender) query.from = filters.sender;
1683
- if (filters.domain) query.from = filters.domain.replace(/^@/, '');
1868
+ // iCloud's FROM search is inconsistent with domains: the bare domain misses some
1869
+ // senders (e.g. "Name <someone@example.com>") that "@domain" finds, so match
1870
+ // either form.
1871
+ if (filters.domain) {
1872
+ const domain = filters.domain.replace(/^@/, '');
1873
+ query.or = [{ from: domain }, { from: `@${domain}` }];
1874
+ }
1684
1875
  if (filters.subject) query.subject = filters.subject;
1685
1876
  if (filters.before) query.before = new Date(filters.before);
1686
1877
  if (filters.since) query.since = new Date(filters.since);
@@ -1732,7 +1923,15 @@ export async function bulkMove(filters, targetMailbox, sourceMailbox = 'INBOX',
1732
1923
  if (limit !== null) uids = uids.slice(0, limit);
1733
1924
 
1734
1925
  if (dryRun) {
1735
- return { dryRun: true, wouldMove: uids.length, sourceMailbox, targetMailbox, filters };
1926
+ return {
1927
+ dryRun: true,
1928
+ wouldMove: uids.length,
1929
+ sourceMailbox,
1930
+ targetMailbox,
1931
+ filters,
1932
+ uids,
1933
+ changes: uids.map(uid => ({ action: 'move', uid, sourceMailbox, targetMailbox })),
1934
+ };
1736
1935
  }
1737
1936
  if (uids.length === 0) return { moved: 0, sourceMailbox, targetMailbox };
1738
1937
 
@@ -1762,7 +1961,14 @@ export async function bulkDelete(filters, sourceMailbox = 'INBOX', dryRun = fals
1762
1961
 
1763
1962
  if (dryRun) {
1764
1963
  await client.logout();
1765
- return { dryRun: true, wouldDelete: uids.length, sourceMailbox, filters };
1964
+ return {
1965
+ dryRun: true,
1966
+ wouldDelete: uids.length,
1967
+ sourceMailbox,
1968
+ filters,
1969
+ uids,
1970
+ changes: uids.map(uid => ({ action: 'delete', uid, mailbox: sourceMailbox })),
1971
+ };
1766
1972
  }
1767
1973
  if (uids.length === 0) { await client.logout(); return { deleted: 0, sourceMailbox }; }
1768
1974
 
@@ -1810,13 +2016,14 @@ export async function countEmails(filters, mailbox = 'INBOX', creds = null) {
1810
2016
  // ─── Saved Rules ─────────────────────────────────────────────────────────────
1811
2017
 
1812
2018
  function readRules() {
1813
- if (!existsSync(RULES_FILE)) return { rules: [] };
1814
- try { return JSON.parse(readFileSync(RULES_FILE, 'utf8')); }
2019
+ const file = rulesFile();
2020
+ if (!existsSync(file)) return { rules: [] };
2021
+ try { return JSON.parse(readFileSync(file, 'utf8')); }
1815
2022
  catch { return { rules: [] }; }
1816
2023
  }
1817
2024
 
1818
2025
  function writeRules(data) {
1819
- writeFileSync(RULES_FILE, JSON.stringify(data, null, 2));
2026
+ writeFileSync(rulesFile(), JSON.stringify(data, null, 2));
1820
2027
  }
1821
2028
 
1822
2029
  export function createRule(name, filters, action, description = '') {
@@ -1849,22 +2056,48 @@ export function listRules() {
1849
2056
  return { rules: readRules().rules };
1850
2057
  }
1851
2058
 
1852
- export function deleteRule(name) {
2059
+ export function deleteRule(name, dryRun = false) {
1853
2060
  const data = readRules();
1854
2061
  const idx = data.rules.findIndex(r => r.name === name);
1855
2062
  if (idx === -1) throw new Error(`Rule '${name}' not found.`);
2063
+ const rule = data.rules[idx];
2064
+ if (dryRun) {
2065
+ return {
2066
+ dryRun: true,
2067
+ wouldDelete: true,
2068
+ name,
2069
+ changes: [{
2070
+ action: 'delete_rule',
2071
+ name,
2072
+ description: rule.description,
2073
+ filters: rule.filters,
2074
+ ruleAction: rule.action,
2075
+ }],
2076
+ };
2077
+ }
1856
2078
  data.rules.splice(idx, 1);
1857
2079
  writeRules(data);
1858
2080
  return { deleted: true, name };
1859
2081
  }
1860
2082
 
1861
- async function bulkMarkByFilters(filters, read, mailbox = 'INBOX', creds = null) {
2083
+ async function bulkMarkByFilters(filters, read, mailbox = 'INBOX', creds = null, dryRun = false) {
1862
2084
  const client = createRateLimitedClient(creds);
1863
2085
  await client.connect();
1864
2086
  await client.mailboxOpen(mailbox);
1865
2087
  const base = buildQuery(filters);
1866
2088
  const query = { ...base, ...(read ? { seen: false } : { seen: true }) };
1867
2089
  const uids = (await client.search(query, { uid: true })) ?? [];
2090
+ if (dryRun) {
2091
+ await client.logout();
2092
+ const action = read ? 'mark_read' : 'mark_unread';
2093
+ return {
2094
+ dryRun: true,
2095
+ wouldAffect: uids.length,
2096
+ mailbox,
2097
+ uids,
2098
+ changes: uids.map(uid => ({ action, uid, mailbox })),
2099
+ };
2100
+ }
1868
2101
  if (uids.length === 0) { await client.logout(); return { marked: 0 }; }
1869
2102
  if (read) {
1870
2103
  await client.messageFlagsAdd(uids, ['\\Seen'], { uid: true });
@@ -1883,34 +2116,14 @@ async function executeRule(rule, dryRun = false) {
1883
2116
  return bulkMove(filters, action.targetMailbox, sourceMailbox, dryRun);
1884
2117
  case 'delete':
1885
2118
  return bulkDelete(filters, sourceMailbox, dryRun);
1886
- case 'mark_read': {
1887
- if (dryRun) {
1888
- const { count } = await countEmails(filters, sourceMailbox);
1889
- return { dryRun: true, wouldAffect: count ?? 0 };
1890
- }
1891
- return bulkMarkByFilters(filters, true, sourceMailbox);
1892
- }
1893
- case 'mark_unread': {
1894
- if (dryRun) {
1895
- const { count } = await countEmails(filters, sourceMailbox);
1896
- return { dryRun: true, wouldAffect: count ?? 0 };
1897
- }
1898
- return bulkMarkByFilters(filters, false, sourceMailbox);
1899
- }
1900
- case 'flag': {
1901
- if (dryRun) {
1902
- const { count } = await countEmails(filters, sourceMailbox);
1903
- return { dryRun: true, wouldAffect: count ?? 0 };
1904
- }
1905
- return bulkFlag(filters, true, sourceMailbox);
1906
- }
1907
- case 'unflag': {
1908
- if (dryRun) {
1909
- const { count } = await countEmails(filters, sourceMailbox);
1910
- return { dryRun: true, wouldAffect: count ?? 0 };
1911
- }
1912
- return bulkFlag(filters, false, sourceMailbox);
1913
- }
2119
+ case 'mark_read':
2120
+ return bulkMarkByFilters(filters, true, sourceMailbox, null, dryRun);
2121
+ case 'mark_unread':
2122
+ return bulkMarkByFilters(filters, false, sourceMailbox, null, dryRun);
2123
+ case 'flag':
2124
+ return bulkFlag(filters, true, sourceMailbox, null, dryRun);
2125
+ case 'unflag':
2126
+ return bulkFlag(filters, false, sourceMailbox, null, dryRun);
1914
2127
  default:
1915
2128
  throw new Error(`Unknown action type: ${action.type}`);
1916
2129
  }