@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,529 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { beforeEach, describe, it } from "node:test";
3
- import { noopLogger } from "@remit/logger-lambda/noop-logger";
4
- import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
5
- import type { AppendSentMessageEvent } from "../events.js";
6
- import {
7
- APPEND_SENT_MAX_ATTEMPTS,
8
- type AppendSentMessageDeps,
9
- handleAppendSentMessage,
10
- } from "./append-sent-message.js";
11
-
12
- interface Call {
13
- method: string;
14
- args: unknown[];
15
- }
16
-
17
- interface Harness {
18
- calls: Call[];
19
- account: {
20
- accountId: string;
21
- accountConfigId: string;
22
- deletedAt?: number;
23
- };
24
- outbox: Record<string, unknown>;
25
- sentMailbox: { mailboxId: string; fullPath: string } | null;
26
- append: (
27
- path: string,
28
- raw: Buffer,
29
- flags: string[],
30
- ) => Promise<{ uid: number; uidValidity: number }>;
31
- /** Stand in for a terminal auth failure: withOAuthLifecycle ACKs the record
32
- * without ever running the work. */
33
- ackWithoutWork: boolean;
34
- disconnectCount: number;
35
- attachments: Array<{
36
- filename: string;
37
- contentType: string;
38
- content: Buffer;
39
- }>;
40
- }
41
-
42
- let h: Harness;
43
-
44
- const record =
45
- (method: string) =>
46
- async (...args: unknown[]) => {
47
- h.calls.push({ method, args });
48
- };
49
-
50
- const fresh = (): Harness => ({
51
- calls: [],
52
- account: { accountId: "acc-1", accountConfigId: "cfg-1" },
53
- outbox: {
54
- status: "sent",
55
- fromName: "Alice",
56
- fromAddress: "alice@example.com",
57
- toAddresses: ["bob@example.com"],
58
- ccAddresses: ["carol@example.com"],
59
- subject: "Quarterly numbers",
60
- textBody: "See attached.",
61
- messageIdValue: "generated-id@example.com",
62
- references: ["parent@example.com"],
63
- inReplyTo: "parent@example.com",
64
- sentAt: 1700000000000,
65
- appendedUid: 0,
66
- },
67
- sentMailbox: { mailboxId: "sent-mbx", fullPath: "INBOX/Sent" },
68
- append: async (path, raw, flags) => {
69
- h.calls.push({ method: "connection.append", args: [path, raw, flags] });
70
- return { uid: 55, uidValidity: 7 };
71
- },
72
- ackWithoutWork: false,
73
- disconnectCount: 0,
74
- attachments: [],
75
- });
76
-
77
- const deps = (): AppendSentMessageDeps =>
78
- ({
79
- getClient: async () => ({
80
- account: {
81
- get: async (accountId: string) => {
82
- h.calls.push({ method: "account.get", args: [accountId] });
83
- return h.account;
84
- },
85
- },
86
- outboxMessage: {
87
- get: async () => h.outbox,
88
- update: record("outboxMessage.update"),
89
- delete: record("outboxMessage.delete"),
90
- },
91
- outboxAttachment: {
92
- discardAll: record("outboxAttachment.discardAll"),
93
- contentsFor: async (...args: unknown[]) => {
94
- h.calls.push({ method: "outboxAttachment.contentsFor", args });
95
- return h.attachments;
96
- },
97
- },
98
- mailboxSpecialUse: {
99
- findSentMailbox: async () => h.sentMailbox,
100
- },
101
- secrets: {},
102
- }),
103
- buildLifecycleDeps: () => ({}),
104
- withOAuthLifecycle: async (
105
- _deps: unknown,
106
- _account: unknown,
107
- _log: unknown,
108
- cb: (credentials: unknown) => Promise<void>,
109
- ) => (h.ackWithoutWork ? undefined : cb({})),
110
- createConnectionScope: () => ({
111
- getConnection: async () => ({
112
- append: (path: string, raw: Buffer, flags: string[]) =>
113
- h.append(path, raw, flags),
114
- }),
115
- disconnect: async () => {
116
- h.disconnectCount += 1;
117
- },
118
- }),
119
- }) as unknown as AppendSentMessageDeps;
120
-
121
- const event: AppendSentMessageEvent = {
122
- type: "APPEND_SENT_MESSAGE",
123
- accountId: "acc-1",
124
- outboxMessageId: "out-1",
125
- } as AppendSentMessageEvent;
126
-
127
- const called = (method: string): Call[] =>
128
- h.calls.filter((c) => c.method === method);
129
-
130
- const patches = (): unknown[] =>
131
- called("outboxMessage.update").map((c) => c.args[2]);
132
-
133
- type BackendClient = Awaited<ReturnType<AppendSentMessageDeps["getClient"]>>;
134
-
135
- const depsWithFailingDelete = (): AppendSentMessageDeps => {
136
- const base = deps();
137
- return {
138
- ...base,
139
- getClient: async (): Promise<BackendClient> => {
140
- const client = await base.getClient();
141
- return {
142
- ...client,
143
- outboxMessage: {
144
- ...client.outboxMessage,
145
- delete: async (): Promise<void> => {
146
- throw new Error("storage down");
147
- },
148
- },
149
- };
150
- },
151
- };
152
- };
153
-
154
- const depsWithFailingUpdate = (): AppendSentMessageDeps => {
155
- const base = deps();
156
- return {
157
- ...base,
158
- getClient: async (): Promise<BackendClient> => {
159
- const client = await base.getClient();
160
- return {
161
- ...client,
162
- outboxMessage: {
163
- ...client.outboxMessage,
164
- update: async (): Promise<never> => {
165
- throw new Error("storage down");
166
- },
167
- },
168
- };
169
- },
170
- };
171
- };
172
-
173
- describe("handleAppendSentMessage", () => {
174
- beforeEach(() => {
175
- h = fresh();
176
- });
177
-
178
- it("appends a seen RFC822 copy to the Sent folder and drops the outbox row", async () => {
179
- await handleAppendSentMessage(event, noopLogger, 1, deps());
180
-
181
- const append = called("connection.append")[0];
182
- assert.equal(append?.args[0], "INBOX/Sent");
183
- assert.deepEqual(append?.args[2], ["\\Seen"]);
184
- assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
185
- "cfg-1",
186
- "out-1",
187
- ]);
188
- assert.equal(h.disconnectCount, 1);
189
- });
190
-
191
- it("takes the draft's stored attachments with the row (#679)", async () => {
192
- await handleAppendSentMessage(event, noopLogger, 1, deps());
193
-
194
- // The row is the only reference to those objects. Sweeping has to happen
195
- // here too, not only on a discard, or a sent message leaves its files
196
- // behind with nothing able to reach them.
197
- assert.deepEqual(called("outboxAttachment.discardAll")[0]?.args, [
198
- "cfg-1",
199
- "acc-1",
200
- "out-1",
201
- ]);
202
- });
203
-
204
- it("files the Sent copy with the same files the wire copy carried", async () => {
205
- h.attachments = [
206
- {
207
- filename: "numbers.csv",
208
- contentType: "text/csv",
209
- content: Buffer.from("q1,q2\n1,2\n"),
210
- },
211
- ];
212
-
213
- await handleAppendSentMessage(event, noopLogger, 1, deps());
214
-
215
- assert.deepEqual(called("outboxAttachment.contentsFor")[0]?.args, [
216
- "cfg-1",
217
- "acc-1",
218
- "out-1",
219
- ]);
220
- const raw = String(called("connection.append")[0]?.args[1] as Buffer);
221
- assert.match(raw, /^Content-Type: multipart\/mixed;/m);
222
- assert.match(
223
- raw,
224
- /^Content-Disposition: attachment; filename=numbers\.csv$/m,
225
- );
226
- assert.match(raw, /^Content-Type: text\/csv; name=numbers\.csv$/m);
227
- });
228
-
229
- const unreadable = new OutboxAttachmentUnavailableError(
230
- "numbers.csv",
231
- "nothing is stored at key/numbers",
232
- );
233
-
234
- const depsWithUnreadableFile = (): AppendSentMessageDeps => {
235
- const base = deps();
236
- return {
237
- ...base,
238
- getClient: async (): Promise<BackendClient> => {
239
- const client = await base.getClient();
240
- client.outboxAttachment.contentsFor = async () => {
241
- throw unreadable;
242
- };
243
- return client;
244
- },
245
- };
246
- };
247
-
248
- it("retries a copy whose files could not be read, below the budget", async () => {
249
- const failing = depsWithUnreadableFile();
250
-
251
- await assert.rejects(
252
- () => handleAppendSentMessage(event, noopLogger, 1, failing),
253
- unreadable,
254
- );
255
- assert.equal(called("connection.append").length, 0);
256
- assert.deepEqual(patches(), [], "the row is left for the retry");
257
- });
258
-
259
- it("settles a delivered message whose files cannot be read as sent, not filed, at the budget", async () => {
260
- const failing = depsWithUnreadableFile();
261
-
262
- await handleAppendSentMessage(
263
- event,
264
- noopLogger,
265
- APPEND_SENT_MAX_ATTEMPTS,
266
- failing,
267
- );
268
- assert.deepEqual(patches(), [
269
- {
270
- status: "unfiled",
271
- lastError:
272
- 'Sent, but not filed: the copy for INBOX/Sent could not be built because "numbers.csv" could not be read: nothing is stored at key/numbers.',
273
- },
274
- ]);
275
- assert.equal(called("connection.append").length, 0);
276
- assert.equal(
277
- called("outboxMessage.delete").length,
278
- 0,
279
- "the row stays in the Outbox with its reason",
280
- );
281
- });
282
-
283
- it("builds the message from the outbox row's own headers", async () => {
284
- await handleAppendSentMessage(event, noopLogger, 1, deps());
285
-
286
- const raw = String(called("connection.append")[0]?.args[1] as Buffer);
287
- assert.match(raw, /^From: Alice <alice@example\.com>$/m);
288
- assert.match(raw, /^To: bob@example\.com$/m);
289
- assert.match(raw, /^Cc: carol@example\.com$/m);
290
- assert.match(raw, /^Subject: Quarterly numbers$/m);
291
- assert.match(raw, /^Message-ID: <generated-id@example\.com>$/m);
292
- assert.match(raw, /^In-Reply-To: <parent@example\.com>$/m);
293
- assert.ok(raw.includes("See attached."));
294
- });
295
-
296
- it("uses a bare address when the outbox row carries no display name", async () => {
297
- h.outbox = { ...h.outbox, fromName: undefined };
298
-
299
- await handleAppendSentMessage(event, noopLogger, 1, deps());
300
-
301
- const raw = String(called("connection.append")[0]?.args[1] as Buffer);
302
- assert.match(raw, /^From: alice@example\.com$/m);
303
- });
304
-
305
- it("settles the row as unfiled when the account has no Sent folder at all", async () => {
306
- h.sentMailbox = null;
307
-
308
- await handleAppendSentMessage(event, noopLogger, 1, deps());
309
-
310
- assert.equal(called("connection.append").length, 0);
311
- assert.equal(called("outboxMessage.delete").length, 0);
312
-
313
- // The message was delivered over SMTP. Leaving the row at `sent` hides it
314
- // from the Outbox list and it exists in no server folder either, so the
315
- // user loses it entirely.
316
- const update = called("outboxMessage.update")[0];
317
- assert.deepEqual(update?.args.slice(0, 2), ["cfg-1", "out-1"]);
318
- const patch = update?.args[2] as { status: string; lastError: string };
319
- assert.equal(patch.status, "unfiled");
320
- assert.match(patch.lastError, /no folder appointed to the Sent role/);
321
- });
322
-
323
- it("skips the append while the outbox row is not yet sent", async () => {
324
- h.outbox = { ...h.outbox, status: "pending" };
325
-
326
- await handleAppendSentMessage(event, noopLogger, 1, deps());
327
-
328
- assert.equal(called("connection.append").length, 0);
329
- assert.equal(called("outboxMessage.delete").length, 0);
330
- });
331
-
332
- it("returns early without touching the outbox when the account is soft-deleted", async () => {
333
- h.account = { ...h.account, deletedAt: Date.now() };
334
-
335
- await handleAppendSentMessage(event, noopLogger, 1, deps());
336
-
337
- assert.equal(called("connection.append").length, 0);
338
- assert.equal(called("outboxMessage.delete").length, 0);
339
- });
340
-
341
- it("rethrows a failed APPEND while the queue still has attempts left", async () => {
342
- h.append = async () => {
343
- throw new Error("server exploded");
344
- };
345
-
346
- await assert.rejects(
347
- handleAppendSentMessage(event, noopLogger, 1, deps()),
348
- /server exploded/,
349
- );
350
-
351
- // Nothing settled: the row stays `sent` and the redelivered event tries
352
- // the APPEND again.
353
- assert.equal(called("outboxMessage.delete").length, 0);
354
- assert.equal(called("outboxMessage.update").length, 0);
355
- assert.equal(h.disconnectCount, 1);
356
- });
357
-
358
- it("settles the row as unfiled on the last attempt instead of dead-lettering it", async () => {
359
- h.append = async () => {
360
- throw new Error("server exploded");
361
- };
362
-
363
- await handleAppendSentMessage(
364
- event,
365
- noopLogger,
366
- APPEND_SENT_MAX_ATTEMPTS,
367
- deps(),
368
- );
369
-
370
- // A DLQ'd APPEND is how the message went missing: the row would keep the
371
- // `sent` status the Outbox list hides, and no Sent folder holds it.
372
- assert.equal(called("outboxMessage.delete").length, 0);
373
- const patch = called("outboxMessage.update")[0]?.args[2] as {
374
- status: string;
375
- lastError: string;
376
- };
377
- assert.equal(patch.status, "unfiled");
378
- assert.match(patch.lastError, /server exploded/);
379
- });
380
-
381
- it("settles the row as unfiled when a terminal auth failure acks the record", async () => {
382
- h.ackWithoutWork = true;
383
-
384
- await handleAppendSentMessage(event, noopLogger, 1, deps());
385
-
386
- const patch = called("outboxMessage.update")[0]?.args[2] as {
387
- status: string;
388
- lastError: string;
389
- };
390
- assert.equal(patch.status, "unfiled");
391
- assert.match(patch.lastError, /signed in again/);
392
- });
393
-
394
- it("leaves the row alone when the APPEND landed but the delete did not", async () => {
395
- await assert.rejects(
396
- handleAppendSentMessage(event, noopLogger, 1, depsWithFailingDelete()),
397
- /storage down/,
398
- );
399
-
400
- // The copy is in Sent, so the message is findable — settling it as unfiled
401
- // would say the opposite.
402
- assert.deepEqual(patches(), [{ appendedUid: 55 }]);
403
- });
404
-
405
- it("stops redelivering a landed APPEND at the budget instead of filing another copy", async () => {
406
- await handleAppendSentMessage(
407
- event,
408
- noopLogger,
409
- APPEND_SENT_MAX_ATTEMPTS,
410
- depsWithFailingDelete(),
411
- );
412
-
413
- assert.equal(called("connection.append").length, 1);
414
- assert.deepEqual(patches(), [{ appendedUid: 55 }]);
415
- });
416
-
417
- it("records the uid the APPEND produced before it deletes the row", async () => {
418
- await handleAppendSentMessage(event, noopLogger, 1, deps());
419
-
420
- // Order is the whole of it: a uid written after the delete is a uid a
421
- // redelivery never sees, and the redelivery is what files the second copy.
422
- const order = h.calls.map((c) => c.method);
423
- assert.deepEqual(patches(), [{ appendedUid: 55 }]);
424
- assert.ok(
425
- order.indexOf("outboxMessage.update") <
426
- order.indexOf("outboxMessage.delete"),
427
- );
428
- });
429
-
430
- it("records a copy the server filed but named no uid for", async () => {
431
- // UIDPLUS is an extension. Without it the APPEND succeeds and reports
432
- // nothing, and "filed" still has to be told apart from "not filed".
433
- h.append = async () => ({ uid: 0, uidValidity: 0 });
434
-
435
- await handleAppendSentMessage(event, noopLogger, 1, deps());
436
-
437
- assert.deepEqual(patches(), [{ appendedUid: -1 }]);
438
- assert.equal(called("outboxMessage.delete").length, 1);
439
- });
440
-
441
- it("files nothing a second time when a redelivery finds a recorded uid (#858)", async () => {
442
- h.outbox = { ...h.outbox, appendedUid: 55 };
443
-
444
- await handleAppendSentMessage(event, noopLogger, 1, deps());
445
-
446
- // The copy is already in the user's Sent folder. All this redelivery owes
447
- // is the delete the last attempt could not make.
448
- assert.equal(called("connection.append").length, 0);
449
- assert.equal(h.disconnectCount, 0);
450
- assert.deepEqual(called("outboxMessage.delete")[0]?.args, [
451
- "cfg-1",
452
- "out-1",
453
- ]);
454
- assert.deepEqual(called("outboxAttachment.discardAll")[0]?.args, [
455
- "cfg-1",
456
- "acc-1",
457
- "out-1",
458
- ]);
459
- });
460
-
461
- it("retries the delete a redelivery owes while the queue has attempts left", async () => {
462
- h.outbox = { ...h.outbox, appendedUid: 55 };
463
-
464
- await assert.rejects(
465
- handleAppendSentMessage(event, noopLogger, 1, depsWithFailingDelete()),
466
- /storage down/,
467
- );
468
-
469
- assert.equal(called("connection.append").length, 0);
470
- assert.deepEqual(patches(), []);
471
- });
472
-
473
- it("keeps the row when the uid cannot be recorded", async () => {
474
- await assert.rejects(
475
- handleAppendSentMessage(event, noopLogger, 1, depsWithFailingUpdate()),
476
- /storage down/,
477
- );
478
-
479
- // The other half of "written before the delete, never after". A row
480
- // deleted without its uid is a row a redelivery reads as never filed.
481
- assert.equal(called("outboxMessage.delete").length, 0);
482
- assert.equal(called("outboxAttachment.discardAll").length, 0);
483
- });
484
-
485
- it("acks an event whose outbox row is already gone", async () => {
486
- // Its own last attempt deleted it, or the boot repair dropped it. Throwing
487
- // the same NotFoundError on every redelivery only dead-letters the record.
488
- const notFound = Object.assign(new Error("gone"), {
489
- name: "NotFoundError",
490
- });
491
- const base = deps();
492
- const failing: AppendSentMessageDeps = {
493
- ...base,
494
- getClient: async (): Promise<BackendClient> => {
495
- const client = await base.getClient();
496
- return {
497
- ...client,
498
- outboxMessage: {
499
- ...client.outboxMessage,
500
- get: async () => {
501
- throw notFound;
502
- },
503
- },
504
- } as BackendClient;
505
- },
506
- };
507
-
508
- await handleAppendSentMessage(event, noopLogger, 1, failing);
509
-
510
- assert.equal(called("connection.append").length, 0);
511
- assert.deepEqual(patches(), []);
512
- });
513
-
514
- it("gives up on that delete at the budget rather than dead-lettering it", async () => {
515
- h.outbox = { ...h.outbox, appendedUid: 55 };
516
-
517
- await handleAppendSentMessage(
518
- event,
519
- noopLogger,
520
- APPEND_SENT_MAX_ATTEMPTS,
521
- depsWithFailingDelete(),
522
- );
523
-
524
- // The row stays `sent` and hidden, which the boot-time repair drops on the
525
- // strength of the same recorded uid. Settling it as unfiled would tell the
526
- // user a message sitting in Sent was never filed.
527
- assert.deepEqual(patches(), []);
528
- });
529
- });