@remit/mailbox-service 0.0.9 → 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.
- package/package.json +1 -1
- package/src/imapflow-connection.test.ts +85 -0
- package/src/imapflow-connection.ts +59 -12
- package/src/mailbox-sync.test.ts +31 -2
- package/src/mailbox-sync.ts +11 -11
- package/src/message-move.ts +8 -4
- package/src/message-sync-changedsince.test.ts +742 -0
- package/src/message-sync.ts +494 -108
- package/src/sync-watermarks.test.ts +418 -0
- package/src/sync-watermarks.ts +322 -0
- package/src/types.ts +22 -0
|
@@ -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,
|