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.
Files changed (73) hide show
  1. package/README.md +8 -2
  2. package/dist/cli.d.ts +11 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +348 -202
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +10 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +304 -143
  9. package/dist/index.js.map +1 -1
  10. package/dist/scripts/check-claude-desktop.d.ts +18 -0
  11. package/dist/scripts/check-claude-desktop.d.ts.map +1 -1
  12. package/dist/scripts/check-claude-desktop.js +91 -30
  13. package/dist/scripts/check-claude-desktop.js.map +1 -1
  14. package/dist/scripts/install-claude-desktop.d.ts +12 -0
  15. package/dist/scripts/install-claude-desktop.d.ts.map +1 -1
  16. package/dist/scripts/install-claude-desktop.js +211 -43
  17. package/dist/scripts/install-claude-desktop.js.map +1 -1
  18. package/dist/services/analytics-service.d.ts +1 -1
  19. package/dist/services/analytics-service.d.ts.map +1 -1
  20. package/dist/services/analytics-service.js +2 -2
  21. package/dist/services/analytics-service.js.map +1 -1
  22. package/dist/services/audit-service.d.ts.map +1 -1
  23. package/dist/services/audit-service.js +7 -2
  24. package/dist/services/audit-service.js.map +1 -1
  25. package/dist/services/background-sync-service.d.ts.map +1 -1
  26. package/dist/services/background-sync-service.js +10 -2
  27. package/dist/services/background-sync-service.js.map +1 -1
  28. package/dist/services/delivery-queue-service.d.ts +4 -1
  29. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  30. package/dist/services/delivery-queue-service.js +21 -27
  31. package/dist/services/delivery-queue-service.js.map +1 -1
  32. package/dist/services/draft-store-service.d.ts.map +1 -1
  33. package/dist/services/draft-store-service.js +13 -25
  34. package/dist/services/draft-store-service.js.map +1 -1
  35. package/dist/services/local-index-service.d.ts +2 -1
  36. package/dist/services/local-index-service.d.ts.map +1 -1
  37. package/dist/services/local-index-service.js +91 -33
  38. package/dist/services/local-index-service.js.map +1 -1
  39. package/dist/services/simple-imap-service.d.ts +22 -2
  40. package/dist/services/simple-imap-service.d.ts.map +1 -1
  41. package/dist/services/simple-imap-service.js +295 -107
  42. package/dist/services/simple-imap-service.js.map +1 -1
  43. package/dist/services/smtp-service.d.ts.map +1 -1
  44. package/dist/services/smtp-service.js +24 -2
  45. package/dist/services/smtp-service.js.map +1 -1
  46. package/dist/services/snooze-service.d.ts.map +1 -1
  47. package/dist/services/snooze-service.js +43 -30
  48. package/dist/services/snooze-service.js.map +1 -1
  49. package/dist/services/template-service.d.ts +4 -2
  50. package/dist/services/template-service.d.ts.map +1 -1
  51. package/dist/services/template-service.js +46 -20
  52. package/dist/services/template-service.js.map +1 -1
  53. package/dist/types/index.d.ts +1 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/dist/utils/atomic-write.d.ts +2 -0
  56. package/dist/utils/atomic-write.d.ts.map +1 -0
  57. package/dist/utils/atomic-write.js +36 -0
  58. package/dist/utils/atomic-write.js.map +1 -0
  59. package/dist/utils/corrupt-store.d.ts +4 -0
  60. package/dist/utils/corrupt-store.d.ts.map +1 -0
  61. package/dist/utils/corrupt-store.js +29 -0
  62. package/dist/utils/corrupt-store.js.map +1 -0
  63. package/dist/utils/file-lock.d.ts.map +1 -1
  64. package/dist/utils/file-lock.js +39 -4
  65. package/dist/utils/file-lock.js.map +1 -1
  66. package/dist/utils/helpers.d.ts +14 -0
  67. package/dist/utils/helpers.d.ts.map +1 -1
  68. package/dist/utils/helpers.js +281 -27
  69. package/dist/utils/helpers.js.map +1 -1
  70. package/dist/utils/logger.d.ts.map +1 -1
  71. package/dist/utils/logger.js +29 -12
  72. package/dist/utils/logger.js.map +1 -1
  73. 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, resolve, sep } from "node:path";
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
- _idleActive = new Map();
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
- if (!this.client) {
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 (this.client.usable) {
672
- await this.client.logout();
740
+ if (client.usable) {
741
+ await client.logout();
673
742
  }
674
743
  else {
675
- this.client.close();
744
+ client.close();
676
745
  }
677
746
  }
678
747
  catch (error) {
679
748
  this.log.warn("IMAP disconnect failed", "IMAPService", error);
680
- this.client.close();
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
- if (this._idleActive.get(folder)) {
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
- this._idleActive.set(folder, true);
713
- const client = await this.ensureConnected();
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 this.waitForMailboxChangesWithClient(client, folder, timeoutMs, true);
811
+ return await session;
716
812
  }
717
813
  finally {
718
- this._idleActive.delete(folder);
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
- const lock = await client.getMailboxLock(folder, { readOnly: true });
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 _idleActive
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
- const reservedRoots = new Set([
1093
- "INBOX",
1094
- "Drafts",
1095
- "Sent",
1096
- "Trash",
1097
- "Spam",
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
- if (reservedRoots.has(trimmed)) {
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
- saved.push(await this.writeAttachmentToPath(input.emailId, attachment, targetPath));
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
- return stripHtmlToText(attachment.content.toString("utf8"));
1482
- }
1483
- if (contentType === "text/calendar") {
1484
- return summarizeCalendarText(attachment.content.toString("utf8"));
1485
- }
1486
- if (isTextLikeMimeType(attachment.contentType)) {
1487
- return attachment.content.toString("utf8");
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, includeBase64 = false) {
1509
- await this.assertAttachmentWithinInlineLimit(emailId, attachmentId);
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
- const folder = input.folder ?? "INBOX";
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 and say so.
1927
- if (nonAsciiCriteria && uids.length > NON_ASCII_SCAN_CAP) {
1928
- uids = [...uids].sort((a, b) => b - a).slice(0, NON_ASCII_SCAN_CAP);
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 approximate ? { folder, count, approximate: true } : { folder, count };
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 { /* best-effort */ }
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 { /* best-effort */ }
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
- const match = attachments.find((attachment) => attachment.id === attachmentId ||
3416
- attachment.filename === attachmentId ||
3417
- attachment.checksum === attachmentId);
3418
- if (!match) {
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
- return match;
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.config.dataDir, "attachments", encodeURIComponent(emailId));
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
- let targetRealPath;
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
- targetRealPath = realpathSync(targetPath);
3863
+ existingReal = realpathSync(existing);
3676
3864
  }
3677
- catch (error) {
3678
- if (error &&
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
- if (!targetRealPath.startsWith(`${allowedRealPath}${sep}`) && targetRealPath !== allowedRealPath) {
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.config.dataDir, "attachments", encodeURIComponent(emailId), filename);
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 (resolved.endsWith("/") || resolved.endsWith("\\")) {
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
- messageId = (await this.parseSource(input.raw)).messageId;
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}`).catch(() => undefined);
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 ?? [], input.internalDate ?? new Date());
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
  }