@remit/mailbox-service 0.0.10 → 0.0.11

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.
@@ -0,0 +1,322 @@
1
+ import type { ImapMessage } from "./types.js";
2
+
3
+ /**
4
+ * Cursor arithmetic for message sync.
5
+ *
6
+ * One rule governs every function here: a cursor may never move past work
7
+ * that has not been durably applied. A watermark that steps over a message —
8
+ * because a batch was cut in the wrong place, or because a failure sat below
9
+ * a success — drops that message for good, since every selection this service
10
+ * makes is a comparison against the watermark and nothing else.
11
+ *
12
+ * The rule applies on two axes: mod-sequence (the CONDSTORE path) and UID
13
+ * (the enumeration path).
14
+ */
15
+
16
+ /**
17
+ * A mod-sequence is an unsigned 64-bit value, so it is stored as decimal
18
+ * digits and only ever compared as a BigInt. `undefined` covers rows written
19
+ * before the field existed, and "0" is the server's own way of saying it has
20
+ * no mod-sequence to report.
21
+ *
22
+ * Anything that is not plain digits reads as no mod-sequence rather than
23
+ * throwing. A cursor is read at the very top of a sync round, so a value this
24
+ * cannot parse would take the mailbox out of sync permanently, and leave no
25
+ * path to repair it — every route to the field runs through here. Degrading to
26
+ * 0 sends the mailbox back to enumeration, which reseeds it.
27
+ */
28
+ const DECIMAL_DIGITS = /^\d+$/;
29
+
30
+ export const parseModseq = (raw: string | undefined): bigint => {
31
+ if (!raw || !DECIMAL_DIGITS.test(raw)) return 0n;
32
+ const value = BigInt(raw);
33
+ return value > 0n ? value : 0n;
34
+ };
35
+
36
+ /**
37
+ * Order a CHANGEDSINCE result by mod-sequence, oldest change first, UID
38
+ * breaking ties.
39
+ *
40
+ * Mod-sequence order is what makes a partial round resumable: a batch taken
41
+ * off the front can advance the watermark to the last change it applied and
42
+ * the next round resumes there.
43
+ */
44
+ export const orderByModseq = (messages: ImapMessage[]): ImapMessage[] =>
45
+ [...messages].sort((a, b) => {
46
+ const left = parseModseq(a.modseq);
47
+ const right = parseModseq(b.modseq);
48
+ if (left === right) return a.uid - b.uid;
49
+ return left < right ? -1 : 1;
50
+ });
51
+
52
+ /**
53
+ * The message-sync cursor: how far CHANGEDSINCE has been applied.
54
+ *
55
+ * `modseq` is what the next fetch asks from. `group` and `uid` describe a
56
+ * mod-sequence a previous round left part-applied: which one, and how far into
57
+ * it the round got. `group` is 0 when the cursor sits on a clean boundary.
58
+ *
59
+ * The sub-position exists because one STORE assigns the SAME mod-sequence to
60
+ * every message it touched (RFC 7162 permits it and servers do it), so "select
61
+ * all, mark read" arrives as one group of arbitrary size. CHANGEDSINCE is
62
+ * strictly greater-than, so a cursor that advanced to a partly-applied group
63
+ * would never see its tail again; a cursor that refused to split it would have
64
+ * to apply the whole group in one round, which for a large mailbox does not
65
+ * finish, and every retry would repeat the same unfinished work. The
66
+ * sub-position is what lets one group span rounds without ever claiming more
67
+ * than was applied.
68
+ *
69
+ * The group is recorded rather than inferred from the fetch result. The
70
+ * in-progress group is NOT reliably the lowest mod-sequence returned: its
71
+ * remaining members can disappear between rounds, by being expunged (which
72
+ * CONDSTORE simply stops reporting) or by being modified again onto a higher
73
+ * mod-sequence. Inferring it then points the skip at an unrelated group and
74
+ * silently drops that group's leading messages.
75
+ */
76
+ export interface ChangeCursor {
77
+ /** Fetch from here — everything above it is still owed. */
78
+ modseq: bigint;
79
+ /** The mod-sequence left part-applied, or 0 on a clean boundary. */
80
+ group: bigint;
81
+ /** Highest UID applied within `group`. */
82
+ uid: number;
83
+ }
84
+
85
+ /**
86
+ * Read a stored cursor.
87
+ *
88
+ * Two forms: `"<modseq>"` is a clean boundary — every value written before the
89
+ * sub-position existed, which is what they always meant. `"<group>:<uid>"` is a
90
+ * position inside a group; the fetch point is implied, because a round only
91
+ * ever stops inside a group once everything below it has been applied.
92
+ *
93
+ * Storing the implied fetch point instead of a third number is what keeps the
94
+ * value inside the field's 32 characters: two 64-bit mod-sequences and a UID
95
+ * would not fit.
96
+ */
97
+ export const parseChangeCursor = (raw: string | undefined): ChangeCursor => {
98
+ // SQLite gives a numeric column back as a number whatever the declared
99
+ // type, so normalize before parsing rather than trusting the static type.
100
+ const text = raw === undefined || raw === null ? "" : String(raw);
101
+ const [left, right] = text.split(":");
102
+
103
+ if (right === undefined) {
104
+ return { modseq: parseModseq(left), group: 0n, uid: 0 };
105
+ }
106
+
107
+ const group = parseModseq(left);
108
+ const uid = DECIMAL_DIGITS.test(right) ? Number.parseInt(right, 10) : 0;
109
+ if (group === 0n || uid === 0) {
110
+ return { modseq: group, group: 0n, uid: 0 };
111
+ }
112
+
113
+ return { modseq: group - 1n, group, uid };
114
+ };
115
+
116
+ /** Render a cursor. A clean boundary stays plain digits. */
117
+ export const formatChangeCursor = (cursor: ChangeCursor): string =>
118
+ cursor.group > 0n && cursor.uid > 0
119
+ ? `${cursor.group.toString()}:${cursor.uid}`
120
+ : cursor.modseq.toString();
121
+
122
+ /** True when the cursor is far enough along to fetch incrementally at all. */
123
+ export const hasChangeCursor = (cursor: ChangeCursor): boolean =>
124
+ cursor.modseq > 0n || cursor.group > 0n;
125
+
126
+ /**
127
+ * Drop the part of a part-applied group that a previous round already applied.
128
+ *
129
+ * The fetch asks from below the in-progress group, so a resumed round is served
130
+ * that group again in full. Its applied members are recognised by position
131
+ * alone — the fetch row carries both its mod-sequence and its UID — so skipping
132
+ * them costs no lookup. Rows of any other mod-sequence are never skipped, which
133
+ * is what makes a vanished remainder harmless instead of lossy.
134
+ */
135
+ export const dropAppliedPrefix = (
136
+ ordered: ImapMessage[],
137
+ cursor: ChangeCursor,
138
+ ): ImapMessage[] => {
139
+ if (cursor.group === 0n || cursor.uid === 0) return ordered;
140
+ return ordered.filter(
141
+ (message) =>
142
+ parseModseq(message.modseq) !== cursor.group || message.uid > cursor.uid,
143
+ );
144
+ };
145
+
146
+ export interface ChangeCursorAdvanceInput {
147
+ /** The cursor this round started from. */
148
+ cursor: ChangeCursor;
149
+ /** HIGHESTMODSEQ the server reported before this round fetched anything. */
150
+ serverModseq: bigint;
151
+ /** The changed set still to apply, in {@link orderByModseq} order. */
152
+ ordered: ImapMessage[];
153
+ /** The prefix of `ordered` this round processed. */
154
+ batch: ImapMessage[];
155
+ /** UIDs whose save threw and must be re-applied. */
156
+ failedUids: ReadonlySet<number>;
157
+ }
158
+
159
+ export interface ChangeCursorAdvance {
160
+ cursor: ChangeCursor;
161
+ /** True when changes remain that this round did not process. */
162
+ hasMore: boolean;
163
+ }
164
+
165
+ /**
166
+ * Decide how far the cursor may move after a round.
167
+ *
168
+ * It moves over the leading run of applied messages and stops at the first one
169
+ * that was not — a failure, or simply the end of the batch. Everything after
170
+ * that point is treated as unapplied even if it succeeded, which costs an
171
+ * idempotent re-apply next round and buys a cursor that can never claim more
172
+ * than it did.
173
+ *
174
+ * Where it stops decides the shape: on a group boundary the cursor is that
175
+ * group's mod-sequence with no sub-position; inside a group it keeps the last
176
+ * complete mod-sequence and records how far into the next one it got.
177
+ *
178
+ * A round that consumed everything without a failure jumps to the server's
179
+ * HIGHESTMODSEQ rather than the last message's mod-sequence. Those are not the
180
+ * same number: a mod-sequence also moves for events that return no message (an
181
+ * expunge, a change to a message the fetch filtered out), and stopping short
182
+ * would re-deliver them on every subsequent round. That value is read before
183
+ * the fetch, so anything changing while the round runs lands above it and
184
+ * arrives next time.
185
+ */
186
+ export const advanceChangeCursor = ({
187
+ cursor,
188
+ serverModseq,
189
+ ordered,
190
+ batch,
191
+ failedUids,
192
+ }: ChangeCursorAdvanceInput): ChangeCursorAdvance => {
193
+ const applied: ImapMessage[] = [];
194
+ for (const message of batch) {
195
+ if (failedUids.has(message.uid)) break;
196
+ applied.push(message);
197
+ }
198
+
199
+ const hasMore = ordered.length > batch.length;
200
+ const next = ordered[applied.length];
201
+
202
+ if (applied.length === 0) {
203
+ return { cursor, hasMore };
204
+ }
205
+
206
+ const last = applied[applied.length - 1];
207
+ const lastModseq = parseModseq(last.modseq);
208
+
209
+ if (next === undefined) {
210
+ return {
211
+ cursor: {
212
+ modseq: serverModseq > lastModseq ? serverModseq : lastModseq,
213
+ group: 0n,
214
+ uid: 0,
215
+ },
216
+ hasMore,
217
+ };
218
+ }
219
+
220
+ const nextModseq = parseModseq(next.modseq);
221
+ if (nextModseq > lastModseq) {
222
+ return { cursor: { modseq: lastModseq, group: 0n, uid: 0 }, hasMore };
223
+ }
224
+
225
+ // Stopped inside a group. Everything below it was applied — the round works
226
+ // through `ordered` in order — so the fetch point is the value just below
227
+ // the group, and the group itself is recorded so the resumed round skips by
228
+ // identity rather than by guessing which group it is looking at.
229
+ //
230
+ // "Everything below it was applied" holds because `ordered` is the COMPLETE
231
+ // set of unapplied changes: the fetch is CHANGEDSINCE over `1:*`. Narrowing
232
+ // that fetch by UID range would leave changes below the group unapplied and
233
+ // unrepresented here, and this cursor would then step over them.
234
+ //
235
+ // The floor matters when a server returns a message with no MODSEQ at all:
236
+ // `nextModseq` is then 0 and the cursor must not go negative.
237
+ const fetchFrom = nextModseq > 0n ? nextModseq - 1n : 0n;
238
+ return {
239
+ cursor: { modseq: fetchFrom, group: nextModseq, uid: last.uid },
240
+ hasMore,
241
+ };
242
+ };
243
+
244
+ export interface UidWatermarkInput {
245
+ /** UIDs this round fetched. */
246
+ batchUids: number[];
247
+ /** UIDs whose save threw. */
248
+ failedUids: ReadonlySet<number>;
249
+ /** Lowest UID synced so far — the backfill floor. 0 before any sync. */
250
+ lastSyncUid: number;
251
+ /** Highest UID synced so far — the forward watermark. */
252
+ highWaterMarkUid: number;
253
+ }
254
+
255
+ export interface UidWatermarks {
256
+ lastSyncUid: number;
257
+ highWaterMarkUid: number;
258
+ }
259
+
260
+ /**
261
+ * Decide how far each UID watermark may move after a round.
262
+ *
263
+ * The two watermarks bound disjoint regions — new mail above
264
+ * `highWaterMarkUid`, backfill below `lastSyncUid` — so a failure constrains
265
+ * only the watermark whose region it sits in.
266
+ *
267
+ * The forward watermark stops below the lowest failure above it. Raising it
268
+ * over a failed UID would drop that UID out of the selection set entirely,
269
+ * and the following round, finding nothing left to enumerate, would seed the
270
+ * mod-sequence watermark over a message that was never stored. The backfill
271
+ * floor stays above the highest failure below it, for the same reason in the
272
+ * other direction.
273
+ */
274
+ export const advanceUidWatermarks = ({
275
+ batchUids,
276
+ failedUids,
277
+ lastSyncUid,
278
+ highWaterMarkUid,
279
+ }: UidWatermarkInput): UidWatermarks => {
280
+ const isFreshSync = lastSyncUid === 0;
281
+
282
+ const forwardUids = batchUids.filter((uid) => uid > highWaterMarkUid);
283
+ const forwardFailures = forwardUids.filter((uid) => failedUids.has(uid));
284
+ const forwardLimit = forwardFailures.length
285
+ ? Math.min(...forwardFailures)
286
+ : Number.POSITIVE_INFINITY;
287
+ const forwardApplied = forwardUids.filter(
288
+ (uid) => !failedUids.has(uid) && uid < forwardLimit,
289
+ );
290
+ const nextHighWaterMark = Math.max(highWaterMarkUid, ...forwardApplied);
291
+
292
+ const backfillUids = batchUids.filter(
293
+ (uid) => isFreshSync || uid < lastSyncUid,
294
+ );
295
+ const backfillFailures = backfillUids.filter((uid) => failedUids.has(uid));
296
+ // The floor must stay strictly above a failed UID for it to stay selectable.
297
+ const backfillFloor = backfillFailures.length
298
+ ? Math.max(...backfillFailures) + 1
299
+ : 0;
300
+ const backfillApplied = backfillUids.filter(
301
+ (uid) => !failedUids.has(uid) && uid >= backfillFloor,
302
+ );
303
+
304
+ // The floor drops to the lowest UID applied above the failure line, or to
305
+ // the line itself when everything below it is being retried. With no
306
+ // failures and nothing applied there is nothing to move it.
307
+ let candidate = lastSyncUid;
308
+ if (backfillApplied.length > 0) {
309
+ candidate = Math.min(...backfillApplied);
310
+ } else if (backfillFailures.length > 0) {
311
+ candidate = backfillFloor;
312
+ }
313
+
314
+ const nextLastSyncUid = isFreshSync
315
+ ? candidate
316
+ : Math.min(lastSyncUid, candidate);
317
+
318
+ return {
319
+ lastSyncUid: nextLastSyncUid,
320
+ highWaterMarkUid: nextHighWaterMark,
321
+ };
322
+ };
package/src/types.ts CHANGED
@@ -200,6 +200,11 @@ export interface ImapMessage {
200
200
  references?: string[];
201
201
  /** Parsed BODYSTRUCTURE tree (RFC 9051 Section 7.5.2). */
202
202
  bodyStructure?: ImapBodyStructure;
203
+ /**
204
+ * Per-message MODSEQ (RFC 7162) as decimal digits of an unsigned 64-bit
205
+ * value. Absent when the session has no CONDSTORE.
206
+ */
207
+ modseq?: string;
203
208
  }
204
209
 
205
210
  /**
@@ -225,6 +230,23 @@ export interface IImapConnection {
225
230
  closeBox(expunge?: boolean): Promise<void>;
226
231
  search(criteria: unknown[]): Promise<number[]>;
227
232
  fetchMessages(uids: number[]): Promise<ImapMessage[]>;
233
+ /**
234
+ * True when the session negotiated CONDSTORE (RFC 7162) AND the currently
235
+ * open mailbox keeps persistent mod-sequences. A mailbox that answered
236
+ * NOMODSEQ on SELECT reports false, as does any server that never
237
+ * advertised the extension. Requires a mailbox to be open.
238
+ */
239
+ supportsCondstore(): boolean;
240
+ /**
241
+ * FETCH every message in the open mailbox whose MODSEQ is strictly greater
242
+ * than `sinceModseq` (RFC 7162 CHANGEDSINCE) — both messages that arrived
243
+ * and messages whose metadata changed. Requires a mailbox to be open.
244
+ *
245
+ * Throws when CONDSTORE is unavailable: the underlying client drops the
246
+ * modifier in that case, which would silently turn the call into a fetch of
247
+ * the entire mailbox.
248
+ */
249
+ fetchMessagesChangedSince(sinceModseq: bigint): Promise<ImapMessage[]>;
228
250
  /**
229
251
  * Cheap envelope-only pass for the UIDVALIDITY cursor rebuild (#1272): UID
230
252
  * + Message-ID + INTERNALDATE for every UID, no BODYSTRUCTURE, no flags,