proton-mail-bridge-client 2.3.2 → 2.4.1

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 (68) 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 +251 -133
  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.map +1 -1
  29. package/dist/services/delivery-queue-service.js +3 -8
  30. package/dist/services/delivery-queue-service.js.map +1 -1
  31. package/dist/services/draft-store-service.d.ts.map +1 -1
  32. package/dist/services/draft-store-service.js +3 -4
  33. package/dist/services/draft-store-service.js.map +1 -1
  34. package/dist/services/local-index-service.d.ts +2 -1
  35. package/dist/services/local-index-service.d.ts.map +1 -1
  36. package/dist/services/local-index-service.js +45 -9
  37. package/dist/services/local-index-service.js.map +1 -1
  38. package/dist/services/simple-imap-service.d.ts +21 -2
  39. package/dist/services/simple-imap-service.d.ts.map +1 -1
  40. package/dist/services/simple-imap-service.js +271 -103
  41. package/dist/services/simple-imap-service.js.map +1 -1
  42. package/dist/services/smtp-service.d.ts.map +1 -1
  43. package/dist/services/smtp-service.js +15 -0
  44. package/dist/services/smtp-service.js.map +1 -1
  45. package/dist/services/snooze-service.d.ts.map +1 -1
  46. package/dist/services/snooze-service.js +40 -20
  47. package/dist/services/snooze-service.js.map +1 -1
  48. package/dist/services/template-service.d.ts +4 -2
  49. package/dist/services/template-service.d.ts.map +1 -1
  50. package/dist/services/template-service.js +43 -10
  51. package/dist/services/template-service.js.map +1 -1
  52. package/dist/types/index.d.ts +1 -0
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/dist/utils/atomic-write.d.ts +2 -0
  55. package/dist/utils/atomic-write.d.ts.map +1 -0
  56. package/dist/utils/atomic-write.js +36 -0
  57. package/dist/utils/atomic-write.js.map +1 -0
  58. package/dist/utils/file-lock.d.ts.map +1 -1
  59. package/dist/utils/file-lock.js +39 -4
  60. package/dist/utils/file-lock.js.map +1 -1
  61. package/dist/utils/helpers.d.ts +14 -0
  62. package/dist/utils/helpers.d.ts.map +1 -1
  63. package/dist/utils/helpers.js +222 -21
  64. package/dist/utils/helpers.js.map +1 -1
  65. package/dist/utils/logger.d.ts.map +1 -1
  66. package/dist/utils/logger.js +29 -12
  67. package/dist/utils/logger.js.map +1 -1
  68. 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, 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;
@@ -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
- if (nonAsciiCriteria && uids.length > NON_ASCII_SCAN_CAP) {
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, NON_ASCII_SCAN_CAP);
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 { /* best-effort */ }
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 { /* best-effort */ }
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
- const match = attachments.find((attachment) => attachment.id === attachmentId ||
3436
- attachment.filename === attachmentId ||
3437
- attachment.checksum === attachmentId);
3438
- 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) {
3439
3587
  throw new Error(`Attachment ${attachmentId} not found on email ${detail.id}`);
3440
3588
  }
3441
- 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];
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.config.dataDir, "attachments", encodeURIComponent(emailId));
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
- 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;
3694
3862
  try {
3695
- targetRealPath = realpathSync(targetPath);
3863
+ existingReal = realpathSync(existing);
3696
3864
  }
3697
- catch (error) {
3698
- if (error &&
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
- if (!targetRealPath.startsWith(`${allowedRealPath}${sep}`) && targetRealPath !== allowedRealPath) {
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.config.dataDir, "attachments", encodeURIComponent(emailId), filename);
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 (resolved.endsWith("/") || resolved.endsWith("\\")) {
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
- 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;
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}`).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.
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 ?? [], input.internalDate ?? new Date());
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
  }