@remit/imap-worker 0.0.85 → 0.0.87

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 (73) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.test.ts +32 -4
  8. package/src/emit.ts +58 -23
  9. package/src/failure-kind.ts +1 -1
  10. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  11. package/src/handlers/append-sent-message.ts +3 -24
  12. package/src/handlers/delete-account-objects.test.ts +1 -1
  13. package/src/handlers/delete-account-objects.ts +1 -1
  14. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  15. package/src/handlers/empty-trash.ts +3 -24
  16. package/src/handlers/flag-push.ts +11 -18
  17. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  18. package/src/handlers/mailbox-management.ts +12 -48
  19. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  20. package/src/handlers/message-copy.ts +31 -49
  21. package/src/handlers/message-delete-terminal.ts +12 -10
  22. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  23. package/src/handlers/message-delete.ts +26 -32
  24. package/src/handlers/message-move-terminal.ts +12 -10
  25. package/src/handlers/message-move.sqlite.test.ts +271 -0
  26. package/src/handlers/message-move.test.ts +76 -863
  27. package/src/handlers/message-move.ts +49 -39
  28. package/src/handlers/placement-move-push.test.ts +96 -329
  29. package/src/handlers/placement-move-push.ts +75 -29
  30. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  31. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  32. package/src/handlers/probe-message-id.ts +15 -0
  33. package/src/handlers/sync-mailboxes.test.ts +0 -1
  34. package/src/handlers/sync-mailboxes.ts +1 -1
  35. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  36. package/src/handlers/sync-message-body.test.ts +3 -299
  37. package/src/handlers/sync-message-body.ts +21 -11
  38. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  39. package/src/handlers/sync-messages.ts +35 -34
  40. package/src/index.ts +5 -3
  41. package/src/mail-sync-selection.sqlite.test.ts +20 -18
  42. package/src/pending-sync.sqlite.test.ts +167 -0
  43. package/src/poller.ts +5 -4
  44. package/src/processor.test.ts +1 -1
  45. package/src/processor.ts +1 -1
  46. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  47. package/src/scheduler/attachment-sweep.ts +1 -1
  48. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  49. package/src/scheduler/calendar-subscriptions.ts +6 -6
  50. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  51. package/src/scheduler/handler.ts +12 -1
  52. package/src/scheduler/loop.test.ts +2 -0
  53. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  54. package/src/scheduler/run-tick.ts +39 -1
  55. package/src/scheduler/runner.ts +21 -1
  56. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  57. package/src/stranded-placement.sqlite.test.ts +795 -0
  58. package/src/test-helpers/test-world.ts +398 -0
  59. package/src/thread-join.sqlite.test.ts +115 -0
  60. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  61. package/src/with-oauth-lifecycle.ts +1 -1
  62. package/src/handlers/append-sent-message.test.ts +0 -529
  63. package/src/handlers/empty-trash.test.ts +0 -728
  64. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  65. package/src/handlers/flag-push.test.ts +0 -325
  66. package/src/handlers/mailbox-management.test.ts +0 -850
  67. package/src/handlers/message-copy.test.ts +0 -654
  68. package/src/handlers/message-delete.test.ts +0 -1869
  69. package/src/handlers/message-move-terminal.test.ts +0 -412
  70. package/src/handlers/sync-messages.test.ts +0 -567
  71. package/src/scheduler/attachment-sweep.test.ts +0 -169
  72. package/src/scheduler/run-tick.test.ts +0 -478
  73. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -1,385 +0,0 @@
1
- /**
2
- * Issue #496: auto-mark-read and the placement move are enqueued from the same
3
- * body-sync pass onto two different queues, so the flag push can reach the
4
- * server first. It must not, because the row it would resolve against names the
5
- * destination mailbox while still carrying the source folder's uid — a STORE
6
- * there lands on the destination's OWN message at that uid.
7
- *
8
- * These tests drive the REAL `PlacementMoveService` and `FlagQueueService` to
9
- * produce the row and the marker, rather than hand-writing the shape the
10
- * handler guards on. A change that stops `PlacementMoveService` marking the row
11
- * `moving` reopens the defect while a hand-written fixture stays green.
12
- */
13
-
14
- import assert from "node:assert/strict";
15
- import { afterEach, beforeEach, describe, it, mock } from "node:test";
16
- import { SQSClient } from "@aws-sdk/client-sqs";
17
- import { getClient, type RemitClient, setClient } from "@remit/backend/client";
18
- import type {
19
- IAddressRepository,
20
- IMailboxSpecialUseRepository,
21
- IMessageFlagPushRepository,
22
- IMessageFlagRepository,
23
- IMessagePlacementMoveRepository,
24
- IMessageRepository,
25
- IThreadMessageRepository,
26
- MessageFlagPushItem,
27
- MessageItem,
28
- MessagePlacementMoveItem,
29
- PlacementPredicate,
30
- PlacementTransitionInput,
31
- } from "@remit/data-ports";
32
- import type { Logger } from "@remit/logger-lambda";
33
- import {
34
- FlagPushService,
35
- FlagQueueService,
36
- PlacementMoveService,
37
- } from "@remit/mailbox-service";
38
- import type { FlagPushEvent } from "../events.js";
39
- import { handleFlagPush } from "./flag-push.js";
40
-
41
- const ACCOUNT_ID = "acc-496";
42
- const ACCOUNT_CONFIG_ID = "cfg-496";
43
- const MESSAGE_ID = "msg-496";
44
- const INBOX_ID = "mbx-inbox";
45
- const ARCHIVE_ID = "mbx-archive";
46
- /** The newsletter's uid in INBOX — and, on a real server, some unrelated message's uid in Archive. */
47
- const INBOX_UID = 42;
48
- const SEEN = "\\Seen";
49
-
50
- const silentLogger = (() => {
51
- const noop = () => {};
52
- const log = {
53
- info: noop,
54
- warn: noop,
55
- error: noop,
56
- debug: noop,
57
- fatal: noop,
58
- trace: noop,
59
- child: () => log,
60
- } as unknown as Logger;
61
- return log;
62
- })();
63
-
64
- interface Harness {
65
- row: MessageItem;
66
- flagMarkers: Map<string, MessageFlagPushItem>;
67
- placementMarkers: Map<string, MessagePlacementMoveItem>;
68
- placementMoveService: PlacementMoveService;
69
- flagQueueService: FlagQueueService;
70
- }
71
-
72
- const buildHarness = (): Harness => {
73
- const row = {
74
- messageId: MESSAGE_ID,
75
- accountConfigId: ACCOUNT_CONFIG_ID,
76
- mailboxId: INBOX_ID,
77
- uid: INBOX_UID,
78
- status: "active",
79
- syncStatus: "synced",
80
- } as unknown as MessageItem;
81
-
82
- const flagMarkers = new Map<string, MessageFlagPushItem>();
83
- const placementMarkers = new Map<string, MessagePlacementMoveItem>();
84
- const localFlags = new Set<string>();
85
-
86
- const messageService = {
87
- get: async (messageId: string) => {
88
- assert.equal(messageId, MESSAGE_ID);
89
- return row;
90
- },
91
- transitionPlacement: async (
92
- _messageId: string,
93
- expected: PlacementPredicate,
94
- next: PlacementTransitionInput,
95
- ) => {
96
- if (expected.status !== undefined && expected.status !== row.status) {
97
- return undefined;
98
- }
99
- if (expected.uid !== undefined && expected.uid !== row.uid) {
100
- return undefined;
101
- }
102
- if (
103
- expected.mailboxId !== undefined &&
104
- expected.mailboxId !== row.mailboxId
105
- ) {
106
- return undefined;
107
- }
108
- Object.assign(row, next);
109
- return row;
110
- },
111
- } as unknown as IMessageRepository;
112
-
113
- const threadMessageService = {
114
- getByMessageId: async () => ({
115
- accountConfigId: ACCOUNT_CONFIG_ID,
116
- threadMessageId: "tm-496",
117
- sentDate: 1,
118
- mailboxId: row.mailboxId,
119
- isRead: false,
120
- isDeleted: false,
121
- hasStars: false,
122
- hasAttachment: false,
123
- }),
124
- findAllByMessageId: async () => [],
125
- update: async () => {},
126
- } as unknown as IThreadMessageRepository;
127
-
128
- const placementMarkerService = {
129
- put: async (input: { messageId: string }) => {
130
- const marker = {
131
- ...input,
132
- state: "pending",
133
- createdAt: Date.now(),
134
- } as unknown as MessagePlacementMoveItem;
135
- placementMarkers.set(input.messageId, marker);
136
- return marker;
137
- },
138
- find: async (messageId: string) => placementMarkers.get(messageId) ?? null,
139
- updateState: async (
140
- messageId: string,
141
- state: MessagePlacementMoveItem["state"],
142
- ) => {
143
- const marker = placementMarkers.get(messageId);
144
- if (!marker) throw new Error(`no placement marker for ${messageId}`);
145
- marker.state = state;
146
- return marker;
147
- },
148
- delete: async (messageId: string) => {
149
- placementMarkers.delete(messageId);
150
- },
151
- } as unknown as IMessagePlacementMoveRepository;
152
-
153
- const flagMarkerService = {
154
- put: async (input: { messageId: string; flagName: string }) => {
155
- const marker = {
156
- ...input,
157
- state: "pending",
158
- createdAt: Date.now(),
159
- } as unknown as MessageFlagPushItem;
160
- flagMarkers.set(`${input.messageId}:${input.flagName}`, marker);
161
- return marker;
162
- },
163
- find: async (messageId: string, flagName: string) =>
164
- flagMarkers.get(`${messageId}:${flagName}`) ?? null,
165
- updateState: async (
166
- messageId: string,
167
- flagName: string,
168
- state: MessageFlagPushItem["state"],
169
- ) => {
170
- const marker = flagMarkers.get(`${messageId}:${flagName}`);
171
- if (!marker) throw new Error(`no flag marker for ${messageId}`);
172
- marker.state = state;
173
- return marker;
174
- },
175
- delete: async (messageId: string, flagName: string) => {
176
- flagMarkers.delete(`${messageId}:${flagName}`);
177
- },
178
- } as unknown as IMessageFlagPushRepository;
179
-
180
- const messageFlagService = {
181
- hasFlag: async (_messageId: string, flagName: string) =>
182
- localFlags.has(flagName),
183
- addFlag: async (_messageId: string, flagName: string) => {
184
- localFlags.add(flagName);
185
- return {} as never;
186
- },
187
- removeFlag: async (_messageId: string, flagName: string) => {
188
- localFlags.delete(flagName);
189
- },
190
- } as unknown as IMessageFlagRepository;
191
-
192
- const placementMoveService = new PlacementMoveService({
193
- messageService,
194
- threadMessageService,
195
- markerService: placementMarkerService,
196
- addressService: {
197
- reconcileJunkOnlyForMessage: async () => {},
198
- } as unknown as IAddressRepository,
199
- mailboxSpecialUseService: {
200
- resolveJunkRolesForConfig: async () => ({
201
- junkMailboxIds: [],
202
- trashMailboxIds: [],
203
- }),
204
- } as unknown as IMailboxSpecialUseRepository,
205
- sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/message-mgmt",
206
- });
207
-
208
- const flagQueueService = new FlagQueueService({
209
- messageFlagService,
210
- messageService,
211
- threadMessageService,
212
- flagPushService: new FlagPushService({
213
- markerService: flagMarkerService,
214
- sqsQueueUrl: "https://sqs.eu-west-1.amazonaws.com/000/messages",
215
- }),
216
- });
217
-
218
- return {
219
- row,
220
- flagMarkers,
221
- placementMarkers,
222
- placementMoveService,
223
- flagQueueService,
224
- };
225
- };
226
-
227
- const event: FlagPushEvent = {
228
- type: "FLAG_PUSH",
229
- accountId: ACCOUNT_ID,
230
- accountConfigId: ACCOUNT_CONFIG_ID,
231
- messageId: MESSAGE_ID,
232
- flagName: SEEN,
233
- } as FlagPushEvent;
234
-
235
- describe("auto-mark-read racing the placement move (#496)", () => {
236
- let harness: Harness;
237
-
238
- beforeEach(async () => {
239
- mock.method(SQSClient.prototype, "send", async () => ({}));
240
- harness = buildHarness();
241
-
242
- setClient({
243
- account: { get: async () => undefined },
244
- message: { get: async () => undefined },
245
- mailbox: { get: async () => undefined },
246
- flagPush: {
247
- find: async () => undefined,
248
- updateState: async () => undefined,
249
- delete: async () => undefined,
250
- },
251
- } as unknown as RemitClient);
252
- });
253
-
254
- afterEach(() => mock.restoreAll());
255
-
256
- it("leaves the row naming the destination while it still carries the source uid", async () => {
257
- await harness.placementMoveService.moveMessage(
258
- ACCOUNT_CONFIG_ID,
259
- MESSAGE_ID,
260
- ARCHIVE_ID,
261
- ACCOUNT_ID,
262
- );
263
-
264
- assert.equal(harness.row.mailboxId, ARCHIVE_ID);
265
- assert.equal(
266
- harness.row.uid,
267
- INBOX_UID,
268
- "the uid still belongs to INBOX — this pair is what any dependent push would resolve",
269
- );
270
- assert.equal(harness.row.status, "moving");
271
- });
272
-
273
- it("never pushes the flag while the move that row is waiting on has not settled", async () => {
274
- await harness.placementMoveService.moveMessage(
275
- ACCOUNT_CONFIG_ID,
276
- MESSAGE_ID,
277
- ARCHIVE_ID,
278
- ACCOUNT_ID,
279
- );
280
- await harness.flagQueueService.markAsRead(
281
- ACCOUNT_CONFIG_ID,
282
- MESSAGE_ID,
283
- ACCOUNT_ID,
284
- );
285
-
286
- const marker = harness.flagMarkers.get(`${MESSAGE_ID}:${SEEN}`);
287
- assert.ok(marker, "the auto-read wrote a pending flag-push marker");
288
- assert.equal(
289
- (marker as unknown as { mailboxId: string }).mailboxId,
290
- ARCHIVE_ID,
291
- "the marker names the destination the unsettled move wrote",
292
- );
293
-
294
- const client = await getClient();
295
- mock.method(client.account, "get", async () => ({
296
- accountId: ACCOUNT_ID,
297
- accountConfigId: ACCOUNT_CONFIG_ID,
298
- }));
299
- mock.method(client.message, "get", async () => [harness.row]);
300
- const mailboxGet = mock.method(client.mailbox, "get", async () => ({
301
- mailboxId: ARCHIVE_ID,
302
- fullPath: "Archive",
303
- }));
304
- mock.method(client.flagPush, "find", async () => marker);
305
- const updateState = mock.method(
306
- client.flagPush,
307
- "updateState",
308
- async () => {},
309
- );
310
- const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
311
-
312
- // Captured rather than awaited bare: unguarded, the handler walks on to
313
- // borrow a connection and fails there, which would mask the assertion
314
- // below that says why it should never have got that far.
315
- const fault = await handleFlagPush(event, silentLogger, 1).then(
316
- () => undefined,
317
- (error: unknown) => error,
318
- );
319
-
320
- assert.equal(
321
- mailboxGet.mock.calls.length,
322
- 0,
323
- "returns before resolving Archive — nothing reaches the server against uid 42",
324
- );
325
- assert.equal(fault, undefined, "the deferral acks, it does not fault");
326
- assert.equal(
327
- deleteMarker.mock.calls.length,
328
- 0,
329
- "the read intent survives the deferral",
330
- );
331
- assert.deepEqual(updateState.mock.calls[0]?.arguments, [
332
- MESSAGE_ID,
333
- SEEN,
334
- "pending",
335
- ]);
336
- });
337
-
338
- it("pushes once the move has settled and the row's uid matches its mailbox", async () => {
339
- await harness.placementMoveService.moveMessage(
340
- ACCOUNT_CONFIG_ID,
341
- MESSAGE_ID,
342
- ARCHIVE_ID,
343
- ACCOUNT_ID,
344
- );
345
- await harness.flagQueueService.markAsRead(
346
- ACCOUNT_CONFIG_ID,
347
- MESSAGE_ID,
348
- ACCOUNT_ID,
349
- );
350
-
351
- // What `updateUid` writes when the IMAP move confirms: the destination's
352
- // own COPYUID, and the status back to active.
353
- Object.assign(harness.row, {
354
- uid: 907,
355
- mailboxId: ARCHIVE_ID,
356
- status: "active",
357
- syncStatus: "synced",
358
- });
359
-
360
- const client = await getClient();
361
- mock.method(client.account, "get", async () => ({
362
- accountId: ACCOUNT_ID,
363
- accountConfigId: ACCOUNT_CONFIG_ID,
364
- }));
365
- mock.method(client.message, "get", async () => [harness.row]);
366
- const mailboxGet = mock.method(client.mailbox, "get", async () => {
367
- throw Object.assign(new Error("stop before connecting"), {
368
- name: "NotFoundError",
369
- });
370
- });
371
- mock.method(client.flagPush, "find", async () =>
372
- harness.flagMarkers.get(`${MESSAGE_ID}:${SEEN}`),
373
- );
374
- mock.method(client.flagPush, "updateState", async () => {});
375
- mock.method(client.flagPush, "delete", async () => {});
376
-
377
- await handleFlagPush(event, silentLogger, 1);
378
-
379
- assert.equal(
380
- mailboxGet.mock.calls.length,
381
- 1,
382
- "the deferral is not permanent — a settled row resolves its mailbox and proceeds",
383
- );
384
- });
385
- });
@@ -1,325 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, before, describe, it, mock } from "node:test";
3
- import { getClient, type RemitClient, setClient } from "@remit/backend/client";
4
- import type { Logger } from "@remit/logger-lambda";
5
- import type { FlagPushEvent } from "../events.js";
6
- import {
7
- FLAG_PUSH_DEFER_MAX_MS,
8
- FLAG_PUSH_MAX_ATTEMPTS,
9
- getFlagPushDeferMaxMs,
10
- getFlagPushMaxAttempts,
11
- handleFlagPush,
12
- } from "./flag-push.js";
13
-
14
- const silentLogger = (() => {
15
- const noop = () => {};
16
- const log = {
17
- info: noop,
18
- warn: noop,
19
- error: noop,
20
- debug: noop,
21
- fatal: noop,
22
- trace: noop,
23
- child: () => log,
24
- } as unknown as Logger;
25
- return log;
26
- })();
27
-
28
- describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts / #1289's getPlacementMoveMaxAttempts)", () => {
29
- it("parses the CDK-injected env var", () => {
30
- assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "3" }), 3);
31
- assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "5" }), 5);
32
- });
33
-
34
- it("defaults to 3 when unset", () => {
35
- assert.equal(getFlagPushMaxAttempts({}), 3);
36
- });
37
-
38
- it("defaults to 3 on a non-numeric or non-positive value", () => {
39
- assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "nope" }), 3);
40
- assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "0" }), 3);
41
- assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "-1" }), 3);
42
- });
43
-
44
- it("the module-level constant reflects the actual process env at load time", () => {
45
- assert.equal(typeof FLAG_PUSH_MAX_ATTEMPTS, "number");
46
- assert.ok(FLAG_PUSH_MAX_ATTEMPTS > 0);
47
- });
48
- });
49
-
50
- describe("getFlagPushDeferMaxMs", () => {
51
- it("parses an env override", () => {
52
- assert.equal(
53
- getFlagPushDeferMaxMs({ FLAG_PUSH_DEFER_MAX_MS: "1000" }),
54
- 1000,
55
- );
56
- });
57
-
58
- it("defaults to 10 minutes when unset or invalid", () => {
59
- assert.equal(getFlagPushDeferMaxMs({}), 10 * 60 * 1000);
60
- assert.equal(
61
- getFlagPushDeferMaxMs({ FLAG_PUSH_DEFER_MAX_MS: "nope" }),
62
- 10 * 60 * 1000,
63
- );
64
- });
65
-
66
- it("the module-level constant reflects the actual process env at load time", () => {
67
- assert.equal(typeof FLAG_PUSH_DEFER_MAX_MS, "number");
68
- assert.ok(FLAG_PUSH_DEFER_MAX_MS > 0);
69
- });
70
- });
71
-
72
- describe("handleFlagPush — deleted mailbox is terminal (#287/#289)", () => {
73
- const accountId = "fp-acc-zzz";
74
- const messageId = "fp-msg-zzz";
75
- const flagName = "\\Seen";
76
-
77
- const event: FlagPushEvent = {
78
- type: "FLAG_PUSH",
79
- accountId,
80
- accountConfigId: "fp-cfg-zzz",
81
- messageId,
82
- flagName,
83
- } as FlagPushEvent;
84
-
85
- // The client is supplied by injection (`setClient`), so these tests register
86
- // the repositories they then mock rather than reaching a composition.
87
- before(() => {
88
- setClient({
89
- account: { get: async () => undefined },
90
- message: { get: async () => undefined },
91
- mailbox: { get: async () => undefined },
92
- flagPush: {
93
- find: async () => undefined,
94
- updateState: async () => undefined,
95
- delete: async () => undefined,
96
- },
97
- } as unknown as RemitClient);
98
- });
99
-
100
- afterEach(() => mock.restoreAll());
101
-
102
- it("drops the orphaned marker and acks without pushing when the mailbox is gone", async () => {
103
- const client = await getClient();
104
- mock.method(client.account, "get", async () => ({
105
- accountId,
106
- accountConfigId: "fp-cfg-zzz",
107
- }));
108
- mock.method(client.message, "get", async () => [
109
- { messageId, mailboxId: "gone-mbx", uid: 42, status: "active" },
110
- ]);
111
- mock.method(client.mailbox, "get", async () => {
112
- throw Object.assign(new Error("Mailbox not found: gone-mbx"), {
113
- name: "NotFoundError",
114
- });
115
- });
116
- mock.method(client.flagPush, "find", async () => ({
117
- operation: "add",
118
- state: "pending",
119
- }));
120
- mock.method(client.flagPush, "updateState", async () => {});
121
- const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
122
-
123
- // Must resolve, not reject — a deleted folder is an expected terminal
124
- // outcome, not a fault to retry on the account's per-group FIFO.
125
- await handleFlagPush(event, silentLogger, 1);
126
-
127
- assert.equal(
128
- deleteMarker.mock.calls.length,
129
- 1,
130
- "the orphaned marker is dropped",
131
- );
132
- assert.deepEqual(deleteMarker.mock.calls[0].arguments, [
133
- messageId,
134
- flagName,
135
- ]);
136
- });
137
- });
138
-
139
- describe("handleFlagPush — defers while a move is in flight, never on an ordinary pending sync", () => {
140
- const accountId = "fp-acc-inflight";
141
- const messageId = "fp-msg-inflight";
142
- const flagName = "$Junk";
143
-
144
- const event: FlagPushEvent = {
145
- type: "FLAG_PUSH",
146
- accountId,
147
- accountConfigId: "fp-cfg-inflight",
148
- messageId,
149
- flagName,
150
- } as FlagPushEvent;
151
-
152
- before(() => {
153
- setClient({
154
- account: { get: async () => undefined },
155
- message: { get: async () => undefined },
156
- mailbox: { get: async () => undefined },
157
- flagPush: {
158
- find: async () => undefined,
159
- updateState: async () => undefined,
160
- delete: async () => undefined,
161
- },
162
- } as unknown as RemitClient);
163
- });
164
-
165
- afterEach(() => mock.restoreAll());
166
-
167
- it("resets the marker to pending and never opens a connection when the message's move has not settled", async () => {
168
- const client = await getClient();
169
- mock.method(client.account, "get", async () => ({
170
- accountId,
171
- accountConfigId: "fp-cfg-inflight",
172
- passwordHash: "not-actually-used-if-guard-works",
173
- }));
174
- mock.method(client.message, "get", async () => [
175
- {
176
- messageId,
177
- mailboxId: "mbx-junk",
178
- uid: 42,
179
- // Exactly what MessageMoveService.moveMessage's local optimistic
180
- // write leaves in place while the IMAP MOVE is still in flight.
181
- status: "moving",
182
- syncStatus: "pending",
183
- },
184
- ]);
185
- const mailboxGet = mock.method(client.mailbox, "get", async () => ({
186
- mailboxId: "mbx-junk",
187
- fullPath: "Junk",
188
- }));
189
- mock.method(client.flagPush, "find", async () => ({
190
- operation: "add",
191
- state: "queued",
192
- createdAt: Date.now(),
193
- }));
194
- const updateState = mock.method(
195
- client.flagPush,
196
- "updateState",
197
- async () => {},
198
- );
199
- const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
200
-
201
- await handleFlagPush(event, silentLogger, 1);
202
-
203
- assert.equal(
204
- mailboxGet.mock.calls.length,
205
- 0,
206
- "never even resolves the mailbox — returns before touching IMAP",
207
- );
208
- assert.equal(
209
- deleteMarker.mock.calls.length,
210
- 0,
211
- "the marker is not cleared",
212
- );
213
- assert.equal(updateState.mock.calls.length, 1);
214
- assert.deepEqual(updateState.mock.calls[0].arguments, [
215
- messageId,
216
- flagName,
217
- "pending",
218
- ]);
219
- });
220
-
221
- it("drops the marker without ever deferring again once a move has been stuck past the defer window", async () => {
222
- const client = await getClient();
223
- mock.method(client.account, "get", async () => ({
224
- accountId,
225
- accountConfigId: "fp-cfg-inflight",
226
- passwordHash: "not-actually-used-if-guard-works",
227
- }));
228
- mock.method(client.message, "get", async () => [
229
- { messageId, mailboxId: "mbx-junk", uid: 42, status: "moving" },
230
- ]);
231
- const mailboxGet = mock.method(client.mailbox, "get", async () => ({
232
- mailboxId: "mbx-junk",
233
- fullPath: "Junk",
234
- }));
235
- mock.method(client.flagPush, "find", async () => ({
236
- operation: "add",
237
- state: "pending",
238
- // Long past FLAG_PUSH_DEFER_MAX_MS — the periodic drain has already
239
- // re-armed and re-deferred this marker many times over.
240
- createdAt: Date.now() - (FLAG_PUSH_DEFER_MAX_MS + 60_000),
241
- }));
242
- const updateState = mock.method(
243
- client.flagPush,
244
- "updateState",
245
- async () => {},
246
- );
247
- const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
248
-
249
- await handleFlagPush(event, silentLogger, 1);
250
-
251
- assert.equal(
252
- mailboxGet.mock.calls.length,
253
- 0,
254
- "never even resolves the mailbox — returns before touching IMAP",
255
- );
256
- assert.equal(
257
- updateState.mock.calls.length,
258
- 0,
259
- "never re-armed to pending — this is the terminal outcome, not another defer",
260
- );
261
- assert.equal(deleteMarker.mock.calls.length, 1);
262
- assert.deepEqual(deleteMarker.mock.calls[0].arguments, [
263
- messageId,
264
- flagName,
265
- ]);
266
- });
267
-
268
- it("does NOT defer an ordinary freshly-synced inbound message — syncStatus stays pending forever on the inbound path", async () => {
269
- // This is the regression the guard must never reintroduce: every
270
- // message message-sync.ts creates comes out `syncStatus: "pending"`
271
- // (DrizzleMessageRepository.create's default) and nothing on the
272
- // inbound path ever promotes it to `synced`. Only `status` names an
273
- // actual move in flight.
274
- const client = await getClient();
275
- mock.method(client.account, "get", async () => ({
276
- accountId,
277
- accountConfigId: "fp-cfg-inflight",
278
- passwordHash: "not-actually-used-if-guard-works",
279
- }));
280
- mock.method(client.message, "get", async () => [
281
- {
282
- messageId,
283
- mailboxId: "mbx-junk",
284
- uid: 42,
285
- status: "active",
286
- syncStatus: "pending",
287
- },
288
- ]);
289
- // Trips the (unrelated, already-covered) cursor-rebuild early return
290
- // right after the mailbox lookup — proves the handler reached past the
291
- // move-in-flight guard without opening a real IMAP connection.
292
- const mailboxGet = mock.method(client.mailbox, "get", async () => ({
293
- mailboxId: "mbx-junk",
294
- fullPath: "Junk",
295
- cursorState: "cursor_invalid",
296
- }));
297
- mock.method(client.flagPush, "find", async () => ({
298
- operation: "add",
299
- state: "queued",
300
- createdAt: Date.now(),
301
- }));
302
- const updateState = mock.method(
303
- client.flagPush,
304
- "updateState",
305
- async () => {},
306
- );
307
- const deleteMarker = mock.method(client.flagPush, "delete", async () => {});
308
-
309
- await handleFlagPush(event, silentLogger, 1);
310
-
311
- assert.equal(
312
- mailboxGet.mock.calls.length,
313
- 1,
314
- "the handler proceeded past the move-in-flight guard",
315
- );
316
- assert.equal(deleteMarker.mock.calls.length, 0);
317
- // "processing" is the advance-to-attempt transition, never "pending" —
318
- // a re-defer here would be exactly blocker 1 again.
319
- assert.deepEqual(updateState.mock.calls[0]?.arguments, [
320
- messageId,
321
- flagName,
322
- "processing",
323
- ]);
324
- });
325
- });