@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,742 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IEnvelopeRepository,
|
|
6
|
+
IMailboxRepository,
|
|
7
|
+
IMessageFlagPushRepository,
|
|
8
|
+
IMessageFlagRepository,
|
|
9
|
+
IMessageRepository,
|
|
10
|
+
IThreadMessageRepository,
|
|
11
|
+
IUnitOfWork,
|
|
12
|
+
MailboxItem,
|
|
13
|
+
ThreadMessageItem,
|
|
14
|
+
UpdateMailboxInput,
|
|
15
|
+
UpdateThreadMessageInput,
|
|
16
|
+
} from "@remit/data-ports";
|
|
17
|
+
import { MailboxCursorState, MessageSystemFlag } from "@remit/domain-enums";
|
|
18
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
19
|
+
import type { FlagPushService } from "./flag-push.js";
|
|
20
|
+
import { FlagQueueService } from "./flag-queue.js";
|
|
21
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
22
|
+
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
23
|
+
|
|
24
|
+
const ACCOUNT_ID = "acc-1";
|
|
25
|
+
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
26
|
+
const MAILBOX_ID = "mbx-1";
|
|
27
|
+
|
|
28
|
+
const mailbox = (over: Partial<MailboxItem> = {}): MailboxItem =>
|
|
29
|
+
({
|
|
30
|
+
mailboxId: MAILBOX_ID,
|
|
31
|
+
accountId: ACCOUNT_ID,
|
|
32
|
+
fullPath: "INBOX",
|
|
33
|
+
uidValidity: 100,
|
|
34
|
+
lastSyncUid: 1,
|
|
35
|
+
highWaterMarkUid: 20,
|
|
36
|
+
highestModseq: "500",
|
|
37
|
+
cursorState: MailboxCursorState.normal,
|
|
38
|
+
...over,
|
|
39
|
+
}) as MailboxItem;
|
|
40
|
+
|
|
41
|
+
const serverMessage = (over: Partial<ImapMessage> = {}): ImapMessage => ({
|
|
42
|
+
uid: 21,
|
|
43
|
+
seq: 21,
|
|
44
|
+
flags: [],
|
|
45
|
+
internalDate: new Date("2026-01-01T00:00:00Z"),
|
|
46
|
+
size: 42,
|
|
47
|
+
modseq: "510",
|
|
48
|
+
envelope: {
|
|
49
|
+
date: "Thu, 01 Jan 2026 00:00:00 +0000",
|
|
50
|
+
subject: "Hello",
|
|
51
|
+
from: [{ mailbox: "sender", host: "example.com" }],
|
|
52
|
+
sender: [],
|
|
53
|
+
replyTo: [],
|
|
54
|
+
to: [{ mailbox: "me", host: "example.com" }],
|
|
55
|
+
cc: [],
|
|
56
|
+
bcc: [],
|
|
57
|
+
inReplyTo: "",
|
|
58
|
+
messageId: "<a@example.com>",
|
|
59
|
+
},
|
|
60
|
+
...over,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const storedRow = (over: Partial<ThreadMessageItem> = {}): ThreadMessageItem =>
|
|
64
|
+
({
|
|
65
|
+
threadMessageId: "tm-1",
|
|
66
|
+
messageId: "msg-1",
|
|
67
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
68
|
+
mailboxId: MAILBOX_ID,
|
|
69
|
+
threadId: "thr-1",
|
|
70
|
+
uid: 21,
|
|
71
|
+
sentDate: 1_767_225_600_000,
|
|
72
|
+
internalDate: 1_767_225_600_000,
|
|
73
|
+
isRead: false,
|
|
74
|
+
isDeleted: false,
|
|
75
|
+
hasStars: false,
|
|
76
|
+
hasAttachment: false,
|
|
77
|
+
...over,
|
|
78
|
+
}) as ThreadMessageItem;
|
|
79
|
+
|
|
80
|
+
interface HarnessOptions {
|
|
81
|
+
mailbox: MailboxItem;
|
|
82
|
+
supportsCondstore: boolean;
|
|
83
|
+
/** UIDVALIDITY the server serves, when it differs from the stored one. */
|
|
84
|
+
servedUidValidity?: number;
|
|
85
|
+
serverModseq?: string;
|
|
86
|
+
changed?: ImapMessage[];
|
|
87
|
+
allUids?: number[];
|
|
88
|
+
enumerated?: ImapMessage[];
|
|
89
|
+
/** Stored rows, keyed by the messageId the sync derives. */
|
|
90
|
+
storedRows?: ThreadMessageItem[];
|
|
91
|
+
/** Flag names with an outbound push still owed to IMAP. */
|
|
92
|
+
pendingFlags?: Set<string>;
|
|
93
|
+
/** Flag names already on the canonical MessageFlag record. */
|
|
94
|
+
storedFlags?: string[];
|
|
95
|
+
/** Envelope snapshots the cursor rebuild sees on the server. */
|
|
96
|
+
snapshots?: Array<{ uid: number; messageId: string; internalDate: Date }>;
|
|
97
|
+
/** UIDs whose save throws, to exercise the failure clamps. */
|
|
98
|
+
failUids?: Set<number>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface Harness {
|
|
102
|
+
service: MessageSyncService;
|
|
103
|
+
mailboxUpdates: UpdateMailboxInput[];
|
|
104
|
+
threadUpdates: Array<{
|
|
105
|
+
threadMessageId: string;
|
|
106
|
+
input: UpdateThreadMessageInput;
|
|
107
|
+
}>;
|
|
108
|
+
created: string[];
|
|
109
|
+
calls: { search: number; fetchMessages: number; changedSince: bigint[] };
|
|
110
|
+
/** Alarm-shaped ERROR logs the round emitted. */
|
|
111
|
+
errors: Array<Record<string, unknown>>;
|
|
112
|
+
/** The canonical flag record after the round. */
|
|
113
|
+
flagStore: Set<string>;
|
|
114
|
+
messageFlagService: IMessageFlagRepository;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const buildHarness = (options: HarnessOptions): Harness => {
|
|
118
|
+
const mailboxUpdates: UpdateMailboxInput[] = [];
|
|
119
|
+
const threadUpdates: Array<{
|
|
120
|
+
threadMessageId: string;
|
|
121
|
+
input: UpdateThreadMessageInput;
|
|
122
|
+
}> = [];
|
|
123
|
+
const created: string[] = [];
|
|
124
|
+
const calls = { search: 0, fetchMessages: 0, changedSince: [] as bigint[] };
|
|
125
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
126
|
+
const logger = {
|
|
127
|
+
info: () => {},
|
|
128
|
+
warn: () => {},
|
|
129
|
+
error: (obj: Record<string, unknown>) => {
|
|
130
|
+
errors.push(obj);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const rows = options.storedRows ?? [];
|
|
134
|
+
const uidValidity = options.servedUidValidity ?? options.mailbox.uidValidity;
|
|
135
|
+
|
|
136
|
+
const connection = {
|
|
137
|
+
openBox: async () => ({ uidvalidity: uidValidity, uidnext: 99 }),
|
|
138
|
+
getMailboxStatus: async () => ({
|
|
139
|
+
messages: 10,
|
|
140
|
+
recent: 0,
|
|
141
|
+
unseen: 1,
|
|
142
|
+
uidNext: 99,
|
|
143
|
+
uidValidity,
|
|
144
|
+
highestModseq: options.serverModseq ?? "600",
|
|
145
|
+
deletedCount: 0,
|
|
146
|
+
}),
|
|
147
|
+
supportsCondstore: () => options.supportsCondstore,
|
|
148
|
+
fetchMessagesChangedSince: async (since: bigint) => {
|
|
149
|
+
calls.changedSince.push(since);
|
|
150
|
+
return options.changed ?? [];
|
|
151
|
+
},
|
|
152
|
+
search: async () => {
|
|
153
|
+
calls.search++;
|
|
154
|
+
return options.allUids ?? [];
|
|
155
|
+
},
|
|
156
|
+
fetchMessages: async () => {
|
|
157
|
+
calls.fetchMessages++;
|
|
158
|
+
return options.enumerated ?? [];
|
|
159
|
+
},
|
|
160
|
+
fetchEnvelopeSnapshots: async () => options.snapshots ?? [],
|
|
161
|
+
} as unknown as IImapConnection;
|
|
162
|
+
|
|
163
|
+
const connectionFactory = {
|
|
164
|
+
getConnection: () => connection,
|
|
165
|
+
close: async () => {},
|
|
166
|
+
} as ManagedConnectionFactory;
|
|
167
|
+
|
|
168
|
+
const mailboxService = {
|
|
169
|
+
get: async () => options.mailbox,
|
|
170
|
+
update: async (_a: string, _m: string, input: UpdateMailboxInput) => {
|
|
171
|
+
mailboxUpdates.push(input);
|
|
172
|
+
return options.mailbox;
|
|
173
|
+
},
|
|
174
|
+
} as unknown as IMailboxRepository;
|
|
175
|
+
|
|
176
|
+
// The sync derives its own messageId from the server envelope, so the fake
|
|
177
|
+
// answers by UID — the one field a test can state up front.
|
|
178
|
+
const threadMessageService = {
|
|
179
|
+
findByMessageId: async () => rows[0] ?? null,
|
|
180
|
+
findAllByMessageId: async () => rows,
|
|
181
|
+
listByMailbox: async () => ({ items: rows, continuationToken: undefined }),
|
|
182
|
+
update: async (
|
|
183
|
+
_cfg: string,
|
|
184
|
+
threadMessageId: string,
|
|
185
|
+
input: UpdateThreadMessageInput,
|
|
186
|
+
) => {
|
|
187
|
+
threadUpdates.push({ threadMessageId, input });
|
|
188
|
+
return storedRow();
|
|
189
|
+
},
|
|
190
|
+
create: async (input: { messageId: string }) => {
|
|
191
|
+
created.push(input.messageId);
|
|
192
|
+
return storedRow();
|
|
193
|
+
},
|
|
194
|
+
} as unknown as IThreadMessageRepository;
|
|
195
|
+
|
|
196
|
+
const unitOfWork: IUnitOfWork = {
|
|
197
|
+
transaction: (fn) =>
|
|
198
|
+
fn({
|
|
199
|
+
message: {
|
|
200
|
+
upsertWithStatus: async (input: {
|
|
201
|
+
mailboxId: string;
|
|
202
|
+
uid: number;
|
|
203
|
+
}) => {
|
|
204
|
+
if (options.failUids?.has(input.uid)) {
|
|
205
|
+
throw new Error(`save failed for uid ${input.uid}`);
|
|
206
|
+
}
|
|
207
|
+
return { item: { mailboxId: input.mailboxId }, created: true };
|
|
208
|
+
},
|
|
209
|
+
updateUid: async () => undefined,
|
|
210
|
+
} as unknown as IMessageRepository,
|
|
211
|
+
envelope: {
|
|
212
|
+
upsertEnvelope: async () => undefined,
|
|
213
|
+
upsertBodyParts: async () => undefined,
|
|
214
|
+
} as unknown as IEnvelopeRepository,
|
|
215
|
+
address: {
|
|
216
|
+
upsertAddress: async () => undefined,
|
|
217
|
+
upsertEnvelopeAddress: async () => undefined,
|
|
218
|
+
} as unknown as IAddressRepository,
|
|
219
|
+
threadMessage: threadMessageService,
|
|
220
|
+
}),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const flagPushMarkerService = {
|
|
224
|
+
find: async (_messageId: string, flagName: string) =>
|
|
225
|
+
options.pendingFlags?.has(flagName)
|
|
226
|
+
? { messageId: _messageId, flagName }
|
|
227
|
+
: null,
|
|
228
|
+
} as unknown as IMessageFlagPushRepository;
|
|
229
|
+
|
|
230
|
+
// The canonical flag record, as a set of flag names held per message.
|
|
231
|
+
const flagStore = new Set<string>(options.storedFlags ?? []);
|
|
232
|
+
const messageFlagService = {
|
|
233
|
+
hasFlag: async (_messageId: string, flagName: string) =>
|
|
234
|
+
flagStore.has(flagName),
|
|
235
|
+
addFlag: async (_messageId: string, flagName: string) => {
|
|
236
|
+
flagStore.add(flagName);
|
|
237
|
+
return { flagName };
|
|
238
|
+
},
|
|
239
|
+
removeFlag: async (_messageId: string, flagName: string) => {
|
|
240
|
+
flagStore.delete(flagName);
|
|
241
|
+
},
|
|
242
|
+
} as unknown as IMessageFlagRepository;
|
|
243
|
+
|
|
244
|
+
const service = new MessageSyncService(
|
|
245
|
+
connectionFactory,
|
|
246
|
+
mailboxService,
|
|
247
|
+
{} as IMessageRepository,
|
|
248
|
+
{} as IEnvelopeRepository,
|
|
249
|
+
{} as IAddressRepository,
|
|
250
|
+
threadMessageService,
|
|
251
|
+
logger,
|
|
252
|
+
unitOfWork,
|
|
253
|
+
flagPushMarkerService,
|
|
254
|
+
messageFlagService,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
service,
|
|
259
|
+
mailboxUpdates,
|
|
260
|
+
threadUpdates,
|
|
261
|
+
created,
|
|
262
|
+
calls,
|
|
263
|
+
errors,
|
|
264
|
+
flagStore,
|
|
265
|
+
messageFlagService,
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const syncOnce = (harness: Harness, batchSize = 50) =>
|
|
270
|
+
harness.service.syncMessages(
|
|
271
|
+
MAILBOX_ID,
|
|
272
|
+
ACCOUNT_ID,
|
|
273
|
+
ACCOUNT_CONFIG_ID,
|
|
274
|
+
batchSize,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
describe("MessageSyncService CHANGEDSINCE path", () => {
|
|
278
|
+
it("picks up a read-state change made on another client without enumerating the folder", async () => {
|
|
279
|
+
const harness = buildHarness({
|
|
280
|
+
mailbox: mailbox(),
|
|
281
|
+
supportsCondstore: true,
|
|
282
|
+
changed: [serverMessage({ flags: ["\\Seen"] })],
|
|
283
|
+
storedRows: [storedRow({ isRead: false })],
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await syncOnce(harness);
|
|
287
|
+
|
|
288
|
+
assert.deepEqual(harness.calls.changedSince, [500n]);
|
|
289
|
+
assert.equal(harness.calls.search, 0);
|
|
290
|
+
assert.equal(harness.calls.fetchMessages, 0);
|
|
291
|
+
assert.deepEqual(harness.threadUpdates, [
|
|
292
|
+
{ threadMessageId: "tm-1", input: { isRead: true } },
|
|
293
|
+
]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("picks up a star set on another client, colour following the boolean", async () => {
|
|
297
|
+
const harness = buildHarness({
|
|
298
|
+
mailbox: mailbox(),
|
|
299
|
+
supportsCondstore: true,
|
|
300
|
+
changed: [serverMessage({ flags: ["\\Flagged"] })],
|
|
301
|
+
storedRows: [storedRow({ hasStars: false })],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await syncOnce(harness);
|
|
305
|
+
|
|
306
|
+
// #58: `hasStars` is the boolean of record and `star` its colour; the
|
|
307
|
+
// two may never disagree.
|
|
308
|
+
assert.deepEqual(harness.threadUpdates, [
|
|
309
|
+
{
|
|
310
|
+
threadMessageId: "tm-1",
|
|
311
|
+
input: { hasStars: true, star: "yellow" },
|
|
312
|
+
},
|
|
313
|
+
]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("keeps a colour the user chose when the server re-flags", async () => {
|
|
317
|
+
const harness = buildHarness({
|
|
318
|
+
mailbox: mailbox(),
|
|
319
|
+
supportsCondstore: true,
|
|
320
|
+
changed: [serverMessage({ flags: ["\\Flagged"] })],
|
|
321
|
+
storedRows: [storedRow({ hasStars: false, star: "purple" })],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await syncOnce(harness);
|
|
325
|
+
|
|
326
|
+
assert.deepEqual(harness.threadUpdates, [
|
|
327
|
+
{ threadMessageId: "tm-1", input: { hasStars: true } },
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("writes nothing when the server flags already match the stored row", async () => {
|
|
332
|
+
const harness = buildHarness({
|
|
333
|
+
mailbox: mailbox(),
|
|
334
|
+
supportsCondstore: true,
|
|
335
|
+
changed: [serverMessage({ flags: ["\\Seen"] })],
|
|
336
|
+
storedRows: [storedRow({ isRead: true })],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await syncOnce(harness);
|
|
340
|
+
|
|
341
|
+
assert.deepEqual(harness.threadUpdates, []);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("leaves a field alone while its local flip is still owed to IMAP", async () => {
|
|
345
|
+
const harness = buildHarness({
|
|
346
|
+
mailbox: mailbox(),
|
|
347
|
+
supportsCondstore: true,
|
|
348
|
+
changed: [serverMessage({ flags: [] })],
|
|
349
|
+
storedRows: [storedRow({ isRead: true })],
|
|
350
|
+
pendingFlags: new Set([MessageSystemFlag.Seen]),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await syncOnce(harness);
|
|
354
|
+
|
|
355
|
+
assert.deepEqual(harness.threadUpdates, []);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("still discovers a new message and reports it for body sync", async () => {
|
|
359
|
+
const harness = buildHarness({
|
|
360
|
+
mailbox: mailbox(),
|
|
361
|
+
supportsCondstore: true,
|
|
362
|
+
changed: [serverMessage({ uid: 31, modseq: "515" })],
|
|
363
|
+
storedRows: [],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const result = await syncOnce(harness);
|
|
367
|
+
|
|
368
|
+
assert.equal(harness.calls.search, 0);
|
|
369
|
+
assert.equal(result.syncedCount, 1);
|
|
370
|
+
assert.deepEqual(
|
|
371
|
+
result.syncedMessages.map((m) => m.uid),
|
|
372
|
+
[31],
|
|
373
|
+
);
|
|
374
|
+
assert.equal(harness.created.length, 1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("advances the watermark to the server value once the round is applied", async () => {
|
|
378
|
+
const harness = buildHarness({
|
|
379
|
+
mailbox: mailbox(),
|
|
380
|
+
supportsCondstore: true,
|
|
381
|
+
serverModseq: "600",
|
|
382
|
+
changed: [serverMessage({ uid: 31, modseq: "515" })],
|
|
383
|
+
storedRows: [],
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = await syncOnce(harness);
|
|
387
|
+
|
|
388
|
+
assert.equal(result.hasMore, false);
|
|
389
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "600");
|
|
390
|
+
assert.equal(harness.mailboxUpdates[0].highWaterMarkUid, 31);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("advances only over the batch it applied and asks to be resumed", async () => {
|
|
394
|
+
const harness = buildHarness({
|
|
395
|
+
mailbox: mailbox(),
|
|
396
|
+
supportsCondstore: true,
|
|
397
|
+
serverModseq: "600",
|
|
398
|
+
changed: [
|
|
399
|
+
serverMessage({ uid: 31, modseq: "515" }),
|
|
400
|
+
serverMessage({ uid: 32, modseq: "525" }),
|
|
401
|
+
],
|
|
402
|
+
storedRows: [],
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const result = await syncOnce(harness, 1);
|
|
406
|
+
|
|
407
|
+
assert.equal(result.hasMore, true);
|
|
408
|
+
assert.equal(result.remainingCount, 1);
|
|
409
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "515");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("splits a bulk change across rounds without losing its tail", async () => {
|
|
413
|
+
// One STORE marked 60 messages read, so all 60 carry mod-sequence 900.
|
|
414
|
+
const bulk = Array.from({ length: 60 }, (_, i) =>
|
|
415
|
+
serverMessage({ uid: 100 + i, modseq: "900" }),
|
|
416
|
+
);
|
|
417
|
+
const harness = buildHarness({
|
|
418
|
+
mailbox: mailbox(),
|
|
419
|
+
supportsCondstore: true,
|
|
420
|
+
serverModseq: "900",
|
|
421
|
+
changed: bulk,
|
|
422
|
+
storedRows: [],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const first = await syncOnce(harness, 50);
|
|
426
|
+
|
|
427
|
+
// The round is bounded, and the cursor records how far into the group it
|
|
428
|
+
// got rather than claiming the whole of mod-sequence 900.
|
|
429
|
+
assert.equal(harness.created.length, 50);
|
|
430
|
+
assert.equal(first.hasMore, true);
|
|
431
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "900:149");
|
|
432
|
+
|
|
433
|
+
// The next round asks from just below the recorded group, so the server
|
|
434
|
+
// serves that group again and the applied half is skipped by identity.
|
|
435
|
+
const resumed = buildHarness({
|
|
436
|
+
mailbox: mailbox({ highestModseq: "900:149" }),
|
|
437
|
+
supportsCondstore: true,
|
|
438
|
+
serverModseq: "900",
|
|
439
|
+
changed: bulk,
|
|
440
|
+
storedRows: [],
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const second = await syncOnce(resumed, 50);
|
|
444
|
+
|
|
445
|
+
assert.deepEqual(resumed.calls.changedSince, [899n]);
|
|
446
|
+
assert.equal(resumed.created.length, 10);
|
|
447
|
+
assert.equal(second.hasMore, false);
|
|
448
|
+
assert.equal(resumed.mailboxUpdates[0].highestModseq, "900");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("applies every change when the group it was resuming has vanished", async () => {
|
|
452
|
+
// The rest of group 900 was expunged, or modified again onto 950. The
|
|
453
|
+
// resumed round must treat none of what it is served as already applied.
|
|
454
|
+
const harness = buildHarness({
|
|
455
|
+
mailbox: mailbox({ highestModseq: "900:149" }),
|
|
456
|
+
supportsCondstore: true,
|
|
457
|
+
serverModseq: "950",
|
|
458
|
+
changed: [
|
|
459
|
+
serverMessage({ uid: 10, modseq: "950" }),
|
|
460
|
+
serverMessage({ uid: 20, modseq: "950" }),
|
|
461
|
+
serverMessage({ uid: 600, modseq: "950" }),
|
|
462
|
+
],
|
|
463
|
+
storedRows: [],
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
await syncOnce(harness);
|
|
467
|
+
|
|
468
|
+
assert.equal(harness.created.length, 3);
|
|
469
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "950");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("keeps the cursor below a group it only partly applied", async () => {
|
|
473
|
+
const bulk = [
|
|
474
|
+
serverMessage({ uid: 99, modseq: "800" }),
|
|
475
|
+
...Array.from({ length: 60 }, (_, i) =>
|
|
476
|
+
serverMessage({ uid: 100 + i, modseq: "900" }),
|
|
477
|
+
),
|
|
478
|
+
];
|
|
479
|
+
const harness = buildHarness({
|
|
480
|
+
mailbox: mailbox(),
|
|
481
|
+
supportsCondstore: true,
|
|
482
|
+
serverModseq: "900",
|
|
483
|
+
changed: bulk,
|
|
484
|
+
storedRows: [],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const result = await syncOnce(harness, 50);
|
|
488
|
+
|
|
489
|
+
assert.equal(result.hasMore, true);
|
|
490
|
+
// Group 800 complete, 49 into group 900 — never a bare "900".
|
|
491
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "900:148");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("reports a stalled cursor when a round moves nothing", async () => {
|
|
495
|
+
const harness = buildHarness({
|
|
496
|
+
mailbox: mailbox(),
|
|
497
|
+
supportsCondstore: true,
|
|
498
|
+
serverModseq: "900",
|
|
499
|
+
changed: [serverMessage({ uid: 31, modseq: "515" })],
|
|
500
|
+
storedRows: [],
|
|
501
|
+
failUids: new Set([31]),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const result = await syncOnce(harness);
|
|
505
|
+
|
|
506
|
+
assert.equal(result.cursorStalled, true);
|
|
507
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "500");
|
|
508
|
+
assert.equal(
|
|
509
|
+
harness.errors.filter((e) => e.alert === "message_sync_cursor_stalled")
|
|
510
|
+
.length,
|
|
511
|
+
1,
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("does not report a stall when the cursor moved", async () => {
|
|
516
|
+
const harness = buildHarness({
|
|
517
|
+
mailbox: mailbox(),
|
|
518
|
+
supportsCondstore: true,
|
|
519
|
+
changed: [serverMessage({ uid: 31, modseq: "515" })],
|
|
520
|
+
storedRows: [],
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const result = await syncOnce(harness);
|
|
524
|
+
|
|
525
|
+
assert.equal(result.cursorStalled, false);
|
|
526
|
+
assert.deepEqual(harness.errors, []);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("writes the canonical flag record, not only the list projection", async () => {
|
|
530
|
+
const harness = buildHarness({
|
|
531
|
+
mailbox: mailbox(),
|
|
532
|
+
supportsCondstore: true,
|
|
533
|
+
changed: [serverMessage({ flags: ["\\Seen"] })],
|
|
534
|
+
storedRows: [storedRow({ isRead: false })],
|
|
535
|
+
storedFlags: [],
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
await syncOnce(harness);
|
|
539
|
+
|
|
540
|
+
assert.deepEqual([...harness.flagStore], [MessageSystemFlag.Seen]);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("clears the canonical flag and the star colour when the server unstars", async () => {
|
|
544
|
+
const harness = buildHarness({
|
|
545
|
+
mailbox: mailbox(),
|
|
546
|
+
supportsCondstore: true,
|
|
547
|
+
changed: [serverMessage({ flags: [] })],
|
|
548
|
+
storedRows: [storedRow({ hasStars: true })],
|
|
549
|
+
storedFlags: [MessageSystemFlag.Flagged],
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
await syncOnce(harness);
|
|
553
|
+
|
|
554
|
+
assert.deepEqual([...harness.flagStore], []);
|
|
555
|
+
assert.deepEqual(harness.threadUpdates, [
|
|
556
|
+
{
|
|
557
|
+
threadMessageId: "tm-1",
|
|
558
|
+
input: { hasStars: false, star: "none" },
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("leaves the user's next flip a real flip, not a redundant no-op", async () => {
|
|
564
|
+
// Another client marks the message read; the reader picks it up.
|
|
565
|
+
const harness = buildHarness({
|
|
566
|
+
mailbox: mailbox(),
|
|
567
|
+
supportsCondstore: true,
|
|
568
|
+
changed: [serverMessage({ flags: ["\\Seen"] })],
|
|
569
|
+
storedRows: [storedRow({ isRead: false })],
|
|
570
|
+
storedFlags: [],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await syncOnce(harness);
|
|
574
|
+
|
|
575
|
+
// The user now clicks "mark unread". FlagQueueService decides whether
|
|
576
|
+
// that is a real change by reading the canonical record.
|
|
577
|
+
const pushes: Array<{ flagName: string; operation: string }> = [];
|
|
578
|
+
const flagQueue = new FlagQueueService({
|
|
579
|
+
messageFlagService: harness.messageFlagService,
|
|
580
|
+
messageService: {
|
|
581
|
+
get: async () => ({ mailboxId: MAILBOX_ID }),
|
|
582
|
+
} as unknown as IMessageRepository,
|
|
583
|
+
threadMessageService: {
|
|
584
|
+
findAllByMessageId: async () => [storedRow({ isRead: true })],
|
|
585
|
+
update: async () => storedRow(),
|
|
586
|
+
} as unknown as IThreadMessageRepository,
|
|
587
|
+
flagPushService: {
|
|
588
|
+
flip: async (params: { flagName: string; operation: string }) => {
|
|
589
|
+
pushes.push({
|
|
590
|
+
flagName: params.flagName,
|
|
591
|
+
operation: params.operation,
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
} as unknown as FlagPushService,
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
await flagQueue.markAsUnread(ACCOUNT_CONFIG_ID, "msg-1", ACCOUNT_ID);
|
|
598
|
+
|
|
599
|
+
assert.deepEqual(pushes, [
|
|
600
|
+
{ flagName: MessageSystemFlag.Seen, operation: "remove" },
|
|
601
|
+
]);
|
|
602
|
+
assert.deepEqual([...harness.flagStore], []);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe("MessageSyncService without CONDSTORE", () => {
|
|
607
|
+
it("falls back to full enumeration and never issues CHANGEDSINCE", async () => {
|
|
608
|
+
const harness = buildHarness({
|
|
609
|
+
mailbox: mailbox(),
|
|
610
|
+
supportsCondstore: false,
|
|
611
|
+
allUids: [],
|
|
612
|
+
serverModseq: "0",
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await syncOnce(harness);
|
|
616
|
+
|
|
617
|
+
assert.deepEqual(harness.calls.changedSince, []);
|
|
618
|
+
assert.equal(harness.calls.search, 1);
|
|
619
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "0");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("enumerates while no mod-sequence watermark has been seeded yet", async () => {
|
|
623
|
+
const harness = buildHarness({
|
|
624
|
+
mailbox: mailbox({ highestModseq: "0" }),
|
|
625
|
+
supportsCondstore: true,
|
|
626
|
+
allUids: [],
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
await syncOnce(harness);
|
|
630
|
+
|
|
631
|
+
assert.deepEqual(harness.calls.changedSince, []);
|
|
632
|
+
assert.equal(harness.calls.search, 1);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("seeds the mod-sequence watermark once enumeration finds nothing left", async () => {
|
|
636
|
+
const harness = buildHarness({
|
|
637
|
+
mailbox: mailbox({ highestModseq: "0" }),
|
|
638
|
+
supportsCondstore: true,
|
|
639
|
+
allUids: [],
|
|
640
|
+
serverModseq: "700",
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
await syncOnce(harness);
|
|
644
|
+
|
|
645
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, "700");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("withholds the seed while enumeration still has work left", async () => {
|
|
649
|
+
const harness = buildHarness({
|
|
650
|
+
mailbox: mailbox({ highestModseq: "0" }),
|
|
651
|
+
supportsCondstore: true,
|
|
652
|
+
allUids: [21, 22],
|
|
653
|
+
enumerated: [serverMessage({ uid: 21 }), serverMessage({ uid: 22 })],
|
|
654
|
+
serverModseq: "700",
|
|
655
|
+
storedRows: [],
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
await syncOnce(harness, 1);
|
|
659
|
+
|
|
660
|
+
assert.equal(harness.mailboxUpdates[0].highestModseq, undefined);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe("MessageSyncService UIDVALIDITY reseed", () => {
|
|
665
|
+
it("trips the cursor instead of trusting the stored mod-sequence", async () => {
|
|
666
|
+
const harness = buildHarness({
|
|
667
|
+
mailbox: mailbox({ uidValidity: 100 }),
|
|
668
|
+
servedUidValidity: 777,
|
|
669
|
+
supportsCondstore: true,
|
|
670
|
+
changed: [serverMessage()],
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const result = await syncOnce(harness);
|
|
674
|
+
|
|
675
|
+
assert.deepEqual(result.syncedMessages, []);
|
|
676
|
+
assert.deepEqual(harness.calls.changedSince, []);
|
|
677
|
+
assert.deepEqual(harness.mailboxUpdates, [
|
|
678
|
+
{ cursorState: MailboxCursorState.cursor_invalid },
|
|
679
|
+
]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("withholds the seed when the rebuild lost a message to a failed save", async () => {
|
|
683
|
+
const harness = buildHarness({
|
|
684
|
+
mailbox: mailbox({
|
|
685
|
+
cursorState: MailboxCursorState.cursor_invalid,
|
|
686
|
+
highestModseq: "500",
|
|
687
|
+
}),
|
|
688
|
+
servedUidValidity: 777,
|
|
689
|
+
supportsCondstore: true,
|
|
690
|
+
serverModseq: "12",
|
|
691
|
+
allUids: [30, 31],
|
|
692
|
+
snapshots: [
|
|
693
|
+
{ uid: 30, messageId: "<a@example.com>", internalDate: new Date(0) },
|
|
694
|
+
{ uid: 31, messageId: "<b@example.com>", internalDate: new Date(0) },
|
|
695
|
+
],
|
|
696
|
+
enumerated: [serverMessage({ uid: 30 }), serverMessage({ uid: 31 })],
|
|
697
|
+
failUids: new Set([31]),
|
|
698
|
+
storedRows: [],
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
await syncOnce(harness);
|
|
702
|
+
|
|
703
|
+
const final = harness.mailboxUpdates[1];
|
|
704
|
+
// Neither the stale value (meaningless on the new axis) nor the new one
|
|
705
|
+
// (already above the message that failed).
|
|
706
|
+
assert.equal(final.highestModseq, "0");
|
|
707
|
+
// The forward watermark stops below the failure, so uid 31 is enumerated
|
|
708
|
+
// again next round.
|
|
709
|
+
assert.equal(final.highWaterMarkUid, 30);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("reseeds the watermark from the new axis when the rebuild completes", async () => {
|
|
713
|
+
const harness = buildHarness({
|
|
714
|
+
mailbox: mailbox({
|
|
715
|
+
cursorState: MailboxCursorState.cursor_invalid,
|
|
716
|
+
highestModseq: "500",
|
|
717
|
+
}),
|
|
718
|
+
servedUidValidity: 777,
|
|
719
|
+
supportsCondstore: true,
|
|
720
|
+
serverModseq: "12",
|
|
721
|
+
storedRows: [],
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await syncOnce(harness);
|
|
725
|
+
|
|
726
|
+
assert.deepEqual(harness.calls.changedSince, []);
|
|
727
|
+
assert.deepEqual(harness.mailboxUpdates, [
|
|
728
|
+
{ cursorState: MailboxCursorState.rebuilding },
|
|
729
|
+
{
|
|
730
|
+
cursorState: MailboxCursorState.normal,
|
|
731
|
+
uidValidity: 777,
|
|
732
|
+
highWaterMarkUid: 0,
|
|
733
|
+
lastSyncUid: 0,
|
|
734
|
+
highestModseq: "12",
|
|
735
|
+
lastMessageSyncAt: harness.mailboxUpdates[1]?.lastMessageSyncAt,
|
|
736
|
+
messageCount: 10,
|
|
737
|
+
unseenCount: 1,
|
|
738
|
+
deletedCount: 0,
|
|
739
|
+
},
|
|
740
|
+
]);
|
|
741
|
+
});
|
|
742
|
+
});
|