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.
- package/README.md +2 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +83 -37
- package/dist/index.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts +5 -0
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +110 -15
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts +3 -0
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +64 -1
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +5 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +191 -18
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +7 -1
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +163 -23
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/snooze-service.d.ts +3 -0
- package/dist/services/snooze-service.d.ts.map +1 -1
- package/dist/services/snooze-service.js +103 -7
- package/dist/services/snooze-service.js.map +1 -1
- package/dist/services/template-service.d.ts +1 -0
- package/dist/services/template-service.d.ts.map +1 -1
- package/dist/services/template-service.js +8 -0
- package/dist/services/template-service.js.map +1 -1
- package/dist/types/index.d.ts +7 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/account-identity.d.ts +8 -0
- package/dist/utils/account-identity.d.ts.map +1 -0
- package/dist/utils/account-identity.js +78 -0
- package/dist/utils/account-identity.js.map +1 -0
- package/dist/utils/file-lock.d.ts +1 -0
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +24 -4
- package/dist/utils/file-lock.js.map +1 -1
- 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
|
-
//
|
|
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:
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
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:
|
|
292
|
-
changed,
|
|
293
|
-
startUid
|
|
294
|
-
endUid
|
|
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
|
-
|
|
880
|
-
|
|
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
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
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}
|
|
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;
|