@remit/mailbox-service 0.0.110 → 0.0.112

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 (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -0,0 +1,762 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
4
+ import { deriveAddressId, deriveCopyMessageId } from "@remit/data-ports/id";
5
+ import {
6
+ MailboxCursorState,
7
+ MessageKeywordFlag,
8
+ MessageSystemFlag,
9
+ } from "@remit/domain-enums";
10
+ import { FlagPushService } from "./flag-push.js";
11
+ import { FlagQueueService } from "./flag-queue.js";
12
+ import { MessageMoveService } from "./message-move.js";
13
+ import { MessageSyncService } from "./message-sync.js";
14
+ import { QuarantineService } from "./quarantine.js";
15
+ import { SyncWarningService } from "./sync-warning.js";
16
+ import {
17
+ eml,
18
+ type FakeImapConnection,
19
+ FakeImapServer,
20
+ } from "./test-helpers/fake-imap.js";
21
+ import {
22
+ captureQueue,
23
+ messageAtUid,
24
+ openStore,
25
+ type QueueCapture,
26
+ type Store,
27
+ seedAccount,
28
+ seedMailbox,
29
+ silentLog,
30
+ } from "./test-helpers/test-store.js";
31
+
32
+ const SPAMMER = eml({
33
+ From: "Pharma Deals <sales@pharma.example>",
34
+ To: "victim@ischen.nl",
35
+ "Message-ID": "<deal@pharma.example>",
36
+ });
37
+
38
+ let store: Store;
39
+ let capture: QueueCapture;
40
+ let imap: FakeImapServer;
41
+ let connection: FakeImapConnection;
42
+ let account: AccountItem;
43
+ let inbox: MailboxItem;
44
+ let junk: MailboxItem;
45
+ let trash: MailboxItem;
46
+ let receipts: MailboxItem;
47
+ let allMail: MailboxItem;
48
+ let logMark: number;
49
+
50
+ beforeEach(async () => {
51
+ capture = captureQueue();
52
+ store = await openStore();
53
+ imap = new FakeImapServer();
54
+ account = await seedAccount(store);
55
+ inbox = await seedMailbox(store, account, "INBOX");
56
+ junk = await seedMailbox(store, account, "INBOX/Rubbish", {
57
+ specialUse: "Junk",
58
+ });
59
+ trash = await seedMailbox(store, account, "Trash", { specialUse: "Trash" });
60
+ receipts = await seedMailbox(store, account, "Receipts");
61
+ allMail = await seedMailbox(store, account, "[Gmail]/All Mail", {
62
+ specialUse: "All",
63
+ });
64
+ for (const mailbox of [inbox, junk, trash, receipts, allMail]) {
65
+ imap.addFolder(mailbox.fullPath);
66
+ }
67
+ connection = imap.connection();
68
+ logMark = 0;
69
+ });
70
+
71
+ afterEach(async () => {
72
+ capture.restore();
73
+ await store.close();
74
+ });
75
+
76
+ const flagPushService = () =>
77
+ new FlagPushService({
78
+ markerService: store.flagPush,
79
+ sqsQueueUrl: "https://sqs.test/flags",
80
+ });
81
+
82
+ const syncService = () =>
83
+ new MessageSyncService(
84
+ { getConnection: () => connection, close: async () => {} },
85
+ store.mailbox,
86
+ store.mailboxSpecialUse,
87
+ store.message,
88
+ store.envelope,
89
+ store.address,
90
+ store.threadMessage,
91
+ new SyncWarningService(store.syncWarning),
92
+ silentLog,
93
+ store.flagPush,
94
+ store.messageFlag,
95
+ new QuarantineService(
96
+ store.quarantine,
97
+ store.mailboxSpecialUse,
98
+ "sha-test",
99
+ silentLog,
100
+ ),
101
+ );
102
+
103
+ const userMoves = () =>
104
+ new MessageMoveService({
105
+ messageService: store.message,
106
+ mailboxService: store.mailbox,
107
+ mailboxSpecialUseService: store.mailboxSpecialUse,
108
+ threadMessageService: store.threadMessage,
109
+ addressService: store.address,
110
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
111
+ });
112
+
113
+ const flagQueue = () =>
114
+ new FlagQueueService({
115
+ messageFlagService: store.messageFlag,
116
+ messageService: store.message,
117
+ threadMessageService: store.threadMessage,
118
+ flagPushService: flagPushService(),
119
+ });
120
+
121
+ const sync = (mailbox: MailboxItem, batchSize = 50) =>
122
+ syncService().syncMessages(
123
+ mailbox.mailboxId,
124
+ account.accountId,
125
+ account.accountConfigId,
126
+ batchSize,
127
+ );
128
+
129
+ const settle = async (mailbox: MailboxItem) => {
130
+ for (;;) {
131
+ const result = await sync(mailbox);
132
+ if (!result.hasMore && result.syncedCount === 0) return;
133
+ }
134
+ };
135
+
136
+ const stored = (mailbox: MailboxItem) =>
137
+ store.mailbox.get(account.accountId, mailbox.mailboxId);
138
+
139
+ const threadOf = async (messageId: string) =>
140
+ (
141
+ await store.threadMessage.findAllByMessageId(
142
+ account.accountConfigId,
143
+ messageId,
144
+ )
145
+ )[0];
146
+
147
+ const address = (email: string) =>
148
+ store.address.getAddress(
149
+ account.accountConfigId,
150
+ deriveAddressId(account.accountConfigId, email),
151
+ );
152
+
153
+ const sent = (verb: "SEARCH" | "FETCH", marker = "") =>
154
+ imap.log
155
+ .slice(logMark)
156
+ .filter((line) => line.startsWith(`${verb} `) && line.includes(marker))
157
+ .length;
158
+
159
+ const serverMove = async (from: MailboxItem, uid: number, to: MailboxItem) => {
160
+ const otherClient = imap.connection();
161
+ await otherClient.openBox(from.fullPath);
162
+ const { uidMap } = await otherClient.moveMessages([uid], to.fullPath);
163
+ const moved = uidMap.get(uid);
164
+ if (moved === undefined) throw new Error(`no uid ${uid} in ${from.fullPath}`);
165
+ return moved;
166
+ };
167
+
168
+ const arrive = async (mailbox: MailboxItem, raw: Buffer = SPAMMER) => {
169
+ const uid = imap.deliver(mailbox.fullPath, raw);
170
+ await sync(mailbox);
171
+ return { uid, message: await messageAtUid(store, mailbox, uid) };
172
+ };
173
+
174
+ describe("harvesting envelope display names", () => {
175
+ const spoofed = eml({
176
+ From: '"matthijs@ischen.nl" <aramirez@secresaludguaviare.gov.co>',
177
+ });
178
+
179
+ it("drops a name that is an address the sender does not own, wherever it lands", async () => {
180
+ const { message } = await arrive(inbox, spoofed);
181
+
182
+ const sender = await address("aramirez@secresaludguaviare.gov.co");
183
+ assert.equal(sender.displayName ?? "", "");
184
+ assert.equal(
185
+ sender.normalizedCompound,
186
+ "aramirez@secresaludguaviare.gov.co",
187
+ );
188
+ const data = await store.envelope.getMessageData(message.messageId);
189
+ assert.ok(data.envelopeAddress.length > 0);
190
+ for (const entry of data.envelopeAddress) {
191
+ if (entry.normalizedEmail !== "aramirez@secresaludguaviare.gov.co")
192
+ continue;
193
+ assert.equal(entry.displayName ?? "", "");
194
+ }
195
+ const thread = await threadOf(message.messageId);
196
+ assert.equal(thread.fromName, undefined);
197
+ assert.equal(thread.fromEmail, "aramirez@secresaludguaviare.gov.co");
198
+ });
199
+
200
+ it("keeps what a name says besides the address it claims", async () => {
201
+ const { message } = await arrive(
202
+ inbox,
203
+ eml({ From: '"Support <matthijs@ischen.nl>" <aramirez@example.gov>' }),
204
+ );
205
+
206
+ assert.equal((await threadOf(message.messageId)).fromName, "Support");
207
+ assert.equal(
208
+ (await address("aramirez@example.gov")).displayName,
209
+ "Support",
210
+ );
211
+ });
212
+
213
+ it("harvests every envelope role", async () => {
214
+ await arrive(
215
+ inbox,
216
+ eml({
217
+ From: "Someone <from@example.com>",
218
+ To: "Someone <to@example.com>",
219
+ Cc: "Someone <cc@example.com>",
220
+ "Reply-To": "Someone <reply-to@example.com>",
221
+ }),
222
+ );
223
+
224
+ for (const email of [
225
+ "from@example.com",
226
+ "to@example.com",
227
+ "cc@example.com",
228
+ "reply-to@example.com",
229
+ ]) {
230
+ assert.equal((await address(email)).displayName, "Someone");
231
+ }
232
+ });
233
+ });
234
+
235
+ describe("what a folder says about the senders on its mail", () => {
236
+ it("harvests the senders of ordinary mail as correspondents", async () => {
237
+ await arrive(inbox);
238
+
239
+ assert.equal(
240
+ (await address("sales@pharma.example")).flags.junkOnly,
241
+ undefined,
242
+ );
243
+ });
244
+
245
+ it("withholds the senders of mail in the appointed Junk folder", async () => {
246
+ const { message } = await arrive(junk);
247
+
248
+ assert.equal(
249
+ (await address("sales@pharma.example")).flags.junkOnly?.value,
250
+ true,
251
+ );
252
+ const data = await store.envelope.getMessageData(message.messageId);
253
+ assert.ok(
254
+ data.envelopeAddress.some(
255
+ (entry) => entry.normalizedEmail === "sales@pharma.example",
256
+ ),
257
+ );
258
+ });
259
+
260
+ it("keeps mail in Trash from deciding either way", async () => {
261
+ await arrive(trash);
262
+
263
+ assert.equal(
264
+ (await address("sales@pharma.example")).flags.junkOnly,
265
+ undefined,
266
+ );
267
+ });
268
+ });
269
+
270
+ describe("a message the database already holds, sighted in another folder", () => {
271
+ it("follows another client's move into Junk, with the new folder's UID", async () => {
272
+ const { uid, message } = await arrive(inbox);
273
+ const moved = await serverMove(inbox, uid, junk);
274
+
275
+ const result = await sync(junk);
276
+
277
+ const row = await store.message.get(message.messageId);
278
+ assert.equal(row.mailboxId, junk.mailboxId);
279
+ assert.equal(row.uid, moved);
280
+ const thread = await threadOf(message.messageId);
281
+ assert.equal(thread.mailboxId, junk.mailboxId);
282
+ assert.equal(thread.uid, moved);
283
+ assert.deepEqual(result.syncedMessageIds, [message.messageId]);
284
+ assert.equal(
285
+ (await address("sales@pharma.example")).flags.junkOnly?.value,
286
+ true,
287
+ );
288
+ });
289
+
290
+ it("follows the rescue back out of Junk and restores the sender", async () => {
291
+ const { uid, message } = await arrive(junk);
292
+ await serverMove(junk, uid, inbox);
293
+
294
+ await sync(inbox);
295
+
296
+ assert.equal(
297
+ (await store.message.get(message.messageId)).mailboxId,
298
+ inbox.mailboxId,
299
+ );
300
+ assert.equal(
301
+ (await address("sales@pharma.example")).flags.junkOnly,
302
+ undefined,
303
+ );
304
+ });
305
+
306
+ it("marks the listing row deleted in Trash and clears it on the way out", async () => {
307
+ imap.condstore = false;
308
+ const { uid, message } = await arrive(inbox);
309
+ const inTrash = await serverMove(inbox, uid, trash);
310
+ await sync(trash);
311
+ assert.equal((await threadOf(message.messageId)).isDeleted, true);
312
+
313
+ await serverMove(trash, inTrash, inbox);
314
+ await sync(inbox);
315
+
316
+ assert.equal((await threadOf(message.messageId)).isDeleted, false);
317
+ });
318
+
319
+ it("leaves a labelled message in the folder that still holds it", async () => {
320
+ const { message } = await arrive(inbox);
321
+ imap.deliver("Receipts", SPAMMER);
322
+
323
+ const result = await sync(receipts);
324
+
325
+ assert.equal(
326
+ (await store.message.get(message.messageId)).mailboxId,
327
+ inbox.mailboxId,
328
+ );
329
+ assert.equal(
330
+ (await threadOf(message.messageId)).mailboxId,
331
+ inbox.mailboxId,
332
+ );
333
+ assert.equal(result.cursorStalled, false);
334
+ assert.equal((await stored(receipts)).highWaterMarkUid, 1);
335
+ });
336
+
337
+ it("holds the watermark when the folder it came from cannot be asked", async () => {
338
+ const { uid, message } = await arrive(inbox);
339
+ await serverMove(inbox, uid, junk);
340
+ imap.removeFolder("INBOX");
341
+
342
+ await sync(junk);
343
+
344
+ assert.equal(
345
+ (await store.message.get(message.messageId)).mailboxId,
346
+ inbox.mailboxId,
347
+ );
348
+ assert.equal((await stored(junk)).highWaterMarkUid, 0);
349
+ });
350
+
351
+ it("declines a sighting in a folder that copies every message", async () => {
352
+ const { message } = await arrive(inbox);
353
+ imap.deliver("[Gmail]/All Mail", SPAMMER);
354
+
355
+ await sync(allMail);
356
+
357
+ assert.equal(
358
+ (await store.message.get(message.messageId)).mailboxId,
359
+ inbox.mailboxId,
360
+ );
361
+ });
362
+
363
+ it("declines a sighting of a copy the user put in that folder", async () => {
364
+ const { message } = await arrive(inbox);
365
+ await userMoves().copyMessage(
366
+ account.accountConfigId,
367
+ message.messageId,
368
+ junk.mailboxId,
369
+ account.accountId,
370
+ );
371
+ const copied = imap.deliver("INBOX/Rubbish", SPAMMER);
372
+ await store.message.updateUid(
373
+ deriveCopyMessageId(message.messageId, junk.mailboxId),
374
+ copied,
375
+ junk.mailboxId,
376
+ );
377
+
378
+ await sync(junk);
379
+
380
+ assert.equal(
381
+ (await store.message.get(message.messageId)).mailboxId,
382
+ inbox.mailboxId,
383
+ );
384
+ });
385
+
386
+ it("follows another client's move into a folder already on CHANGEDSINCE", async () => {
387
+ const { uid, message } = await arrive(inbox);
388
+ await settle(junk);
389
+ await serverMove(inbox, uid, junk);
390
+
391
+ await sync(junk);
392
+
393
+ assert.equal(
394
+ (await store.message.get(message.messageId)).mailboxId,
395
+ junk.mailboxId,
396
+ );
397
+ });
398
+
399
+ it("holds a row whose own move has not settled", async () => {
400
+ const { uid, message } = await arrive(inbox);
401
+ await userMoves().moveMessage(
402
+ account.accountConfigId,
403
+ message.messageId,
404
+ receipts.mailboxId,
405
+ account.accountId,
406
+ );
407
+ await serverMove(inbox, uid, junk);
408
+
409
+ await sync(junk);
410
+
411
+ const row = await store.message.get(message.messageId);
412
+ assert.equal(row.mailboxId, receipts.mailboxId);
413
+ assert.equal(row.status, "moving");
414
+ });
415
+ });
416
+
417
+ describe("CHANGEDSINCE picks up what another client changed", () => {
418
+ const seeded = async (count = 1) => {
419
+ const uids: number[] = [];
420
+ for (let i = 0; i < count; i++) {
421
+ uids.push(
422
+ imap.deliver("INBOX", eml({ "Message-ID": `<m${i}@example.com>` })),
423
+ );
424
+ }
425
+ await settle(inbox);
426
+ logMark = imap.log.length;
427
+ const messages = await Promise.all(
428
+ uids.map((uid) => messageAtUid(store, inbox, uid)),
429
+ );
430
+ return { uids, messages };
431
+ };
432
+
433
+ it("applies a read-state change without enumerating the folder", async () => {
434
+ const {
435
+ uids: [uid],
436
+ messages: [message],
437
+ } = await seeded();
438
+ imap.store("INBOX", [uid], [MessageSystemFlag.Seen]);
439
+
440
+ await sync(inbox);
441
+
442
+ assert.equal(sent("SEARCH"), 0);
443
+ assert.equal((await threadOf(message.messageId)).isRead, true);
444
+ assert.ok(
445
+ await store.messageFlag.hasFlag(
446
+ message.messageId,
447
+ MessageSystemFlag.Seen,
448
+ ),
449
+ );
450
+ });
451
+
452
+ it("stars with the default colour and unstars clearing it", async () => {
453
+ const {
454
+ uids: [uid],
455
+ messages: [message],
456
+ } = await seeded();
457
+ imap.store("INBOX", [uid], [MessageSystemFlag.Flagged]);
458
+ await sync(inbox);
459
+ let thread = await threadOf(message.messageId);
460
+ assert.equal(thread.hasStars, true);
461
+ assert.equal(thread.star, "yellow");
462
+
463
+ imap.store("INBOX", [uid], []);
464
+ await sync(inbox);
465
+
466
+ thread = await threadOf(message.messageId);
467
+ assert.equal(thread.hasStars, false);
468
+ assert.equal(thread.star, "none");
469
+ assert.equal(
470
+ await store.messageFlag.hasFlag(
471
+ message.messageId,
472
+ MessageSystemFlag.Flagged,
473
+ ),
474
+ false,
475
+ );
476
+ });
477
+
478
+ it("keeps a colour the user chose when the server re-flags", async () => {
479
+ const {
480
+ uids: [uid],
481
+ messages: [message],
482
+ } = await seeded();
483
+ await flagQueue().updateFlags(
484
+ account.accountConfigId,
485
+ message.messageId,
486
+ account.accountId,
487
+ { isStarred: false, starColor: "purple" },
488
+ );
489
+ await store.flagPush.delete(message.messageId, MessageSystemFlag.Flagged);
490
+ imap.store("INBOX", [uid], [MessageSystemFlag.Flagged]);
491
+
492
+ await sync(inbox);
493
+
494
+ assert.equal((await threadOf(message.messageId)).star, "purple");
495
+ });
496
+
497
+ it("leaves a field alone while the user's flip is still owed to the server", async () => {
498
+ const {
499
+ uids: [uid],
500
+ messages: [message],
501
+ } = await seeded();
502
+ const flags = new FlagQueueService({
503
+ messageFlagService: store.messageFlag,
504
+ messageService: store.message,
505
+ threadMessageService: store.threadMessage,
506
+ flagPushService: flagPushService(),
507
+ });
508
+ await flags.markAsRead(
509
+ account.accountConfigId,
510
+ message.messageId,
511
+ account.accountId,
512
+ );
513
+ imap.store("INBOX", [uid], [MessageSystemFlag.Flagged]);
514
+
515
+ await sync(inbox);
516
+
517
+ const thread = await threadOf(message.messageId);
518
+ assert.equal(thread.isRead, true);
519
+ assert.equal(thread.hasStars, true);
520
+ });
521
+
522
+ it("leaves the user's next flip a real flip", async () => {
523
+ const {
524
+ uids: [uid],
525
+ messages: [message],
526
+ } = await seeded();
527
+ imap.store("INBOX", [uid], [MessageSystemFlag.Seen]);
528
+ await sync(inbox);
529
+
530
+ await new FlagQueueService({
531
+ messageFlagService: store.messageFlag,
532
+ messageService: store.message,
533
+ threadMessageService: store.threadMessage,
534
+ flagPushService: flagPushService(),
535
+ }).markAsUnread(
536
+ account.accountConfigId,
537
+ message.messageId,
538
+ account.accountId,
539
+ );
540
+
541
+ assert.equal(
542
+ (await store.flagPush.find(message.messageId, MessageSystemFlag.Seen))
543
+ ?.operation,
544
+ "remove",
545
+ );
546
+ assert.equal((await threadOf(message.messageId)).isRead, false);
547
+ });
548
+
549
+ it("mirrors $Junk and $NotJunk as message state, never as sender standing", async () => {
550
+ const {
551
+ uids: [first, second],
552
+ messages: [junked, cleared],
553
+ } = await seeded(2);
554
+ const before = await address("sender@example.com");
555
+ imap.store("INBOX", [first], [MessageKeywordFlag.Junk]);
556
+ imap.store("INBOX", [second], [MessageKeywordFlag.NotJunk]);
557
+
558
+ await sync(inbox);
559
+
560
+ assert.ok(
561
+ await store.messageFlag.hasFlag(
562
+ junked.messageId,
563
+ MessageKeywordFlag.Junk,
564
+ ),
565
+ );
566
+ assert.ok(
567
+ await store.messageFlag.hasFlag(
568
+ cleared.messageId,
569
+ MessageKeywordFlag.NotJunk,
570
+ ),
571
+ );
572
+ assert.deepEqual((await address("sender@example.com")).flags, before.flags);
573
+ });
574
+
575
+ it("discovers new mail and hands it to body sync", async () => {
576
+ await seeded();
577
+ const uid = imap.deliver(
578
+ "INBOX",
579
+ eml({ "Message-ID": "<new@example.com>" }),
580
+ );
581
+
582
+ const result = await sync(inbox);
583
+
584
+ assert.equal(sent("SEARCH"), 0);
585
+ assert.deepEqual(
586
+ result.syncedMessages.map((message) => message.uid),
587
+ [uid],
588
+ );
589
+ assert.equal((await stored(inbox)).highWaterMarkUid, uid);
590
+ assert.equal(
591
+ (await stored(inbox)).highestModseq,
592
+ imap.highestModseq().toString(),
593
+ );
594
+ });
595
+
596
+ it("splits one bulk change across rounds without losing its tail", async () => {
597
+ const { uids, messages } = await seeded(60);
598
+ imap.store("INBOX", uids, [MessageSystemFlag.Seen]);
599
+
600
+ const first = await sync(inbox, 50);
601
+ assert.equal(first.hasMore, true);
602
+ imap.store("INBOX", uids, [
603
+ MessageSystemFlag.Seen,
604
+ MessageSystemFlag.Flagged,
605
+ ]);
606
+ await settle(inbox);
607
+
608
+ for (const message of messages) {
609
+ const thread = await threadOf(message.messageId);
610
+ assert.equal(thread.isRead, true);
611
+ assert.equal(thread.hasStars, true);
612
+ }
613
+ assert.equal(
614
+ (await stored(inbox)).highestModseq,
615
+ imap.highestModseq().toString(),
616
+ );
617
+ });
618
+ });
619
+
620
+ describe("a server without CONDSTORE", () => {
621
+ it("enumerates every round and never seeds a mod-sequence", async () => {
622
+ imap.condstore = false;
623
+ imap.deliver("INBOX", eml({}));
624
+ await settle(inbox);
625
+
626
+ await sync(inbox);
627
+
628
+ assert.equal(sent("FETCH", "CHANGEDSINCE"), 0);
629
+ assert.ok(sent("SEARCH") > 0);
630
+ assert.equal((await stored(inbox)).highestModseq, "0");
631
+ });
632
+
633
+ it("seeds the mod-sequence only once enumeration has nothing left", async () => {
634
+ imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
635
+ imap.deliver("INBOX", eml({ "Message-ID": "<b@example.com>" }));
636
+
637
+ await sync(inbox, 1);
638
+ assert.equal((await stored(inbox)).highestModseq, "0");
639
+
640
+ await settle(inbox);
641
+ assert.equal(
642
+ (await stored(inbox)).highestModseq,
643
+ imap.highestModseq().toString(),
644
+ );
645
+ });
646
+ });
647
+
648
+ describe("a UIDVALIDITY change", () => {
649
+ it("trips the cursor, then rebuilds on the new axis and reseeds", async () => {
650
+ imap.deliver("INBOX", eml({ "Message-ID": "<a@example.com>" }));
651
+ await settle(inbox);
652
+ imap.renumber("INBOX", 777);
653
+
654
+ const tripped = await sync(inbox);
655
+ assert.deepEqual(tripped.syncedMessages, []);
656
+ assert.equal(
657
+ (await stored(inbox)).cursorState,
658
+ MailboxCursorState.cursor_invalid,
659
+ );
660
+
661
+ await sync(inbox);
662
+
663
+ const rebuilt = await stored(inbox);
664
+ assert.equal(rebuilt.cursorState, MailboxCursorState.normal);
665
+ assert.equal(rebuilt.uidValidity, 777);
666
+ assert.equal(rebuilt.highestModseq, imap.highestModseq().toString());
667
+ });
668
+
669
+ it("withholds the seed and the watermark when the rebuild missed a UID", async () => {
670
+ for (const id of ["a", "b", "c"]) {
671
+ imap.deliver("INBOX", eml({ "Message-ID": `<${id}@example.com>` }));
672
+ }
673
+ await settle(inbox);
674
+ imap.renumber("INBOX", 777);
675
+ const arrived = imap.deliver(
676
+ "INBOX",
677
+ eml({ "Message-ID": "<d@example.com>" }),
678
+ );
679
+ await sync(inbox);
680
+ const fetch = connection.fetchMessages;
681
+ connection.fetchMessages = async (uids) =>
682
+ (await fetch(uids)).filter((row) => row.uid !== arrived);
683
+
684
+ await sync(inbox);
685
+
686
+ const rebuilt = await stored(inbox);
687
+ assert.equal(rebuilt.highestModseq, "0");
688
+ assert.ok(rebuilt.highWaterMarkUid < arrived);
689
+ });
690
+ });
691
+
692
+ describe("a FETCH that leaves a UID unaccounted for", () => {
693
+ it("holds the watermark below a row the FETCH dropped", async () => {
694
+ for (const id of ["a", "b", "c"]) {
695
+ imap.deliver("INBOX", eml({ "Message-ID": `<${id}@example.com>` }));
696
+ }
697
+ const fetch = connection.fetchMessages;
698
+ connection.fetchMessages = async (uids) =>
699
+ (await fetch(uids)).filter((row) => row.uid !== 2);
700
+
701
+ await sync(inbox);
702
+
703
+ const mailbox = await stored(inbox);
704
+ assert.equal(mailbox.highWaterMarkUid, 1);
705
+ assert.equal(mailbox.highestModseq, "0");
706
+ });
707
+
708
+ it("holds a row with no ENVELOPE for retry rather than setting it aside", async () => {
709
+ imap.deliver("INBOX", eml({}));
710
+ const fetch = connection.fetchMessages;
711
+ connection.fetchMessages = async (uids) =>
712
+ (await fetch(uids)).map((row) => ({ ...row, envelope: undefined }));
713
+
714
+ const result = await sync(inbox);
715
+
716
+ assert.equal(result.syncedCount, 0);
717
+ assert.equal((await stored(inbox)).highWaterMarkUid, 0);
718
+ assert.deepEqual(
719
+ await store.quarantine.listByAccountConfigId(account.accountConfigId),
720
+ [],
721
+ );
722
+ });
723
+
724
+ it("passes over a UID already quarantined without fetching it", async () => {
725
+ const first = imap.deliver(
726
+ "INBOX",
727
+ eml({ "Message-ID": "<a@example.com>" }),
728
+ );
729
+ const second = imap.deliver(
730
+ "INBOX",
731
+ eml({ "Message-ID": "<b@example.com>" }),
732
+ );
733
+ await new QuarantineService(
734
+ store.quarantine,
735
+ store.mailboxSpecialUse,
736
+ "sha-test",
737
+ silentLog,
738
+ ).record(
739
+ {
740
+ accountId: account.accountId,
741
+ accountConfigId: account.accountConfigId,
742
+ mailboxId: inbox.mailboxId,
743
+ mailboxPath: "INBOX",
744
+ uidValidity: 7,
745
+ attempts: 3,
746
+ },
747
+ first,
748
+ { stage: "BodyParse", code: "UnreadableBody", message: "no" },
749
+ );
750
+ const asked: number[][] = [];
751
+ const fetch = connection.fetchMessages;
752
+ connection.fetchMessages = async (uids) => {
753
+ asked.push(uids);
754
+ return fetch(uids);
755
+ };
756
+
757
+ await sync(inbox);
758
+
759
+ assert.deepEqual(asked, [[second]]);
760
+ assert.equal((await stored(inbox)).highWaterMarkUid, second);
761
+ });
762
+ });