proton-mail-bridge-client 2.3.2 → 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 +251 -133
- 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.map +1 -1
- package/dist/services/delivery-queue-service.js +3 -8
- 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 +3 -4
- 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 +45 -9
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +21 -2
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +271 -103
- 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 +15 -0
- 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 +40 -20
- 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 +43 -10
- 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/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 +222 -21
- 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 +1 -1
|
@@ -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;
|
|
@@ -1940,12 +2076,13 @@ export class SimpleIMAPService {
|
|
|
1940
2076
|
}
|
|
1941
2077
|
// The narrowed query can be broad; check only the newest candidates (by date, which is what
|
|
1942
2078
|
// searchEmails ranks by: UID order does not follow date order after an import) and say so.
|
|
1943
|
-
|
|
2079
|
+
const scanCap = nonAsciiCriteria ? NON_ASCII_SCAN_CAP : LOCAL_FILTER_COUNT_SCAN_CAP;
|
|
2080
|
+
if (uids.length > scanCap) {
|
|
1944
2081
|
const dated = [];
|
|
1945
2082
|
for await (const message of client.fetch(uids, FETCH_INDEX_QUERY, { uid: true })) {
|
|
1946
2083
|
dated.push({ uid: message.uid, date: new Date(message.internalDate ?? 0).getTime() });
|
|
1947
2084
|
}
|
|
1948
|
-
uids = pickNewestUids(dated,
|
|
2085
|
+
uids = pickNewestUids(dated, scanCap);
|
|
1949
2086
|
approximate = true;
|
|
1950
2087
|
}
|
|
1951
2088
|
// A non-ASCII free-text query is verified against the body, so it needs the source too.
|
|
@@ -2010,6 +2147,9 @@ export class SimpleIMAPService {
|
|
|
2010
2147
|
}
|
|
2011
2148
|
const uidSet = uids.join(",");
|
|
2012
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);
|
|
2013
2153
|
await client.messageDelete(uidSet, { uid: true });
|
|
2014
2154
|
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms emptying folder ${folder}`);
|
|
2015
2155
|
// Purge from cache — must reconstruct the exact same id toSummary/etc.
|
|
@@ -2084,20 +2224,12 @@ export class SimpleIMAPService {
|
|
|
2084
2224
|
.map((parsed) => parsed.uid);
|
|
2085
2225
|
}
|
|
2086
2226
|
// match path
|
|
2087
|
-
const searchInput =
|
|
2088
|
-
folder,
|
|
2089
|
-
from: match.from,
|
|
2090
|
-
subject: match.subject,
|
|
2091
|
-
query: match.text,
|
|
2092
|
-
dateFrom: match.since,
|
|
2093
|
-
dateTo: match.before,
|
|
2094
|
-
isRead: match.isRead,
|
|
2095
|
-
isStarred: match.isStarred,
|
|
2096
|
-
sizeLarger: match.sizeLarger,
|
|
2097
|
-
sizeSmaller: match.sizeSmaller,
|
|
2098
|
-
};
|
|
2227
|
+
const searchInput = normalizeBulkMatch(match, folder);
|
|
2099
2228
|
const query = this.buildSearchQuery(searchInput);
|
|
2100
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);
|
|
2101
2233
|
const found = await client.search(query, { uid: true });
|
|
2102
2234
|
return Array.isArray(found) ? found : [];
|
|
2103
2235
|
});
|
|
@@ -2160,7 +2292,7 @@ export class SimpleIMAPService {
|
|
|
2160
2292
|
}
|
|
2161
2293
|
uidMap = moved.uidMap;
|
|
2162
2294
|
hasUidPlus = client.capabilities.has("UIDPLUS");
|
|
2163
|
-
}), 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.`);
|
|
2164
2296
|
for (const uid of uids) {
|
|
2165
2297
|
const emailId = createEmailId(folder, uid, sourceUidValidity);
|
|
2166
2298
|
// `moved === false` above only catches an empty/invalid range,
|
|
@@ -2510,6 +2642,7 @@ export class SimpleIMAPService {
|
|
|
2510
2642
|
return { folder, scanned: page.length, senders: sorted };
|
|
2511
2643
|
}
|
|
2512
2644
|
async resolveThreadUids(messageId, acrossFolders = true, folders) {
|
|
2645
|
+
messageId = validateThreadMessageId(messageId);
|
|
2513
2646
|
const results = [];
|
|
2514
2647
|
// acrossFolders was previously accepted and documented ("Also search
|
|
2515
2648
|
// Sent and All Mail.", default false) but silently discarded — every
|
|
@@ -2573,8 +2706,10 @@ export class SimpleIMAPService {
|
|
|
2573
2706
|
if (input.dryRun) {
|
|
2574
2707
|
return { messageId: input.messageId, destination: input.destination, moved: matches.length, notMoved: 0, dryRun: true };
|
|
2575
2708
|
}
|
|
2709
|
+
assertThreadBatchSize(matches.length, input.maxBatchSize);
|
|
2576
2710
|
let moved = 0;
|
|
2577
2711
|
let notMoved = 0;
|
|
2712
|
+
const errors = [];
|
|
2578
2713
|
for (const { folder, uid, emailId, uidValidity } of matches) {
|
|
2579
2714
|
try {
|
|
2580
2715
|
await this.withTimeout(this.withMailbox(folder, false, async (client) => {
|
|
@@ -2588,12 +2723,13 @@ export class SimpleIMAPService {
|
|
|
2588
2723
|
const result = await client.messageMove(String(uid), input.destination, { uid: true });
|
|
2589
2724
|
if (result === false)
|
|
2590
2725
|
throw new Error(`Server did not move uid ${uid}`);
|
|
2591
|
-
}), 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.`);
|
|
2592
2727
|
this.messageCache.delete(emailId);
|
|
2593
2728
|
moved++;
|
|
2594
2729
|
}
|
|
2595
|
-
catch {
|
|
2730
|
+
catch (error) {
|
|
2596
2731
|
notMoved++;
|
|
2732
|
+
errors.push(describeThreadFailure(folder, uid, error));
|
|
2597
2733
|
}
|
|
2598
2734
|
}
|
|
2599
2735
|
if (moved > 0) {
|
|
@@ -2603,13 +2739,14 @@ export class SimpleIMAPService {
|
|
|
2603
2739
|
this.folderCache = undefined;
|
|
2604
2740
|
}
|
|
2605
2741
|
this.lastSyncAt = new Date().toISOString();
|
|
2606
|
-
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) } : {}) };
|
|
2607
2743
|
}
|
|
2608
2744
|
async deleteThread(input) {
|
|
2609
2745
|
const matches = await this.resolveThreadUids(input.messageId, input.acrossFolders ?? false);
|
|
2610
2746
|
if (input.dryRun) {
|
|
2611
|
-
return { messageId: input.messageId, deleted: matches.length, dryRun: true };
|
|
2747
|
+
return { messageId: input.messageId, deleted: matches.length, notDeleted: 0, dryRun: true };
|
|
2612
2748
|
}
|
|
2749
|
+
assertThreadBatchSize(matches.length, input.maxBatchSize);
|
|
2613
2750
|
// No .catch() here — a Trash-resolution failure must propagate as a
|
|
2614
2751
|
// hard error rather than silently falling into the permanent-delete
|
|
2615
2752
|
// branch below when the caller explicitly asked for permanent:false.
|
|
@@ -2620,6 +2757,7 @@ export class SimpleIMAPService {
|
|
|
2620
2757
|
? undefined
|
|
2621
2758
|
: await this.resolveSpecialFolder("\\Trash", ["Trash", "INBOX.Trash"]);
|
|
2622
2759
|
let deleted = 0;
|
|
2760
|
+
const deleteErrors = [];
|
|
2623
2761
|
for (const { folder, uid, emailId, uidValidity } of matches) {
|
|
2624
2762
|
try {
|
|
2625
2763
|
// Re-verify the generation this match was resolved under — see
|
|
@@ -2637,7 +2775,9 @@ export class SimpleIMAPService {
|
|
|
2637
2775
|
this.messageCache.delete(emailId);
|
|
2638
2776
|
deleted++;
|
|
2639
2777
|
}
|
|
2640
|
-
catch {
|
|
2778
|
+
catch (error) {
|
|
2779
|
+
deleteErrors.push(describeThreadFailure(folder, uid, error));
|
|
2780
|
+
}
|
|
2641
2781
|
}
|
|
2642
2782
|
if (deleted > 0) {
|
|
2643
2783
|
// Deletion changes the folder's message count — see the
|
|
@@ -2646,16 +2786,18 @@ export class SimpleIMAPService {
|
|
|
2646
2786
|
this.folderCache = undefined;
|
|
2647
2787
|
}
|
|
2648
2788
|
this.lastSyncAt = new Date().toISOString();
|
|
2649
|
-
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) } : {}) };
|
|
2650
2790
|
}
|
|
2651
2791
|
async flagThread(input) {
|
|
2652
2792
|
const matches = await this.resolveThreadUids(input.messageId, input.acrossFolders ?? false);
|
|
2653
2793
|
if (input.dryRun) {
|
|
2654
|
-
return { messageId: input.messageId, affected: matches.length, notApplied: [], dryRun: true };
|
|
2794
|
+
return { messageId: input.messageId, affected: matches.length, notAffected: 0, notApplied: [], dryRun: true };
|
|
2655
2795
|
}
|
|
2796
|
+
assertThreadBatchSize(matches.length, input.maxBatchSize);
|
|
2656
2797
|
const flagsToAdd = input.flagsToAdd ?? [];
|
|
2657
2798
|
const flagsToRemove = input.flagsToRemove ?? [];
|
|
2658
2799
|
let affected = 0;
|
|
2800
|
+
const flagErrors = [];
|
|
2659
2801
|
const allNotApplied = [];
|
|
2660
2802
|
for (const { folder, uid, uidValidity } of matches) {
|
|
2661
2803
|
try {
|
|
@@ -2679,7 +2821,9 @@ export class SimpleIMAPService {
|
|
|
2679
2821
|
}), BULK_ITEM_TIMEOUT_MS, `Timed out after ${BULK_ITEM_TIMEOUT_MS}ms flagging uid ${uid} for thread ${input.messageId}`);
|
|
2680
2822
|
affected++;
|
|
2681
2823
|
}
|
|
2682
|
-
catch {
|
|
2824
|
+
catch (error) {
|
|
2825
|
+
flagErrors.push(describeThreadFailure(folder, uid, error));
|
|
2826
|
+
}
|
|
2683
2827
|
}
|
|
2684
2828
|
if (affected > 0 && [...flagsToAdd, ...flagsToRemove].some((flag) => flag.toLowerCase() === "\\seen")) {
|
|
2685
2829
|
// \Seen changes the folder's unseen count — see the markEmailRead
|
|
@@ -2687,7 +2831,7 @@ export class SimpleIMAPService {
|
|
|
2687
2831
|
// invalidation on the single-email path.
|
|
2688
2832
|
this.folderCache = undefined;
|
|
2689
2833
|
}
|
|
2690
|
-
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) } : {}) };
|
|
2691
2835
|
}
|
|
2692
2836
|
async syncEmails(input = {}) {
|
|
2693
2837
|
const snapshot = await this.collectEmailsForIndex(input);
|
|
@@ -3401,6 +3545,7 @@ export class SimpleIMAPService {
|
|
|
3401
3545
|
cid: attachment.cid,
|
|
3402
3546
|
}),
|
|
3403
3547
|
content: attachment.content,
|
|
3548
|
+
charset: attachment.headers?.get?.("content-type")?.params?.charset,
|
|
3404
3549
|
}));
|
|
3405
3550
|
}
|
|
3406
3551
|
mapHeaders(parsed) {
|
|
@@ -3432,13 +3577,19 @@ export class SimpleIMAPService {
|
|
|
3432
3577
|
async getParsedAttachment(emailId, attachmentId) {
|
|
3433
3578
|
const { detail, parsed } = await this.getParsedMailDetail(emailId);
|
|
3434
3579
|
const attachments = this.mapParsedAttachmentsWithContent(parsed);
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
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) {
|
|
3439
3587
|
throw new Error(`Attachment ${attachmentId} not found on email ${detail.id}`);
|
|
3440
3588
|
}
|
|
3441
|
-
|
|
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];
|
|
3442
3593
|
}
|
|
3443
3594
|
async assertAttachmentWithinInlineLimit(emailId, attachmentId) {
|
|
3444
3595
|
const { folder, uid } = parseEmailId(emailId);
|
|
@@ -3585,6 +3736,13 @@ export class SimpleIMAPService {
|
|
|
3585
3736
|
}
|
|
3586
3737
|
return String(err);
|
|
3587
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
|
+
}
|
|
3588
3746
|
async writeAttachmentToPath(emailId, attachment, outputPath) {
|
|
3589
3747
|
let outputFilePath;
|
|
3590
3748
|
if (!outputPath) {
|
|
@@ -3609,7 +3767,7 @@ export class SimpleIMAPService {
|
|
|
3609
3767
|
this.identityChecked = true;
|
|
3610
3768
|
}
|
|
3611
3769
|
const filename = sanitizeFileName(attachment.filename, attachment.id || "attachment");
|
|
3612
|
-
const dirPath = join(this.
|
|
3770
|
+
const dirPath = join(this.defaultAttachmentDir(), encodeURIComponent(emailId));
|
|
3613
3771
|
// 0o700/0o600: this writes the user's own private email content — restrict
|
|
3614
3772
|
// it to the owner regardless of the destination directory's own permissions.
|
|
3615
3773
|
await mkdir(dirPath, { recursive: true, mode: 0o700 });
|
|
@@ -3684,33 +3842,31 @@ export class SimpleIMAPService {
|
|
|
3684
3842
|
// passed. Returning the already-realpath'd path collapses that into the
|
|
3685
3843
|
// single unavoidable race between this check and the actual write.
|
|
3686
3844
|
guardAttachmentOutputPath(outputPath) {
|
|
3687
|
-
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();
|
|
3688
3846
|
if (!allowDir) {
|
|
3689
3847
|
throw new Error("outputPath requires PROTONMAIL_ALLOW_FILE_DOWNLOAD_DIR to be configured.");
|
|
3690
3848
|
}
|
|
3691
3849
|
const targetPath = resolve(outputPath);
|
|
3692
3850
|
const allowedRealPath = realpathSync(resolve(allowDir));
|
|
3693
|
-
|
|
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;
|
|
3694
3862
|
try {
|
|
3695
|
-
|
|
3863
|
+
existingReal = realpathSync(existing);
|
|
3696
3864
|
}
|
|
3697
|
-
catch
|
|
3698
|
-
|
|
3699
|
-
typeof error === "object" &&
|
|
3700
|
-
"code" in error &&
|
|
3701
|
-
error.code === "ENOENT") {
|
|
3702
|
-
try {
|
|
3703
|
-
targetRealPath = realpathSync(dirname(targetPath)) + sep + basename(targetPath);
|
|
3704
|
-
}
|
|
3705
|
-
catch {
|
|
3706
|
-
throw new Error(`Output directory does not exist: ${dirname(targetPath)}`);
|
|
3707
|
-
}
|
|
3708
|
-
}
|
|
3709
|
-
else {
|
|
3710
|
-
throw error;
|
|
3711
|
-
}
|
|
3865
|
+
catch {
|
|
3866
|
+
throw new Error(`Output path cannot be resolved: ${targetPath}`);
|
|
3712
3867
|
}
|
|
3713
|
-
|
|
3868
|
+
const targetRealPath = existing === targetPath ? existingReal : join(existingReal, relative(existing, targetPath));
|
|
3869
|
+
if (!isPathInside(allowedRealPath, targetRealPath)) {
|
|
3714
3870
|
throw new Error("outputPath path escapes the allowed directory.");
|
|
3715
3871
|
}
|
|
3716
3872
|
return targetRealPath;
|
|
@@ -3718,8 +3874,10 @@ export class SimpleIMAPService {
|
|
|
3718
3874
|
async resolveAttachmentOutputPath(emailId, attachment, outputPath) {
|
|
3719
3875
|
const filename = sanitizeFileName(attachment.filename, attachment.id || "attachment");
|
|
3720
3876
|
if (!outputPath) {
|
|
3721
|
-
return join(this.
|
|
3877
|
+
return join(this.defaultAttachmentDir(), encodeURIComponent(emailId), filename);
|
|
3722
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);
|
|
3723
3881
|
const resolved = resolve(outputPath);
|
|
3724
3882
|
try {
|
|
3725
3883
|
const existing = await stat(resolved);
|
|
@@ -3736,7 +3894,7 @@ export class SimpleIMAPService {
|
|
|
3736
3894
|
throw error;
|
|
3737
3895
|
}
|
|
3738
3896
|
}
|
|
3739
|
-
if (
|
|
3897
|
+
if (wantsDirectory) {
|
|
3740
3898
|
const directoryTarget = join(resolved, filename);
|
|
3741
3899
|
return this.guardAttachmentOutputPath(directoryTarget);
|
|
3742
3900
|
}
|
|
@@ -3787,26 +3945,36 @@ export class SimpleIMAPService {
|
|
|
3787
3945
|
// there's nothing to dedup against — fall through to a normal import,
|
|
3788
3946
|
// same as before this fix (not a regression).
|
|
3789
3947
|
let messageId;
|
|
3948
|
+
let sentAt;
|
|
3790
3949
|
try {
|
|
3791
|
-
|
|
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;
|
|
3792
3953
|
}
|
|
3793
3954
|
catch {
|
|
3794
3955
|
// Best-effort — an unparseable message still gets imported below.
|
|
3795
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));
|
|
3796
3962
|
if (messageId) {
|
|
3797
3963
|
let existingFolderUidValidity;
|
|
3798
3964
|
const existingUid = await this.withTimeout(this.withMailbox(folder, true, async (client) => {
|
|
3799
3965
|
existingFolderUidValidity = (client.mailbox || undefined)?.uidValidity?.toString();
|
|
3800
3966
|
const uids = await client.search({ header: { "Message-ID": messageId } }, { uid: true });
|
|
3801
3967
|
return Array.isArray(uids) && uids.length > 0 ? uids[uids.length - 1] : undefined;
|
|
3802
|
-
}), 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.
|
|
3803
3971
|
if (existingUid !== undefined) {
|
|
3804
3972
|
const existingEmailId = createEmailId(folder, existingUid, existingFolderUidValidity);
|
|
3805
3973
|
return { folder, uid: existingUid, emailId: existingEmailId, alreadyExists: true, existingEmailId };
|
|
3806
3974
|
}
|
|
3807
3975
|
}
|
|
3808
3976
|
const client = await this.ensureConnected();
|
|
3809
|
-
const appended = await client.append(folder, input.raw, input.flags ?? [],
|
|
3977
|
+
const appended = await client.append(folder, input.raw, input.flags ?? [], internalDate);
|
|
3810
3978
|
if (!appended) {
|
|
3811
3979
|
throw new Error(`Server did not append the imported message into ${folder}`);
|
|
3812
3980
|
}
|