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/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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
+
const file = manifestFile();
|
|
107
|
+
if (!existsSync(file)) return { current: null, history: [] };
|
|
89
108
|
try {
|
|
90
|
-
return JSON.parse(readFileSync(
|
|
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(
|
|
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
|
|
736
|
-
from: msg.envelope
|
|
737
|
-
date: msg.envelope
|
|
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
|
|
755
|
-
from: msg.envelope
|
|
756
|
-
date: msg.envelope
|
|
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
|
|
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
|
|
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
|
|
840
|
-
from: msg.envelope
|
|
841
|
-
date: msg.envelope
|
|
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)
|
|
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
|
|
947
|
-
from: msg.envelope
|
|
948
|
-
date: msg.envelope
|
|
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 {
|
|
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)
|
|
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
|
|
1361
|
-
from: meta.envelope
|
|
1362
|
-
date: meta.envelope
|
|
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
|
|
1377
|
-
cc: meta.envelope
|
|
1378
|
-
replyTo: meta.envelope
|
|
1379
|
-
messageId: meta.envelope
|
|
1380
|
-
inReplyTo: meta.envelope
|
|
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
|
|
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 ?
|
|
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
|
|
1635
|
-
from: msg.envelope
|
|
1636
|
-
date: msg.envelope
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1814
|
-
|
|
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(
|
|
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
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
return
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
}
|