proton-mail-bridge-client 2.3.1 → 2.4.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/README.md +8 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +348 -202
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -143
- package/dist/index.js.map +1 -1
- package/dist/scripts/check-claude-desktop.d.ts +18 -0
- package/dist/scripts/check-claude-desktop.d.ts.map +1 -1
- package/dist/scripts/check-claude-desktop.js +91 -30
- package/dist/scripts/check-claude-desktop.js.map +1 -1
- package/dist/scripts/install-claude-desktop.d.ts +12 -0
- package/dist/scripts/install-claude-desktop.d.ts.map +1 -1
- package/dist/scripts/install-claude-desktop.js +211 -43
- package/dist/scripts/install-claude-desktop.js.map +1 -1
- package/dist/services/analytics-service.d.ts +1 -1
- package/dist/services/analytics-service.d.ts.map +1 -1
- package/dist/services/analytics-service.js +2 -2
- package/dist/services/analytics-service.js.map +1 -1
- package/dist/services/audit-service.d.ts.map +1 -1
- package/dist/services/audit-service.js +7 -2
- package/dist/services/audit-service.js.map +1 -1
- package/dist/services/background-sync-service.d.ts.map +1 -1
- package/dist/services/background-sync-service.js +10 -2
- package/dist/services/background-sync-service.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts +4 -1
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +21 -27
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +13 -25
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +2 -1
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +91 -33
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +22 -2
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +295 -107
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts.map +1 -1
- package/dist/services/smtp-service.js +24 -2
- package/dist/services/smtp-service.js.map +1 -1
- package/dist/services/snooze-service.d.ts.map +1 -1
- package/dist/services/snooze-service.js +43 -30
- package/dist/services/snooze-service.js.map +1 -1
- package/dist/services/template-service.d.ts +4 -2
- package/dist/services/template-service.d.ts.map +1 -1
- package/dist/services/template-service.js +46 -20
- package/dist/services/template-service.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/atomic-write.d.ts +2 -0
- package/dist/utils/atomic-write.d.ts.map +1 -0
- package/dist/utils/atomic-write.js +36 -0
- package/dist/utils/atomic-write.js.map +1 -0
- package/dist/utils/corrupt-store.d.ts +4 -0
- package/dist/utils/corrupt-store.d.ts.map +1 -0
- package/dist/utils/corrupt-store.js +29 -0
- package/dist/utils/corrupt-store.js.map +1 -0
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +39 -4
- package/dist/utils/file-lock.js.map +1 -1
- package/dist/utils/helpers.d.ts +14 -0
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +281 -27
- package/dist/utils/helpers.js.map +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +29 -12
- package/dist/utils/logger.js.map +1 -1
- package/package.json +3 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
2
|
import { mkdir, open, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, join,
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { ImapFlow } from "imapflow";
|
|
5
5
|
import { simpleParser } from "mailparser";
|
|
6
|
-
import { classifyAttachment, createEmailId, dedupeEmails, extractAttachments, extractMessageIdList, isSelfAddress, isTextLikeMimeType, mapEnvelopeAddresses, mapParsedAddresses, labelMatchesFolder, matchesLocalSearchFilters, matchesNonAsciiCriteria, splitNonAsciiCriteria, nextDay, normalizeLimit, parseDateInput, htmlToMarkdown, redactInlineData, parseEmailId, previewText, sanitizeFileName, sortEmailsByNewest, stripHtmlToText, summarizeCalendarText, } from "../utils/helpers.js";
|
|
6
|
+
import { classifyAttachment, createEmailId, dedupeEmails, extractAttachments, extractMessageIdList, isSelfAddress, isTextLikeMimeType, mapEnvelopeAddresses, mapParsedAddresses, labelMatchesFolder, matchesLocalSearchFilters, matchesNonAsciiCriteria, splitNonAsciiCriteria, nextDay, InvalidArgumentError, decodeAttachmentText, isPathInside, normalizeLimit, optionalBoolean, optionalNumber, parseDateInput, htmlToMarkdown, redactInlineData, parseEmailId, previewText, sanitizeFileName, sortEmailsByNewest, stripHtmlToText, summarizeCalendarText, } from "../utils/helpers.js";
|
|
7
7
|
import { logger } from "../utils/logger.js";
|
|
8
8
|
import { ensureAccountIdentityMatches } from "../utils/account-identity.js";
|
|
9
9
|
const FETCH_SUMMARY_QUERY = {
|
|
@@ -61,6 +61,9 @@ const MAX_ATTACHMENT_TEXT_BYTES = 512_000;
|
|
|
61
61
|
// per candidate.
|
|
62
62
|
// Newest candidates examined when a non-ASCII criterion has to be verified locally.
|
|
63
63
|
const NON_ASCII_SCAN_CAP = 500;
|
|
64
|
+
// A count with a local-only filter (hasAttachment, senderDomain...) must fetch every candidate, because the
|
|
65
|
+
// server cannot evaluate it. Beyond this many, only the newest are checked and the count is marked approximate.
|
|
66
|
+
const LOCAL_FILTER_COUNT_SCAN_CAP = 5000;
|
|
64
67
|
export const SEARCH_FILTER_BATCH_SIZE = 50;
|
|
65
68
|
// A healthy IMAP IDLE blocks until a mailbox change or the requested timeout.
|
|
66
69
|
// If client.idle() returns faster than this with no events, IDLE never actually
|
|
@@ -273,6 +276,66 @@ export function isVirtualMailView(entry) {
|
|
|
273
276
|
// UID order does not track date order (e.g. after a cross-provider import), so picking
|
|
274
277
|
// the target subset by UID (slice(-limit)) can silently drop the newest messages. This
|
|
275
278
|
// picks by INTERNALDATE instead. See GitHub issue #6.
|
|
279
|
+
const BULK_MATCH_FIELDS = ["from", "subject", "text", "since", "before", "isRead", "isStarred", "sizeLarger", "sizeSmaller"];
|
|
280
|
+
// A bulk `match` selects messages to act on, so it must say what to select. A match with no usable criterion
|
|
281
|
+
// ({}, a misspelled field, an empty string) used to fall back to IMAP "ALL" and resolve to every message in
|
|
282
|
+
// the folder. Values arrive as the client sent them ("false", "1000"), so they are read here, not assumed.
|
|
283
|
+
export function normalizeBulkMatch(match, folder) {
|
|
284
|
+
const raw = match;
|
|
285
|
+
const unknown = Object.keys(raw).filter((key) => !BULK_MATCH_FIELDS.includes(key) && raw[key] !== undefined);
|
|
286
|
+
if (unknown.length > 0) {
|
|
287
|
+
throw new InvalidArgumentError(`Unknown match field${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Valid fields: ${BULK_MATCH_FIELDS.join(", ")}.`);
|
|
288
|
+
}
|
|
289
|
+
const text = (key) => {
|
|
290
|
+
const value = raw[key];
|
|
291
|
+
if (value === undefined || value === null)
|
|
292
|
+
return undefined;
|
|
293
|
+
if (typeof value !== "string")
|
|
294
|
+
throw new InvalidArgumentError(`match.${key} must be a string.`);
|
|
295
|
+
return value.trim() || undefined;
|
|
296
|
+
};
|
|
297
|
+
const input = {
|
|
298
|
+
folder,
|
|
299
|
+
from: text("from"),
|
|
300
|
+
subject: text("subject"),
|
|
301
|
+
query: text("text"),
|
|
302
|
+
dateFrom: text("since"),
|
|
303
|
+
dateTo: text("before"),
|
|
304
|
+
isRead: optionalBoolean(raw.isRead),
|
|
305
|
+
isStarred: optionalBoolean(raw.isStarred),
|
|
306
|
+
sizeLarger: optionalNumber(raw.sizeLarger, 0, Number.MAX_SAFE_INTEGER),
|
|
307
|
+
sizeSmaller: optionalNumber(raw.sizeSmaller, 0, Number.MAX_SAFE_INTEGER),
|
|
308
|
+
};
|
|
309
|
+
const given = [input.from, input.subject, input.query, input.dateFrom, input.dateTo, input.isRead, input.isStarred, input.sizeLarger, input.sizeSmaller];
|
|
310
|
+
if (given.every((value) => value === undefined)) {
|
|
311
|
+
throw new InvalidArgumentError(`match needs at least one criterion (${BULK_MATCH_FIELDS.join(", ")}); with none it would select every message in the folder.`);
|
|
312
|
+
}
|
|
313
|
+
return input;
|
|
314
|
+
}
|
|
315
|
+
// A thread operation looks up every message whose Message-ID or References contains this value, and then
|
|
316
|
+
// moves, deletes or flags them. IMAP HEADER search is a substring match (RFC 3501), so a value like "@" or
|
|
317
|
+
// "ab" matches far more than one conversation. A real Message-ID is "<local@domain>" (the brackets may have
|
|
318
|
+
// been dropped by the caller): no whitespace, no angle brackets inside, an "@" with something on both sides.
|
|
319
|
+
export function validateThreadMessageId(value) {
|
|
320
|
+
const trimmed = (value ?? "").trim();
|
|
321
|
+
const inner = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
|
|
322
|
+
if (inner.length < 3 || inner.length > 500 || /[\s<>]/.test(inner)) {
|
|
323
|
+
throw new InvalidArgumentError("messageId must be a Message-ID such as <abc123@mail.example>.");
|
|
324
|
+
}
|
|
325
|
+
const at = inner.lastIndexOf("@");
|
|
326
|
+
if (at < 1 || at === inner.length - 1) {
|
|
327
|
+
throw new InvalidArgumentError("messageId must be a Message-ID such as <abc123@mail.example>.");
|
|
328
|
+
}
|
|
329
|
+
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
|
|
330
|
+
}
|
|
331
|
+
function describeThreadFailure(folder, uid, error) {
|
|
332
|
+
return `${folder} uid ${uid}: ${error instanceof Error ? error.message : String(error)}`;
|
|
333
|
+
}
|
|
334
|
+
function assertThreadBatchSize(count, max) {
|
|
335
|
+
if (max !== undefined && count > max) {
|
|
336
|
+
throw new Error(`The thread has ${count} messages, which exceeds the limit of ${max} (maxBatchSize). Nothing was changed; raise maxBatchSize to act on all of them.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
276
339
|
export function pickNewestUids(dated, limit) {
|
|
277
340
|
return [...dated]
|
|
278
341
|
.sort((a, b) => b.date - a.date)
|
|
@@ -615,7 +678,8 @@ export class SimpleIMAPService {
|
|
|
615
678
|
lastIdleError;
|
|
616
679
|
_lastOpTs = 0;
|
|
617
680
|
_connectingPromise;
|
|
618
|
-
|
|
681
|
+
// One running IDLE session per folder; later callers wait on it (see waitForMailboxChanges).
|
|
682
|
+
_idleSession = new Map();
|
|
619
683
|
// Operations currently waiting for the mailbox lock (see withMailbox); the IDLE watcher
|
|
620
684
|
// yields while this is non-zero.
|
|
621
685
|
_pendingMailboxOps = 0;
|
|
@@ -664,23 +728,25 @@ export class SimpleIMAPService {
|
|
|
664
728
|
return this._connectingPromise;
|
|
665
729
|
}
|
|
666
730
|
async disconnect() {
|
|
667
|
-
|
|
731
|
+
const client = this.client;
|
|
732
|
+
if (!client) {
|
|
668
733
|
return;
|
|
669
734
|
}
|
|
735
|
+
// Forget this client now, not after the logout: a call that connects while the logout is still running
|
|
736
|
+
// installs a new client, and clearing `this.client` afterwards used to forget THAT one, leaving it open
|
|
737
|
+
// and never reused (one leaked Bridge session each time).
|
|
738
|
+
this.client = undefined;
|
|
670
739
|
try {
|
|
671
|
-
if (
|
|
672
|
-
await
|
|
740
|
+
if (client.usable) {
|
|
741
|
+
await client.logout();
|
|
673
742
|
}
|
|
674
743
|
else {
|
|
675
|
-
|
|
744
|
+
client.close();
|
|
676
745
|
}
|
|
677
746
|
}
|
|
678
747
|
catch (error) {
|
|
679
748
|
this.log.warn("IMAP disconnect failed", "IMAPService", error);
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
finally {
|
|
683
|
-
this.client = undefined;
|
|
749
|
+
client.close();
|
|
684
750
|
}
|
|
685
751
|
}
|
|
686
752
|
isConnected() {
|
|
@@ -705,17 +771,48 @@ export class SimpleIMAPService {
|
|
|
705
771
|
const folder = input.folder?.trim() || "INBOX";
|
|
706
772
|
const timeoutMs = normalizeLimit(input.timeoutMs, this.config.runtime.idleMaxSeconds * 1000, 1_000, 300_000);
|
|
707
773
|
// IDLE semaphore: prevent stacking concurrent IDLE sessions per folder,
|
|
708
|
-
// which would exhaust Proton Bridge's connection limit.
|
|
709
|
-
|
|
774
|
+
// which would exhaust Proton Bridge's connection limit. A caller that arrives while a session is
|
|
775
|
+
// already watching (typically the background watcher, while the wait_for_mailbox_changes tool is
|
|
776
|
+
// called) does not start another one: it waits, up to its own timeout, for the next session to see a
|
|
777
|
+
// change, instead of answering "no changes" at once.
|
|
778
|
+
const deadline = Date.now() + timeoutMs;
|
|
779
|
+
for (;;) {
|
|
780
|
+
const active = this._idleSession.get(folder);
|
|
781
|
+
if (!active)
|
|
782
|
+
break;
|
|
783
|
+
const remaining = deadline - Date.now();
|
|
784
|
+
if (remaining <= 0)
|
|
785
|
+
break;
|
|
786
|
+
let timer;
|
|
787
|
+
const result = await Promise.race([
|
|
788
|
+
active.catch(() => undefined),
|
|
789
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), remaining); timer.unref?.(); }),
|
|
790
|
+
]);
|
|
791
|
+
if (timer)
|
|
792
|
+
clearTimeout(timer);
|
|
793
|
+
if (result?.changed)
|
|
794
|
+
return { ...result, timeoutMs };
|
|
795
|
+
// The session ended without a change (or its caller failed): give the next one a moment to start,
|
|
796
|
+
// then wait on it, until this caller's own time is up.
|
|
797
|
+
await new Promise((resolve) => { const t = setTimeout(resolve, 20); t.unref?.(); });
|
|
798
|
+
}
|
|
799
|
+
if (this._idleSession.get(folder)) {
|
|
710
800
|
return { folder, timeoutMs, checkedAt: new Date().toISOString(), changed: false, events: [] };
|
|
711
801
|
}
|
|
712
|
-
|
|
713
|
-
|
|
802
|
+
// The mark must be cleared whatever happens, including a failure to connect: it used to be set before
|
|
803
|
+
// connecting, outside the try/finally, so one failed connect (Bridge down at startup) left the folder
|
|
804
|
+
// "watched" forever and every later call returned "no changes" instantly.
|
|
805
|
+
const session = (async () => {
|
|
806
|
+
const client = await this.ensureConnected();
|
|
807
|
+
return this.waitForMailboxChangesWithClient(client, folder, timeoutMs, true);
|
|
808
|
+
})();
|
|
809
|
+
this._idleSession.set(folder, session);
|
|
714
810
|
try {
|
|
715
|
-
return await
|
|
811
|
+
return await session;
|
|
716
812
|
}
|
|
717
813
|
finally {
|
|
718
|
-
this.
|
|
814
|
+
if (this._idleSession.get(folder) === session)
|
|
815
|
+
this._idleSession.delete(folder);
|
|
719
816
|
}
|
|
720
817
|
}
|
|
721
818
|
async waitForMailboxChangesWithClient(client, folder, timeoutMs, allowReconnectRetry) {
|
|
@@ -757,7 +854,17 @@ export class SimpleIMAPService {
|
|
|
757
854
|
client.on("exists", onExists);
|
|
758
855
|
client.on("expunge", onExpunge);
|
|
759
856
|
client.on("flags", onFlags);
|
|
760
|
-
|
|
857
|
+
let lock;
|
|
858
|
+
try {
|
|
859
|
+
lock = await client.getMailboxLock(folder, { readOnly: true });
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
// The client is reused: listeners left on it would keep collecting events for a call that is gone.
|
|
863
|
+
client.off("exists", onExists);
|
|
864
|
+
client.off("expunge", onExpunge);
|
|
865
|
+
client.off("flags", onFlags);
|
|
866
|
+
throw error;
|
|
867
|
+
}
|
|
761
868
|
// Yield to operations waiting for the lock (see IDLE_YIELD_* above). The graceful
|
|
762
869
|
// break is retried on every poll because preCheck only exists once IDLE has actually
|
|
763
870
|
// started; if the lock is still held IDLE_YIELD_FORCE_MS after the first waiter was
|
|
@@ -804,7 +911,7 @@ export class SimpleIMAPService {
|
|
|
804
911
|
// still "usable" — so ensureConnected() won't reconnect, and every
|
|
805
912
|
// subsequent idle() returns instantly. That turns the caller's watch loop
|
|
806
913
|
// into a 100%-CPU busy spin (observed: an orphaned process burning a full
|
|
807
|
-
// core for days). Our IDLE calls are serialized per folder via
|
|
914
|
+
// core for days). Our IDLE calls are serialized per folder via _idleSession
|
|
808
915
|
// and awaited by the caller, so no legitimate IDLE can be in flight here —
|
|
809
916
|
// if the flag is set, it is stuck. Clear it so idle() actually enters IDLE
|
|
810
917
|
// and blocks.
|
|
@@ -1089,20 +1196,22 @@ export class SimpleIMAPService {
|
|
|
1089
1196
|
if (!trimmed) {
|
|
1090
1197
|
throw new Error("Folder path is required.");
|
|
1091
1198
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
"
|
|
1097
|
-
"
|
|
1098
|
-
"Archive",
|
|
1099
|
-
"All Mail",
|
|
1100
|
-
"Folders",
|
|
1101
|
-
"Labels",
|
|
1199
|
+
// System folders, by name (any capitalisation, with or without surrounding slashes) and by the special-use
|
|
1200
|
+
// the server itself reports. Compared on the normalised name: "inbox", "INBOX/" and "/Trash" are the same
|
|
1201
|
+
// folders as "INBOX" and "Trash".
|
|
1202
|
+
const reservedNames = new Set([
|
|
1203
|
+
"inbox", "drafts", "sent", "sent mail", "trash", "deleted messages", "spam", "junk", "archive",
|
|
1204
|
+
"all mail", "starred", "folders", "labels",
|
|
1102
1205
|
]);
|
|
1103
|
-
|
|
1206
|
+
const normalized = trimmed.replace(/^\/+|\/+$/g, "").trim().toLowerCase();
|
|
1207
|
+
if (reservedNames.has(normalized)) {
|
|
1104
1208
|
throw new Error(`Refusing to delete reserved system folder ${trimmed}.`);
|
|
1105
1209
|
}
|
|
1210
|
+
const known = await this.getFolders().catch(() => []);
|
|
1211
|
+
const target = known.find((entry) => entry.path.replace(/^\/+|\/+$/g, "").toLowerCase() === normalized);
|
|
1212
|
+
if (target?.specialUse) {
|
|
1213
|
+
throw new Error(`Refusing to delete system folder ${trimmed} (it is the server's ${target.specialUse} folder).`);
|
|
1214
|
+
}
|
|
1106
1215
|
const response = await this.mutateFolderWithReconnectCheck(async () => {
|
|
1107
1216
|
const client = await this.ensureConnected();
|
|
1108
1217
|
return client.mailboxDelete(trimmed);
|
|
@@ -1163,6 +1272,11 @@ export class SimpleIMAPService {
|
|
|
1163
1272
|
const emails = [];
|
|
1164
1273
|
const fetchQuery = input.includeSnippet ? FETCH_DETAIL_QUERY : FETCH_SUMMARY_QUERY;
|
|
1165
1274
|
if (input.beforeUid !== undefined) {
|
|
1275
|
+
// Nothing lies below UID 1. Asking for "1:0" would not mean "empty": IMAP ranges are unordered, so it
|
|
1276
|
+
// is "0:1" and returns UID 1 again, which would keep a cursor loop at the oldest message going forever.
|
|
1277
|
+
if (!(input.beforeUid > 1)) {
|
|
1278
|
+
return { folder, total, limit, offset, emails: [] };
|
|
1279
|
+
}
|
|
1166
1280
|
// UID-based pagination: search for UIDs below the given upper bound
|
|
1167
1281
|
const searchQuery = { uid: `1:${input.beforeUid - 1}` };
|
|
1168
1282
|
const uids = await client.search(searchQuery, { uid: true });
|
|
@@ -1416,8 +1530,12 @@ export class SimpleIMAPService {
|
|
|
1416
1530
|
const { parsed } = await this.getParsedMailDetail(input.emailId);
|
|
1417
1531
|
const attachments = this.mapParsedAttachmentsWithContent(parsed);
|
|
1418
1532
|
const saved = [];
|
|
1533
|
+
const failed = [];
|
|
1534
|
+
let firstError;
|
|
1419
1535
|
let skipped = 0;
|
|
1420
|
-
// Track resolved output paths to detect filename collisions within this batch
|
|
1536
|
+
// Track resolved output paths to detect filename collisions within this batch. Compared in lower case:
|
|
1537
|
+
// the default filesystems of macOS and Windows treat "Invoice.pdf" and "invoice.pdf" as one file, and
|
|
1538
|
+
// the second would silently replace the first.
|
|
1421
1539
|
const usedPaths = new Set();
|
|
1422
1540
|
for (const attachment of attachments) {
|
|
1423
1541
|
if (!input.includeInline && attachment.isInline) {
|
|
@@ -1444,7 +1562,7 @@ export class SimpleIMAPService {
|
|
|
1444
1562
|
const sanitized = sanitizeFileName(attachment.filename, attachmentId);
|
|
1445
1563
|
const base = join(resolve(input.outputPath), sanitized);
|
|
1446
1564
|
// Deduplicate: if path already used, append numeric suffix (image.png → image (1).png)
|
|
1447
|
-
if (!usedPaths.has(base)) {
|
|
1565
|
+
if (!usedPaths.has(base.toLowerCase())) {
|
|
1448
1566
|
targetPath = base;
|
|
1449
1567
|
}
|
|
1450
1568
|
else {
|
|
@@ -1455,19 +1573,32 @@ export class SimpleIMAPService {
|
|
|
1455
1573
|
do {
|
|
1456
1574
|
candidate = join(resolve(input.outputPath), `${stem} (${counter})${ext}`);
|
|
1457
1575
|
counter++;
|
|
1458
|
-
} while (usedPaths.has(candidate));
|
|
1576
|
+
} while (usedPaths.has(candidate.toLowerCase()));
|
|
1459
1577
|
targetPath = candidate;
|
|
1460
1578
|
}
|
|
1461
|
-
usedPaths.add(targetPath);
|
|
1579
|
+
usedPaths.add(targetPath.toLowerCase());
|
|
1462
1580
|
}
|
|
1463
1581
|
else {
|
|
1464
1582
|
targetPath = input.outputPath;
|
|
1465
1583
|
}
|
|
1466
|
-
|
|
1584
|
+
// One attachment that cannot be written must not lose the ones already saved or still to come.
|
|
1585
|
+
try {
|
|
1586
|
+
saved.push(await this.writeAttachmentToPath(input.emailId, attachment, targetPath));
|
|
1587
|
+
}
|
|
1588
|
+
catch (error) {
|
|
1589
|
+
firstError ??= error;
|
|
1590
|
+
failed.push({ id: attachment.id, filename: attachment.filename, error: error instanceof Error ? error.message : String(error) });
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
// Nothing could be saved at all: that is a failure of the call, not an empty success. The first error is
|
|
1594
|
+
// raised as it was (an account mismatch, a missing download directory...) so its meaning is kept.
|
|
1595
|
+
if (saved.length === 0 && failed.length > 0) {
|
|
1596
|
+
throw firstError;
|
|
1467
1597
|
}
|
|
1468
1598
|
return {
|
|
1469
1599
|
emailId: input.emailId,
|
|
1470
1600
|
saved,
|
|
1601
|
+
failed,
|
|
1471
1602
|
skipped,
|
|
1472
1603
|
};
|
|
1473
1604
|
}
|
|
@@ -1477,14 +1608,15 @@ export class SimpleIMAPService {
|
|
|
1477
1608
|
return undefined;
|
|
1478
1609
|
}
|
|
1479
1610
|
const contentType = attachment.contentType?.toLowerCase();
|
|
1480
|
-
if (contentType === "text/html") {
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1611
|
+
if (contentType === "text/html" || contentType === "text/calendar" || isTextLikeMimeType(attachment.contentType)) {
|
|
1612
|
+
const text = decodeAttachmentText(attachment.content, attachment.charset);
|
|
1613
|
+
if (text === undefined)
|
|
1614
|
+
return undefined;
|
|
1615
|
+
if (contentType === "text/html")
|
|
1616
|
+
return stripHtmlToText(text);
|
|
1617
|
+
if (contentType === "text/calendar")
|
|
1618
|
+
return summarizeCalendarText(text);
|
|
1619
|
+
return text;
|
|
1488
1620
|
}
|
|
1489
1621
|
return undefined;
|
|
1490
1622
|
}
|
|
@@ -1505,8 +1637,12 @@ export class SimpleIMAPService {
|
|
|
1505
1637
|
contentDisposition: attachment.disposition,
|
|
1506
1638
|
};
|
|
1507
1639
|
}
|
|
1508
|
-
async getAttachmentContent(emailId, attachmentId,
|
|
1509
|
-
|
|
1640
|
+
async getAttachmentContent(emailId, attachmentId, options = false) {
|
|
1641
|
+
const { includeBase64 = false, forSave = false } = typeof options === "boolean" ? { includeBase64: options } : options;
|
|
1642
|
+
// The inline-size limit bounds what is put into the reply. A save writes to disk and puts nothing into it.
|
|
1643
|
+
if (!forSave) {
|
|
1644
|
+
await this.assertAttachmentWithinInlineLimit(emailId, attachmentId);
|
|
1645
|
+
}
|
|
1510
1646
|
const attachment = await this.getParsedAttachment(emailId, attachmentId);
|
|
1511
1647
|
const base64 = attachment.content.toString("base64");
|
|
1512
1648
|
return {
|
|
@@ -1524,8 +1660,8 @@ export class SimpleIMAPService {
|
|
|
1524
1660
|
isCalendarInvite: attachment.isCalendarInvite,
|
|
1525
1661
|
isSignature: attachment.isSignature,
|
|
1526
1662
|
},
|
|
1527
|
-
text: this.extractAttachmentText(attachment),
|
|
1528
|
-
base64: includeBase64 ? base64 : undefined,
|
|
1663
|
+
text: forSave ? undefined : this.extractAttachmentText(attachment),
|
|
1664
|
+
base64: includeBase64 || forSave ? base64 : undefined,
|
|
1529
1665
|
};
|
|
1530
1666
|
}
|
|
1531
1667
|
// First-class text extraction, deliberately NOT gated by the base64-oriented
|
|
@@ -1704,7 +1840,7 @@ export class SimpleIMAPService {
|
|
|
1704
1840
|
.catch(() => undefined)) || undefined;
|
|
1705
1841
|
targetFolderUidValidity = targetStatus?.uidValidity?.toString();
|
|
1706
1842
|
}
|
|
1707
|
-
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms moving email ${emailId} to ${targetFolder}
|
|
1843
|
+
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms moving email ${emailId} to ${targetFolder}. The outcome is unknown: the server may have completed the move, so check both folders before retrying.`);
|
|
1708
1844
|
const cached = this.messageCache.get(emailId);
|
|
1709
1845
|
this.messageCache.delete(emailId);
|
|
1710
1846
|
const targetEmailId = targetUid ? createEmailId(targetFolder, targetUid, targetFolderUidValidity) : undefined;
|
|
@@ -1903,7 +2039,22 @@ export class SimpleIMAPService {
|
|
|
1903
2039
|
return { emailId, added: flagsToAdd, removed: flagsToRemove, notApplied };
|
|
1904
2040
|
}
|
|
1905
2041
|
async countMessages(input = {}) {
|
|
1906
|
-
|
|
2042
|
+
// A label that names real folder(s) (Proton: "Newsletters" = Labels/Newsletters) is answered by
|
|
2043
|
+
// counting those folders, exactly as searchEmails does: every message in them has the label, and
|
|
2044
|
+
// Bridge never reports labels on the messages themselves.
|
|
2045
|
+
const labelFolders = await this.resolveLabelFolders(input);
|
|
2046
|
+
const folders = labelFolders ?? [input.folder ?? "INBOX"];
|
|
2047
|
+
let count = 0;
|
|
2048
|
+
let approximate = false;
|
|
2049
|
+
for (const folder of folders) {
|
|
2050
|
+
const counted = await this.countInFolder(folder, input, labelFolders !== undefined);
|
|
2051
|
+
count += counted.count;
|
|
2052
|
+
approximate = approximate || counted.approximate;
|
|
2053
|
+
}
|
|
2054
|
+
const folder = labelFolders ? labelFolders.join(", ") || input.folder || "INBOX" : folders[0];
|
|
2055
|
+
return approximate ? { folder, count, approximate: true } : { folder, count };
|
|
2056
|
+
}
|
|
2057
|
+
async countInFolder(folder, input, labelIsFolder) {
|
|
1907
2058
|
// Bridge cannot match non-ASCII values: send a narrowed ASCII query and verify locally,
|
|
1908
2059
|
// exactly as searchEmails does, so the two agree.
|
|
1909
2060
|
const { imapInput, criteria: nonAsciiCriteria } = splitNonAsciiCriteria(input);
|
|
@@ -1912,7 +2063,7 @@ export class SimpleIMAPService {
|
|
|
1912
2063
|
// this they were silently dropped and the unfiltered SEARCH count was returned.
|
|
1913
2064
|
const hasLocalOnlyFilters = typeof input.hasAttachment === "boolean" ||
|
|
1914
2065
|
Boolean(input.threadId) ||
|
|
1915
|
-
Boolean(input.label) ||
|
|
2066
|
+
(Boolean(input.label) && !labelIsFolder) ||
|
|
1916
2067
|
Boolean(input.attachmentName) ||
|
|
1917
2068
|
Boolean(input.senderDomain) ||
|
|
1918
2069
|
Boolean(input.mailboxRole);
|
|
@@ -1923,9 +2074,15 @@ export class SimpleIMAPService {
|
|
|
1923
2074
|
if ((!hasLocalOnlyFilters && !nonAsciiCriteria) || uids.length === 0) {
|
|
1924
2075
|
return uids.length;
|
|
1925
2076
|
}
|
|
1926
|
-
// The narrowed query can be broad; check only the newest candidates
|
|
1927
|
-
|
|
1928
|
-
|
|
2077
|
+
// The narrowed query can be broad; check only the newest candidates (by date, which is what
|
|
2078
|
+
// searchEmails ranks by: UID order does not follow date order after an import) and say so.
|
|
2079
|
+
const scanCap = nonAsciiCriteria ? NON_ASCII_SCAN_CAP : LOCAL_FILTER_COUNT_SCAN_CAP;
|
|
2080
|
+
if (uids.length > scanCap) {
|
|
2081
|
+
const dated = [];
|
|
2082
|
+
for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
|
|
2083
|
+
dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
|
|
2084
|
+
}
|
|
2085
|
+
uids = pickNewestUids(dated, scanCap);
|
|
1929
2086
|
approximate = true;
|
|
1930
2087
|
}
|
|
1931
2088
|
// A non-ASCII free-text query is verified against the body, so it needs the source too.
|
|
@@ -1945,7 +2102,7 @@ export class SimpleIMAPService {
|
|
|
1945
2102
|
}
|
|
1946
2103
|
return matched;
|
|
1947
2104
|
});
|
|
1948
|
-
return
|
|
2105
|
+
return { count, approximate };
|
|
1949
2106
|
}
|
|
1950
2107
|
async getFolderStats(folder) {
|
|
1951
2108
|
const target = folder ?? "INBOX";
|
|
@@ -1990,6 +2147,9 @@ export class SimpleIMAPService {
|
|
|
1990
2147
|
}
|
|
1991
2148
|
const uidSet = uids.join(",");
|
|
1992
2149
|
await this.withTimeout(this.withMailbox(folder, false, async (client) => {
|
|
2150
|
+
// The UIDs above were listed under one lock and are deleted under another. If the folder was
|
|
2151
|
+
// recreated in between, those numbers now belong to other messages.
|
|
2152
|
+
this.assertMailboxUidValidity(client, folderUidValidity);
|
|
1993
2153
|
await client.messageDelete(uidSet, { uid: true });
|
|
1994
2154
|
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms emptying folder ${folder}`);
|
|
1995
2155
|
// Purge from cache — must reconstruct the exact same id toSummary/etc.
|
|
@@ -2064,20 +2224,12 @@ export class SimpleIMAPService {
|
|
|
2064
2224
|
.map((parsed) => parsed.uid);
|
|
2065
2225
|
}
|
|
2066
2226
|
// match path
|
|
2067
|
-
const searchInput =
|
|
2068
|
-
folder,
|
|
2069
|
-
from: match.from,
|
|
2070
|
-
subject: match.subject,
|
|
2071
|
-
query: match.text,
|
|
2072
|
-
dateFrom: match.since,
|
|
2073
|
-
dateTo: match.before,
|
|
2074
|
-
isRead: match.isRead,
|
|
2075
|
-
isStarred: match.isStarred,
|
|
2076
|
-
sizeLarger: match.sizeLarger,
|
|
2077
|
-
sizeSmaller: match.sizeSmaller,
|
|
2078
|
-
};
|
|
2227
|
+
const searchInput = normalizeBulkMatch(match, folder);
|
|
2079
2228
|
const query = this.buildSearchQuery(searchInput);
|
|
2080
2229
|
const search = () => this.withMailbox(folder, true, async (client) => {
|
|
2230
|
+
// The folder may have been recreated since the caller read its UIDVALIDITY: UIDs found now would
|
|
2231
|
+
// not be the messages the caller saw. Refuse instead of acting on whatever has those numbers.
|
|
2232
|
+
this.assertMailboxUidValidity(client, currentUidValidity);
|
|
2081
2233
|
const found = await client.search(query, { uid: true });
|
|
2082
2234
|
return Array.isArray(found) ? found : [];
|
|
2083
2235
|
});
|
|
@@ -2140,7 +2292,7 @@ export class SimpleIMAPService {
|
|
|
2140
2292
|
}
|
|
2141
2293
|
uidMap = moved.uidMap;
|
|
2142
2294
|
hasUidPlus = client.capabilities.has("UIDPLUS");
|
|
2143
|
-
}), BULK_BATCH_TIMEOUT_MS, `Timed out after ${BULK_BATCH_TIMEOUT_MS}ms moving uid set ${uidSet}
|
|
2295
|
+
}), BULK_BATCH_TIMEOUT_MS, `Timed out after ${BULK_BATCH_TIMEOUT_MS}ms moving uid set ${uidSet} The outcome is unknown: the server may have completed the move, so check the folders before retrying.`);
|
|
2144
2296
|
for (const uid of uids) {
|
|
2145
2297
|
const emailId = createEmailId(folder, uid, sourceUidValidity);
|
|
2146
2298
|
// `moved === false` above only catches an empty/invalid range,
|
|
@@ -2490,6 +2642,7 @@ export class SimpleIMAPService {
|
|
|
2490
2642
|
return { folder, scanned: page.length, senders: sorted };
|
|
2491
2643
|
}
|
|
2492
2644
|
async resolveThreadUids(messageId, acrossFolders = true, folders) {
|
|
2645
|
+
messageId = validateThreadMessageId(messageId);
|
|
2493
2646
|
const results = [];
|
|
2494
2647
|
// acrossFolders was previously accepted and documented ("Also search
|
|
2495
2648
|
// Sent and All Mail.", default false) but silently discarded — every
|
|
@@ -2553,8 +2706,10 @@ export class SimpleIMAPService {
|
|
|
2553
2706
|
if (input.dryRun) {
|
|
2554
2707
|
return { messageId: input.messageId, destination: input.destination, moved: matches.length, notMoved: 0, dryRun: true };
|
|
2555
2708
|
}
|
|
2709
|
+
assertThreadBatchSize(matches.length, input.maxBatchSize);
|
|
2556
2710
|
let moved = 0;
|
|
2557
2711
|
let notMoved = 0;
|
|
2712
|
+
const errors = [];
|
|
2558
2713
|
for (const { folder, uid, emailId, uidValidity } of matches) {
|
|
2559
2714
|
try {
|
|
2560
2715
|
await this.withTimeout(this.withMailbox(folder, false, async (client) => {
|
|
@@ -2568,12 +2723,13 @@ export class SimpleIMAPService {
|
|
|
2568
2723
|
const result = await client.messageMove(String(uid), input.destination, { uid: true });
|
|
2569
2724
|
if (result === false)
|
|
2570
2725
|
throw new Error(`Server did not move uid ${uid}`);
|
|
2571
|
-
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms moving uid ${uid} for thread ${input.messageId}
|
|
2726
|
+
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms moving uid ${uid} for thread ${input.messageId} The outcome is unknown: the server may have completed the move, so check the folders before retrying.`);
|
|
2572
2727
|
this.messageCache.delete(emailId);
|
|
2573
2728
|
moved++;
|
|
2574
2729
|
}
|
|
2575
|
-
catch {
|
|
2730
|
+
catch (error) {
|
|
2576
2731
|
notMoved++;
|
|
2732
|
+
errors.push(describeThreadFailure(folder, uid, error));
|
|
2577
2733
|
}
|
|
2578
2734
|
}
|
|
2579
2735
|
if (moved > 0) {
|
|
@@ -2583,13 +2739,14 @@ export class SimpleIMAPService {
|
|
|
2583
2739
|
this.folderCache = undefined;
|
|
2584
2740
|
}
|
|
2585
2741
|
this.lastSyncAt = new Date().toISOString();
|
|
2586
|
-
return { messageId: input.messageId, destination: input.destination, moved, notMoved, dryRun: false };
|
|
2742
|
+
return { messageId: input.messageId, destination: input.destination, moved, notMoved, dryRun: false, ...(errors.length > 0 ? { errors: errors.slice(0, 10) } : {}) };
|
|
2587
2743
|
}
|
|
2588
2744
|
async deleteThread(input) {
|
|
2589
2745
|
const matches = await this.resolveThreadUids(input.messageId, input.acrossFolders ?? false);
|
|
2590
2746
|
if (input.dryRun) {
|
|
2591
|
-
return { messageId: input.messageId, deleted: matches.length, dryRun: true };
|
|
2747
|
+
return { messageId: input.messageId, deleted: matches.length, notDeleted: 0, dryRun: true };
|
|
2592
2748
|
}
|
|
2749
|
+
assertThreadBatchSize(matches.length, input.maxBatchSize);
|
|
2593
2750
|
// No .catch() here — a Trash-resolution failure must propagate as a
|
|
2594
2751
|
// hard error rather than silently falling into the permanent-delete
|
|
2595
2752
|
// branch below when the caller explicitly asked for permanent:false.
|
|
@@ -2600,6 +2757,7 @@ export class SimpleIMAPService {
|
|
|
2600
2757
|
? undefined
|
|
2601
2758
|
: await this.resolveSpecialFolder("\\Trash", ["Trash", "INBOX.Trash"]);
|
|
2602
2759
|
let deleted = 0;
|
|
2760
|
+
const deleteErrors = [];
|
|
2603
2761
|
for (const { folder, uid, emailId, uidValidity } of matches) {
|
|
2604
2762
|
try {
|
|
2605
2763
|
// Re-verify the generation this match was resolved under — see
|
|
@@ -2617,7 +2775,9 @@ export class SimpleIMAPService {
|
|
|
2617
2775
|
this.messageCache.delete(emailId);
|
|
2618
2776
|
deleted++;
|
|
2619
2777
|
}
|
|
2620
|
-
catch {
|
|
2778
|
+
catch (error) {
|
|
2779
|
+
deleteErrors.push(describeThreadFailure(folder, uid, error));
|
|
2780
|
+
}
|
|
2621
2781
|
}
|
|
2622
2782
|
if (deleted > 0) {
|
|
2623
2783
|
// Deletion changes the folder's message count — see the
|
|
@@ -2626,16 +2786,18 @@ export class SimpleIMAPService {
|
|
|
2626
2786
|
this.folderCache = undefined;
|
|
2627
2787
|
}
|
|
2628
2788
|
this.lastSyncAt = new Date().toISOString();
|
|
2629
|
-
return { messageId: input.messageId, deleted, dryRun: false };
|
|
2789
|
+
return { messageId: input.messageId, deleted, notDeleted: deleteErrors.length, dryRun: false, ...(deleteErrors.length > 0 ? { errors: deleteErrors.slice(0, 10) } : {}) };
|
|
2630
2790
|
}
|
|
2631
2791
|
async flagThread(input) {
|
|
2632
2792
|
const matches = await this.resolveThreadUids(input.messageId, input.acrossFolders ?? false);
|
|
2633
2793
|
if (input.dryRun) {
|
|
2634
|
-
return { messageId: input.messageId, affected: matches.length, notApplied: [], dryRun: true };
|
|
2794
|
+
return { messageId: input.messageId, affected: matches.length, notAffected: 0, notApplied: [], dryRun: true };
|
|
2635
2795
|
}
|
|
2796
|
+
assertThreadBatchSize(matches.length, input.maxBatchSize);
|
|
2636
2797
|
const flagsToAdd = input.flagsToAdd ?? [];
|
|
2637
2798
|
const flagsToRemove = input.flagsToRemove ?? [];
|
|
2638
2799
|
let affected = 0;
|
|
2800
|
+
const flagErrors = [];
|
|
2639
2801
|
const allNotApplied = [];
|
|
2640
2802
|
for (const { folder, uid, uidValidity } of matches) {
|
|
2641
2803
|
try {
|
|
@@ -2659,7 +2821,9 @@ export class SimpleIMAPService {
|
|
|
2659
2821
|
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms flagging uid ${uid} for thread ${input.messageId}`);
|
|
2660
2822
|
affected++;
|
|
2661
2823
|
}
|
|
2662
|
-
catch {
|
|
2824
|
+
catch (error) {
|
|
2825
|
+
flagErrors.push(describeThreadFailure(folder, uid, error));
|
|
2826
|
+
}
|
|
2663
2827
|
}
|
|
2664
2828
|
if (affected > 0 && [...flagsToAdd, ...flagsToRemove].some((flag) => flag.toLowerCase() === "\\seen")) {
|
|
2665
2829
|
// \Seen changes the folder's unseen count — see the markEmailRead
|
|
@@ -2667,7 +2831,7 @@ export class SimpleIMAPService {
|
|
|
2667
2831
|
// invalidation on the single-email path.
|
|
2668
2832
|
this.folderCache = undefined;
|
|
2669
2833
|
}
|
|
2670
|
-
return { messageId: input.messageId, affected, notApplied: [...new Set(allNotApplied)], dryRun: false };
|
|
2834
|
+
return { messageId: input.messageId, affected, notAffected: flagErrors.length, notApplied: [...new Set(allNotApplied)], dryRun: false, ...(flagErrors.length > 0 ? { errors: flagErrors.slice(0, 10) } : {}) };
|
|
2671
2835
|
}
|
|
2672
2836
|
async syncEmails(input = {}) {
|
|
2673
2837
|
const snapshot = await this.collectEmailsForIndex(input);
|
|
@@ -3381,6 +3545,7 @@ export class SimpleIMAPService {
|
|
|
3381
3545
|
cid: attachment.cid,
|
|
3382
3546
|
}),
|
|
3383
3547
|
content: attachment.content,
|
|
3548
|
+
charset: attachment.headers?.get?.("content-type")?.params?.charset,
|
|
3384
3549
|
}));
|
|
3385
3550
|
}
|
|
3386
3551
|
mapHeaders(parsed) {
|
|
@@ -3412,13 +3577,19 @@ export class SimpleIMAPService {
|
|
|
3412
3577
|
async getParsedAttachment(emailId, attachmentId) {
|
|
3413
3578
|
const { detail, parsed } = await this.getParsedMailDetail(emailId);
|
|
3414
3579
|
const attachments = this.mapParsedAttachmentsWithContent(parsed);
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
if (
|
|
3580
|
+
// An id is exact. A checksum or a filename is a convenience, and a filename is not unique: two
|
|
3581
|
+
// attachments can both be "image.png". Returning the first one silently would hand back the wrong file.
|
|
3582
|
+
const byId = attachments.find((attachment) => attachment.id === attachmentId);
|
|
3583
|
+
if (byId)
|
|
3584
|
+
return byId;
|
|
3585
|
+
const candidates = attachments.filter((attachment) => attachment.checksum === attachmentId || attachment.filename === attachmentId);
|
|
3586
|
+
if (candidates.length === 0) {
|
|
3419
3587
|
throw new Error(`Attachment ${attachmentId} not found on email ${detail.id}`);
|
|
3420
3588
|
}
|
|
3421
|
-
|
|
3589
|
+
if (candidates.length > 1) {
|
|
3590
|
+
throw new Error(`${candidates.length} attachments on email ${detail.id} match "${attachmentId}". Use one of these ids instead: ${candidates.map((candidate) => candidate.id ?? "(no id)").join(", ")}`);
|
|
3591
|
+
}
|
|
3592
|
+
return candidates[0];
|
|
3422
3593
|
}
|
|
3423
3594
|
async assertAttachmentWithinInlineLimit(emailId, attachmentId) {
|
|
3424
3595
|
const { folder, uid } = parseEmailId(emailId);
|
|
@@ -3565,6 +3736,13 @@ export class SimpleIMAPService {
|
|
|
3565
3736
|
}
|
|
3566
3737
|
return String(err);
|
|
3567
3738
|
}
|
|
3739
|
+
// Where a save with no path given goes. Attachments are only meant to be written into the configured
|
|
3740
|
+
// download directory (PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR); the server's private data directory is only the
|
|
3741
|
+
// fallback when none is configured.
|
|
3742
|
+
defaultAttachmentDir() {
|
|
3743
|
+
const allowed = this.config.runtime?.allowFileDownloadDir;
|
|
3744
|
+
return allowed ? resolve(allowed) : join(this.config.dataDir, "attachments");
|
|
3745
|
+
}
|
|
3568
3746
|
async writeAttachmentToPath(emailId, attachment, outputPath) {
|
|
3569
3747
|
let outputFilePath;
|
|
3570
3748
|
if (!outputPath) {
|
|
@@ -3589,7 +3767,7 @@ export class SimpleIMAPService {
|
|
|
3589
3767
|
this.identityChecked = true;
|
|
3590
3768
|
}
|
|
3591
3769
|
const filename = sanitizeFileName(attachment.filename, attachment.id || "attachment");
|
|
3592
|
-
const dirPath = join(this.
|
|
3770
|
+
const dirPath = join(this.defaultAttachmentDir(), encodeURIComponent(emailId));
|
|
3593
3771
|
// 0o700/0o600: this writes the user's own private email content — restrict
|
|
3594
3772
|
// it to the owner regardless of the destination directory's own permissions.
|
|
3595
3773
|
await mkdir(dirPath, { recursive: true, mode: 0o700 });
|
|
@@ -3664,33 +3842,31 @@ export class SimpleIMAPService {
|
|
|
3664
3842
|
// passed. Returning the already-realpath'd path collapses that into the
|
|
3665
3843
|
// single unavoidable race between this check and the actual write.
|
|
3666
3844
|
guardAttachmentOutputPath(outputPath) {
|
|
3667
|
-
const allowDir = process.env.PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR?.trim();
|
|
3845
|
+
const allowDir = (this.config.runtime?.allowFileDownloadDir ?? process.env.PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR)?.trim();
|
|
3668
3846
|
if (!allowDir) {
|
|
3669
3847
|
throw new Error("outputPath requires PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR to be configured.");
|
|
3670
3848
|
}
|
|
3671
3849
|
const targetPath = resolve(outputPath);
|
|
3672
3850
|
const allowedRealPath = realpathSync(resolve(allowDir));
|
|
3673
|
-
|
|
3851
|
+
// Canonicalise the part of the target that exists (following symlinks) and keep the rest as written:
|
|
3852
|
+
// a save into a directory that does not exist yet is fine as long as the deepest existing ancestor is
|
|
3853
|
+
// inside the download directory. Checked before anything is created.
|
|
3854
|
+
let existing = targetPath;
|
|
3855
|
+
while (!existsSync(existing)) {
|
|
3856
|
+
const parent = dirname(existing);
|
|
3857
|
+
if (parent === existing)
|
|
3858
|
+
break;
|
|
3859
|
+
existing = parent;
|
|
3860
|
+
}
|
|
3861
|
+
let existingReal;
|
|
3674
3862
|
try {
|
|
3675
|
-
|
|
3863
|
+
existingReal = realpathSync(existing);
|
|
3676
3864
|
}
|
|
3677
|
-
catch
|
|
3678
|
-
|
|
3679
|
-
typeof error === "object" &&
|
|
3680
|
-
"code" in error &&
|
|
3681
|
-
error.code === "ENOENT") {
|
|
3682
|
-
try {
|
|
3683
|
-
targetRealPath = realpathSync(dirname(targetPath)) + sep + basename(targetPath);
|
|
3684
|
-
}
|
|
3685
|
-
catch {
|
|
3686
|
-
throw new Error(`Output directory does not exist: ${dirname(targetPath)}`);
|
|
3687
|
-
}
|
|
3688
|
-
}
|
|
3689
|
-
else {
|
|
3690
|
-
throw error;
|
|
3691
|
-
}
|
|
3865
|
+
catch {
|
|
3866
|
+
throw new Error(`Output path cannot be resolved: ${targetPath}`);
|
|
3692
3867
|
}
|
|
3693
|
-
|
|
3868
|
+
const targetRealPath = existing === targetPath ? existingReal : join(existingReal, relative(existing, targetPath));
|
|
3869
|
+
if (!isPathInside(allowedRealPath, targetRealPath)) {
|
|
3694
3870
|
throw new Error("outputPath path escapes the allowed directory.");
|
|
3695
3871
|
}
|
|
3696
3872
|
return targetRealPath;
|
|
@@ -3698,8 +3874,10 @@ export class SimpleIMAPService {
|
|
|
3698
3874
|
async resolveAttachmentOutputPath(emailId, attachment, outputPath) {
|
|
3699
3875
|
const filename = sanitizeFileName(attachment.filename, attachment.id || "attachment");
|
|
3700
3876
|
if (!outputPath) {
|
|
3701
|
-
return join(this.
|
|
3877
|
+
return join(this.defaultAttachmentDir(), encodeURIComponent(emailId), filename);
|
|
3702
3878
|
}
|
|
3879
|
+
// Whether a directory was meant has to be read from the string as given: resolve() strips a trailing slash.
|
|
3880
|
+
const wantsDirectory = /[\\/]$/.test(outputPath);
|
|
3703
3881
|
const resolved = resolve(outputPath);
|
|
3704
3882
|
try {
|
|
3705
3883
|
const existing = await stat(resolved);
|
|
@@ -3716,7 +3894,7 @@ export class SimpleIMAPService {
|
|
|
3716
3894
|
throw error;
|
|
3717
3895
|
}
|
|
3718
3896
|
}
|
|
3719
|
-
if (
|
|
3897
|
+
if (wantsDirectory) {
|
|
3720
3898
|
const directoryTarget = join(resolved, filename);
|
|
3721
3899
|
return this.guardAttachmentOutputPath(directoryTarget);
|
|
3722
3900
|
}
|
|
@@ -3767,26 +3945,36 @@ export class SimpleIMAPService {
|
|
|
3767
3945
|
// there's nothing to dedup against — fall through to a normal import,
|
|
3768
3946
|
// same as before this fix (not a regression).
|
|
3769
3947
|
let messageId;
|
|
3948
|
+
let sentAt;
|
|
3770
3949
|
try {
|
|
3771
|
-
|
|
3950
|
+
const parsed = await this.parseSource(input.raw);
|
|
3951
|
+
messageId = parsed.messageId;
|
|
3952
|
+
sentAt = parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime()) ? parsed.date : undefined;
|
|
3772
3953
|
}
|
|
3773
3954
|
catch {
|
|
3774
3955
|
// Best-effort — an unparseable message still gets imported below.
|
|
3775
3956
|
}
|
|
3957
|
+
// The server's own INTERNALDATE decides where the message sorts. Without one, every import was stamped
|
|
3958
|
+
// with the time of the import, so migrated mail from years ago sat at the top as if it had just arrived.
|
|
3959
|
+
// The Date header is the best record of when it really came; a missing, unreadable or future one is not.
|
|
3960
|
+
const now = Date.now();
|
|
3961
|
+
const internalDate = input.internalDate ?? (sentAt && sentAt.getTime() <= now + 24 * 60 * 60 * 1000 ? sentAt : new Date(now));
|
|
3776
3962
|
if (messageId) {
|
|
3777
3963
|
let existingFolderUidValidity;
|
|
3778
3964
|
const existingUid = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
|
|
3779
3965
|
existingFolderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
|
|
3780
3966
|
const uids = await client.search({ header: { "Message-ID": messageId } }, { uid: true });
|
|
3781
3967
|
return Array.isArray(uids) && uids.length > 0 ? uids[uids.length - 1] : undefined;
|
|
3782
|
-
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms checking for an existing import of ${messageId} in ${folder}`)
|
|
3968
|
+
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms checking for an existing import of ${messageId} in ${folder}`);
|
|
3969
|
+
// No .catch(): a failed check used to be treated as "not there" and the import went ahead, so a retry of
|
|
3970
|
+
// an import whose check had failed could silently create a duplicate. The error is raised instead.
|
|
3783
3971
|
if (existingUid !== undefined) {
|
|
3784
3972
|
const existingEmailId = createEmailId(folder, existingUid, existingFolderUidValidity);
|
|
3785
3973
|
return { folder, uid: existingUid, emailId: existingEmailId, alreadyExists: true, existingEmailId };
|
|
3786
3974
|
}
|
|
3787
3975
|
}
|
|
3788
3976
|
const client = await this.ensureConnected();
|
|
3789
|
-
const appended = await client.append(folder, input.raw, input.flags ?? [],
|
|
3977
|
+
const appended = await client.append(folder, input.raw, input.flags ?? [], internalDate);
|
|
3790
3978
|
if (!appended) {
|
|
3791
3979
|
throw new Error(`Server did not append the imported message into ${folder}`);
|
|
3792
3980
|
}
|