proton-mail-bridge-client 2.0.0 → 2.0.2

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 (42) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +6 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +83 -37
  7. package/dist/index.js.map +1 -1
  8. package/dist/services/delivery-queue-service.d.ts +5 -0
  9. package/dist/services/delivery-queue-service.d.ts.map +1 -1
  10. package/dist/services/delivery-queue-service.js +110 -15
  11. package/dist/services/delivery-queue-service.js.map +1 -1
  12. package/dist/services/draft-store-service.d.ts +3 -0
  13. package/dist/services/draft-store-service.d.ts.map +1 -1
  14. package/dist/services/draft-store-service.js +64 -1
  15. package/dist/services/draft-store-service.js.map +1 -1
  16. package/dist/services/local-index-service.d.ts +5 -0
  17. package/dist/services/local-index-service.d.ts.map +1 -1
  18. package/dist/services/local-index-service.js +191 -18
  19. package/dist/services/local-index-service.js.map +1 -1
  20. package/dist/services/simple-imap-service.d.ts +7 -1
  21. package/dist/services/simple-imap-service.d.ts.map +1 -1
  22. package/dist/services/simple-imap-service.js +163 -23
  23. package/dist/services/simple-imap-service.js.map +1 -1
  24. package/dist/services/snooze-service.d.ts +3 -0
  25. package/dist/services/snooze-service.d.ts.map +1 -1
  26. package/dist/services/snooze-service.js +103 -7
  27. package/dist/services/snooze-service.js.map +1 -1
  28. package/dist/services/template-service.d.ts +1 -0
  29. package/dist/services/template-service.d.ts.map +1 -1
  30. package/dist/services/template-service.js +8 -0
  31. package/dist/services/template-service.js.map +1 -1
  32. package/dist/types/index.d.ts +7 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/utils/account-identity.d.ts +8 -0
  35. package/dist/utils/account-identity.d.ts.map +1 -0
  36. package/dist/utils/account-identity.js +78 -0
  37. package/dist/utils/account-identity.js.map +1 -0
  38. package/dist/utils/file-lock.d.ts +1 -0
  39. package/dist/utils/file-lock.d.ts.map +1 -1
  40. package/dist/utils/file-lock.js +24 -4
  41. package/dist/utils/file-lock.js.map +1 -1
  42. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { mkdir, stat, writeFile } from "node:fs/promises";
2
+ import { mkdir, open, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join, resolve, sep } from "node:path";
4
4
  import { ImapFlow } from "imapflow";
5
5
  import { simpleParser } from "mailparser";
@@ -225,6 +225,10 @@ export function planFolderSync(input) {
225
225
  strategy: "empty",
226
226
  changed: false,
227
227
  highestKnownUid,
228
+ // Only a genuine server-reported exists === 0 means the mailbox is
229
+ // actually empty — highestKnownUid === 0 alone (uidNext missing/1)
230
+ // isn't proof of that, so it must not trigger index cleanup on its own.
231
+ folderObservedEmpty: input.exists === 0,
228
232
  };
229
233
  }
230
234
  if (input.full) {
@@ -238,12 +242,29 @@ export function planFolderSync(input) {
238
242
  const uidValidityMatches = !input.checkpoint?.uidValidity || !input.uidValidity || input.checkpoint.uidValidity === input.uidValidity;
239
243
  const priorFloor = uidValidityMatches ? input.checkpoint?.backfilledToUid : undefined;
240
244
  if (priorFloor !== undefined && priorFloor <= 1) {
241
- // Already backfilled all the way back to UID 1 in a previous call —
242
- // nothing older left to fetch.
245
+ // Already backfilled all the way back to UID 1 in a previous call.
246
+ // History is fully covered, but full:true must still surface mail
247
+ // that arrived *after* backfill completed — otherwise, once a folder
248
+ // finishes backfilling, full:true silently stops discovering any new
249
+ // mail forever, even as highestKnownUid keeps growing. Top up with a
250
+ // bounded fetch of just the newly-arrived range.
251
+ const priorHighest = input.checkpoint?.highestUid ?? 0;
252
+ if (highestKnownUid <= priorHighest) {
253
+ return {
254
+ folder: input.folder,
255
+ strategy: "full",
256
+ changed: false,
257
+ highestKnownUid,
258
+ backfilledToUid: priorFloor,
259
+ };
260
+ }
261
+ const topUpStart = Math.max(1, priorHighest + 1, highestKnownUid - input.limit + 1);
243
262
  return {
244
263
  folder: input.folder,
245
264
  strategy: "full",
246
- changed: false,
265
+ changed: true,
266
+ startUid: topUpStart,
267
+ endUid: highestKnownUid,
247
268
  highestKnownUid,
248
269
  backfilledToUid: priorFloor,
249
270
  };
@@ -283,16 +304,46 @@ export function planFolderSync(input) {
283
304
  };
284
305
  }
285
306
  const overlap = Math.min(input.limit, Math.max(25, Math.min(100, Math.ceil(input.limit / 2))));
286
- const changed = highestKnownUid > (input.checkpoint.highestUid ?? 0) ||
287
- uidNext !== (input.checkpoint.uidNext ?? uidNext) ||
288
- input.exists !== (input.checkpoint.total ?? input.exists);
307
+ const knownHighUid = input.checkpoint.highestUid ?? 0;
308
+ // Undefined (not stored as null-ish 0) means "no catch-up in progress" — see the
309
+ // backfilledToUid comment above for why this codebase maps SQLite NULL to
310
+ // undefined rather than null for exactly this kind of cursor field.
311
+ const resumeUid = input.checkpoint.incrementalResumeUid;
312
+ if (resumeUid === undefined && highestKnownUid - knownHighUid <= input.limit) {
313
+ // Fully caught up (or the gap is small enough to close in one call): the
314
+ // established incremental behavior — re-fetch a small overlap window to catch
315
+ // flag changes on already-seen messages, then fetch forward to the true top.
316
+ const changed = highestKnownUid > knownHighUid ||
317
+ uidNext !== (input.checkpoint.uidNext ?? uidNext) ||
318
+ input.exists !== (input.checkpoint.total ?? input.exists);
319
+ return {
320
+ folder: input.folder,
321
+ strategy: changed ? "incremental" : "incremental_window",
322
+ changed,
323
+ startUid: Math.max(1, Math.min(highestKnownUid, knownHighUid) - overlap + 1),
324
+ endUid: highestKnownUid,
325
+ highestKnownUid,
326
+ };
327
+ }
328
+ // The gap between the checkpoint and the mailbox's current top UID is larger than
329
+ // `limit` — e.g. after a long time offline or a large mail import. Previously this
330
+ // branch fetched startUid:highestKnownUid unconditionally, so `limit` only ever
331
+ // shrank the overlap, never bounded the actual range: a checkpoint at UID 1000 with
332
+ // the server at UID 100000 could plan a 976:100000 fetch — ~99k messages parsed and
333
+ // held in memory in one call regardless of `limit`. Fetch one limit-sized bounded
334
+ // slice instead, and persist how far it reached as incrementalResumeUid so the next
335
+ // call continues forward from there rather than re-planning the whole remaining gap.
336
+ const startUid = resumeUid !== undefined ? resumeUid + 1 : Math.max(1, knownHighUid - overlap + 1);
337
+ const endUid = Math.min(highestKnownUid, startUid + input.limit - 1);
338
+ const reachesTop = endUid === highestKnownUid;
289
339
  return {
290
340
  folder: input.folder,
291
- strategy: changed ? "incremental" : "incremental_window",
292
- changed,
293
- startUid: Math.max(1, Math.min(highestKnownUid, input.checkpoint.highestUid) - overlap + 1),
294
- endUid: highestKnownUid,
341
+ strategy: "incremental",
342
+ changed: true,
343
+ startUid,
344
+ endUid,
295
345
  highestKnownUid,
346
+ incrementalResumeUid: reachesTop ? undefined : endUid,
296
347
  };
297
348
  }
298
349
  function mapFolder(entry) {
@@ -876,8 +927,24 @@ export class SimpleIMAPService {
876
927
  }
877
928
  }
878
929
  else {
879
- const endSeq = total - offset;
880
- const startSeq = Math.max(1, endSeq - limit + 1);
930
+ // Found on review: sortByUid only sorted the fetched page afterward
931
+ // (below) — the *range itself* was always anchored to the newest
932
+ // end regardless of direction, so "asc" (oldest first) paginated
933
+ // through progressively OLDER newest-end windows (91-100, then
934
+ // 81-90) instead of walking forward from the true oldest message
935
+ // (1-10, then 11-20), contradicting the documented "oldest first"
936
+ // behavior and never reaching a stable, monotonically-advancing
937
+ // cursor across pages.
938
+ let startSeq;
939
+ let endSeq;
940
+ if (input.sortByUid === "asc") {
941
+ startSeq = offset + 1;
942
+ endSeq = Math.min(total, startSeq + limit - 1);
943
+ }
944
+ else {
945
+ endSeq = total - offset;
946
+ startSeq = Math.max(1, endSeq - limit + 1);
947
+ }
881
948
  for await (const message of client.fetch(`${startSeq}:${endSeq}`, fetchQuery)) {
882
949
  const summary = this.toSummary(folder, message);
883
950
  const enriched = input.includeSnippet && message.source
@@ -1450,6 +1517,17 @@ export class SimpleIMAPService {
1450
1517
  this.folderCache = undefined;
1451
1518
  return { folder, deleted: uids.length };
1452
1519
  }
1520
+ // Not private: index.ts's bulk_delete/bulk_update_flags/bulk_update_labels
1521
+ // handlers call this directly to resolve a match/emailIds set exactly
1522
+ // once, then pass the result back in as `resolvedUids` so the dryRun and
1523
+ // real-run calls act on the identical set instead of each re-resolving
1524
+ // `match` against the live mailbox. See resolvedUids below for why: two
1525
+ // separate resolutions of the same `match` can return different UID sets
1526
+ // if the mailbox changes between them (e.g. new mail arrives), letting a
1527
+ // bulk op exceed its configured maxBatchSize. Found live: with
1528
+ // maxBatchSize 1, a first resolution returned [1], a second (between the
1529
+ // dry-run check and the real run) returned [1,2], and the real run acted
1530
+ // on both — silently exceeding the limit.
1453
1531
  async resolveUidsForBulkOp(folder, emailIds, match) {
1454
1532
  if (emailIds !== undefined && match !== undefined) {
1455
1533
  throw new Error("Provide either emailIds or match, not both");
@@ -1585,7 +1663,7 @@ export class SimpleIMAPService {
1585
1663
  }
1586
1664
  async bulkDelete(input) {
1587
1665
  const folder = input.folder?.trim() || "INBOX";
1588
- const uids = await this.resolveUidsForBulkOp(folder, input.emailIds, input.match);
1666
+ const uids = input.resolvedUids ?? await this.resolveUidsForBulkOp(folder, input.emailIds, input.match);
1589
1667
  if (input.dryRun) {
1590
1668
  return {
1591
1669
  dryRun: true,
@@ -1670,7 +1748,7 @@ export class SimpleIMAPService {
1670
1748
  }
1671
1749
  async bulkUpdateFlags(input) {
1672
1750
  const folder = input.folder?.trim() || "INBOX";
1673
- const uids = await this.resolveUidsForBulkOp(folder, input.emailIds, input.match);
1751
+ const uids = input.resolvedUids ?? await this.resolveUidsForBulkOp(folder, input.emailIds, input.match);
1674
1752
  if (input.dryRun) {
1675
1753
  return {
1676
1754
  dryRun: true,
@@ -1743,7 +1821,7 @@ export class SimpleIMAPService {
1743
1821
  }
1744
1822
  async bulkUpdateLabels(input) {
1745
1823
  const folder = input.folder?.trim() || "INBOX";
1746
- const uids = await this.resolveUidsForBulkOp(folder, input.emailIds, input.match);
1824
+ const uids = input.resolvedUids ?? await this.resolveUidsForBulkOp(folder, input.emailIds, input.match);
1747
1825
  if (input.dryRun) {
1748
1826
  return {
1749
1827
  dryRun: true,
@@ -2072,6 +2150,8 @@ export class SimpleIMAPService {
2072
2150
  fetched: 0,
2073
2151
  total: exists,
2074
2152
  backfilledToUid: plan.backfilledToUid,
2153
+ folderObservedEmpty: plan.folderObservedEmpty,
2154
+ incrementalResumeUid: plan.incrementalResumeUid,
2075
2155
  },
2076
2156
  emails: [],
2077
2157
  };
@@ -2100,7 +2180,10 @@ export class SimpleIMAPService {
2100
2180
  // unchanged; using this batch's (much lower) fetched UIDs here would
2101
2181
  // silently roll the incremental-sync high-water-mark backward and
2102
2182
  // make every subsequent incremental sync think a huge amount of
2103
- // "new" mail exists again.
2183
+ // "new" mail exists again. The same reasoning applies to a bounded
2184
+ // incremental catch-up window (large gap since the last checkpoint):
2185
+ // its endUid is also below highestKnownUid until the last batch closes
2186
+ // the gap.
2104
2187
  const reachesTop = plan.endUid === plan.highestKnownUid;
2105
2188
  const highestUid = reachesTop
2106
2189
  ? emails.reduce((max, email) => Math.max(max, email.uid), 0) || plan.highestKnownUid
@@ -2122,6 +2205,7 @@ export class SimpleIMAPService {
2122
2205
  rangeStartUid: plan.startUid,
2123
2206
  rangeEndUid: plan.endUid,
2124
2207
  backfilledToUid: plan.backfilledToUid,
2208
+ incrementalResumeUid: plan.incrementalResumeUid,
2125
2209
  },
2126
2210
  emails,
2127
2211
  };
@@ -2761,11 +2845,30 @@ export class SimpleIMAPService {
2761
2845
  }
2762
2846
  }
2763
2847
  async writeAttachmentToPath(emailId, attachment, outputPath) {
2764
- const outputFilePath = await this.resolveAttachmentOutputPath(emailId, attachment, outputPath);
2765
- // 0o700/0o600: this writes the user's own private email content — restrict
2766
- // it to the owner regardless of the destination directory's own permissions.
2767
- await mkdir(dirname(outputFilePath), { recursive: true, mode: 0o700 });
2768
- await writeFile(outputFilePath, attachment.content, { mode: 0o600 });
2848
+ let outputFilePath;
2849
+ if (!outputPath) {
2850
+ // No caller-supplied outputPath: attachments land in a shared default
2851
+ // per-message directory keyed only by their own (sanitized) filename,
2852
+ // so two attachments sharing a name — in this batch, or left over from
2853
+ // a prior save — must never silently clobber each other. Resolve the
2854
+ // final on-disk name via atomic exclusive-create instead of the
2855
+ // explicit-outputPath branch below, which already gets its uniqueness
2856
+ // and containment guarantees from the caller (saveAttachments' usedPaths
2857
+ // dedup) and guardAttachmentOutputPath.
2858
+ const filename = sanitizeFileName(attachment.filename, attachment.id || "attachment");
2859
+ const dirPath = join(this.config.dataDir, "attachments", encodeURIComponent(emailId));
2860
+ // 0o700/0o600: this writes the user's own private email content — restrict
2861
+ // it to the owner regardless of the destination directory's own permissions.
2862
+ await mkdir(dirPath, { recursive: true, mode: 0o700 });
2863
+ outputFilePath = await this.writeAttachmentUnique(dirPath, filename, attachment.content);
2864
+ }
2865
+ else {
2866
+ outputFilePath = await this.resolveAttachmentOutputPath(emailId, attachment, outputPath);
2867
+ // 0o700/0o600: this writes the user's own private email content — restrict
2868
+ // it to the owner regardless of the destination directory's own permissions.
2869
+ await mkdir(dirname(outputFilePath), { recursive: true, mode: 0o700 });
2870
+ await writeFile(outputFilePath, attachment.content, { mode: 0o600 });
2871
+ }
2769
2872
  return {
2770
2873
  emailId,
2771
2874
  attachment: {
@@ -2784,6 +2887,43 @@ export class SimpleIMAPService {
2784
2887
  outputPath: basename(outputFilePath),
2785
2888
  };
2786
2889
  }
2890
+ // Writes `content` under dirPath as `filename`, guaranteeing the result
2891
+ // never silently overwrites an existing file — whether that file is another
2892
+ // attachment from the same save() call or one left over from a prior save.
2893
+ // Uses open(..., "wx") (atomic exclusive-create, same primitive
2894
+ // file-lock.ts uses for its own lock files) rather than existsSync() +
2895
+ // writeFile(), so a second concurrent save can't interleave between the
2896
+ // check and the write. On a collision, retries with a numeric suffix before
2897
+ // the extension (report.txt → report (1).txt → report (2).txt → ...),
2898
+ // matching the convention saveAttachments already uses for its
2899
+ // explicit-outputPath batch dedup.
2900
+ async writeAttachmentUnique(dirPath, filename, content) {
2901
+ const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")) : "";
2902
+ const stem = filename.slice(0, filename.length - ext.length);
2903
+ let candidateName = filename;
2904
+ let counter = 0;
2905
+ for (;;) {
2906
+ const candidatePath = join(dirPath, candidateName);
2907
+ try {
2908
+ const handle = await open(candidatePath, "wx", 0o600);
2909
+ try {
2910
+ await handle.writeFile(content);
2911
+ }
2912
+ finally {
2913
+ await handle.close();
2914
+ }
2915
+ return candidatePath;
2916
+ }
2917
+ catch (error) {
2918
+ if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
2919
+ counter += 1;
2920
+ candidateName = `${stem} (${counter})${ext}`;
2921
+ continue;
2922
+ }
2923
+ throw error;
2924
+ }
2925
+ }
2926
+ }
2787
2927
  // Returns the validated real path so callers write through the same
2788
2928
  // resolved path they just checked, instead of re-deriving it — re-deriving
2789
2929
  // left a TOCTOU window where a symlink swapped in between validation and
@@ -2817,7 +2957,7 @@ export class SimpleIMAPService {
2817
2957
  throw error;
2818
2958
  }
2819
2959
  }
2820
- if (!targetRealPath.startsWith(`${allowedRealPath}/`) && targetRealPath !== allowedRealPath) {
2960
+ if (!targetRealPath.startsWith(`${allowedRealPath}${sep}`) && targetRealPath !== allowedRealPath) {
2821
2961
  throw new Error("outputPath path escapes the allowed directory.");
2822
2962
  }
2823
2963
  return targetRealPath;