proton-mail-bridge-client 1.11.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 +445 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1627 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3173 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/bridge-smoke.d.ts +2 -0
- package/dist/scripts/bridge-smoke.d.ts.map +1 -0
- package/dist/scripts/bridge-smoke.js +415 -0
- package/dist/scripts/bridge-smoke.js.map +1 -0
- package/dist/scripts/check-claude-desktop.d.ts +17 -0
- package/dist/scripts/check-claude-desktop.d.ts.map +1 -0
- package/dist/scripts/check-claude-desktop.js +138 -0
- package/dist/scripts/check-claude-desktop.js.map +1 -0
- package/dist/scripts/install-claude-desktop.d.ts +39 -0
- package/dist/scripts/install-claude-desktop.d.ts.map +1 -0
- package/dist/scripts/install-claude-desktop.js +257 -0
- package/dist/scripts/install-claude-desktop.js.map +1 -0
- package/dist/scripts/setup-claude-desktop.d.ts +15 -0
- package/dist/scripts/setup-claude-desktop.d.ts.map +1 -0
- package/dist/scripts/setup-claude-desktop.js +187 -0
- package/dist/scripts/setup-claude-desktop.js.map +1 -0
- package/dist/services/analytics-service.d.ts +45 -0
- package/dist/services/analytics-service.d.ts.map +1 -0
- package/dist/services/analytics-service.js +189 -0
- package/dist/services/analytics-service.js.map +1 -0
- package/dist/services/audit-service.d.ts +13 -0
- package/dist/services/audit-service.d.ts.map +1 -0
- package/dist/services/audit-service.js +67 -0
- package/dist/services/audit-service.js.map +1 -0
- package/dist/services/background-sync-service.d.ts +28 -0
- package/dist/services/background-sync-service.d.ts.map +1 -0
- package/dist/services/background-sync-service.js +216 -0
- package/dist/services/background-sync-service.js.map +1 -0
- package/dist/services/draft-store-service.d.ts +59 -0
- package/dist/services/draft-store-service.d.ts.map +1 -0
- package/dist/services/draft-store-service.js +251 -0
- package/dist/services/draft-store-service.js.map +1 -0
- package/dist/services/local-index-service.d.ts +90 -0
- package/dist/services/local-index-service.d.ts.map +1 -0
- package/dist/services/local-index-service.js +1462 -0
- package/dist/services/local-index-service.js.map +1 -0
- package/dist/services/simple-imap-service.d.ts +209 -0
- package/dist/services/simple-imap-service.d.ts.map +1 -0
- package/dist/services/simple-imap-service.js +1100 -0
- package/dist/services/simple-imap-service.js.map +1 -0
- package/dist/services/smtp-service.d.ts +15 -0
- package/dist/services/smtp-service.d.ts.map +1 -0
- package/dist/services/smtp-service.js +100 -0
- package/dist/services/smtp-service.js.map +1 -0
- package/dist/types/index.d.ts +355 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/helpers.d.ts +45 -0
- package/dist/utils/helpers.d.ts.map +1 -0
- package/dist/utils/helpers.js +393 -0
- package/dist/utils/helpers.js.map +1 -0
- package/dist/utils/logger.d.ts +22 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +91 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/runtime-policy.d.ts +12 -0
- package/dist/utils/runtime-policy.d.ts.map +1 -0
- package/dist/utils/runtime-policy.js +62 -0
- package/dist/utils/runtime-policy.js.map +1 -0
- package/glama.json +6 -0
- package/package.json +91 -0
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { ImapFlow } from "imapflow";
|
|
4
|
+
import { simpleParser } from "mailparser";
|
|
5
|
+
import { classifyAttachment, createEmailId, dedupeEmails, extractAttachments, extractMessageIdList, isTextLikeMimeType, mapEnvelopeAddresses, mapParsedAddresses, matchesLocalSearchFilters, nextDay, normalizeLimit, parseDateInput, parseEmailId, previewText, sanitizeFileName, sortEmailsByNewest, stripHtmlToText, summarizeCalendarText, } from "../utils/helpers.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
const FETCH_SUMMARY_QUERY = {
|
|
8
|
+
uid: true,
|
|
9
|
+
flags: true,
|
|
10
|
+
envelope: true,
|
|
11
|
+
internalDate: true,
|
|
12
|
+
size: true,
|
|
13
|
+
bodyStructure: true,
|
|
14
|
+
labels: true,
|
|
15
|
+
};
|
|
16
|
+
const FETCH_DETAIL_QUERY = {
|
|
17
|
+
...FETCH_SUMMARY_QUERY,
|
|
18
|
+
source: true,
|
|
19
|
+
};
|
|
20
|
+
const MAX_ATTACHMENT_TEXT_BYTES = 512_000;
|
|
21
|
+
export function isLikelyAuthenticationError(error) {
|
|
22
|
+
if (!error) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const values = [];
|
|
26
|
+
if (error instanceof Error) {
|
|
27
|
+
values.push(error.message);
|
|
28
|
+
values.push(error.name);
|
|
29
|
+
const maybeResponseText = error.responseText;
|
|
30
|
+
if (typeof maybeResponseText === "string") {
|
|
31
|
+
values.push(maybeResponseText);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (typeof error === "string") {
|
|
35
|
+
values.push(error);
|
|
36
|
+
}
|
|
37
|
+
else if (typeof error === "object") {
|
|
38
|
+
const maybeMessage = error.message;
|
|
39
|
+
const maybeCode = error.code;
|
|
40
|
+
const maybeResponse = error.response;
|
|
41
|
+
if (typeof maybeMessage === "string") {
|
|
42
|
+
values.push(maybeMessage);
|
|
43
|
+
}
|
|
44
|
+
if (typeof maybeCode === "string") {
|
|
45
|
+
values.push(maybeCode);
|
|
46
|
+
}
|
|
47
|
+
if (typeof maybeResponse === "string") {
|
|
48
|
+
values.push(maybeResponse);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const haystack = values.join(" ").toLowerCase();
|
|
52
|
+
if (!haystack) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return [
|
|
56
|
+
"auth",
|
|
57
|
+
"login failed",
|
|
58
|
+
"incorrect login credentials",
|
|
59
|
+
"invalid credentials",
|
|
60
|
+
"authentication failed",
|
|
61
|
+
"no such user",
|
|
62
|
+
"too many login attempts",
|
|
63
|
+
].some((needle) => haystack.includes(needle));
|
|
64
|
+
}
|
|
65
|
+
export function planFolderSync(input) {
|
|
66
|
+
const uidNext = input.uidNext ?? 1;
|
|
67
|
+
const highestKnownUid = Math.max(0, uidNext - 1);
|
|
68
|
+
if (input.exists === 0 || highestKnownUid === 0) {
|
|
69
|
+
return {
|
|
70
|
+
folder: input.folder,
|
|
71
|
+
strategy: "empty",
|
|
72
|
+
changed: false,
|
|
73
|
+
highestKnownUid,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (input.full) {
|
|
77
|
+
return {
|
|
78
|
+
folder: input.folder,
|
|
79
|
+
strategy: "full",
|
|
80
|
+
changed: true,
|
|
81
|
+
startUid: Math.max(1, highestKnownUid - input.limit + 1),
|
|
82
|
+
endUid: highestKnownUid,
|
|
83
|
+
highestKnownUid,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!input.checkpoint ||
|
|
87
|
+
!input.checkpoint.highestUid ||
|
|
88
|
+
(input.checkpoint.uidValidity && input.uidValidity && input.checkpoint.uidValidity !== input.uidValidity)) {
|
|
89
|
+
return {
|
|
90
|
+
folder: input.folder,
|
|
91
|
+
strategy: "recent",
|
|
92
|
+
changed: true,
|
|
93
|
+
startUid: Math.max(1, highestKnownUid - input.limit + 1),
|
|
94
|
+
endUid: highestKnownUid,
|
|
95
|
+
highestKnownUid,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const overlap = Math.min(input.limit, Math.max(25, Math.min(100, Math.ceil(input.limit / 2))));
|
|
99
|
+
const changed = highestKnownUid > (input.checkpoint.highestUid ?? 0) ||
|
|
100
|
+
uidNext !== (input.checkpoint.uidNext ?? uidNext) ||
|
|
101
|
+
input.exists !== (input.checkpoint.total ?? input.exists);
|
|
102
|
+
return {
|
|
103
|
+
folder: input.folder,
|
|
104
|
+
strategy: changed ? "incremental" : "incremental_window",
|
|
105
|
+
changed,
|
|
106
|
+
startUid: Math.max(1, Math.min(highestKnownUid, input.checkpoint.highestUid) - overlap + 1),
|
|
107
|
+
endUid: highestKnownUid,
|
|
108
|
+
highestKnownUid,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function mapFolder(entry) {
|
|
112
|
+
return {
|
|
113
|
+
path: entry.path,
|
|
114
|
+
name: entry.name,
|
|
115
|
+
delimiter: entry.delimiter,
|
|
116
|
+
specialUse: entry.specialUse,
|
|
117
|
+
listed: entry.listed,
|
|
118
|
+
subscribed: entry.subscribed,
|
|
119
|
+
flags: [...entry.flags],
|
|
120
|
+
messages: entry.status?.messages,
|
|
121
|
+
unseen: entry.status?.unseen,
|
|
122
|
+
uidNext: entry.status?.uidNext,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function createParsedAttachmentId(attachment, index) {
|
|
126
|
+
return attachment.checksum || attachment.cid || `attachment-${index + 1}`;
|
|
127
|
+
}
|
|
128
|
+
export class SimpleIMAPService {
|
|
129
|
+
config;
|
|
130
|
+
log;
|
|
131
|
+
client;
|
|
132
|
+
folderCache;
|
|
133
|
+
messageCache = new Map();
|
|
134
|
+
lastSyncAt;
|
|
135
|
+
lastIdleAt;
|
|
136
|
+
lastIdleChangeAt;
|
|
137
|
+
lastIdleEventCount;
|
|
138
|
+
lastIdleError;
|
|
139
|
+
constructor(config, log = logger) {
|
|
140
|
+
this.config = config;
|
|
141
|
+
this.log = log;
|
|
142
|
+
}
|
|
143
|
+
async connect() {
|
|
144
|
+
if (this.client?.usable) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await this.disconnect();
|
|
148
|
+
const client = new ImapFlow({
|
|
149
|
+
host: this.config.imap.host,
|
|
150
|
+
port: this.config.imap.port,
|
|
151
|
+
secure: this.config.imap.secure,
|
|
152
|
+
doSTARTTLS: this.config.imap.secure ? undefined : true,
|
|
153
|
+
auth: {
|
|
154
|
+
user: this.config.imap.username,
|
|
155
|
+
pass: this.config.imap.password,
|
|
156
|
+
},
|
|
157
|
+
tls: this.shouldRelaxTlsVerification() ? { rejectUnauthorized: false } : undefined,
|
|
158
|
+
disableAutoIdle: true,
|
|
159
|
+
maxIdleTime: this.config.runtime.idleMaxSeconds * 1000,
|
|
160
|
+
logger: false,
|
|
161
|
+
connectionTimeout: 15_000,
|
|
162
|
+
greetingTimeout: 15_000,
|
|
163
|
+
});
|
|
164
|
+
client.on("error", (error) => {
|
|
165
|
+
this.log.warn("IMAP client error", "IMAPService", error);
|
|
166
|
+
});
|
|
167
|
+
await client.connect();
|
|
168
|
+
this.client = client;
|
|
169
|
+
}
|
|
170
|
+
async disconnect() {
|
|
171
|
+
if (!this.client) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (this.client.usable) {
|
|
176
|
+
await this.client.logout();
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
this.client.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.log.warn("IMAP disconnect failed", "IMAPService", error);
|
|
184
|
+
this.client.close();
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
this.client = undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
isConnected() {
|
|
191
|
+
return Boolean(this.client?.usable);
|
|
192
|
+
}
|
|
193
|
+
async ping() {
|
|
194
|
+
const client = await this.ensureConnected();
|
|
195
|
+
await client.noop();
|
|
196
|
+
}
|
|
197
|
+
getIdleStatus() {
|
|
198
|
+
return {
|
|
199
|
+
enabled: this.config.runtime.idleWatchEnabled,
|
|
200
|
+
connected: this.isConnected(),
|
|
201
|
+
maxSeconds: this.config.runtime.idleMaxSeconds,
|
|
202
|
+
lastIdleAt: this.lastIdleAt,
|
|
203
|
+
lastIdleChangeAt: this.lastIdleChangeAt,
|
|
204
|
+
lastIdleEventCount: this.lastIdleEventCount,
|
|
205
|
+
lastIdleError: this.lastIdleError,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
async waitForMailboxChanges(input = {}) {
|
|
209
|
+
const folder = input.folder?.trim() || "INBOX";
|
|
210
|
+
const timeoutMs = normalizeLimit(input.timeoutMs, this.config.runtime.idleMaxSeconds * 1000, 1_000, 300_000);
|
|
211
|
+
const client = await this.ensureConnected();
|
|
212
|
+
return this.waitForMailboxChangesWithClient(client, folder, timeoutMs, true);
|
|
213
|
+
}
|
|
214
|
+
async waitForMailboxChangesWithClient(client, folder, timeoutMs, allowReconnectRetry) {
|
|
215
|
+
const idleClient = client;
|
|
216
|
+
const previousMaxIdle = idleClient.maxIdleTime;
|
|
217
|
+
const events = [];
|
|
218
|
+
let idleBreakRequested = false;
|
|
219
|
+
const requestIdleBreak = () => {
|
|
220
|
+
if (idleBreakRequested) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
idleBreakRequested = true;
|
|
224
|
+
const breaker = idleClient.preCheck;
|
|
225
|
+
if (typeof breaker === "function") {
|
|
226
|
+
breaker().catch((error) => {
|
|
227
|
+
this.log.warn("Failed to break IMAP IDLE probe", "IMAPService", {
|
|
228
|
+
folder,
|
|
229
|
+
error,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const onExists = (event) => {
|
|
235
|
+
const mailbox = client.mailbox || undefined;
|
|
236
|
+
events.push({ type: "exists", count: event.count ?? event.exists ?? mailbox?.exists });
|
|
237
|
+
requestIdleBreak();
|
|
238
|
+
};
|
|
239
|
+
const onExpunge = (event) => {
|
|
240
|
+
events.push({ type: "expunge", seq: event.seq });
|
|
241
|
+
requestIdleBreak();
|
|
242
|
+
};
|
|
243
|
+
const onFlags = (event) => {
|
|
244
|
+
events.push({ type: "flags", seq: event.seq, uid: event.uid });
|
|
245
|
+
requestIdleBreak();
|
|
246
|
+
};
|
|
247
|
+
client.on("exists", onExists);
|
|
248
|
+
client.on("expunge", onExpunge);
|
|
249
|
+
client.on("flags", onFlags);
|
|
250
|
+
const lock = await client.getMailboxLock(folder, { readOnly: true });
|
|
251
|
+
const timeout = setTimeout(() => {
|
|
252
|
+
requestIdleBreak();
|
|
253
|
+
}, timeoutMs);
|
|
254
|
+
timeout.unref?.();
|
|
255
|
+
try {
|
|
256
|
+
idleClient.maxIdleTime = timeoutMs;
|
|
257
|
+
await client.idle();
|
|
258
|
+
const checkedAt = new Date().toISOString();
|
|
259
|
+
const changed = events.length > 0;
|
|
260
|
+
this.lastIdleAt = checkedAt;
|
|
261
|
+
this.lastIdleError = undefined;
|
|
262
|
+
if (changed) {
|
|
263
|
+
this.lastIdleChangeAt = checkedAt;
|
|
264
|
+
this.lastIdleEventCount = events.length;
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
folder,
|
|
268
|
+
timeoutMs,
|
|
269
|
+
checkedAt,
|
|
270
|
+
changed,
|
|
271
|
+
events,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
this.lastIdleError = error instanceof Error ? error.message : String(error);
|
|
276
|
+
if (allowReconnectRetry && !isLikelyAuthenticationError(error)) {
|
|
277
|
+
await this.disconnect();
|
|
278
|
+
const freshClient = await this.ensureConnected();
|
|
279
|
+
return this.waitForMailboxChangesWithClient(freshClient, folder, timeoutMs, false);
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
clearTimeout(timeout);
|
|
285
|
+
lock.release();
|
|
286
|
+
client.off("exists", onExists);
|
|
287
|
+
client.off("expunge", onExpunge);
|
|
288
|
+
client.off("flags", onFlags);
|
|
289
|
+
idleClient.maxIdleTime = previousMaxIdle;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async getFolders(forceRefresh = false) {
|
|
293
|
+
if (this.folderCache && !forceRefresh) {
|
|
294
|
+
return this.folderCache;
|
|
295
|
+
}
|
|
296
|
+
const client = await this.ensureConnected();
|
|
297
|
+
const folders = await client.list({
|
|
298
|
+
statusQuery: {
|
|
299
|
+
messages: true,
|
|
300
|
+
unseen: true,
|
|
301
|
+
uidNext: true,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
this.folderCache = folders.map(mapFolder);
|
|
305
|
+
return this.folderCache;
|
|
306
|
+
}
|
|
307
|
+
async syncFolders() {
|
|
308
|
+
const folders = await this.getFolders(true);
|
|
309
|
+
const syncedAt = new Date().toISOString();
|
|
310
|
+
return { syncedAt, folders };
|
|
311
|
+
}
|
|
312
|
+
async createFolder(path) {
|
|
313
|
+
const trimmed = path?.trim();
|
|
314
|
+
if (!trimmed) {
|
|
315
|
+
throw new Error("Folder path is required.");
|
|
316
|
+
}
|
|
317
|
+
const client = await this.ensureConnected();
|
|
318
|
+
const response = await client.mailboxCreate(trimmed);
|
|
319
|
+
this.folderCache = undefined;
|
|
320
|
+
const folders = await this.getFolders(true);
|
|
321
|
+
const folder = folders.find((entry) => entry.path === response.path);
|
|
322
|
+
return { path: response.path, created: response.created !== false, folder };
|
|
323
|
+
}
|
|
324
|
+
async renameFolder(path, newPath) {
|
|
325
|
+
const fromPath = path?.trim();
|
|
326
|
+
const toPath = newPath?.trim();
|
|
327
|
+
if (!fromPath) {
|
|
328
|
+
throw new Error("Source folder path is required.");
|
|
329
|
+
}
|
|
330
|
+
if (!toPath) {
|
|
331
|
+
throw new Error("Target folder path is required.");
|
|
332
|
+
}
|
|
333
|
+
if (fromPath === toPath) {
|
|
334
|
+
throw new Error("Source and target paths are identical.");
|
|
335
|
+
}
|
|
336
|
+
const client = await this.ensureConnected();
|
|
337
|
+
const response = await client.mailboxRename(fromPath, toPath);
|
|
338
|
+
this.folderCache = undefined;
|
|
339
|
+
for (const [id, cached] of this.messageCache) {
|
|
340
|
+
if (cached.folder === response.path) {
|
|
341
|
+
this.messageCache.delete(id);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const folders = await this.getFolders(true);
|
|
345
|
+
const folder = folders.find((entry) => entry.path === response.newPath);
|
|
346
|
+
return { path: response.path, newPath: response.newPath, folder };
|
|
347
|
+
}
|
|
348
|
+
async deleteFolder(path) {
|
|
349
|
+
const trimmed = path?.trim();
|
|
350
|
+
if (!trimmed) {
|
|
351
|
+
throw new Error("Folder path is required.");
|
|
352
|
+
}
|
|
353
|
+
const reservedRoots = new Set([
|
|
354
|
+
"INBOX",
|
|
355
|
+
"Drafts",
|
|
356
|
+
"Sent",
|
|
357
|
+
"Trash",
|
|
358
|
+
"Spam",
|
|
359
|
+
"Archive",
|
|
360
|
+
"All Mail",
|
|
361
|
+
"Folders",
|
|
362
|
+
"Labels",
|
|
363
|
+
]);
|
|
364
|
+
if (reservedRoots.has(trimmed)) {
|
|
365
|
+
throw new Error(`Refusing to delete reserved system folder ${trimmed}.`);
|
|
366
|
+
}
|
|
367
|
+
const client = await this.ensureConnected();
|
|
368
|
+
const response = await client.mailboxDelete(trimmed);
|
|
369
|
+
this.folderCache = undefined;
|
|
370
|
+
for (const [id, cached] of this.messageCache) {
|
|
371
|
+
if (cached.folder === response.path) {
|
|
372
|
+
this.messageCache.delete(id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
await this.getFolders(true);
|
|
376
|
+
return { path: response.path, deleted: true };
|
|
377
|
+
}
|
|
378
|
+
async getEmails(input = {}) {
|
|
379
|
+
const folder = input.folder?.trim() || "INBOX";
|
|
380
|
+
const limit = normalizeLimit(input.limit, 50);
|
|
381
|
+
const offset = normalizeLimit(input.offset, 0, 0, 10_000);
|
|
382
|
+
return this.withMailbox(folder, true, async (client) => {
|
|
383
|
+
const total = client.mailbox && client.mailbox.exists ? client.mailbox.exists : 0;
|
|
384
|
+
if (total === 0 || offset >= total) {
|
|
385
|
+
return { folder, total, limit, offset, emails: [] };
|
|
386
|
+
}
|
|
387
|
+
const endSeq = total - offset;
|
|
388
|
+
const startSeq = Math.max(1, endSeq - limit + 1);
|
|
389
|
+
const emails = [];
|
|
390
|
+
for await (const message of client.fetch(`${startSeq}:${endSeq}`, FETCH_SUMMARY_QUERY)) {
|
|
391
|
+
const summary = this.toSummary(folder, message);
|
|
392
|
+
emails.push(summary);
|
|
393
|
+
this.messageCache.set(summary.id, summary);
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
folder,
|
|
397
|
+
total,
|
|
398
|
+
limit,
|
|
399
|
+
offset,
|
|
400
|
+
emails: sortEmailsByNewest(emails),
|
|
401
|
+
};
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async getEmailById(emailId) {
|
|
405
|
+
const { folder, uid } = parseEmailId(emailId);
|
|
406
|
+
return this.withMailbox(folder, true, async (client) => {
|
|
407
|
+
const message = await client.fetchOne(String(uid), FETCH_DETAIL_QUERY, { uid: true });
|
|
408
|
+
if (!message) {
|
|
409
|
+
throw new Error(`Email not found for id ${emailId}`);
|
|
410
|
+
}
|
|
411
|
+
const summary = this.toSummary(folder, message);
|
|
412
|
+
const parsed = message.source ? await this.parseSource(message.source) : undefined;
|
|
413
|
+
const enriched = parsed ? this.enrichSummaryFromParsed(summary, parsed, true) : summary;
|
|
414
|
+
const detail = {
|
|
415
|
+
...enriched,
|
|
416
|
+
text: parsed?.text || stripHtmlToText(typeof parsed?.html === "string" ? parsed.html : undefined),
|
|
417
|
+
html: parsed?.html,
|
|
418
|
+
headers: this.mapHeaders(parsed),
|
|
419
|
+
};
|
|
420
|
+
this.messageCache.set(detail.id, detail);
|
|
421
|
+
return detail;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
async searchEmails(input = {}) {
|
|
425
|
+
const limit = normalizeLimit(input.limit, 100);
|
|
426
|
+
const folders = await this.resolveFolders(input.folder);
|
|
427
|
+
const searchQuery = this.buildSearchQuery(input);
|
|
428
|
+
const collected = [];
|
|
429
|
+
for (const folder of folders) {
|
|
430
|
+
const emails = await this.withMailbox(folder, true, async (client) => {
|
|
431
|
+
const searchResult = await client.search(searchQuery, { uid: true });
|
|
432
|
+
const uids = searchResult || [];
|
|
433
|
+
if (uids.length === 0) {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
const targetUids = [...uids].slice(-limit).reverse();
|
|
437
|
+
const results = [];
|
|
438
|
+
for await (const message of client.fetch(targetUids, FETCH_SUMMARY_QUERY, { uid: true })) {
|
|
439
|
+
const summary = this.toSummary(folder, message);
|
|
440
|
+
results.push(summary);
|
|
441
|
+
this.messageCache.set(summary.id, summary);
|
|
442
|
+
}
|
|
443
|
+
return results.filter((email) => matchesLocalSearchFilters(email, input));
|
|
444
|
+
});
|
|
445
|
+
collected.push(...emails);
|
|
446
|
+
}
|
|
447
|
+
const sorted = sortEmailsByNewest(dedupeEmails(collected)).slice(0, limit);
|
|
448
|
+
return {
|
|
449
|
+
folders,
|
|
450
|
+
limit,
|
|
451
|
+
total: sorted.length,
|
|
452
|
+
emails: sorted,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async listAttachments(emailId) {
|
|
456
|
+
const detail = await this.getEmailById(emailId);
|
|
457
|
+
return {
|
|
458
|
+
emailId,
|
|
459
|
+
attachments: detail.attachments,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
async saveAttachments(input) {
|
|
463
|
+
const attachmentList = await this.listAttachments(input.emailId);
|
|
464
|
+
const saved = [];
|
|
465
|
+
let skipped = 0;
|
|
466
|
+
for (const attachment of attachmentList.attachments) {
|
|
467
|
+
if (!input.includeInline && attachment.isInline) {
|
|
468
|
+
skipped += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (input.filenameContains &&
|
|
472
|
+
!(attachment.filename || "").toLowerCase().includes(input.filenameContains.toLowerCase())) {
|
|
473
|
+
skipped += 1;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (input.contentType &&
|
|
477
|
+
(attachment.contentType || "").toLowerCase() !== input.contentType.toLowerCase()) {
|
|
478
|
+
skipped += 1;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const attachmentId = attachment.id || attachment.filename || attachment.checksum;
|
|
482
|
+
if (!attachmentId) {
|
|
483
|
+
skipped += 1;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const targetPath = input.outputPath && attachment.filename
|
|
487
|
+
? join(resolve(input.outputPath), sanitizeFileName(attachment.filename, attachmentId))
|
|
488
|
+
: input.outputPath;
|
|
489
|
+
saved.push(await this.saveAttachment(input.emailId, attachmentId, targetPath));
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
emailId: input.emailId,
|
|
493
|
+
saved,
|
|
494
|
+
skipped,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async getAttachmentContent(emailId, attachmentId, includeBase64 = false) {
|
|
498
|
+
const attachment = await this.getParsedAttachment(emailId, attachmentId);
|
|
499
|
+
const base64 = attachment.content.toString("base64");
|
|
500
|
+
return {
|
|
501
|
+
emailId,
|
|
502
|
+
attachment: {
|
|
503
|
+
id: attachment.id,
|
|
504
|
+
filename: attachment.filename,
|
|
505
|
+
contentType: attachment.contentType,
|
|
506
|
+
size: attachment.size,
|
|
507
|
+
disposition: attachment.disposition,
|
|
508
|
+
cid: attachment.cid,
|
|
509
|
+
checksum: attachment.checksum,
|
|
510
|
+
isInline: attachment.isInline,
|
|
511
|
+
kind: attachment.kind,
|
|
512
|
+
isCalendarInvite: attachment.isCalendarInvite,
|
|
513
|
+
isSignature: attachment.isSignature,
|
|
514
|
+
},
|
|
515
|
+
text: attachment.content.length <= MAX_ATTACHMENT_TEXT_BYTES
|
|
516
|
+
? attachment.contentType?.toLowerCase() === "text/html"
|
|
517
|
+
? stripHtmlToText(attachment.content.toString("utf8"))
|
|
518
|
+
: attachment.contentType?.toLowerCase() === "text/calendar"
|
|
519
|
+
? summarizeCalendarText(attachment.content.toString("utf8"))
|
|
520
|
+
: isTextLikeMimeType(attachment.contentType)
|
|
521
|
+
? attachment.content.toString("utf8")
|
|
522
|
+
: undefined
|
|
523
|
+
: undefined,
|
|
524
|
+
base64: includeBase64 ? base64 : undefined,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async saveAttachment(emailId, attachmentId, outputPath) {
|
|
528
|
+
const attachment = await this.getParsedAttachment(emailId, attachmentId);
|
|
529
|
+
const path = await this.resolveAttachmentOutputPath(emailId, attachment, outputPath);
|
|
530
|
+
await mkdir(dirname(path), { recursive: true });
|
|
531
|
+
await writeFile(path, attachment.content);
|
|
532
|
+
return {
|
|
533
|
+
emailId,
|
|
534
|
+
attachment: {
|
|
535
|
+
id: attachment.id,
|
|
536
|
+
filename: attachment.filename,
|
|
537
|
+
contentType: attachment.contentType,
|
|
538
|
+
size: attachment.size,
|
|
539
|
+
disposition: attachment.disposition,
|
|
540
|
+
cid: attachment.cid,
|
|
541
|
+
checksum: attachment.checksum,
|
|
542
|
+
isInline: attachment.isInline,
|
|
543
|
+
kind: attachment.kind,
|
|
544
|
+
isCalendarInvite: attachment.isCalendarInvite,
|
|
545
|
+
isSignature: attachment.isSignature,
|
|
546
|
+
},
|
|
547
|
+
outputPath: path,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
async markEmailRead(emailId, isRead = true) {
|
|
551
|
+
const { folder, uid } = parseEmailId(emailId);
|
|
552
|
+
await this.withMailbox(folder, false, async (client) => {
|
|
553
|
+
if (isRead) {
|
|
554
|
+
await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true });
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
await client.messageFlagsRemove(String(uid), ["\\Seen"], { uid: true });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
this.updateCachedMessage(emailId, (email) => ({ ...email, isRead }));
|
|
561
|
+
return { emailId, folder, uid, isRead };
|
|
562
|
+
}
|
|
563
|
+
async starEmail(emailId, isStarred = true) {
|
|
564
|
+
const { folder, uid } = parseEmailId(emailId);
|
|
565
|
+
await this.withMailbox(folder, false, async (client) => {
|
|
566
|
+
if (isStarred) {
|
|
567
|
+
await client.messageFlagsAdd(String(uid), ["\\Flagged"], { uid: true });
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
await client.messageFlagsRemove(String(uid), ["\\Flagged"], { uid: true });
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
this.updateCachedMessage(emailId, (email) => ({ ...email, isStarred }));
|
|
574
|
+
return { emailId, folder, uid, isStarred };
|
|
575
|
+
}
|
|
576
|
+
async moveEmail(emailId, targetFolder) {
|
|
577
|
+
const { folder, uid } = parseEmailId(emailId);
|
|
578
|
+
let targetUid;
|
|
579
|
+
await this.withMailbox(folder, false, async (client) => {
|
|
580
|
+
const moved = await client.messageMove(String(uid), targetFolder, { uid: true });
|
|
581
|
+
if (moved === false) {
|
|
582
|
+
throw new Error(`Server did not move email ${emailId} to ${targetFolder}`);
|
|
583
|
+
}
|
|
584
|
+
targetUid = moved.uidMap?.get(uid);
|
|
585
|
+
});
|
|
586
|
+
const cached = this.messageCache.get(emailId);
|
|
587
|
+
this.messageCache.delete(emailId);
|
|
588
|
+
const targetEmailId = targetUid ? createEmailId(targetFolder, targetUid) : undefined;
|
|
589
|
+
if (cached && targetUid && targetEmailId) {
|
|
590
|
+
this.messageCache.set(targetEmailId, {
|
|
591
|
+
...cached,
|
|
592
|
+
id: targetEmailId,
|
|
593
|
+
folder: targetFolder,
|
|
594
|
+
uid: targetUid,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
this.lastSyncAt = new Date().toISOString();
|
|
598
|
+
return {
|
|
599
|
+
emailId,
|
|
600
|
+
sourceEmailId: emailId,
|
|
601
|
+
fromFolder: folder,
|
|
602
|
+
targetFolder,
|
|
603
|
+
uid,
|
|
604
|
+
targetUid,
|
|
605
|
+
targetEmailId,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
async deleteEmail(emailId) {
|
|
609
|
+
const { folder, uid } = parseEmailId(emailId);
|
|
610
|
+
await this.withMailbox(folder, false, async (client) => {
|
|
611
|
+
const deleted = await client.messageDelete(String(uid), { uid: true });
|
|
612
|
+
if (!deleted) {
|
|
613
|
+
throw new Error(`Server did not delete email ${emailId}`);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
this.messageCache.delete(emailId);
|
|
617
|
+
this.lastSyncAt = new Date().toISOString();
|
|
618
|
+
return {
|
|
619
|
+
emailId,
|
|
620
|
+
folder,
|
|
621
|
+
uid,
|
|
622
|
+
deleted: true,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
async syncEmails(input = {}) {
|
|
626
|
+
const snapshot = await this.collectEmailsForIndex(input);
|
|
627
|
+
return {
|
|
628
|
+
syncedAt: snapshot.syncedAt,
|
|
629
|
+
full: snapshot.full,
|
|
630
|
+
folders: snapshot.folderStats,
|
|
631
|
+
cachedMessages: this.messageCache.size,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async collectEmailsForIndex(input = {}) {
|
|
635
|
+
const folders = await this.resolveFolders(input.folder);
|
|
636
|
+
const full = Boolean(input.full);
|
|
637
|
+
const limit = normalizeLimit(input.limitPerFolder, full ? 250 : 50, 1, 500);
|
|
638
|
+
const includeAttachmentText = input.includeAttachmentText !== false;
|
|
639
|
+
const folderStats = [];
|
|
640
|
+
const emails = [];
|
|
641
|
+
const syncedAt = new Date().toISOString();
|
|
642
|
+
for (const folder of folders) {
|
|
643
|
+
const batch = await this.collectFolderForIndex(folder, {
|
|
644
|
+
full,
|
|
645
|
+
limit,
|
|
646
|
+
includeAttachmentText,
|
|
647
|
+
checkpoint: input.checkpoints?.[folder],
|
|
648
|
+
syncedAt,
|
|
649
|
+
});
|
|
650
|
+
folderStats.push(batch.checkpoint);
|
|
651
|
+
emails.push(...batch.emails);
|
|
652
|
+
}
|
|
653
|
+
this.lastSyncAt = syncedAt;
|
|
654
|
+
return {
|
|
655
|
+
syncedAt,
|
|
656
|
+
full,
|
|
657
|
+
folders: await this.getFolders(true),
|
|
658
|
+
folderStats,
|
|
659
|
+
emails,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
async collectFolderForIndex(folder, input) {
|
|
663
|
+
return this.withMailbox(folder, true, async (client) => {
|
|
664
|
+
const mailbox = client.mailbox || undefined;
|
|
665
|
+
const uidNext = mailbox?.uidNext;
|
|
666
|
+
const uidValidity = mailbox?.uidValidity?.toString();
|
|
667
|
+
const exists = mailbox?.exists ?? 0;
|
|
668
|
+
const plan = planFolderSync({
|
|
669
|
+
folder,
|
|
670
|
+
exists,
|
|
671
|
+
uidNext,
|
|
672
|
+
uidValidity,
|
|
673
|
+
full: input.full,
|
|
674
|
+
limit: input.limit,
|
|
675
|
+
checkpoint: input.checkpoint,
|
|
676
|
+
});
|
|
677
|
+
if (!plan.startUid || !plan.endUid || plan.endUid < plan.startUid) {
|
|
678
|
+
return {
|
|
679
|
+
checkpoint: {
|
|
680
|
+
folder,
|
|
681
|
+
uidValidity,
|
|
682
|
+
uidNext,
|
|
683
|
+
highestUid: plan.highestKnownUid,
|
|
684
|
+
lastSyncAt: input.syncedAt,
|
|
685
|
+
lastFullSyncAt: plan.strategy === "full"
|
|
686
|
+
? input.syncedAt
|
|
687
|
+
: input.checkpoint?.lastFullSyncAt,
|
|
688
|
+
strategy: plan.strategy,
|
|
689
|
+
changed: plan.changed,
|
|
690
|
+
fetched: 0,
|
|
691
|
+
total: exists,
|
|
692
|
+
},
|
|
693
|
+
emails: [],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const emails = [];
|
|
697
|
+
for await (const message of client.fetch(`${plan.startUid}:${plan.endUid}`, FETCH_DETAIL_QUERY, { uid: true })) {
|
|
698
|
+
const summary = this.toSummary(folder, message);
|
|
699
|
+
if (!message.source) {
|
|
700
|
+
emails.push(summary);
|
|
701
|
+
this.messageCache.set(summary.id, summary);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
const parsed = await this.parseSource(message.source);
|
|
706
|
+
const enriched = this.enrichSummaryFromParsed(summary, parsed, input.includeAttachmentText);
|
|
707
|
+
emails.push(enriched);
|
|
708
|
+
this.messageCache.set(enriched.id, enriched);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
this.log.warn("Failed to parse message source during indexing", "IMAPService", {
|
|
712
|
+
folder,
|
|
713
|
+
uid: message.uid,
|
|
714
|
+
error,
|
|
715
|
+
});
|
|
716
|
+
emails.push(summary);
|
|
717
|
+
this.messageCache.set(summary.id, summary);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const highestUid = emails.reduce((max, email) => Math.max(max, email.uid), 0) || plan.highestKnownUid;
|
|
721
|
+
return {
|
|
722
|
+
checkpoint: {
|
|
723
|
+
folder,
|
|
724
|
+
uidValidity,
|
|
725
|
+
uidNext,
|
|
726
|
+
highestUid,
|
|
727
|
+
lastSyncAt: input.syncedAt,
|
|
728
|
+
lastFullSyncAt: plan.strategy === "full"
|
|
729
|
+
? input.syncedAt
|
|
730
|
+
: input.checkpoint?.lastFullSyncAt,
|
|
731
|
+
strategy: plan.strategy,
|
|
732
|
+
changed: plan.changed,
|
|
733
|
+
fetched: emails.length,
|
|
734
|
+
total: exists,
|
|
735
|
+
},
|
|
736
|
+
emails,
|
|
737
|
+
};
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
async archiveEmail(emailId) {
|
|
741
|
+
const targetFolder = await this.resolveSpecialFolder("\\Archive", ["Archive", "All Mail"]);
|
|
742
|
+
return this.moveEmail(emailId, targetFolder);
|
|
743
|
+
}
|
|
744
|
+
async trashEmail(emailId) {
|
|
745
|
+
const targetFolder = await this.resolveSpecialFolder("\\Trash", ["Trash"]);
|
|
746
|
+
return this.moveEmail(emailId, targetFolder);
|
|
747
|
+
}
|
|
748
|
+
async restoreEmail(emailId, targetFolder) {
|
|
749
|
+
const destination = targetFolder?.trim() || (await this.resolveSpecialFolder("\\Inbox", ["INBOX"]));
|
|
750
|
+
return this.moveEmail(emailId, destination);
|
|
751
|
+
}
|
|
752
|
+
async getAnalyticsSample(days = 30, limitPerFolder = 100) {
|
|
753
|
+
const dateFrom = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
754
|
+
const folders = await this.resolveFolders();
|
|
755
|
+
const emails = [];
|
|
756
|
+
for (const folder of folders) {
|
|
757
|
+
const result = await this.searchEmails({
|
|
758
|
+
folder,
|
|
759
|
+
dateFrom,
|
|
760
|
+
limit: limitPerFolder,
|
|
761
|
+
});
|
|
762
|
+
emails.push(...result.emails);
|
|
763
|
+
}
|
|
764
|
+
return sortEmailsByNewest(dedupeEmails(emails));
|
|
765
|
+
}
|
|
766
|
+
clearCache() {
|
|
767
|
+
const clearedMessages = this.messageCache.size;
|
|
768
|
+
const clearedFolders = Boolean(this.folderCache);
|
|
769
|
+
this.messageCache.clear();
|
|
770
|
+
this.folderCache = undefined;
|
|
771
|
+
this.lastSyncAt = undefined;
|
|
772
|
+
return { clearedMessages, clearedFolders };
|
|
773
|
+
}
|
|
774
|
+
async listRemoteDrafts(limit = 50, offset = 0) {
|
|
775
|
+
const folder = await this.resolveSpecialFolder("\\Drafts", ["Drafts"]);
|
|
776
|
+
return this.getEmails({ folder, limit, offset });
|
|
777
|
+
}
|
|
778
|
+
async upsertRemoteDraft(input) {
|
|
779
|
+
const folder = await this.resolveSpecialFolder("\\Drafts", ["Drafts"]);
|
|
780
|
+
if (input.existingEmailId) {
|
|
781
|
+
try {
|
|
782
|
+
await this.deleteEmail(input.existingEmailId);
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
this.log.warn("Failed to delete previous remote draft before re-sync", "IMAPService", {
|
|
786
|
+
existingEmailId: input.existingEmailId,
|
|
787
|
+
error,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const client = await this.ensureConnected();
|
|
792
|
+
const appended = await client.append(folder, input.raw, ["\\Draft"], new Date());
|
|
793
|
+
if (!appended) {
|
|
794
|
+
throw new Error("Server did not append the draft message");
|
|
795
|
+
}
|
|
796
|
+
let uid = appended.uid;
|
|
797
|
+
if (!uid) {
|
|
798
|
+
uid = await this.findUidByHeader(folder, "message-id", input.messageId);
|
|
799
|
+
}
|
|
800
|
+
this.folderCache = undefined;
|
|
801
|
+
this.lastSyncAt = new Date().toISOString();
|
|
802
|
+
return {
|
|
803
|
+
folder,
|
|
804
|
+
uid,
|
|
805
|
+
emailId: uid ? createEmailId(folder, uid) : undefined,
|
|
806
|
+
messageId: input.messageId,
|
|
807
|
+
syncedAt: this.lastSyncAt,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
async deleteRemoteDraft(emailId) {
|
|
811
|
+
return this.deleteEmail(emailId);
|
|
812
|
+
}
|
|
813
|
+
async ensureConnected() {
|
|
814
|
+
if (!this.client?.usable) {
|
|
815
|
+
await this.connect();
|
|
816
|
+
}
|
|
817
|
+
if (!this.client) {
|
|
818
|
+
throw new Error("Failed to initialize IMAP client");
|
|
819
|
+
}
|
|
820
|
+
return this.client;
|
|
821
|
+
}
|
|
822
|
+
shouldRelaxTlsVerification() {
|
|
823
|
+
const host = this.config.imap.host.trim().toLowerCase();
|
|
824
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
825
|
+
}
|
|
826
|
+
async withMailbox(folder, readOnly, action) {
|
|
827
|
+
const client = await this.ensureConnected();
|
|
828
|
+
const lock = await client.getMailboxLock(folder, { readOnly });
|
|
829
|
+
try {
|
|
830
|
+
return await action(client);
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
lock.release();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
buildSearchQuery(input) {
|
|
837
|
+
const query = {};
|
|
838
|
+
if (input.query) {
|
|
839
|
+
query.text = input.query;
|
|
840
|
+
}
|
|
841
|
+
if (input.from) {
|
|
842
|
+
query.from = input.from;
|
|
843
|
+
}
|
|
844
|
+
if (input.to) {
|
|
845
|
+
query.to = input.to;
|
|
846
|
+
}
|
|
847
|
+
if (input.subject) {
|
|
848
|
+
query.subject = input.subject;
|
|
849
|
+
}
|
|
850
|
+
if (typeof input.isRead === "boolean") {
|
|
851
|
+
query.seen = input.isRead;
|
|
852
|
+
}
|
|
853
|
+
if (typeof input.isStarred === "boolean") {
|
|
854
|
+
query.flagged = input.isStarred;
|
|
855
|
+
}
|
|
856
|
+
const dateFrom = parseDateInput(input.dateFrom);
|
|
857
|
+
const dateTo = parseDateInput(input.dateTo);
|
|
858
|
+
if (dateFrom) {
|
|
859
|
+
query.since = dateFrom;
|
|
860
|
+
}
|
|
861
|
+
if (dateTo) {
|
|
862
|
+
query.before = nextDay(dateTo);
|
|
863
|
+
}
|
|
864
|
+
if (Object.keys(query).length === 0) {
|
|
865
|
+
query.all = true;
|
|
866
|
+
}
|
|
867
|
+
return query;
|
|
868
|
+
}
|
|
869
|
+
async resolveFolders(folder) {
|
|
870
|
+
if (folder?.trim()) {
|
|
871
|
+
return [folder.trim()];
|
|
872
|
+
}
|
|
873
|
+
const folders = await this.getFolders();
|
|
874
|
+
return folders
|
|
875
|
+
.filter((entry) => !entry.flags.includes("\\Noselect"))
|
|
876
|
+
.map((entry) => entry.path);
|
|
877
|
+
}
|
|
878
|
+
async resolveSpecialFolder(specialUse, fallbacks) {
|
|
879
|
+
const folders = await this.getFolders();
|
|
880
|
+
const bySpecialUse = folders.find((folder) => folder.specialUse === specialUse);
|
|
881
|
+
if (bySpecialUse) {
|
|
882
|
+
return bySpecialUse.path;
|
|
883
|
+
}
|
|
884
|
+
const byFallback = folders.find((folder) => fallbacks.some((fallback) => folder.path.toLowerCase() === fallback.toLowerCase()));
|
|
885
|
+
if (byFallback) {
|
|
886
|
+
return byFallback.path;
|
|
887
|
+
}
|
|
888
|
+
throw new Error(`Unable to find target folder for ${specialUse}`);
|
|
889
|
+
}
|
|
890
|
+
async findUidByHeader(folder, header, value) {
|
|
891
|
+
return this.withMailbox(folder, true, async (client) => {
|
|
892
|
+
const result = await client.search({
|
|
893
|
+
header: {
|
|
894
|
+
[header]: value,
|
|
895
|
+
},
|
|
896
|
+
}, { uid: true });
|
|
897
|
+
if (!result || result.length === 0) {
|
|
898
|
+
return undefined;
|
|
899
|
+
}
|
|
900
|
+
return [...result].sort((left, right) => right - left)[0];
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
toSummary(folder, message) {
|
|
904
|
+
const flags = [...(message.flags ?? [])];
|
|
905
|
+
const attachments = extractAttachments(message.bodyStructure);
|
|
906
|
+
return {
|
|
907
|
+
id: createEmailId(folder, message.uid),
|
|
908
|
+
folder,
|
|
909
|
+
uid: message.uid,
|
|
910
|
+
seq: message.seq,
|
|
911
|
+
messageId: message.envelope?.messageId,
|
|
912
|
+
inReplyTo: message.envelope?.inReplyTo,
|
|
913
|
+
references: [],
|
|
914
|
+
threadId: message.threadId,
|
|
915
|
+
subject: message.envelope?.subject || "(no subject)",
|
|
916
|
+
from: mapEnvelopeAddresses(message.envelope?.from),
|
|
917
|
+
to: mapEnvelopeAddresses(message.envelope?.to),
|
|
918
|
+
cc: mapEnvelopeAddresses(message.envelope?.cc),
|
|
919
|
+
bcc: mapEnvelopeAddresses(message.envelope?.bcc),
|
|
920
|
+
replyTo: mapEnvelopeAddresses(message.envelope?.replyTo),
|
|
921
|
+
date: message.envelope?.date?.toISOString(),
|
|
922
|
+
internalDate: message.internalDate instanceof Date
|
|
923
|
+
? message.internalDate.toISOString()
|
|
924
|
+
: message.internalDate,
|
|
925
|
+
isRead: flags.includes("\\Seen"),
|
|
926
|
+
isStarred: flags.includes("\\Flagged"),
|
|
927
|
+
flags,
|
|
928
|
+
size: message.size,
|
|
929
|
+
preview: undefined,
|
|
930
|
+
hasAttachments: attachments.length > 0,
|
|
931
|
+
attachments,
|
|
932
|
+
attachmentText: undefined,
|
|
933
|
+
labels: [...(message.labels ?? [])],
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
enrichSummaryFromParsed(summary, parsed, includeAttachmentText) {
|
|
937
|
+
const parsedAttachments = this.mapParsedAttachments(parsed);
|
|
938
|
+
const htmlText = stripHtmlToText(typeof parsed.html === "string" ? parsed.html : undefined);
|
|
939
|
+
const references = extractMessageIdList(this.readHeaderValue(parsed, "references"));
|
|
940
|
+
return {
|
|
941
|
+
...summary,
|
|
942
|
+
subject: parsed.subject || summary.subject,
|
|
943
|
+
from: parsed.from ? mapParsedAddresses(parsed.from) : summary.from,
|
|
944
|
+
to: parsed.to ? mapParsedAddresses(parsed.to) : summary.to,
|
|
945
|
+
cc: parsed.cc ? mapParsedAddresses(parsed.cc) : summary.cc,
|
|
946
|
+
bcc: parsed.bcc ? mapParsedAddresses(parsed.bcc) : summary.bcc,
|
|
947
|
+
replyTo: parsed.replyTo ? mapParsedAddresses(parsed.replyTo) : summary.replyTo,
|
|
948
|
+
preview: previewText(parsed.text || htmlText || summary.preview),
|
|
949
|
+
references,
|
|
950
|
+
attachments: parsedAttachments.length > 0 && parsedAttachments.length >= summary.attachments.length
|
|
951
|
+
? parsedAttachments
|
|
952
|
+
: summary.attachments,
|
|
953
|
+
hasAttachments: summary.hasAttachments || parsedAttachments.length > 0,
|
|
954
|
+
attachmentText: includeAttachmentText ? this.extractAttachmentSearchText(parsed) : undefined,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
async parseSource(source) {
|
|
958
|
+
return simpleParser(source);
|
|
959
|
+
}
|
|
960
|
+
readHeaderValue(parsed, headerName) {
|
|
961
|
+
if (!parsed) {
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
964
|
+
const value = parsed.headers.get(headerName);
|
|
965
|
+
if (Array.isArray(value)) {
|
|
966
|
+
return value.map((entry) => String(entry)).join(" ");
|
|
967
|
+
}
|
|
968
|
+
if (value === undefined || value === null) {
|
|
969
|
+
return undefined;
|
|
970
|
+
}
|
|
971
|
+
return String(value);
|
|
972
|
+
}
|
|
973
|
+
extractAttachmentSearchText(parsed) {
|
|
974
|
+
const parts = (parsed.attachments ?? [])
|
|
975
|
+
.flatMap((attachment) => {
|
|
976
|
+
if (attachment.content.length > MAX_ATTACHMENT_TEXT_BYTES) {
|
|
977
|
+
return [];
|
|
978
|
+
}
|
|
979
|
+
const contentType = attachment.contentType?.toLowerCase();
|
|
980
|
+
if (contentType === "text/html") {
|
|
981
|
+
return [stripHtmlToText(attachment.content.toString("utf8")) || ""];
|
|
982
|
+
}
|
|
983
|
+
if (contentType === "text/calendar") {
|
|
984
|
+
return [summarizeCalendarText(attachment.content.toString("utf8")) || ""];
|
|
985
|
+
}
|
|
986
|
+
if (isTextLikeMimeType(contentType)) {
|
|
987
|
+
return [previewText(attachment.content.toString("utf8"), 8_000) || ""];
|
|
988
|
+
}
|
|
989
|
+
return [];
|
|
990
|
+
})
|
|
991
|
+
.filter(Boolean);
|
|
992
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
993
|
+
}
|
|
994
|
+
mapParsedAttachments(parsed) {
|
|
995
|
+
return (parsed?.attachments ?? []).map((attachment, index) => {
|
|
996
|
+
const classification = classifyAttachment({
|
|
997
|
+
filename: attachment.filename,
|
|
998
|
+
contentType: attachment.contentType,
|
|
999
|
+
disposition: attachment.contentDisposition,
|
|
1000
|
+
cid: attachment.cid,
|
|
1001
|
+
});
|
|
1002
|
+
return {
|
|
1003
|
+
id: createParsedAttachmentId(attachment, index),
|
|
1004
|
+
filename: attachment.filename,
|
|
1005
|
+
contentType: attachment.contentType,
|
|
1006
|
+
size: attachment.size,
|
|
1007
|
+
disposition: attachment.contentDisposition,
|
|
1008
|
+
cid: attachment.cid,
|
|
1009
|
+
checksum: attachment.checksum,
|
|
1010
|
+
isInline: attachment.contentDisposition === "inline",
|
|
1011
|
+
kind: classification.kind,
|
|
1012
|
+
isCalendarInvite: classification.isCalendarInvite,
|
|
1013
|
+
isSignature: classification.isSignature,
|
|
1014
|
+
};
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
mapHeaders(parsed) {
|
|
1018
|
+
if (!parsed) {
|
|
1019
|
+
return {};
|
|
1020
|
+
}
|
|
1021
|
+
return Object.fromEntries([...parsed.headers.entries()].map(([key, value]) => {
|
|
1022
|
+
if (Array.isArray(value)) {
|
|
1023
|
+
return [key, value.map((item) => String(item))];
|
|
1024
|
+
}
|
|
1025
|
+
return [key, String(value)];
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
updateCachedMessage(emailId, updater) {
|
|
1029
|
+
const cached = this.messageCache.get(emailId);
|
|
1030
|
+
if (!cached) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this.messageCache.set(emailId, updater(cached));
|
|
1034
|
+
}
|
|
1035
|
+
async getParsedAttachment(emailId, attachmentId) {
|
|
1036
|
+
const detail = await this.getEmailById(emailId);
|
|
1037
|
+
const parsed = await this.loadParsedMail(emailId);
|
|
1038
|
+
const attachments = (parsed.attachments ?? []).map((attachment, index) => ({
|
|
1039
|
+
id: createParsedAttachmentId(attachment, index),
|
|
1040
|
+
filename: attachment.filename,
|
|
1041
|
+
contentType: attachment.contentType,
|
|
1042
|
+
size: attachment.size,
|
|
1043
|
+
disposition: attachment.contentDisposition,
|
|
1044
|
+
cid: attachment.cid,
|
|
1045
|
+
checksum: attachment.checksum,
|
|
1046
|
+
isInline: attachment.contentDisposition === "inline",
|
|
1047
|
+
...classifyAttachment({
|
|
1048
|
+
filename: attachment.filename,
|
|
1049
|
+
contentType: attachment.contentType,
|
|
1050
|
+
disposition: attachment.contentDisposition,
|
|
1051
|
+
cid: attachment.cid,
|
|
1052
|
+
}),
|
|
1053
|
+
content: attachment.content,
|
|
1054
|
+
}));
|
|
1055
|
+
const match = attachments.find((attachment) => attachment.id === attachmentId ||
|
|
1056
|
+
attachment.filename === attachmentId ||
|
|
1057
|
+
attachment.checksum === attachmentId);
|
|
1058
|
+
if (!match) {
|
|
1059
|
+
throw new Error(`Attachment ${attachmentId} not found on email ${detail.id}`);
|
|
1060
|
+
}
|
|
1061
|
+
return match;
|
|
1062
|
+
}
|
|
1063
|
+
async loadParsedMail(emailId) {
|
|
1064
|
+
const { folder, uid } = parseEmailId(emailId);
|
|
1065
|
+
return this.withMailbox(folder, true, async (client) => {
|
|
1066
|
+
const message = await client.fetchOne(String(uid), { source: true }, { uid: true });
|
|
1067
|
+
if (!message || !message.source) {
|
|
1068
|
+
throw new Error(`Email not found for id ${emailId}`);
|
|
1069
|
+
}
|
|
1070
|
+
const source = message.source;
|
|
1071
|
+
return this.parseSource(source);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
async resolveAttachmentOutputPath(emailId, attachment, outputPath) {
|
|
1075
|
+
const filename = sanitizeFileName(attachment.filename, attachment.id || "attachment");
|
|
1076
|
+
if (!outputPath) {
|
|
1077
|
+
return join(this.config.dataDir, "attachments", encodeURIComponent(emailId), filename);
|
|
1078
|
+
}
|
|
1079
|
+
const resolved = resolve(outputPath);
|
|
1080
|
+
try {
|
|
1081
|
+
const existing = await stat(resolved);
|
|
1082
|
+
if (existing.isDirectory()) {
|
|
1083
|
+
return join(resolved, filename);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
if (error &&
|
|
1088
|
+
typeof error === "object" &&
|
|
1089
|
+
"code" in error &&
|
|
1090
|
+
error.code !== "ENOENT") {
|
|
1091
|
+
throw error;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (resolved.endsWith("/") || resolved.endsWith("\\")) {
|
|
1095
|
+
return join(resolved, filename);
|
|
1096
|
+
}
|
|
1097
|
+
return resolved;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
//# sourceMappingURL=simple-imap-service.js.map
|