@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
@@ -5,8 +5,8 @@ import {
5
5
  MessageStatus,
6
6
  MessageSyncStatus,
7
7
  } from "@remit/domain-enums";
8
- import type { Logger } from "@remit/logger-lambda";
9
- import { recordImapFailure } from "@remit/logger-lambda";
8
+ import type { Logger } from "@remit/logger";
9
+ import { recordImapFailure } from "@remit/logger-lambda/metrics";
10
10
  import {
11
11
  guardConnectionCursor,
12
12
  type IImapConnection,
@@ -23,6 +23,7 @@ import { findMailboxRow } from "../mailbox-row.js";
23
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
25
25
  import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
26
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
26
27
 
27
28
  export const getMessageMoveMaxAttempts = (
28
29
  processEnv: NodeJS.ProcessEnv = process.env,
@@ -229,22 +230,6 @@ export const buildThreadMessageMoveUpdate = (
229
230
  },
230
231
  });
231
232
 
232
- export interface MessageMoveDeps {
233
- getClient: typeof getClient;
234
- buildLifecycleDeps: typeof buildLifecycleDeps;
235
- withOAuthLifecycle: typeof withOAuthLifecycle;
236
- createConnectionScope: typeof createConnectionScopeWithCredentials;
237
- emitEvent: typeof emitEvent;
238
- }
239
-
240
- const defaultDeps: MessageMoveDeps = {
241
- getClient,
242
- buildLifecycleDeps,
243
- withOAuthLifecycle,
244
- createConnectionScope: createConnectionScopeWithCredentials,
245
- emitEvent,
246
- };
247
-
248
233
  /**
249
234
  * Handle MESSAGE_MOVE events.
250
235
  * Executes IMAP MOVE command and updates local state with new UID.
@@ -260,16 +245,8 @@ export const handleMessageMove = async (
260
245
  event: MessageMoveEvent,
261
246
  log: Logger,
262
247
  receiveCount = 1,
263
- deps: MessageMoveDeps = defaultDeps,
248
+ createConnectionScope = createConnectionScopeWithCredentials,
264
249
  ): Promise<void> => {
265
- const {
266
- getClient,
267
- buildLifecycleDeps,
268
- withOAuthLifecycle,
269
- createConnectionScope: createConnectionScopeWithCredentials,
270
- emitEvent,
271
- } = deps;
272
-
273
250
  const {
274
251
  account: accountService,
275
252
  message: messageService,
@@ -341,17 +318,35 @@ export const handleMessageMove = async (
341
318
  // a queued event pointing at a gone row. The lookup then throws
342
319
  // NotFoundError forever, and on the account's per-group FIFO that head
343
320
  // message stalls the whole pipeline (issues #287, #289, #290). A deleted
344
- // source mailbox makes the move moot: ack with a WARN.
321
+ // source mailbox makes the move moot, but not the row: left `moving`, it
322
+ // names the destination with a uid from a folder that no longer exists,
323
+ // and nothing else settles it. It is reconciled away, and the destination
324
+ // resync re-projects the message if the MOVE did land there.
345
325
  const source = await findMailboxRow(
346
326
  mailboxService,
347
327
  accountId,
348
328
  sourceMailboxId,
349
329
  );
350
330
  if (source.kind === "gone") {
331
+ const reconciled = await messageService.deleteIfPlacement(messageId, {
332
+ status: message.status,
333
+ mailboxId: message.mailboxId,
334
+ uid: message.uid,
335
+ });
351
336
  log.warn(
352
- { accountId, messageId, mailboxId: sourceMailboxId },
353
- "Skipping MESSAGE_MOVE: source mailbox no longer exists (deleted)",
337
+ {
338
+ metric: "message_move_source_gone",
339
+ accountId,
340
+ messageId,
341
+ mailboxId: sourceMailboxId,
342
+ reconciled,
343
+ },
344
+ "MESSAGE_MOVE not pushed: source mailbox no longer exists (deleted); stale row reconciled",
354
345
  );
346
+ await emitMailboxResync(emitEvent, {
347
+ accountId,
348
+ mailboxId: destinationMailboxId,
349
+ });
355
350
  return;
356
351
  }
357
352
  const mailbox = source.mailbox;
@@ -462,7 +457,7 @@ export const handleMessageMove = async (
462
457
  }
463
458
  const destinationMailboxPath = destination.mailbox.fullPath;
464
459
 
465
- const scope = createConnectionScopeWithCredentials(account, credentials);
460
+ const scope = createConnectionScope(account, credentials);
466
461
 
467
462
  // The terminal resolver opens the source itself, and its answer now
468
463
  // WRITES a placement, so it has to reach IMAP through the same
@@ -504,7 +499,11 @@ export const handleMessageMove = async (
504
499
 
505
500
  const placement: PausedPlacement = mayHaveIssued
506
501
  ? await probePausedPlacement(await scope.getConnection(), {
507
- messageIdHeader: message.messageIdHeader,
502
+ messageIdHeader: await probeableMessageIdHeader(
503
+ threadMessageService,
504
+ account.accountConfigId,
505
+ messageId,
506
+ ),
508
507
  sourceMailboxPath,
509
508
  destinationMailboxPath,
510
509
  })
@@ -602,13 +601,19 @@ export const handleMessageMove = async (
602
601
  // because a handler that returns never redelivers.
603
602
  const newUid =
604
603
  result.uidMap.get(uid) ??
605
- (message.messageIdHeader
606
- ? await searchMailboxForHighestMessageIdUid(
607
- rawConnection,
608
- destinationMailboxPath,
609
- message.messageIdHeader,
610
- )
611
- : null);
604
+ (await probeableMessageIdHeader(
605
+ threadMessageService,
606
+ account.accountConfigId,
607
+ messageId,
608
+ ).then((messageIdHeader) =>
609
+ messageIdHeader
610
+ ? searchMailboxForHighestMessageIdUid(
611
+ rawConnection,
612
+ destinationMailboxPath,
613
+ messageIdHeader,
614
+ )
615
+ : null,
616
+ ));
612
617
 
613
618
  if (!newUid) {
614
619
  throw new Error(
@@ -679,6 +684,11 @@ export const handleMessageMove = async (
679
684
  const settled = await resolveExhaustedMessageMoveFailure(
680
685
  { messageService, threadMessageService, log },
681
686
  {
687
+ placement: {
688
+ status: message.status,
689
+ mailboxId: message.mailboxId,
690
+ uid: message.uid,
691
+ },
682
692
  accountId,
683
693
  accountConfigId: account.accountConfigId,
684
694
  messageId,
@@ -1,370 +1,137 @@
1
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 { IImapConnection } from "@remit/mailbox-service";
6
- import type { PlacementMovePushEvent } from "../events.js";
2
+ import { describe, it } from "node:test";
7
3
  import {
8
- attemptMove,
9
- getPlacementMoveMaxAttempts,
10
- handlePlacementMovePush,
11
- PLACEMENT_MOVE_MAX_ATTEMPTS,
12
- } from "./placement-move-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
- const buildConnection = (opts: {
29
- uidMap?: Map<number, number>;
30
- moveError?: Error;
31
- /** Message-ID search hits in the destination mailbox — the verification probe. */
32
- destinationSearchUids?: number[];
33
- /** Whether the message is still in the source mailbox. */
34
- stillAtSource?: boolean;
35
- /** The source FETCH drops the row for a message that is still there. */
36
- sourceFetchDrops?: boolean;
37
- }): IImapConnection =>
38
- ({
39
- openBox: async () => ({}) as never,
40
- moveMessages: async (_uids: number[], destination: string) => {
41
- if (opts.moveError) throw opts.moveError;
42
- return {
43
- destination,
44
- uidValidity: 1,
45
- uidMap: opts.uidMap ?? new Map(),
46
- };
47
- },
48
- search: async (criteria: unknown[]) => {
49
- const [first] = criteria;
50
- if (Array.isArray(first) && first[0] === "UID") {
51
- return opts.stillAtSource ? [Number(first[1])] : [];
52
- }
53
- return opts.destinationSearchUids ?? [];
54
- },
55
- fetchMessages: async (uids: number[]) =>
56
- opts.stillAtSource && !opts.sourceFetchDrops
57
- ? uids.map((uid) => ({ uid }) as unknown as never)
58
- : [],
59
- }) as unknown as IImapConnection;
60
-
61
- const MESSAGE_ID_HEADER = "<abc@example.com>";
4
+ eml,
5
+ FakeImapServer,
6
+ } from "@remit/mailbox-service/test-helpers/fake-imap";
7
+ import { attemptMove } from "./placement-move-push.js";
8
+
9
+ const HEADER = "<abc@example.com>";
10
+
11
+ /** Junk holds the message at uid 42; INBOX is where the placement sends it. */
12
+ const arrange = (
13
+ options: { inJunk?: boolean; inInbox?: boolean; inboxExists?: boolean } = {},
14
+ ) => {
15
+ const server = new FakeImapServer();
16
+ server.addFolder("Junk");
17
+ if (options.inboxExists !== false) server.addFolder("INBOX");
18
+ if (options.inJunk !== false)
19
+ server.deliver("Junk", eml({ "Message-ID": HEADER }), { uid: 42 });
20
+ if (options.inInbox) server.deliver("INBOX", eml({ "Message-ID": HEADER }));
21
+ return server;
22
+ };
23
+
24
+ const move = (
25
+ server: FakeImapServer,
26
+ header: string | undefined = HEADER,
27
+ source = server.connection(),
28
+ ) => attemptMove(source, server.connection(), "Junk", "INBOX", 42, header);
62
29
 
63
30
  describe("attemptMove — the IMAP push (issue #1271)", () => {
64
- // Tests use the SAME fake connection for both the source and destination
65
- // params — attemptMove treats them as independent handles (real cursor
66
- // guarding per mailbox is #1272's `guardConnectionCursor`, tested in
67
- // remit-mailbox-service; not re-tested here).
68
- it("moved: returns the new uid from the COPYUID map", async () => {
69
- const connection = buildConnection({ uidMap: new Map([[42, 99]]) });
31
+ it("moves the message and returns the uid from the COPYUID map", async () => {
32
+ const server = arrange();
70
33
 
71
- const outcome = await attemptMove(
72
- connection,
73
- connection,
74
- "Junk",
75
- "INBOX",
76
- 42,
77
- MESSAGE_ID_HEADER,
78
- );
79
-
80
- assert.equal(outcome.kind, "moved");
81
- assert.equal(outcome.newUid, 99);
82
- });
34
+ const outcome = await move(server);
83
35
 
84
- it("trycreate: the destination mailbox does not exist yet", async () => {
85
- const connection = buildConnection({
86
- moveError: new Error("Command failed: TRYCREATE"),
36
+ assert.deepEqual(outcome, {
37
+ kind: "moved",
38
+ newUid: server.uids("INBOX", HEADER)[0],
87
39
  });
88
-
89
- const outcome = await attemptMove(
90
- connection,
91
- connection,
92
- "Junk",
93
- "NewLabel",
94
- 42,
95
- MESSAGE_ID_HEADER,
96
- );
97
-
98
- assert.equal(outcome.kind, "trycreate");
40
+ assert.deepEqual(server.uids("Junk"), []);
99
41
  });
100
42
 
101
- it("propagates any other error untouched (transient/infra failure, retried by the caller)", async () => {
102
- const connection = buildConnection({
103
- moveError: new Error("ECONNRESET"),
104
- });
43
+ it("answers trycreate when the destination does not exist yet", async () => {
44
+ const server = arrange({ inboxExists: false });
105
45
 
106
- await assert.rejects(
107
- () =>
108
- attemptMove(
109
- connection,
110
- connection,
111
- "Junk",
112
- "INBOX",
113
- 42,
114
- MESSAGE_ID_HEADER,
115
- ),
116
- /ECONNRESET/,
117
- );
46
+ assert.equal((await move(server)).kind, "trycreate");
118
47
  });
119
48
 
120
- describe("no COPYUID entry / explicit not-found — never trust either without verification (PR #1289 review finding 2)", () => {
121
- it("moved: no COPYUID entry, but a Message-ID search finds it at the destination (non-UIDPLUS server, genuine success)", async () => {
122
- const connection = buildConnection({
123
- uidMap: new Map(),
124
- destinationSearchUids: [77],
125
- });
126
-
127
- const outcome = await attemptMove(
128
- connection,
129
- connection,
130
- "Junk",
131
- "INBOX",
132
- 42,
133
- MESSAGE_ID_HEADER,
134
- );
135
-
136
- assert.equal(outcome.kind, "moved");
137
- assert.equal(outcome.newUid, 77);
138
- });
139
-
140
- it("moved: explicit NONEXISTENT error, but a Message-ID search finds it at the destination", async () => {
141
- const connection = buildConnection({
142
- moveError: new Error("Command failed: NONEXISTENT no such message"),
143
- destinationSearchUids: [77],
144
- });
145
-
146
- const outcome = await attemptMove(
147
- connection,
148
- connection,
149
- "Junk",
150
- "INBOX",
151
- 42,
152
- MESSAGE_ID_HEADER,
153
- );
154
-
155
- assert.equal(outcome.kind, "moved");
156
- assert.equal(outcome.newUid, 77);
157
- });
158
-
159
- // Issue #1122. The destination already held an older copy of this
160
- // Message-ID; the marker must bind to the copy this MOVE delivered, or
161
- // the placement row names mail this push never touched.
162
- it("moved: binds to the newest copy when the destination already held an older one", async () => {
163
- const connection = buildConnection({
164
- uidMap: new Map(),
165
- destinationSearchUids: [15, 77],
166
- });
167
-
168
- const outcome = await attemptMove(
169
- connection,
170
- connection,
171
- "Junk",
172
- "INBOX",
173
- 42,
174
- MESSAGE_ID_HEADER,
175
- );
176
-
177
- assert.equal(outcome.kind, "moved");
178
- assert.equal(outcome.newUid, 77);
179
- });
49
+ it("propagates any other failure untouched for the caller to retry", async () => {
50
+ const server = arrange();
51
+ server.failNext.MOVE = new Error("ECONNRESET");
180
52
 
181
- it("throws (never deletes) when unconfirmed at the destination but STILL present at the source", async () => {
182
- const connection = buildConnection({
183
- uidMap: new Map(),
184
- destinationSearchUids: [],
185
- stillAtSource: true,
186
- });
53
+ await assert.rejects(move(server), /ECONNRESET/);
54
+ });
187
55
 
188
- await assert.rejects(
189
- () =>
190
- attemptMove(
191
- connection,
192
- connection,
193
- "Junk",
194
- "INBOX",
195
- 42,
196
- MESSAGE_ID_HEADER,
197
- ),
198
- /unresolved/,
199
- );
200
- });
56
+ // PR #1289 review finding 2: neither a missing COPYUID entry nor an explicit
57
+ // not-found is trusted without asking the server.
58
+ it("confirms a move by Message-ID when the server sends no COPYUID", async () => {
59
+ const server = arrange();
60
+ server.uidplus = false;
201
61
 
202
- it("throws (never deletes) when the source FETCH drops the row for a message the server still lists", async () => {
203
- const connection = buildConnection({
204
- uidMap: new Map(),
205
- destinationSearchUids: [],
206
- stillAtSource: true,
207
- sourceFetchDrops: true,
208
- });
62
+ const outcome = await move(server);
209
63
 
210
- await assert.rejects(
211
- () =>
212
- attemptMove(
213
- connection,
214
- connection,
215
- "Junk",
216
- "INBOX",
217
- 42,
218
- MESSAGE_ID_HEADER,
219
- ),
220
- /unresolved/,
221
- );
64
+ assert.deepEqual(outcome, {
65
+ kind: "moved",
66
+ newUid: server.uids("INBOX", HEADER)[0],
222
67
  });
68
+ });
223
69
 
224
- it("not-found: confirmed absent from BOTH destination (search miss) AND source (search miss)", async () => {
225
- const connection = buildConnection({
226
- uidMap: new Map(),
227
- destinationSearchUids: [],
228
- stillAtSource: false,
229
- });
230
-
231
- const outcome = await attemptMove(
232
- connection,
233
- connection,
234
- "Junk",
235
- "INBOX",
236
- 42,
237
- MESSAGE_ID_HEADER,
238
- );
239
-
240
- assert.equal(outcome.kind, "not-found");
241
- });
70
+ it("confirms a move by Message-ID when the server answers NONEXISTENT for one that landed", async () => {
71
+ const server = arrange({ inJunk: false, inInbox: true });
72
+ server.failNext.MOVE = new Error(
73
+ "Command failed: NONEXISTENT no such message",
74
+ );
242
75
 
243
- it("no messageIdHeader to verify with: falls back to the source-presence check alone — still never deletes while present at source", async () => {
244
- const connection = buildConnection({
245
- uidMap: new Map(),
246
- stillAtSource: true,
247
- });
76
+ const outcome = await move(server);
248
77
 
249
- await assert.rejects(() =>
250
- attemptMove(connection, connection, "Junk", "INBOX", 42, undefined),
251
- );
78
+ assert.deepEqual(outcome, {
79
+ kind: "moved",
80
+ newUid: server.uids("INBOX", HEADER)[0],
252
81
  });
82
+ });
253
83
 
254
- it("no messageIdHeader to verify with: resolves not-found once confirmed absent from source", async () => {
255
- const connection = buildConnection({
256
- uidMap: new Map(),
257
- stillAtSource: false,
258
- });
84
+ // Issue #1122: the destination already held an older copy, and the row must
85
+ // bind to the one this MOVE delivered.
86
+ it("binds to the newest copy when the destination already held an older one", async () => {
87
+ const server = arrange({ inInbox: true });
88
+ server.uidplus = false;
259
89
 
260
- const outcome = await attemptMove(
261
- connection,
262
- connection,
263
- "Junk",
264
- "INBOX",
265
- 42,
266
- undefined,
267
- );
90
+ const outcome = await move(server);
268
91
 
269
- assert.equal(outcome.kind, "not-found");
92
+ assert.deepEqual(outcome, {
93
+ kind: "moved",
94
+ newUid: Math.max(...server.uids("INBOX", HEADER)),
270
95
  });
271
96
  });
272
- });
273
97
 
274
- describe("getPlacementMoveMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts)", () => {
275
- it("parses the CDK-injected env var", () => {
276
- assert.equal(
277
- getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "3" }),
278
- 3,
279
- );
280
- assert.equal(
281
- getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "5" }),
282
- 5,
283
- );
284
- });
98
+ it("never settles a move the destination does not confirm while the source still holds it", async () => {
99
+ const server = arrange();
100
+ server.uidplus = false;
101
+ server.ignoreNext.add("MOVE");
285
102
 
286
- it("defaults to 3 when unset", () => {
287
- assert.equal(getPlacementMoveMaxAttempts({}), 3);
103
+ await assert.rejects(move(server), /unresolved/);
288
104
  });
289
105
 
290
- it("defaults to 3 on a non-numeric or non-positive value", () => {
291
- assert.equal(
292
- getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "nope" }),
293
- 3,
294
- );
295
- assert.equal(
296
- getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "0" }),
297
- 3,
298
- );
299
- });
106
+ it("never settles when the source FETCH drops the row for a message the server still lists", async () => {
107
+ const server = arrange();
108
+ server.uidplus = false;
109
+ server.ignoreNext.add("MOVE");
110
+ const source = server.connection();
111
+ source.fetchMessages = async () => [];
300
112
 
301
- it("PLACEMENT_MOVE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
302
- assert.ok(PLACEMENT_MOVE_MAX_ATTEMPTS > 0);
113
+ await assert.rejects(move(server, HEADER, source), /unresolved/);
303
114
  });
304
- });
305
-
306
- describe("handlePlacementMovePush — deleted mailbox is terminal (#287/#289)", () => {
307
- const accountId = "pm-acc-zzz";
308
- const messageId = "pm-msg-zzz";
309
- const destinationMailboxId = "pm-dest-zzz";
310
115
 
311
- const event: PlacementMovePushEvent = {
312
- type: "PLACEMENT_MOVE_PUSH",
313
- accountId,
314
- accountConfigId: "pm-cfg-zzz",
315
- messageId,
316
- eventId: "pm-evt-zzz",
317
- timestamp: 1700000000000,
318
- };
116
+ it("answers not-found once neither folder holds the message", async () => {
117
+ const server = arrange({ inJunk: false });
118
+ server.uidplus = false;
319
119
 
320
- // The client is supplied by injection (`setClient`), so these tests register
321
- // the repositories they then mock rather than reaching a composition.
322
- before(() => {
323
- setClient({
324
- account: { get: async () => undefined },
325
- message: { get: async () => undefined },
326
- mailbox: { get: async () => undefined },
327
- placementMove: {
328
- find: async () => undefined,
329
- updateState: async () => undefined,
330
- delete: async () => undefined,
331
- },
332
- } as unknown as RemitClient);
120
+ assert.equal((await move(server)).kind, "not-found");
333
121
  });
334
122
 
335
- afterEach(() => mock.restoreAll());
123
+ it("falls back to the source alone without a Message-ID, and still never settles while it holds the message", async () => {
124
+ const server = arrange();
125
+ server.uidplus = false;
126
+ server.ignoreNext.add("MOVE");
336
127
 
337
- it("drops the marker and acks without pushing when a mailbox is gone", async () => {
338
- const client = await getClient();
339
- mock.method(client.account, "get", async () => ({
340
- accountId,
341
- accountConfigId: "pm-cfg-zzz",
342
- }));
343
- mock.method(client.message, "get", async () => ({
344
- messageId,
345
- mailboxId: destinationMailboxId,
346
- }));
347
- mock.method(client.mailbox, "get", async () => {
348
- throw Object.assign(new Error("Mailbox not found: pm-src-zzz"), {
349
- name: "NotFoundError",
350
- });
351
- });
352
- mock.method(client.placementMove, "find", async () => ({
353
- sourceMailboxId: "pm-src-zzz",
354
- destinationMailboxId,
355
- }));
356
- mock.method(client.placementMove, "updateState", async () => {});
357
- const deleteMarker = mock.method(
358
- client.placementMove,
359
- "delete",
360
- async () => {},
361
- );
128
+ await assert.rejects(move(server, undefined));
129
+ });
362
130
 
363
- // Must resolve, not reject — a deleted folder is an expected terminal
364
- // outcome, not a fault to retry on the account's per-group FIFO.
365
- await handlePlacementMovePush(event, silentLogger, 1);
131
+ it("answers not-found without a Message-ID once the source no longer holds the message", async () => {
132
+ const server = arrange({ inJunk: false });
133
+ server.uidplus = false;
366
134
 
367
- assert.equal(deleteMarker.mock.calls.length, 1, "the marker is dropped");
368
- assert.deepEqual(deleteMarker.mock.calls[0].arguments, [messageId]);
135
+ assert.equal((await move(server, undefined)).kind, "not-found");
369
136
  });
370
137
  });