@remit/mailbox-service 0.0.111 → 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 +156 -235
  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 +5 -4
  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/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  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
@@ -1,408 +1,112 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it, mock } from "node:test";
3
- import type { IMessageFlagPushRepository } from "@remit/data-ports";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
4
3
  import { MessageSystemFlag } from "@remit/domain-enums";
5
- import { type FlagPushConfig, FlagPushService } from "./flag-push.js";
6
-
7
- const accountId = "acc-1";
8
- const accountConfigId = "acc-cfg-1";
9
- const messageId = "msg-1";
10
- const mailboxId = "mbx-1";
11
-
12
- const createMockSqs = (impl?: () => Promise<unknown>) => {
13
- const sent: unknown[] = [];
14
- return {
15
- send: mock.fn(async (cmd: { input: unknown }) => {
16
- if (impl) await impl();
17
- sent.push(cmd.input);
18
- return { MessageId: "ok" };
19
- }),
20
- _sent: sent,
21
- };
4
+ import { FlagPushService } from "./flag-push.js";
5
+ import {
6
+ captureQueue,
7
+ openStore,
8
+ type QueueCapture,
9
+ type Store,
10
+ } from "./test-helpers/test-store.js";
11
+
12
+ const target = {
13
+ accountId: "acc-1",
14
+ accountConfigId: "cfg-1",
15
+ messageId: "msg-1",
16
+ mailboxId: "mbx-1",
17
+ flagName: MessageSystemFlag.Seen,
18
+ operation: "add" as const,
22
19
  };
23
20
 
24
- interface Harness {
25
- service: FlagPushService;
26
- markerPuts: Array<Record<string, unknown>>;
27
- markerStateUpdates: Array<{
28
- messageId: string;
29
- flagName: string;
30
- state: string;
31
- }>;
32
- mockSqs: ReturnType<typeof createMockSqs>;
33
- logs: { info: unknown[]; error: unknown[] };
34
- }
21
+ let store: Store;
22
+ let capture: QueueCapture;
23
+ let errors: Array<Record<string, unknown>>;
35
24
 
36
- const createHarness = (opts?: {
37
- sqsImpl?: () => Promise<unknown>;
38
- queueUrl?: string;
39
- updateStateImpl?: () => Promise<unknown>;
40
- findImpl?: () => Promise<unknown>;
41
- }): Harness => {
42
- const markerPuts: Array<Record<string, unknown>> = [];
43
- const markerStateUpdates: Array<{
44
- messageId: string;
45
- flagName: string;
46
- state: string;
47
- }> = [];
48
- const logs = { info: [] as unknown[], error: [] as unknown[] };
25
+ beforeEach(async () => {
26
+ store = await openStore();
27
+ capture = captureQueue();
28
+ errors = [];
29
+ });
49
30
 
50
- const markerService: IMessageFlagPushRepository = {
51
- put: mock.fn(async (input: Record<string, unknown>) => {
52
- markerPuts.push(input);
53
- return {
54
- ...input,
55
- state: "pending",
56
- createdAt: 1,
57
- updatedAt: 1,
58
- } as never;
59
- }),
60
- find: mock.fn(async () =>
61
- opts?.findImpl ? ((await opts.findImpl()) as never) : null,
62
- ),
63
- updateState: mock.fn(
64
- async (id: string, flagName: string, state: string) => {
65
- if (opts?.updateStateImpl) return opts.updateStateImpl() as never;
66
- markerStateUpdates.push({ messageId: id, flagName, state });
67
- return {} as never;
68
- },
69
- ),
70
- delete: mock.fn(async () => {}),
71
- listByAccountId: mock.fn(async () => []),
72
- listByMailboxId: mock.fn(async () => []),
73
- };
31
+ afterEach(async () => {
32
+ capture.restore();
33
+ await store.close();
34
+ });
74
35
 
75
- const mockSqs = createMockSqs(opts?.sqsImpl);
36
+ const queueAnswers = (answer: () => Promise<unknown>) => capture.answer(answer);
76
37
 
77
- const config: FlagPushConfig = {
78
- markerService,
79
- sqsQueueUrl: opts?.queueUrl ?? "http://localhost:4566/test-queue",
38
+ const flips = (queueUrl = "http://localhost:9324/000/remit-flags") =>
39
+ new FlagPushService({
40
+ markerService: store.flagPush,
41
+ sqsQueueUrl: queueUrl,
80
42
  logger: {
81
- info: (fields: Record<string, unknown>) => {
82
- logs.info.push(fields);
83
- },
84
- error: (fields: Record<string, unknown>) => {
85
- logs.error.push(fields);
86
- },
43
+ info: () => {},
44
+ error: (fields: Record<string, unknown>) => errors.push(fields),
87
45
  },
88
- };
89
-
90
- const service = new FlagPushService(config);
91
- // @ts-expect-error - inject mock SQS client for testing
92
- service.sqs = mockSqs;
93
-
94
- return { service, markerPuts, markerStateUpdates, mockSqs, logs };
95
- };
96
-
97
- describe("FlagPushService.flip", () => {
98
- it("persists a pending marker before enqueueing the wake-up hint", async () => {
99
- const { service, markerPuts } = createHarness();
100
-
101
- await service.flip({
102
- accountId,
103
- accountConfigId,
104
- messageId,
105
- mailboxId,
106
- flagName: MessageSystemFlag.Seen,
107
- operation: "add",
108
- });
109
-
110
- assert.equal(markerPuts.length, 1);
111
- assert.deepEqual(markerPuts[0], {
112
- messageId,
113
- flagName: MessageSystemFlag.Seen,
114
- accountId,
115
- accountConfigId,
116
- mailboxId,
117
- operation: "add",
118
- });
119
46
  });
120
47
 
121
- it("advances the marker to queued once the SQS hint is confirmed sent", async () => {
122
- const { service, markerStateUpdates } = createHarness();
123
-
124
- await service.flip({
125
- accountId,
126
- accountConfigId,
127
- messageId,
128
- mailboxId,
129
- flagName: MessageSystemFlag.Flagged,
130
- operation: "remove",
131
- });
48
+ const marker = () => store.flagPush.find(target.messageId, target.flagName);
132
49
 
133
- assert.deepEqual(markerStateUpdates, [
134
- { messageId, flagName: MessageSystemFlag.Flagged, state: "queued" },
135
- ]);
136
- });
50
+ describe("FlagPushService.flip", () => {
51
+ it("records the owed push and marks it queued once the hint is sent", async () => {
52
+ queueAnswers(async () => ({}));
137
53
 
138
- it("does NOT reject flip when the wake-up hint enqueue fails (queue down)", async () => {
139
- const { service } = createHarness({
140
- sqsImpl: () => {
141
- throw Object.assign(new Error(""), {
142
- name: "AggregateError",
143
- code: "ECONNREFUSED",
144
- });
145
- },
146
- });
54
+ await flips().flip(target);
147
55
 
148
- await assert.doesNotReject(
149
- service.flip({
150
- accountId,
151
- accountConfigId,
152
- messageId,
153
- mailboxId,
154
- flagName: MessageSystemFlag.Seen,
155
- operation: "add",
156
- }),
157
- );
56
+ const recorded = await marker();
57
+ assert.equal(recorded?.operation, "add");
58
+ assert.equal(recorded?.mailboxId, target.mailboxId);
59
+ assert.equal(recorded?.state, "queued");
158
60
  });
159
61
 
160
- it("leaves the marker in `pending` (never advances to queued) when the hint enqueue fails", async () => {
161
- const { service, markerStateUpdates } = createHarness({
162
- sqsImpl: () => {
163
- throw new Error("ECONNREFUSED");
164
- },
165
- });
62
+ it("names the message and flag in the hint, never the operation or a uid", async () => {
63
+ queueAnswers(async () => ({}));
166
64
 
167
- await service.flip({
168
- accountId,
169
- accountConfigId,
170
- messageId,
171
- mailboxId,
172
- flagName: MessageSystemFlag.Seen,
173
- operation: "add",
174
- });
65
+ await flips().flip(target);
175
66
 
176
- assert.deepEqual(markerStateUpdates, []);
67
+ const event = JSON.parse(capture.inputs()[0].MessageBody ?? "{}");
68
+ assert.equal(event.type, "FLAG_PUSH");
69
+ assert.equal(event.messageId, target.messageId);
70
+ assert.equal(event.flagName, target.flagName);
71
+ assert.equal("operation" in event, false);
72
+ assert.equal("uid" in event, false);
177
73
  });
178
74
 
179
- it("logs the enqueue failure loudly with an alertable field (not silent)", async () => {
180
- const { service, logs } = createHarness({
181
- sqsImpl: () => {
182
- throw Object.assign(new Error(""), {
183
- name: "AggregateError",
184
- code: "ECONNREFUSED",
185
- });
186
- },
187
- });
75
+ it("groups a FIFO hint by account and leaves a standard queue ungrouped", async () => {
76
+ queueAnswers(async () => ({}));
188
77
 
189
- await service.flip({
190
- accountId,
191
- accountConfigId,
192
- messageId,
193
- mailboxId,
194
- flagName: MessageSystemFlag.Seen,
195
- operation: "add",
196
- });
78
+ await flips("http://localhost:9324/000/remit-flags.fifo").flip(target);
79
+ await flips("http://localhost:9324/000/remit-flags").flip(target);
197
80
 
198
- const alerted = logs.error.find(
199
- (entry) =>
200
- (entry as { alert?: string }).alert === "flag_push_hint_enqueue_failed",
201
- );
202
- assert.ok(
203
- alerted,
204
- "expected an alertable flag_push_hint_enqueue_failed log",
205
- );
206
- assert.equal((alerted as { errorCode?: string }).errorCode, "ECONNREFUSED");
81
+ assert.equal(capture.inputs()[0].MessageGroupId, target.accountId);
82
+ assert.equal(capture.inputs()[1].MessageGroupId, undefined);
207
83
  });
208
84
 
209
- it("does NOT leak an unhandled rejection onto a concurrent caller when the enqueue fails", async () => {
210
- const leaked: unknown[] = [];
211
- const onUnhandled = (reason: unknown): void => {
212
- leaked.push(reason);
213
- };
214
- process.on("unhandledRejection", onUnhandled);
215
-
216
- const { service } = createHarness({
217
- sqsImpl: () =>
218
- new Promise((_resolve, reject) => {
219
- setImmediate(() =>
220
- reject(
221
- Object.assign(new Error(""), {
222
- name: "AggregateError",
223
- code: "ECONNREFUSED",
224
- }),
225
- ),
226
- );
227
- }),
228
- });
229
-
230
- try {
231
- void service.flip({
232
- accountId,
233
- accountConfigId,
234
- messageId,
235
- mailboxId,
236
- flagName: MessageSystemFlag.Seen,
237
- operation: "add",
85
+ it("keeps the push owed and alarms, without rejecting, when the queue is down", async () => {
86
+ queueAnswers(async () => {
87
+ throw Object.assign(new Error("connect ECONNREFUSED"), {
88
+ code: "ECONNREFUSED",
238
89
  });
239
- await new Promise((resolve) => setImmediate(resolve));
240
- await new Promise((resolve) => setImmediate(resolve));
241
- await new Promise((resolve) => setImmediate(resolve));
242
- } finally {
243
- process.off("unhandledRejection", onUnhandled);
244
- }
245
-
246
- assert.equal(leaked.length, 0);
247
- });
248
-
249
- it("sets MessageGroupId=accountId for FIFO queue URLs", async () => {
250
- const { service, mockSqs } = createHarness({
251
- queueUrl:
252
- "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes.fifo",
253
- });
254
-
255
- await service.flip({
256
- accountId,
257
- accountConfigId,
258
- messageId,
259
- mailboxId,
260
- flagName: MessageSystemFlag.Seen,
261
- operation: "add",
262
- });
263
-
264
- assert.equal(mockSqs._sent.length, 1);
265
- const sent = mockSqs._sent[0] as { MessageGroupId?: string };
266
- assert.equal(sent.MessageGroupId, accountId);
267
- });
268
-
269
- it("omits FIFO params for standard queue URLs", async () => {
270
- const { service, mockSqs } = createHarness({
271
- queueUrl:
272
- "https://sqs.eu-west-1.amazonaws.com/123456789012/remit-dev-mailboxes",
273
90
  });
274
91
 
275
- await service.flip({
276
- accountId,
277
- accountConfigId,
278
- messageId,
279
- mailboxId,
280
- flagName: MessageSystemFlag.Seen,
281
- operation: "add",
282
- });
283
-
284
- assert.equal(mockSqs._sent.length, 1);
285
- const sent = mockSqs._sent[0] as { MessageGroupId?: string };
286
- assert.equal(sent.MessageGroupId, undefined);
287
- });
288
-
289
- it("event body carries only messageId + flagName — never the operation or a UID", async () => {
290
- const { service, mockSqs } = createHarness();
92
+ await flips().flip(target);
291
93
 
292
- await service.flip({
293
- accountId,
294
- accountConfigId,
295
- messageId,
296
- mailboxId,
297
- flagName: MessageSystemFlag.Flagged,
298
- operation: "remove",
299
- });
300
-
301
- const sent = mockSqs._sent[0] as { MessageBody: string };
302
- const event = JSON.parse(sent.MessageBody);
303
- assert.equal(event.type, "FLAG_PUSH");
304
- assert.equal(event.messageId, messageId);
305
- assert.equal(event.flagName, MessageSystemFlag.Flagged);
306
- assert.equal(event.accountId, accountId);
307
- assert.equal(event.accountConfigId, accountConfigId);
308
- assert.equal("operation" in event, false);
309
- assert.equal("uid" in event, false);
94
+ assert.equal((await marker())?.state, "pending");
95
+ const alarm = errors.find(
96
+ (entry) => entry.alert === "flag_push_hint_enqueue_failed",
97
+ );
98
+ assert.equal(alarm?.errorCode, "ECONNREFUSED");
310
99
  });
311
100
 
312
- describe("fast-path race: the worker drains and deletes the marker before the queued-state transition runs (review finding on #1292)", () => {
313
- it("does NOT log the flag_push_hint_enqueue_failed alarm when the marker is already gone", async () => {
314
- const { service, logs } = createHarness({
315
- updateStateImpl: () => {
316
- throw new Error(
317
- "Cannot update state on a MessageFlagPush that does not exist: msg-1/\\Seen",
318
- );
319
- },
320
- findImpl: () => Promise.resolve(null),
321
- });
322
-
323
- await service.flip({
324
- accountId,
325
- accountConfigId,
326
- messageId,
327
- mailboxId,
328
- flagName: MessageSystemFlag.Seen,
329
- operation: "add",
330
- });
331
-
332
- assert.equal(
333
- logs.error.length,
334
- 0,
335
- "the enqueue succeeded; a fast-path race must never be logged as flag_push_hint_enqueue_failed",
336
- );
337
- assert.ok(
338
- logs.info.length > 0,
339
- "the race is still visible as routine info, not silent",
340
- );
341
- });
342
-
343
- it("still logs the flag_push_hint_enqueue_failed alarm when updateState fails for a REAL reason (marker still present)", async () => {
344
- const { service, logs } = createHarness({
345
- updateStateImpl: () => {
346
- throw new Error("ProvisionedThroughputExceededException");
347
- },
348
- findImpl: () =>
349
- Promise.resolve({
350
- messageId,
351
- flagName: MessageSystemFlag.Seen,
352
- state: "pending",
353
- }),
354
- });
355
-
356
- await service.flip({
357
- accountId,
358
- accountConfigId,
359
- messageId,
360
- mailboxId,
361
- flagName: MessageSystemFlag.Seen,
362
- operation: "add",
363
- });
364
-
365
- const alerted = logs.error.find(
366
- (entry) =>
367
- (entry as { alert?: string }).alert ===
368
- "flag_push_hint_enqueue_failed",
369
- );
370
- assert.ok(
371
- alerted,
372
- "a genuine failure behind the race-check must still alarm",
373
- );
101
+ it("raises no alarm when the worker drained the push before it was marked queued", async () => {
102
+ queueAnswers(async () => {
103
+ await store.flagPush.delete(target.messageId, target.flagName);
104
+ return {};
374
105
  });
375
106
 
376
- it("still logs the flag_push_hint_enqueue_failed alarm when the re-check itself fails (backend genuinely down)", async () => {
377
- const { service, logs } = createHarness({
378
- updateStateImpl: () => {
379
- throw new Error(
380
- "Cannot update state on a MessageFlagPush that does not exist: msg-1/\\Seen",
381
- );
382
- },
383
- findImpl: () => {
384
- throw new Error("ECONNREFUSED");
385
- },
386
- });
387
-
388
- await service.flip({
389
- accountId,
390
- accountConfigId,
391
- messageId,
392
- mailboxId,
393
- flagName: MessageSystemFlag.Seen,
394
- operation: "add",
395
- });
107
+ await flips().flip(target);
396
108
 
397
- const alerted = logs.error.find(
398
- (entry) =>
399
- (entry as { alert?: string }).alert ===
400
- "flag_push_hint_enqueue_failed",
401
- );
402
- assert.ok(
403
- alerted,
404
- "a re-check failure is a real infra problem — must alarm",
405
- );
406
- });
109
+ assert.equal(await marker(), null);
110
+ assert.deepEqual(errors, []);
407
111
  });
408
112
  });
@@ -0,0 +1,197 @@
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 { MessageSystemFlag } from "@remit/domain-enums";
5
+ import { FlagPushService } from "./flag-push.js";
6
+ import { resolveExhaustedFlagPushFailure } from "./flag-push-terminal.js";
7
+ import { FlagQueueService, type UpdateFlagsInput } from "./flag-queue.js";
8
+ import {
9
+ eml,
10
+ type FakeImapConnection,
11
+ FakeImapServer,
12
+ } from "./test-helpers/fake-imap.js";
13
+ import {
14
+ captureQueue,
15
+ openStore,
16
+ type QueueCapture,
17
+ type Store,
18
+ seedAccount,
19
+ seedMailbox,
20
+ seedMessage,
21
+ silentLog,
22
+ } from "./test-helpers/test-store.js";
23
+
24
+ let store: Store;
25
+ let capture: QueueCapture;
26
+ let account: AccountItem;
27
+ let inbox: MailboxItem;
28
+ let messageId: string;
29
+
30
+ beforeEach(async () => {
31
+ capture = captureQueue();
32
+ store = await openStore();
33
+ account = await seedAccount(store);
34
+ inbox = await seedMailbox(store, account, "INBOX");
35
+ messageId = (await seedMessage(store, account, inbox, 1)).message.messageId;
36
+ });
37
+
38
+ afterEach(async () => {
39
+ capture.restore();
40
+ await store.close();
41
+ });
42
+
43
+ const flags = () =>
44
+ new FlagQueueService({
45
+ messageFlagService: store.messageFlag,
46
+ messageService: store.message,
47
+ threadMessageService: store.threadMessage,
48
+ flagPushService: new FlagPushService({
49
+ markerService: store.flagPush,
50
+ sqsQueueUrl: "https://sqs.test/flags",
51
+ }),
52
+ });
53
+
54
+ const update = (input: UpdateFlagsInput) =>
55
+ flags().updateFlags(
56
+ account.accountConfigId,
57
+ messageId,
58
+ account.accountId,
59
+ input,
60
+ );
61
+
62
+ const listing = async () =>
63
+ (
64
+ await store.threadMessage.findAllByMessageId(
65
+ account.accountConfigId,
66
+ messageId,
67
+ )
68
+ )[0];
69
+
70
+ const owedFlagged = async () =>
71
+ (await store.flagPush.find(messageId, MessageSystemFlag.Flagged))?.operation;
72
+
73
+ describe("updateFlags keeps the star and its colour in step", () => {
74
+ for (const [input, hasStars, star, owed] of [
75
+ [{ isStarred: true }, true, "yellow", "add"],
76
+ [{ isStarred: true, starColor: "red" }, true, "red", "add"],
77
+ [{ starColor: "blue" }, true, "blue", "add"],
78
+ [{ isStarred: true, starColor: "none" }, true, "none", "add"],
79
+ ] as const) {
80
+ it(`${JSON.stringify(input)} stars as ${star} and owes the server \\Flagged`, async () => {
81
+ await update(input);
82
+
83
+ const row = await listing();
84
+ assert.equal(row.hasStars, hasStars);
85
+ assert.equal(row.star, star);
86
+ assert.equal(await owedFlagged(), owed);
87
+ });
88
+ }
89
+
90
+ for (const input of [{ isStarred: false }, { starColor: "none" }] as const) {
91
+ it(`${JSON.stringify(input)} unstars a starred message`, async () => {
92
+ await update({ isStarred: true });
93
+
94
+ await update(input);
95
+
96
+ const row = await listing();
97
+ assert.equal(row.hasStars, false);
98
+ assert.equal(row.star, "none");
99
+ assert.equal(await owedFlagged(), "remove");
100
+ });
101
+ }
102
+
103
+ it("marks read once and treats a repeat as no change", async () => {
104
+ await update({ isRead: true });
105
+ const first = await store.flagPush.find(messageId, MessageSystemFlag.Seen);
106
+
107
+ const result = await update({ isRead: true });
108
+
109
+ assert.equal(result.isRead, true);
110
+ assert.equal((await listing()).isRead, true);
111
+ assert.deepEqual(
112
+ await store.flagPush.find(messageId, MessageSystemFlag.Seen),
113
+ first,
114
+ );
115
+ });
116
+ });
117
+
118
+ describe("a flag push that exhausted its retries", () => {
119
+ const resolve = async (
120
+ connect: () => FakeImapConnection,
121
+ afterRead: () => Promise<unknown> = async () => {},
122
+ ) => {
123
+ await update({ isRead: true });
124
+ const read = await store.message.get(messageId);
125
+ await afterRead();
126
+ return resolveExhaustedFlagPushFailure(
127
+ {
128
+ messageService: store.message,
129
+ markerService: store.flagPush,
130
+ log: silentLog,
131
+ },
132
+ {
133
+ placement: {
134
+ status: read.status,
135
+ mailboxId: read.mailboxId,
136
+ uid: read.uid,
137
+ },
138
+ accountId: account.accountId,
139
+ accountConfigId: account.accountConfigId,
140
+ messageId,
141
+ flagName: MessageSystemFlag.Seen,
142
+ uid: 1,
143
+ mailboxPath: "INBOX",
144
+ getConnection: async () => connect(),
145
+ },
146
+ );
147
+ };
148
+
149
+ it("drops the push and the stale rows of a message the server no longer holds", async () => {
150
+ const imap = new FakeImapServer();
151
+ imap.addFolder("INBOX");
152
+
153
+ const { outcome } = await resolve(() => imap.connection());
154
+
155
+ assert.equal(outcome, "reconciled");
156
+ assert.equal(
157
+ await store.flagPush.find(messageId, MessageSystemFlag.Seen),
158
+ null,
159
+ );
160
+ await assert.rejects(store.message.get(messageId));
161
+ });
162
+
163
+ it("leaves a row the user moved after the push read it", async () => {
164
+ const imap = new FakeImapServer();
165
+ imap.addFolder("INBOX");
166
+ const archive = await seedMailbox(store, account, "Archive");
167
+
168
+ const { outcome } = await resolve(
169
+ () => imap.connection(),
170
+ () =>
171
+ store.message.transitionPlacement(
172
+ messageId,
173
+ { mailboxId: inbox.mailboxId },
174
+ { mailboxId: archive.mailboxId, uid: 7 },
175
+ ),
176
+ );
177
+
178
+ assert.equal(outcome, "reconciled");
179
+ const moved = await store.message.get(messageId);
180
+ assert.equal(moved.mailboxId, archive.mailboxId);
181
+ assert.equal(moved.uid, 7);
182
+ });
183
+
184
+ it("keeps the push and the rows of a message still there, dropped FETCH row or not", async () => {
185
+ const imap = new FakeImapServer();
186
+ imap.addFolder("INBOX");
187
+ imap.deliver("INBOX", eml({}));
188
+ const connection = imap.connection();
189
+ connection.fetchMessages = async () => [];
190
+
191
+ const { outcome } = await resolve(() => connection);
192
+
193
+ assert.equal(outcome, "broken");
194
+ assert.ok(await store.flagPush.find(messageId, MessageSystemFlag.Seen));
195
+ assert.ok(await store.message.get(messageId));
196
+ });
197
+ });