@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,38 +1,16 @@
1
1
  import assert from "node:assert";
2
- import { afterEach, before, beforeEach, describe, it, mock } from "node:test";
3
- import { getClient, type RemitClient, setClient } from "@remit/backend/client";
4
- import type { AccountItem, ThreadMessageItem } from "@remit/data-ports";
5
- import type { Logger } from "@remit/logger-lambda";
6
- import type { IImapConnection } from "@remit/mailbox-service";
7
- import type { MessageMoveEvent } from "../events.js";
8
- import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
9
- import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
2
+ import { describe, it } from "node:test";
3
+ import type { ThreadMessageItem } from "@remit/data-ports";
4
+ import {
5
+ eml,
6
+ FakeImapServer,
7
+ } from "@remit/mailbox-service/test-helpers/fake-imap";
10
8
  import {
11
9
  buildThreadMessageMoveUpdate,
12
- emitMoveResync,
13
- getMessageMoveMaxAttempts,
14
- handleMessageMove,
15
- MESSAGE_MOVE_MAX_ATTEMPTS,
16
- type MessageMoveDeps,
17
- moveThenResync,
18
10
  probePausedPlacement,
19
11
  searchMailboxForHighestMessageIdUid,
20
12
  } from "./message-move.js";
21
13
 
22
- const silentLogger = (() => {
23
- const noop = () => {};
24
- const log = {
25
- info: noop,
26
- warn: noop,
27
- error: noop,
28
- debug: noop,
29
- fatal: noop,
30
- trace: noop,
31
- child: () => log,
32
- } as unknown as Logger;
33
- return log;
34
- })();
35
-
36
14
  const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
37
15
  const destinationMailboxId = "destination-mailbox-aaaaa";
38
16
 
@@ -80,39 +58,6 @@ describe("buildThreadMessageMoveUpdate", () => {
80
58
  );
81
59
  });
82
60
 
83
- it("composites.mailboxId is the CURRENT (source) mailboxId, not the destination", () => {
84
- const args = buildThreadMessageMoveUpdate(
85
- baseThreadMessage,
86
- 42,
87
- destinationMailboxId,
88
- );
89
-
90
- assert.strictEqual(
91
- args.composites.mailboxId,
92
- sourceMailboxId,
93
- "composites.mailboxId must be the CURRENT source mailbox; passing the destination breaks the conditional check",
94
- );
95
- assert.notStrictEqual(
96
- args.composites.mailboxId,
97
- destinationMailboxId,
98
- "composites.mailboxId must NOT match the new destinationMailboxId",
99
- );
100
- });
101
-
102
- it("composites.isDeleted is the CURRENT value, not the new false", () => {
103
- const args = buildThreadMessageMoveUpdate(
104
- { ...baseThreadMessage, isDeleted: true },
105
- 42,
106
- destinationMailboxId,
107
- );
108
-
109
- assert.strictEqual(
110
- args.composites.isDeleted,
111
- true,
112
- "composites.isDeleted must be the CURRENT value (true here), not the new value",
113
- );
114
- });
115
-
116
61
  it("composites mirrors every CURRENT indexed attribute on the threadMessage", () => {
117
62
  const tm = {
118
63
  sentDate: 1700000000123,
@@ -136,868 +81,136 @@ describe("buildThreadMessageMoveUpdate", () => {
136
81
  });
137
82
  });
138
83
 
139
- const accountId = "alice-account-aaaaaaaaaa";
140
-
141
- describe("emitMoveResync (#1031)", () => {
142
- // A move shifts a message between two folders; both folders' counts must
143
- // refresh from IMAP via the existing per-folder SYNC_MESSAGES sync. Counts
144
- // are never mutated locally — only re-read downstream.
145
-
146
- it("emits SYNC_MESSAGES for both the source and destination folders", async () => {
147
- const emit = mock.fn(async () => undefined);
148
-
149
- await emitMoveResync(emit, {
150
- accountId,
151
- sourceMailboxId,
152
- destinationMailboxId,
153
- });
154
-
155
- assert.equal(emit.mock.calls.length, 2);
156
- assert.deepEqual(emit.mock.calls[0].arguments, [
157
- { type: "SYNC_MESSAGES", accountId, mailboxId: sourceMailboxId },
158
- ]);
159
- assert.deepEqual(emit.mock.calls[1].arguments, [
160
- { type: "SYNC_MESSAGES", accountId, mailboxId: destinationMailboxId },
161
- ]);
162
- });
163
- });
164
-
165
- describe("moveThenResync (#1031)", () => {
166
- it("runs the resync after the move resolves", async () => {
167
- const order: string[] = [];
168
- const performMove = mock.fn(async () => {
169
- order.push("move");
170
- });
171
- const resync = mock.fn(async () => {
172
- order.push("resync");
173
- });
174
-
175
- await moveThenResync(performMove, resync);
176
-
177
- assert.deepEqual(order, ["move", "resync"]);
178
- assert.equal(resync.mock.calls.length, 1);
179
- });
180
-
181
- it("does not resync when the move fails, and propagates the error", async () => {
182
- const performMove = mock.fn(async () => {
183
- throw new Error("IMAP move failed");
184
- });
185
- const resync = mock.fn(async () => undefined);
186
-
187
- await assert.rejects(
188
- () => moveThenResync(performMove, resync),
189
- /IMAP move failed/,
190
- );
191
-
192
- assert.equal(resync.mock.calls.length, 0);
193
- });
194
- });
195
-
196
- describe("getMessageMoveMaxAttempts — env-derived threshold (#655)", () => {
197
- it("parses the injected env var", () => {
198
- assert.equal(
199
- getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "3" }),
200
- 3,
201
- );
202
- assert.equal(
203
- getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "5" }),
204
- 5,
205
- );
206
- });
207
-
208
- it("defaults to the message queue's own maxReceiveCount when unset", () => {
209
- assert.equal(getMessageMoveMaxAttempts({}), 3);
210
- });
211
-
212
- it("defaults on a non-numeric or non-positive value", () => {
213
- assert.equal(
214
- getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "nope" }),
215
- 3,
216
- );
217
- assert.equal(
218
- getMessageMoveMaxAttempts({ MESSAGE_MOVE_MAX_ATTEMPTS: "0" }),
219
- 3,
220
- );
221
- });
222
-
223
- it("MESSAGE_MOVE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
224
- assert.ok(MESSAGE_MOVE_MAX_ATTEMPTS > 0);
225
- });
226
- });
227
-
228
- describe("handleMessageMove — the move's own pending state gates every attempt", () => {
229
- const acctId = "mm-acc-zzz";
230
-
231
- const cappedAccount = (): AccountItem =>
232
- ({
233
- accountId: acctId,
234
- accountConfigId: "mm-cfg-zzz",
235
- connectionState: "authenticated",
236
- username: "mm@imap.example.com",
237
- imapHost: "imap.example.com",
238
- imapPort: 993,
239
- imapTls: true,
240
- passwordHash: JSON.stringify({
241
- encryptedDek: "",
242
- encryptedData: "",
243
- iv: "",
244
- authTag: "",
245
- }),
246
- }) as unknown as AccountItem;
247
-
248
- const event: MessageMoveEvent = {
249
- type: "MESSAGE_MOVE",
250
- accountId: acctId,
251
- accountConfigId: "mm-cfg-zzz",
252
- messageId: "mm-msg-zzz",
253
- sourceMailboxId: "mm-src-zzz",
254
- sourceMailboxPath: "INBOX",
255
- destinationMailboxId: "mm-dst-zzz",
256
- destinationMailboxPath: "Archive",
257
- uid: 10,
258
- eventId: "mm-evt-zzz",
259
- timestamp: 1700000000000,
260
- } as MessageMoveEvent;
261
-
262
- const pendingRow = () => ({
263
- messageId: "mm-msg-zzz",
264
- mailboxId: "mm-dst-zzz",
265
- uid: 10,
266
- status: "moving",
267
- syncStatus: "pending",
268
- });
269
-
270
- // The client is supplied by injection (`setClient`), so these tests register
271
- // the repositories they then mock rather than reaching a composition.
272
- before(() => {
273
- setClient({
274
- account: { get: async () => undefined },
275
- mailbox: { get: async () => undefined },
276
- message: {
277
- get: async () => [],
278
- update: async () => undefined,
279
- updateUid: async () => undefined,
280
- transitionPlacement: async () => undefined,
281
- },
282
- threadMessage: {
283
- findAllByMessageId: async () => [],
284
- findByMessageId: async () => undefined,
285
- update: async () => undefined,
286
- },
287
- secrets: { decrypt: async () => undefined },
288
- } as unknown as RemitClient);
289
- });
290
-
291
- let emitted: unknown[] = [];
292
- let connectCount = 0;
293
- let logLines: { level: string; fields: Record<string, unknown> }[] = [];
294
-
295
- // The level a give-up is logged at is behaviour: a routine pause must not
296
- // page anyone, and a placement the server never settled must.
297
- const capturingLogger = (): Logger => {
298
- const at =
299
- (level: string) =>
300
- (fields: Record<string, unknown>): void => {
301
- logLines.push({ level, fields });
302
- };
303
- const log = {
304
- info: at("info"),
305
- warn: at("warn"),
306
- error: at("error"),
307
- debug: at("debug"),
308
- fatal: at("fatal"),
309
- trace: at("trace"),
310
- child: () => log,
311
- } as unknown as Logger;
312
- return log;
313
- };
314
-
315
- // One folder's Message-ID SEARCH answers, keyed by the box last opened.
316
- // `openBox` on this handle is deliberately unguarded: the paused settle asks
317
- // on the identity axis, which a UIDVALIDITY change leaves intact.
318
- const holdingConnection = (
319
- holdings: Record<string, number[]>,
320
- ): IImapConnection => {
321
- let opened = "";
322
- return {
323
- openBox: async (path: string) => {
324
- opened = path;
325
- return {} as never;
326
- },
327
- search: async () => holdings[opened] ?? [],
328
- } as unknown as IImapConnection;
329
- };
330
-
331
- const moveDeps = (connection?: IImapConnection): MessageMoveDeps => ({
332
- getClient,
333
- buildLifecycleDeps,
334
- withOAuthLifecycle,
335
- createConnectionScope: () => ({
336
- getConnection: async () => {
337
- connectCount += 1;
338
- if (!connection) throw new Error("this case must not connect");
339
- return connection;
340
- },
341
- disconnect: async () => undefined,
342
- }),
343
- emitEvent: (async (event: unknown) => {
344
- emitted.push(event);
345
- }) as MessageMoveDeps["emitEvent"],
346
- });
347
-
348
- const pausedSource = () => ({
349
- mailboxId: "mm-src-zzz",
350
- uidValidity: 1,
351
- cursorState: "rebuilding",
352
- });
353
-
354
- beforeEach(() => {
355
- emitted = [];
356
- connectCount = 0;
357
- logLines = [];
358
- });
359
-
360
- afterEach(() => mock.restoreAll());
361
-
362
- it("acks without connecting when the source mailbox was deleted", async () => {
363
- const client = await getClient();
364
- mock.method(client.account, "get", async () => cappedAccount());
365
- mock.method(client.secrets, "decrypt", async () => "fake-password");
366
- mock.method(client.message, "get", async () => [pendingRow()]);
367
- mock.method(client.mailbox, "get", async () => {
368
- throw Object.assign(new Error("Mailbox not found: mm-src-zzz"), {
369
- name: "NotFoundError",
370
- });
371
- });
372
- const updateUid = mock.method(client.message, "updateUid", async () => {});
373
-
374
- // Must resolve, not reject — a deleted source folder makes the move moot.
375
- await handleMessageMove(event, silentLogger);
376
-
377
- assert.equal(
378
- updateUid.mock.calls.length,
379
- 0,
380
- "a deleted mailbox never reaches the IMAP move",
381
- );
382
- });
383
-
384
- // Without this gate a redelivery of an already-confirmed move re-runs the
385
- // MOVE against a UID the source no longer holds, and on exhaustion the
386
- // terminal resolver reads the source's honest "gone" as grounds to delete a
387
- // row that is correct and settled.
388
- it("acks without connecting when the move already settled", async () => {
389
- const client = await getClient();
390
- mock.method(client.account, "get", async () => cappedAccount());
391
- mock.method(client.secrets, "decrypt", async () => "fake-password");
392
- mock.method(client.message, "get", async () => [
393
- { ...pendingRow(), uid: 4711, status: "active", syncStatus: "synced" },
394
- ]);
395
- const mailboxGet = mock.method(client.mailbox, "get", async () => {
396
- throw new Error("a settled move must never resolve a mailbox");
397
- });
398
- const update = mock.method(client.message, "update", async () => {});
399
-
400
- await handleMessageMove(event, silentLogger, MESSAGE_MOVE_MAX_ATTEMPTS);
401
-
402
- assert.equal(mailboxGet.mock.calls.length, 0);
403
- assert.equal(
404
- update.mock.calls.length,
405
- 0,
406
- "a settled row is never written again",
407
- );
408
- });
409
-
410
- const arrangePausedMove = async (
411
- options: { probeable: boolean } = { probeable: true },
412
- ): Promise<{
413
- transitionCalls: unknown[][];
414
- updateUidCalls: unknown[][];
415
- threadUpdateCalls: unknown[][];
416
- }> => {
417
- const client = await getClient();
418
- mock.method(client.account, "get", async () => cappedAccount());
419
- mock.method(client.secrets, "decrypt", async () => "fake-password");
420
- mock.method(client.message, "get", async () => [
421
- {
422
- ...pendingRow(),
423
- messageIdHeader: options.probeable ? "<moved@example.com>" : undefined,
424
- },
425
- ]);
426
- mock.method(
427
- client.mailbox,
428
- "get",
429
- async (_accountId: string, id: string) =>
430
- id === "mm-src-zzz"
431
- ? { ...pausedSource(), fullPath: "INBOX" }
432
- : {
433
- mailboxId: id,
434
- uidValidity: 1,
435
- cursorState: "normal",
436
- fullPath: "Archive",
437
- },
438
- );
439
- const transitionCalls: unknown[][] = [];
440
- mock.method(
441
- client.message,
442
- "transitionPlacement",
443
- async (...args: unknown[]) => {
444
- transitionCalls.push(args);
445
- return { messageId: "mm-msg-zzz" };
446
- },
447
- );
448
- const updateUidCalls: unknown[][] = [];
449
- mock.method(client.message, "updateUid", async (...args: unknown[]) => {
450
- updateUidCalls.push(args);
451
- });
452
- const threadRow = {
453
- ...baseThreadMessage,
454
- accountConfigId: "mm-cfg-zzz",
455
- threadMessageId: "mm-tm-zzz",
456
- mailboxId: "mm-dst-zzz",
457
- };
458
- mock.method(client.threadMessage, "findAllByMessageId", async () => [
459
- threadRow,
460
- ]);
461
- mock.method(client.threadMessage, "findByMessageId", async () => threadRow);
462
- const threadUpdateCalls: unknown[][] = [];
463
- mock.method(client.threadMessage, "update", async (...args: unknown[]) => {
464
- threadUpdateCalls.push(args);
465
- });
466
- return { transitionCalls, updateUidCalls, threadUpdateCalls };
467
- };
468
-
469
- // Issue #1203. Acking a paused cursor left the row `moving` with `mailboxId`
470
- // on the destination and `uid` on the source. Nothing re-enqueues a
471
- // MESSAGE_MOVE, and the cursor rebuild matches rows by
472
- // `(accountConfigId, mailboxId)`, so a row naming the destination sits in
473
- // neither folder's set: `placementBindingOf` answered `in_flight` for good
474
- // and the message became unmovable and undeletable.
475
- it("hands the row back to its source on a first delivery, without connecting", async () => {
476
- const { transitionCalls, threadUpdateCalls } = await arrangePausedMove();
477
-
478
- await handleMessageMove(event, silentLogger, 1, moveDeps());
479
-
480
- assert.equal(
481
- connectCount,
482
- 0,
483
- "a first delivery has provably issued no MOVE, so it needs no answer from the server",
484
- );
485
- assert.deepEqual(
486
- transitionCalls[0],
487
- [
488
- "mm-msg-zzz",
489
- { status: ["moving", "deleting"] },
490
- {
491
- mailboxId: "mm-src-zzz",
492
- uid: 10,
493
- status: "active",
494
- syncStatus: "synced",
495
- abandonedMutation: "none",
496
- },
497
- ],
498
- "the row goes back to the source pair, which is the set the rebuild adjudicates",
499
- );
500
- assert.equal(
501
- (threadUpdateCalls[0]?.[2] as { mailboxId?: string })?.mailboxId,
502
- "mm-src-zzz",
503
- "the listing row follows the message back to its source folder",
504
- );
505
- });
506
-
507
- // A paused settle is a settle, so both folders re-read their counts from
508
- // IMAP — the same resync every other terminal verdict in this handler runs.
509
- // Without it the repair depended entirely on someone else arming the
510
- // mailbox's rebuild.
511
- it("resyncs both folders after a paused move settles", async () => {
512
- await arrangePausedMove();
513
-
514
- await handleMessageMove(event, silentLogger, 1, moveDeps());
515
-
516
- assert.deepEqual(emitted, [
517
- { type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-src-zzz" },
518
- { type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-dst-zzz" },
519
- ]);
520
- });
521
-
522
- // Issue #1203, the redelivery half, and the mail-loss edge in it. INBOX was
523
- // recreated (UIDVALIDITY bumped, which is why the cursor is paused), the
524
- // MOVE never ran, and INBOX honestly answers "no" for the Message-ID because
525
- // it no longer holds anything it used to. Archive holds an older copy of the
526
- // same Message-ID at uid 40 — a sieve `fileinto` + `keep`, a resend — which
527
- // `deriveMessageId` folds into this one local row. Settling `moved` on 40
528
- // binds the row to that copy, and the next permanent delete expunges it:
529
- // Archive is not paused, so no rebuild is ever coming to adjudicate there.
530
- it("never settles a redelivered paused move as moved off a destination sighting", async () => {
531
- const { transitionCalls, updateUidCalls } = await arrangePausedMove();
532
-
533
- await handleMessageMove(
534
- event,
535
- capturingLogger(),
536
- 2,
537
- moveDeps(holdingConnection({ INBOX: [], Archive: [40] })),
538
- );
539
-
540
- assert.equal(
541
- updateUidCalls.length,
542
- 0,
543
- "a sighting off a source whose uid axis has moved is not proof this move ran",
544
- );
545
- // The source pair is the set its own cursor rebuild walks and adjudicates
546
- // by Message-ID, so handing the row back there is the reconcile path.
547
- assert.deepEqual(transitionCalls[0]?.[2], {
548
- mailboxId: "mm-src-zzz",
549
- uid: 10,
550
- status: "active",
551
- syncStatus: "abandoned",
552
- abandonedMutation: "move",
553
- });
554
- assert.equal(
555
- logLines.filter(
556
- (line) =>
557
- line.fields.alert === "message_move_paused_placement_unproven",
558
- ).length,
559
- 1,
560
- "a placement the server never settled is an operator's to adjudicate",
561
- );
562
- });
563
-
564
- // The taxonomy `message-copy.ts` already applies: an evidence-free
565
- // redelivery is broken, not settled. With no Message-ID header neither
566
- // folder was asked, and silence is not an answer to write `synced` on.
567
- it("settles a redelivered paused move broken when neither folder can be asked", async () => {
568
- const { transitionCalls } = await arrangePausedMove({
569
- probeable: false,
570
- });
571
-
572
- await handleMessageMove(
573
- event,
574
- capturingLogger(),
575
- 2,
576
- moveDeps(holdingConnection({})),
577
- );
578
-
579
- assert.equal(
580
- (transitionCalls[0]?.[2] as { syncStatus?: string })?.syncStatus,
581
- "abandoned",
582
- );
583
- assert.equal(
584
- logLines.filter(
585
- (line) =>
586
- line.fields.alert === "message_move_paused_placement_unproven",
587
- ).length,
588
- 1,
589
- );
590
- });
591
-
592
- // The user's move was silently dropped. Nothing throws, nothing alerts and
593
- // the row reads settled, so the only trace it ever leaves is this metric —
594
- // the counterpart of copy's `message_copy_not_landed`.
595
- it("counts a paused move the server was never told about", async () => {
596
- await arrangePausedMove();
84
+ const _accountId = "alice-account-aaaaaaaaaa";
597
85
 
598
- await handleMessageMove(event, capturingLogger(), 1, moveDeps());
599
-
600
- const dropped = logLines.filter(
601
- (line) => line.fields.metric === "message_move_dropped_on_pause",
602
- );
603
- assert.equal(dropped.length, 1);
604
- assert.equal(dropped[0]?.level, "warn");
605
- });
606
-
607
- // Issue #1122 on the same path. The destination already held an older copy
608
- // of this Message-ID and the MOVE never ran, so the destination hit is not
609
- // this message. The source is asked first for exactly that reason, and its
610
- // answer ends it.
611
- it("never binds a redelivered paused move to an older copy while the source still answers", async () => {
612
- const { transitionCalls, updateUidCalls } = await arrangePausedMove();
613
-
614
- await handleMessageMove(
615
- event,
616
- silentLogger,
617
- 2,
618
- moveDeps(holdingConnection({ INBOX: [4], Archive: [12] })),
619
- );
620
-
621
- assert.equal(updateUidCalls.length, 0);
622
- assert.deepEqual(transitionCalls[0]?.[2], {
623
- mailboxId: "mm-src-zzz",
624
- uid: 10,
625
- status: "active",
626
- syncStatus: "synced",
627
- abandonedMutation: "none",
628
- });
629
- });
630
-
631
- const arrangeLiveMove = async (
632
- folders: Record<string, string>,
633
- ): Promise<{
634
- opened: string[];
635
- movedTo: string[];
636
- updateUidCalls: unknown[][];
637
- transitionCalls: unknown[][];
638
- connection: IImapConnection;
639
- }> => {
640
- const client = await getClient();
641
- mock.method(client.account, "get", async () => cappedAccount());
642
- mock.method(client.secrets, "decrypt", async () => "fake-password");
643
- mock.method(client.message, "get", async () => [pendingRow()]);
644
- mock.method(
645
- client.mailbox,
646
- "get",
647
- async (_accountId: string, id: string) => {
648
- const fullPath = folders[id];
649
- if (fullPath === undefined) {
650
- throw Object.assign(new Error(`Mailbox not found: ${id}`), {
651
- name: "NotFoundError",
652
- });
653
- }
654
- return {
655
- mailboxId: id,
656
- uidValidity: 1,
657
- cursorState: "normal",
658
- fullPath,
659
- };
660
- },
661
- );
662
- const updateUidCalls: unknown[][] = [];
663
- mock.method(client.message, "updateUid", async (...args: unknown[]) => {
664
- updateUidCalls.push(args);
665
- });
666
- const transitionCalls: unknown[][] = [];
667
- mock.method(
668
- client.message,
669
- "transitionPlacement",
670
- async (...args: unknown[]) => {
671
- transitionCalls.push(args);
672
- return { messageId: "mm-msg-zzz" };
673
- },
674
- );
675
- const opened: string[] = [];
676
- const movedTo: string[] = [];
677
- const connection = {
678
- openBox: async (path: string) => {
679
- opened.push(path);
680
- return { uidvalidity: 1 } as never;
681
- },
682
- moveMessages: async (_uids: number[], destination: string) => {
683
- movedTo.push(destination);
684
- return { uidMap: new Map([[10, 44]]) };
685
- },
686
- } as unknown as IImapConnection;
687
- return { opened, movedTo, updateUidCalls, transitionCalls, connection };
86
+ describe("probePausedPlacement — which folder a paused mutation left the message in (#1203)", () => {
87
+ const HEADER = "<paused@example.com>";
88
+
89
+ const serverHolding = (holdings: Record<string, number[]>) => {
90
+ const server = new FakeImapServer();
91
+ server.addFolder("INBOX");
92
+ server.addFolder("Archive");
93
+ for (const [path, uids] of Object.entries(holdings)) {
94
+ for (const uid of uids)
95
+ server.deliver(path, eml({ "Message-ID": HEADER }), { uid: uid });
96
+ }
97
+ return server;
688
98
  };
689
99
 
690
- it("runs a move enqueued with the unversioned payload that carries folder paths", async () => {
691
- const { movedTo, updateUidCalls, connection } = await arrangeLiveMove({
692
- "mm-src-zzz": "INBOX",
693
- "mm-dst-zzz": "Archive",
694
- });
695
-
696
- await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
697
-
698
- assert.deepEqual(movedTo, ["Archive"]);
699
- assert.deepEqual(updateUidCalls[0], ["mm-msg-zzz", 44, "mm-dst-zzz"]);
700
- });
701
-
702
- it("runs a move enqueued with the pathless payload", async () => {
703
- const { opened, movedTo, connection } = await arrangeLiveMove({
704
- "mm-src-zzz": "INBOX",
705
- "mm-dst-zzz": "Archive",
706
- });
707
- const pathless: MessageMoveEvent = {
708
- type: "MESSAGE_MOVE",
709
- schemaVersion: 3,
710
- accountId: acctId,
711
- messageId: "mm-msg-zzz",
712
- sourceMailboxId: "mm-src-zzz",
713
- destinationMailboxId: "mm-dst-zzz",
714
- uid: 10,
715
- eventId: "mm-evt-zzz",
716
- timestamp: 1700000000000,
717
- };
718
-
719
- await handleMessageMove(pathless, silentLogger, 1, moveDeps(connection));
100
+ const opened = (server: FakeImapServer): string[] =>
101
+ server.log
102
+ .filter((line) => /^(SELECT|EXAMINE) /.test(line))
103
+ .map((line) => line.split(" ")[1] ?? "");
720
104
 
721
- assert.deepEqual(opened, ["INBOX"]);
722
- assert.deepEqual(movedTo, ["Archive"]);
723
- });
724
-
725
- it("moves under the destination's current path when the folder was renamed after enqueue", async () => {
726
- const { opened, movedTo, updateUidCalls, connection } =
727
- await arrangeLiveMove({
728
- "mm-src-zzz": "INBOX",
729
- "mm-dst-zzz": "Records",
730
- });
731
-
732
- await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
733
-
734
- assert.deepEqual(opened, ["INBOX"]);
735
- assert.deepEqual(
736
- movedTo,
737
- ["Records"],
738
- "the payload still says Archive; the row says where the folder is now",
739
- );
740
- assert.deepEqual(updateUidCalls[0], ["mm-msg-zzz", 44, "mm-dst-zzz"]);
741
- });
742
-
743
- it("opens the source under its current path when it was renamed after enqueue", async () => {
744
- const { opened, movedTo, connection } = await arrangeLiveMove({
745
- "mm-src-zzz": "Old/Inbox-renamed",
746
- "mm-dst-zzz": "Archive",
747
- });
748
-
749
- await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
750
-
751
- assert.deepEqual(
752
- opened,
753
- ["Old/Inbox-renamed"],
754
- "the payload still says INBOX; the row says where the folder is now",
755
- );
756
- assert.deepEqual(movedTo, ["Archive"]);
757
- });
758
-
759
- it("hands the row back to its source without connecting when the destination mailbox was deleted", async () => {
760
- const { transitionCalls, updateUidCalls } = await arrangeLiveMove({
761
- "mm-src-zzz": "INBOX",
105
+ const probe = (
106
+ server: FakeImapServer,
107
+ input: Partial<Parameters<typeof probePausedPlacement>[1]> = {},
108
+ ) =>
109
+ probePausedPlacement(server.connection(), {
110
+ messageIdHeader: HEADER,
111
+ sourceMailboxPath: "INBOX",
112
+ destinationMailboxPath: "Archive",
113
+ ...input,
762
114
  });
763
115
 
764
- await handleMessageMove(event, capturingLogger(), 1, moveDeps());
765
-
766
- assert.equal(connectCount, 0);
767
- assert.equal(updateUidCalls.length, 0);
768
- assert.deepEqual(transitionCalls[0]?.[2], {
769
- mailboxId: "mm-src-zzz",
770
- uid: 10,
771
- status: "active",
772
- syncStatus: "synced",
773
- abandonedMutation: "none",
774
- });
775
- assert.equal(
776
- logLines.filter(
777
- (line) => line.fields.metric === "message_move_destination_gone",
778
- ).length,
779
- 1,
780
- );
781
- assert.deepEqual(emitted, [
782
- { type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-src-zzz" },
783
- ]);
784
- });
785
-
786
- it("acks without connecting when the message row is already gone", async () => {
787
- const client = await getClient();
788
- mock.method(client.account, "get", async () => cappedAccount());
789
- mock.method(client.secrets, "decrypt", async () => "fake-password");
790
- mock.method(client.message, "get", async () => []);
791
- const mailboxGet = mock.method(client.mailbox, "get", async () => {
792
- throw new Error("a missing row must never resolve a mailbox");
793
- });
794
-
795
- await handleMessageMove(event, silentLogger, MESSAGE_MOVE_MAX_ATTEMPTS);
796
-
797
- assert.equal(mailboxGet.mock.calls.length, 0);
798
- });
799
- });
800
-
801
- describe("probePausedPlacement — which folder a paused mutation left the message in (#1203)", () => {
802
- const buildConnection = (
803
- holdings: Record<string, number[]>,
804
- ): { connection: IImapConnection; opened: string[] } => {
805
- const opened: string[] = [];
806
- let current = "";
807
- return {
808
- opened,
809
- connection: {
810
- openBox: async (path: string) => {
811
- opened.push(path);
812
- current = path;
813
- return {} as never;
814
- },
815
- search: async () => holdings[current] ?? [],
816
- } as unknown as IImapConnection,
817
- };
818
- };
819
-
820
- const probe = (holdings: Record<string, number[]>) => {
821
- const { connection, opened } = buildConnection(holdings);
822
- return {
823
- opened,
824
- result: probePausedPlacement(connection, {
825
- messageIdHeader: "<paused@example.com>",
826
- sourceMailboxPath: "INBOX",
827
- destinationMailboxPath: "Archive",
828
- }),
829
- };
830
- };
831
-
832
116
  it("stops at the source and never asks the destination when the source still holds it", async () => {
833
- const { opened, result } = probe({ INBOX: [4], Archive: [12] });
117
+ const server = serverHolding({ INBOX: [4], Archive: [12] });
834
118
 
835
- assert.deepStrictEqual(await result, { kind: "at-source" });
119
+ assert.deepStrictEqual(await probe(server), { kind: "at-source" });
836
120
  assert.deepStrictEqual(
837
- opened,
121
+ opened(server),
838
122
  ["INBOX"],
839
123
  "a source hit ends it: with the mutation unrun, every destination hit is an older copy (#1122)",
840
124
  );
841
125
  });
842
126
 
843
127
  it("answers the destination's highest matching uid once the source has let go", async () => {
844
- const { result } = probe({ INBOX: [], Archive: [12, 77] });
128
+ const server = serverHolding({ Archive: [12, 77] });
845
129
 
846
- assert.deepStrictEqual(await result, { kind: "at-destination", uid: 77 });
130
+ assert.deepStrictEqual(await probe(server), {
131
+ kind: "at-destination",
132
+ uid: 77,
133
+ });
847
134
  });
848
135
 
849
136
  it("answers gone when neither folder holds it", async () => {
850
- assert.deepStrictEqual(await probe({}).result, { kind: "gone" });
137
+ assert.deepStrictEqual(await probe(serverHolding({})), { kind: "gone" });
851
138
  });
852
139
 
853
140
  it("answers gone, not at-source, when there is no destination to ask", async () => {
854
- const { connection, opened } = buildConnection({ INBOX: [] });
141
+ const server = serverHolding({});
855
142
 
856
143
  assert.deepStrictEqual(
857
- await probePausedPlacement(connection, {
858
- messageIdHeader: "<paused@example.com>",
859
- sourceMailboxPath: "INBOX",
860
- destinationMailboxPath: undefined,
861
- }),
144
+ await probe(server, { destinationMailboxPath: undefined }),
862
145
  { kind: "gone" },
863
146
  );
864
- assert.deepStrictEqual(opened, ["INBOX"]);
147
+ assert.deepStrictEqual(opened(server), ["INBOX"]);
865
148
  });
866
149
 
867
150
  // A row with no Message-ID header cannot be asked about, and the server's
868
151
  // silence is not an answer. `unprobeable` keeps that distinct from `gone`.
869
152
  it("asks nothing at all when the row carries no Message-ID header", async () => {
870
- const { connection, opened } = buildConnection({ INBOX: [4] });
153
+ const server = serverHolding({ INBOX: [4] });
871
154
 
872
155
  assert.deepStrictEqual(
873
- await probePausedPlacement(connection, {
874
- messageIdHeader: undefined,
875
- sourceMailboxPath: "INBOX",
876
- destinationMailboxPath: "Archive",
877
- }),
878
- { kind: "unprobeable" },
156
+ await probe(server, { messageIdHeader: undefined }),
157
+ {
158
+ kind: "unprobeable",
159
+ },
879
160
  );
880
- assert.deepStrictEqual(opened, []);
161
+ assert.deepStrictEqual(server.log, []);
881
162
  });
882
163
  });
883
164
 
884
165
  describe("searchMailboxForHighestMessageIdUid — the probe that binds a move to a UID (#912)", () => {
885
- const isMessageIdCriterion = (
886
- criterion: unknown,
887
- ): criterion is [string, string, string] =>
888
- Array.isArray(criterion) &&
889
- criterion.length === 3 &&
890
- typeof criterion[0] === "string" &&
891
- criterion[0].toUpperCase() === "HEADER" &&
892
- typeof criterion[1] === "string" &&
893
- criterion[1].toLowerCase() === "message-id" &&
894
- typeof criterion[2] === "string";
895
-
896
- const buildDestination = (
897
- messages: Array<{ uid: number; messageIdHeader: string }>,
898
- ): IImapConnection => {
899
- const searchAll = () => messages.map((row) => row.uid);
900
- return {
901
- openBox: async () => ({}) as never,
902
- search: async (criteria: unknown[]) => {
903
- const criterion = criteria.find(isMessageIdCriterion);
904
- if (criterion === undefined) return searchAll();
905
- return messages
906
- .filter((row) => row.messageIdHeader === criterion[2])
907
- .map((row) => row.uid);
908
- },
909
- } as unknown as IImapConnection;
166
+ const archiveHolding = (messages: Array<[uid: number, header: string]>) => {
167
+ const server = new FakeImapServer();
168
+ server.addFolder("Archive");
169
+ for (const [uid, header] of messages)
170
+ server.deliver("Archive", eml({ "Message-ID": header }), { uid: uid });
171
+ return server;
910
172
  };
911
173
 
174
+ const search = (server: FakeImapServer, header: string) =>
175
+ searchMailboxForHighestMessageIdUid(server.connection(), "Archive", header);
176
+
912
177
  it("asks the folder by Message-ID rather than by an interpolated string", async () => {
913
- const sent: unknown[][] = [];
914
- const destination = {
915
- openBox: async () => ({}) as never,
916
- search: async (criteria: unknown[]) => {
917
- sent.push(criteria);
918
- return [];
919
- },
920
- } as unknown as IImapConnection;
178
+ const server = archiveHolding([]);
179
+ const hostile = '<a"b@example.com>\r\nUID 1';
921
180
 
922
- await searchMailboxForHighestMessageIdUid(
923
- destination,
924
- "Archive",
925
- '<a"b@example.com>\r\nUID 1',
926
- );
181
+ await search(server, hostile);
927
182
 
928
- assert.deepStrictEqual(sent, [
929
- [["HEADER", "Message-ID", '<a"b@example.com>\r\nUID 1']],
183
+ assert.deepStrictEqual(server.commands("SEARCH"), [
184
+ `SEARCH Archive ${JSON.stringify([["HEADER", "Message-ID", hostile]])}`,
930
185
  ]);
931
186
  });
932
187
 
933
188
  it("answers null, not another message's UID, when the folder holds no such message", async () => {
934
- const destination = buildDestination([
935
- { uid: 11, messageIdHeader: "<stranger-a@example.com>" },
936
- { uid: 12, messageIdHeader: "<stranger-b@example.com>" },
189
+ const server = archiveHolding([
190
+ [11, "<stranger-a@example.com>"],
191
+ [12, "<stranger-b@example.com>"],
937
192
  ]);
938
193
 
939
- assert.strictEqual(
940
- await searchMailboxForHighestMessageIdUid(
941
- destination,
942
- "Archive",
943
- "<moved@example.com>",
944
- ),
945
- null,
946
- );
194
+ assert.strictEqual(await search(server, "<moved@example.com>"), null);
947
195
  });
948
196
 
949
197
  it("answers the UID of the message carrying that Message-ID", async () => {
950
- const destination = buildDestination([
951
- { uid: 11, messageIdHeader: "<stranger-a@example.com>" },
952
- { uid: 12, messageIdHeader: "<moved@example.com>" },
953
- ]);
954
-
955
- assert.strictEqual(
956
- await searchMailboxForHighestMessageIdUid(
957
- destination,
958
- "Archive",
959
- "<moved@example.com>",
960
- ),
961
- 12,
962
- );
963
- });
964
-
965
- // Issue #1122. The folder already held an older copy of this Message-ID — a
966
- // sieve `fileinto` + `keep`, a resend, an earlier copy of the same message —
967
- // and the uid this probe hands back settles the row the fresh copy lives in.
968
- // Answering 12 binds that row to mail this operation never touched and
969
- // leaves the copy that just arrived unreachable by any later delete.
970
- it("answers the newest copy's UID when the folder already held an older copy of it", async () => {
971
- const destination = buildDestination([
972
- { uid: 12, messageIdHeader: "<moved@example.com>" },
973
- { uid: 40, messageIdHeader: "<moved@example.com>" },
198
+ const server = archiveHolding([
199
+ [11, "<stranger-a@example.com>"],
200
+ [12, "<moved@example.com>"],
974
201
  ]);
975
202
 
976
- assert.strictEqual(
977
- await searchMailboxForHighestMessageIdUid(
978
- destination,
979
- "Archive",
980
- "<moved@example.com>",
981
- ),
982
- 40,
983
- );
203
+ assert.strictEqual(await search(server, "<moved@example.com>"), 12);
984
204
  });
985
205
 
986
206
  // RFC 3501 does not order a SEARCH response, so the fresh copy is the
987
207
  // highest uid returned rather than the last one returned.
988
208
  it("takes the highest match whatever order the server lists them in", async () => {
989
- const destination = buildDestination([
990
- { uid: 40, messageIdHeader: "<moved@example.com>" },
991
- { uid: 12, messageIdHeader: "<moved@example.com>" },
209
+ const server = archiveHolding([
210
+ [40, "<moved@example.com>"],
211
+ [12, "<moved@example.com>"],
992
212
  ]);
993
213
 
994
- assert.strictEqual(
995
- await searchMailboxForHighestMessageIdUid(
996
- destination,
997
- "Archive",
998
- "<moved@example.com>",
999
- ),
1000
- 40,
1001
- );
214
+ assert.strictEqual(await search(server, "<moved@example.com>"), 40);
1002
215
  });
1003
216
  });