@remit/imap-worker 0.0.86 → 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 (71) 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.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +16 -18
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +115 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -1,412 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
3
- import type {
4
- IMessageRepository,
5
- IThreadMessageRepository,
6
- } from "@remit/data-ports";
7
- import {
8
- hasAbandonedDelete,
9
- hasAbandonedMove,
10
- } from "@remit/data-ports/message-settlement";
11
- import {
12
- type IImapConnection,
13
- placementBindingOf,
14
- } from "@remit/mailbox-service";
15
- import {
16
- type MessageMoveTerminalLogger,
17
- type ResolveExhaustedMessageMoveDeps,
18
- resolveExhaustedMessageMoveFailure,
19
- } from "./message-move-terminal.js";
20
-
21
- interface LogEntry {
22
- obj: Record<string, unknown>;
23
- msg: string;
24
- }
25
-
26
- const buildLogger = (): {
27
- log: MessageMoveTerminalLogger;
28
- infos: LogEntry[];
29
- errors: LogEntry[];
30
- } => {
31
- const infos: LogEntry[] = [];
32
- const errors: LogEntry[] = [];
33
- return {
34
- log: {
35
- info: (obj, msg) => infos.push({ obj, msg }),
36
- error: (obj, msg) => errors.push({ obj, msg }),
37
- },
38
- infos,
39
- errors,
40
- };
41
- };
42
-
43
- const buildConnection = (
44
- present: Set<number>,
45
- fetchDrops: Set<number> = new Set(),
46
- ): IImapConnection =>
47
- ({
48
- openBox: async () => ({}) as never,
49
- fetchMessages: async (uids: number[]) =>
50
- uids
51
- .filter((uid) => present.has(uid) && !fetchDrops.has(uid))
52
- .map((uid) => ({ uid }) as unknown as never),
53
- search: async (criteria: unknown[]) => {
54
- const [, value] = (criteria as Array<[string, string]>)[0];
55
- const uid = Number(value);
56
- return present.has(uid) ? [uid] : [];
57
- },
58
- }) as unknown as IImapConnection;
59
-
60
- interface MessageRow {
61
- messageId: string;
62
- mailboxId: string;
63
- uid: number;
64
- status: string;
65
- syncStatus: string;
66
- originalMailboxId: string;
67
- originalUid: number;
68
- }
69
-
70
- interface ThreadRow {
71
- accountConfigId: string;
72
- threadMessageId: string;
73
- mailboxId: string;
74
- uid: number;
75
- isDeleted: boolean;
76
- }
77
-
78
- /**
79
- * The move's pending state IS the Message row, so these fakes hold real rows
80
- * and the assertions read the rows back — a resolver that settled the row on
81
- * anything but the server's own answer shows up here as a changed row, not as
82
- * an uncalled mock. `transitionPlacement` honours its predicate the way the
83
- * repository does: the write lands only while the row is still in one of the
84
- * from-states the caller named (imap-mutations R3), and answers `undefined`
85
- * otherwise.
86
- */
87
- const buildRepositories = (row: MessageRow) => {
88
- const messages = new Map<string, MessageRow>([[row.messageId, row]]);
89
- const threadMessages = new Map<string, ThreadRow>([
90
- [
91
- `tm-${row.messageId}`,
92
- {
93
- accountConfigId: "cfg-1",
94
- threadMessageId: `tm-${row.messageId}`,
95
- mailboxId: row.mailboxId,
96
- uid: row.uid,
97
- isDeleted: false,
98
- },
99
- ],
100
- ]);
101
-
102
- return {
103
- messages,
104
- threadMessages,
105
- messageService: {
106
- delete: async (messageId: string) => {
107
- messages.delete(messageId);
108
- },
109
- update: async (messageId: string, input: Partial<MessageRow>) => {
110
- const current = messages.get(messageId);
111
- if (current) messages.set(messageId, { ...current, ...input });
112
- },
113
- transitionPlacement: async (
114
- messageId: string,
115
- expected: { status?: string[] },
116
- next: Partial<MessageRow>,
117
- ) => {
118
- const current = messages.get(messageId);
119
- if (!current) return undefined;
120
- if (expected.status && !expected.status.includes(current.status)) {
121
- return undefined;
122
- }
123
- const written = { ...current, ...next };
124
- messages.set(messageId, written);
125
- return written;
126
- },
127
- } as unknown as Pick<IMessageRepository, "delete" | "transitionPlacement">,
128
- threadMessageService: {
129
- findAllByMessageId: async () => [...threadMessages.values()],
130
- deleteMany: async (
131
- keys: Array<{ accountConfigId: string; threadMessageId: string }>,
132
- ) => {
133
- for (const key of keys) threadMessages.delete(key.threadMessageId);
134
- },
135
- update: async (
136
- _accountConfigId: string,
137
- threadMessageId: string,
138
- set: Partial<ThreadRow>,
139
- ) => {
140
- const current = threadMessages.get(threadMessageId);
141
- if (current)
142
- threadMessages.set(threadMessageId, { ...current, ...set });
143
- },
144
- } as unknown as Pick<
145
- IThreadMessageRepository,
146
- "findAllByMessageId" | "deleteMany" | "update"
147
- >,
148
- };
149
- };
150
-
151
- const pendingMoveRow = (): MessageRow => ({
152
- messageId: "msg-1",
153
- mailboxId: "mbx-archive",
154
- uid: 101,
155
- status: "moving",
156
- syncStatus: "failed",
157
- originalMailboxId: "mbx-inbox",
158
- originalUid: 101,
159
- });
160
-
161
- const input = {
162
- accountId: "acc-1",
163
- accountConfigId: "cfg-1",
164
- messageId: "msg-1",
165
- sourceMailboxId: "mbx-inbox",
166
- uid: 101,
167
- sourceMailboxPath: "INBOX",
168
- };
169
-
170
- describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issue #655)", () => {
171
- it("RECONCILED: the message is gone from the move's source — stale row reconciled, no alarm", async () => {
172
- const repos = buildRepositories(pendingMoveRow());
173
- const { log, infos, errors } = buildLogger();
174
- const deps: ResolveExhaustedMessageMoveDeps = {
175
- messageService: repos.messageService,
176
- threadMessageService: repos.threadMessageService,
177
- log,
178
- };
179
-
180
- const result = await resolveExhaustedMessageMoveFailure(deps, {
181
- ...input,
182
- getConnection: async () => buildConnection(new Set()),
183
- });
184
-
185
- assert.equal(result.outcome, "reconciled");
186
- assert.equal(
187
- repos.messages.get("msg-1"),
188
- undefined,
189
- "the stale Message row is deleted so a resync can re-project it",
190
- );
191
- assert.equal(repos.threadMessages.size, 0);
192
- assert.equal(errors.length, 0, "no alarm for the expected/routine outcome");
193
- assert.ok(
194
- infos.some((e) => e.obj.metric === "message_move_stale_row_reconciled"),
195
- );
196
- });
197
-
198
- it("BROKEN: the message is still at the source — the row is put back on the pair the server confirmed, alarm logged, never re-thrown", async () => {
199
- const repos = buildRepositories(pendingMoveRow());
200
- const { log, errors } = buildLogger();
201
- const deps: ResolveExhaustedMessageMoveDeps = {
202
- messageService: repos.messageService,
203
- threadMessageService: repos.threadMessageService,
204
- log,
205
- };
206
-
207
- const result = await resolveExhaustedMessageMoveFailure(deps, {
208
- ...input,
209
- getConnection: async () => buildConnection(new Set([101])),
210
- });
211
-
212
- assert.equal(result.outcome, "broken");
213
- assert.equal(repos.messages.get("msg-1")?.mailboxId, "mbx-inbox");
214
- assert.equal(repos.messages.get("msg-1")?.uid, 101);
215
- assert.equal(
216
- repos.threadMessages.get("tm-msg-1")?.mailboxId,
217
- "mbx-inbox",
218
- "the listing row follows the message back to the folder it never left",
219
- );
220
- assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
221
- });
222
-
223
- // Issue #1153: before the give-up had a value of its own, this row settled
224
- // on `synced` and read exactly like a move that never happened — nothing on
225
- // it said "the move you asked for is not coming". #1229 is why it also has
226
- // to say WHICH mutation: a hand-back clears `status`, and `status` was the
227
- // only field naming one.
228
- it("BROKEN: the row says a move gave up, and says it was a move", async () => {
229
- const repos = buildRepositories(pendingMoveRow());
230
- const { log } = buildLogger();
231
- const deps: ResolveExhaustedMessageMoveDeps = {
232
- messageService: repos.messageService,
233
- threadMessageService: repos.threadMessageService,
234
- log,
235
- };
236
-
237
- await resolveExhaustedMessageMoveFailure(deps, {
238
- ...input,
239
- getConnection: async () => buildConnection(new Set([101])),
240
- });
241
-
242
- const settled = repos.messages.get("msg-1") as unknown as {
243
- syncStatus: string;
244
- abandonedMutation: string;
245
- };
246
- assert.equal(settled.syncStatus, "abandoned");
247
- assert.equal(settled.abandonedMutation, "move");
248
- assert.equal(
249
- hasAbandonedDelete(settled as never),
250
- false,
251
- "and never reads as a delete that gave up",
252
- );
253
- assert.equal(hasAbandonedMove(settled as never), true);
254
- });
255
-
256
- // Issue #1005: a give-up that never writes `status` leaves the row naming
257
- // the destination with the source's uid — a pair every dependent mutation
258
- // refuses, which made the message undeletable and unmovable for good.
259
- it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
260
- const repos = buildRepositories(pendingMoveRow());
261
- const { log } = buildLogger();
262
- const deps: ResolveExhaustedMessageMoveDeps = {
263
- messageService: repos.messageService,
264
- threadMessageService: repos.threadMessageService,
265
- log,
266
- };
267
-
268
- assert.equal(
269
- placementBindingOf(pendingMoveRow() as never),
270
- "in_flight",
271
- "the row starts on a pair the delete route will not act on",
272
- );
273
-
274
- await resolveExhaustedMessageMoveFailure(deps, {
275
- ...input,
276
- getConnection: async () => buildConnection(new Set([101])),
277
- });
278
-
279
- const settled = repos.messages.get("msg-1");
280
- assert.ok(settled);
281
- assert.equal(placementBindingOf(settled as never), "consistent");
282
- assert.equal(settled.status, "active");
283
- });
284
-
285
- it("BROKEN: an empty FETCH the SEARCH contradicts never counts as gone", async () => {
286
- const repos = buildRepositories(pendingMoveRow());
287
- const { log } = buildLogger();
288
- const deps: ResolveExhaustedMessageMoveDeps = {
289
- messageService: repos.messageService,
290
- threadMessageService: repos.threadMessageService,
291
- log,
292
- };
293
-
294
- const result = await resolveExhaustedMessageMoveFailure(deps, {
295
- ...input,
296
- getConnection: async () =>
297
- buildConnection(new Set([101]), new Set([101])),
298
- });
299
-
300
- assert.equal(result.outcome, "broken");
301
- assert.equal(
302
- repos.messages.get("msg-1")?.mailboxId,
303
- "mbx-inbox",
304
- "a message the SEARCH still lists is at the source, and the row says so",
305
- );
306
- });
307
-
308
- // The resolver's contract is that it is never re-thrown, so the caller can
309
- // ack the record and emit its resync. A row another path deleted or settled
310
- // while the probe was in flight has nothing left to restore, and it loses
311
- // the transition's predicate rather than raising; a NotFound still wraps an
312
- // ElectroDB composites miss on the listing row.
313
- it("BROKEN: a row settled underneath the probe loses the predicate instead of throwing", async () => {
314
- const repos = buildRepositories(pendingMoveRow());
315
- const { log } = buildLogger();
316
- const deps: ResolveExhaustedMessageMoveDeps = {
317
- messageService: {
318
- ...repos.messageService,
319
- transitionPlacement: async () => undefined,
320
- } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
321
- threadMessageService: repos.threadMessageService,
322
- log,
323
- };
324
-
325
- const result = await resolveExhaustedMessageMoveFailure(deps, {
326
- ...input,
327
- getConnection: async () => buildConnection(new Set([101])),
328
- });
329
-
330
- assert.equal(result.outcome, "broken");
331
- });
332
-
333
- it("BROKEN: a listing row whose composites moved on does not fail the settle", async () => {
334
- const repos = buildRepositories(pendingMoveRow());
335
- const { log } = buildLogger();
336
- const deps: ResolveExhaustedMessageMoveDeps = {
337
- messageService: repos.messageService,
338
- threadMessageService: {
339
- ...repos.threadMessageService,
340
- update: async () => {
341
- throw Object.assign(new Error("Thread message not found"), {
342
- name: "NotFoundError",
343
- });
344
- },
345
- } as unknown as ResolveExhaustedMessageMoveDeps["threadMessageService"],
346
- log,
347
- };
348
-
349
- const result = await resolveExhaustedMessageMoveFailure(deps, {
350
- ...input,
351
- getConnection: async () => buildConnection(new Set([101])),
352
- });
353
-
354
- assert.equal(result.outcome, "broken");
355
- assert.equal(
356
- repos.messages.get("msg-1")?.mailboxId,
357
- "mbx-inbox",
358
- "the Message row still lands on the pair the server confirmed",
359
- );
360
- });
361
-
362
- it("a repository fault that is not a missing row still propagates", async () => {
363
- const repos = buildRepositories(pendingMoveRow());
364
- const { log } = buildLogger();
365
- const deps: ResolveExhaustedMessageMoveDeps = {
366
- messageService: {
367
- ...repos.messageService,
368
- transitionPlacement: async () => {
369
- throw new Error("ProvisionedThroughputExceeded");
370
- },
371
- } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
372
- threadMessageService: repos.threadMessageService,
373
- log,
374
- };
375
-
376
- await assert.rejects(
377
- () =>
378
- resolveExhaustedMessageMoveFailure(deps, {
379
- ...input,
380
- getConnection: async () => buildConnection(new Set([101])),
381
- }),
382
- /ProvisionedThroughputExceeded/,
383
- );
384
- });
385
-
386
- it("an unreachable server reaches no verdict at all — the probe propagates and the row is untouched", async () => {
387
- const repos = buildRepositories(pendingMoveRow());
388
- const { log } = buildLogger();
389
- const deps: ResolveExhaustedMessageMoveDeps = {
390
- messageService: repos.messageService,
391
- threadMessageService: repos.threadMessageService,
392
- log,
393
- };
394
-
395
- await assert.rejects(
396
- () =>
397
- resolveExhaustedMessageMoveFailure(deps, {
398
- ...input,
399
- getConnection: async () => {
400
- throw new Error("ECONNRESET");
401
- },
402
- }),
403
- /ECONNRESET/,
404
- );
405
-
406
- assert.deepEqual(
407
- repos.messages.get("msg-1"),
408
- pendingMoveRow(),
409
- "absence is only ever concluded from an answer the server gave",
410
- );
411
- });
412
- });