@remit/imap-worker 0.0.71 → 0.0.73

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.
@@ -4,23 +4,29 @@ import type {
4
4
  IThreadMessageRepository,
5
5
  MessageItem,
6
6
  } from "@remit/data-ports";
7
+ import { isNotFoundError } from "@remit/data-ports/errors";
7
8
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
8
- import { MessageSyncStatus } from "@remit/domain-enums";
9
+ import {
10
+ MessageMutation,
11
+ MessageStatus,
12
+ MessageSyncStatus,
13
+ } from "@remit/domain-enums";
9
14
  import type { Logger } from "@remit/logger-lambda";
10
15
  import { recordImapFailure } from "@remit/logger-lambda";
11
16
  import {
17
+ buildThreadMessageTrashUpdate,
12
18
  guardConnectionCursor,
13
19
  type IImapConnection,
14
20
  isCursorRebuildNeeded,
15
21
  isMessageGoneFromOpenMailbox,
16
22
  MailboxCursorPausedError,
23
+ restoreSourcePlacement,
17
24
  } from "@remit/mailbox-service";
18
25
  import { attemptBudget } from "@remit/sqs-client/attempt-budget";
19
26
  import { isAccountDeleted } from "../account-check.js";
20
27
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
21
28
  import { emitEvent } from "../emit.js";
22
29
  import type { MessageDeleteEvent } from "../events.js";
23
- import { isNotFoundError } from "../is-not-found.js";
24
30
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
25
31
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
26
32
  import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
@@ -31,8 +37,6 @@ import {
31
37
  probePausedPlacement,
32
38
  searchMailboxForHighestMessageIdUid,
33
39
  } from "./message-move.js";
34
- import { restoreSourcePlacement } from "./restore-source-placement.js";
35
- import { buildThreadMessageTrashUpdate } from "./thread-message-rows.js";
36
40
 
37
41
  export const getMessageDeleteMaxAttempts = (
38
42
  processEnv: NodeJS.ProcessEnv = process.env,
@@ -196,6 +200,55 @@ export const handleMessageDelete = async (
196
200
  return;
197
201
  }
198
202
 
203
+ const [message] = await messageService.get([messageId]);
204
+
205
+ // The row is gone — a permanent delete that already ran, or a
206
+ // reconciliation path that removed it. Nothing left to delete; ack.
207
+ if (!message) {
208
+ log.warn(
209
+ { accountId, messageId, operation },
210
+ "Skipping MESSAGE_DELETE: message row no longer exists",
211
+ );
212
+ return;
213
+ }
214
+
215
+ // This delete already settled. The guard asks for the state THIS delete
216
+ // wrote, the way MESSAGE_MOVE and MESSAGE_COPY ask for theirs: a move to
217
+ // Trash records `moving`, a permanent delete records `deleting`, and a row
218
+ // in the other one is a different mutation's work that this handler must
219
+ // not read as its own to finish.
220
+ //
221
+ // Without it a lost SQS acknowledgement re-runs the delete against a uid
222
+ // the source no longer holds, exhausts, and lets the terminal resolver
223
+ // read the source's honest "gone" as grounds to delete rows that are
224
+ // correct — taking spamReport, classificationState, category and the Undo
225
+ // target's `originalMailboxId` with them, none of which the resync
226
+ // re-projection can rebuild.
227
+ // An operation this build does not recognise gets no verdict here: it falls
228
+ // through to `abandonDelete` below, which is the designed refusal. Skipping
229
+ // it as "already settled" would swallow the very event the refusal exists to
230
+ // report.
231
+ const outstandingStatus =
232
+ operation === "move_to_trash"
233
+ ? MessageStatus.moving
234
+ : operation === "permanent_delete"
235
+ ? MessageStatus.deleting
236
+ : undefined;
237
+ if (outstandingStatus !== undefined && message.status !== outstandingStatus) {
238
+ log.info(
239
+ {
240
+ accountId,
241
+ messageId,
242
+ operation,
243
+ uid: message.uid,
244
+ status: message.status,
245
+ syncStatus: message.syncStatus,
246
+ },
247
+ "Skipping MESSAGE_DELETE: the row no longer carries the mutation this event was enqueued for",
248
+ );
249
+ return;
250
+ }
251
+
199
252
  /**
200
253
  * Re-read the folders this delete touched. A move to trash has two; a
201
254
  * permanent delete has only the source, and it is owed one just as much —
@@ -271,6 +324,24 @@ export const handleMessageDelete = async (
271
324
  }
272
325
 
273
326
  if (localRowsRemoved) {
327
+ // Removing the Message row is destructive and unpredicated on its own,
328
+ // so it is claimed first: the row is transitioned to `deleted` from a
329
+ // placement this delete actually owns, and removed only if that claim
330
+ // wins (imap-mutations R3). A row nothing is deleting — an unrecognised
331
+ // operation reaches this refusal on whatever the row happens to be —
332
+ // is left where it is rather than destroyed on the way past.
333
+ const claimed = await messageService.transitionPlacement(
334
+ messageId,
335
+ { status: [MessageStatus.moving, MessageStatus.deleting] },
336
+ { status: MessageStatus.deleted },
337
+ );
338
+ if (!claimed) {
339
+ log.warn(
340
+ { ...context, status: message.status },
341
+ "Delete given up with no listing rows left, and no delete outstanding on the row; the local row was left alone",
342
+ );
343
+ return;
344
+ }
274
345
  await messageService.delete(messageId);
275
346
  return;
276
347
  }
@@ -283,6 +354,12 @@ export const handleMessageDelete = async (
283
354
  sourceMailboxId: mailboxId,
284
355
  uid,
285
356
  syncStatus,
357
+ // Every hand-back this handler makes is a delete's, so the mutation
358
+ // follows the give-up rather than being passed in beside it.
359
+ abandonedMutation:
360
+ syncStatus === MessageSyncStatus.abandoned
361
+ ? MessageMutation.delete
362
+ : MessageMutation.none,
286
363
  threadMessages,
287
364
  },
288
365
  );
@@ -294,11 +371,12 @@ export const handleMessageDelete = async (
294
371
  // "anything that is not move_to_trash is an expunge" inference is the same
295
372
  // one that destroyed mail in the service, and an unrecoverable EXPUNGE is
296
373
  // not a default. Abandoning hands the row back where the server still has
297
- // it: an invisible `failed` on a row the user cannot see is the shape of
298
- // the incident this whole change is about. The row keeps `failed`, because
299
- // the product refused something the user asked for.
374
+ // it: an invisible failure on a row the user cannot see is the shape of the
375
+ // incident this whole change is about. The row carries `abandoned` — the
376
+ // give-up value, not the transient one a redelivery follows (R3) — because
377
+ // the product refused something the user asked for and nothing is coming.
300
378
  const abandonDelete = (reason: string, alert: string): Promise<void> =>
301
- handBackDelete(MessageSyncStatus.failed, { alert }, reason);
379
+ handBackDelete(MessageSyncStatus.abandoned, { alert }, reason);
302
380
 
303
381
  const settleTrashMoveConfirmed = async (
304
382
  newUid: number,
@@ -386,7 +464,7 @@ export const handleMessageDelete = async (
386
464
 
387
465
  if (unproven) {
388
466
  await handBackDelete(
389
- MessageSyncStatus.failed,
467
+ MessageSyncStatus.abandoned,
390
468
  { alert: "message_delete_paused_placement_unproven" },
391
469
  placement.kind === "at-destination"
392
470
  ? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
@@ -751,9 +829,11 @@ export const handleMessageDelete = async (
751
829
  // Transient failure — connections drop. No alarm; redelivery
752
830
  // retries, and `failed` marks the row unsettled meanwhile. It is
753
831
  // not a terminal signal: only the resolver below settles anything.
754
- await messageService.update(messageId, {
755
- syncStatus: MessageSyncStatus.failed,
756
- });
832
+ await messageService.transitionPlacement(
833
+ messageId,
834
+ { status: [MessageStatus.moving, MessageStatus.deleting] },
835
+ { syncStatus: MessageSyncStatus.failed },
836
+ );
757
837
  throw error;
758
838
  }
759
839
 
@@ -4,6 +4,10 @@ import type {
4
4
  IMessageRepository,
5
5
  IThreadMessageRepository,
6
6
  } from "@remit/data-ports";
7
+ import {
8
+ hasAbandonedDelete,
9
+ hasAbandonedMove,
10
+ } from "@remit/data-ports/message-settlement";
7
11
  import {
8
12
  type IImapConnection,
9
13
  placementBindingOf,
@@ -75,8 +79,10 @@ interface ThreadRow {
75
79
  * The move's pending state IS the Message row, so these fakes hold real rows
76
80
  * and the assertions read the rows back — a resolver that settled the row on
77
81
  * anything but the server's own answer shows up here as a changed row, not as
78
- * an uncalled mock. `updateForMove` writes what the repository writes: whatever
79
- * fields the caller set, and nothing it left out.
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.
80
86
  */
81
87
  const buildRepositories = (row: MessageRow) => {
82
88
  const messages = new Map<string, MessageRow>([[row.messageId, row]]);
@@ -104,11 +110,21 @@ const buildRepositories = (row: MessageRow) => {
104
110
  const current = messages.get(messageId);
105
111
  if (current) messages.set(messageId, { ...current, ...input });
106
112
  },
107
- updateForMove: async (messageId: string, set: Partial<MessageRow>) => {
113
+ transitionPlacement: async (
114
+ messageId: string,
115
+ expected: { status?: string[] },
116
+ next: Partial<MessageRow>,
117
+ ) => {
108
118
  const current = messages.get(messageId);
109
- if (current) messages.set(messageId, { ...current, ...set });
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;
110
126
  },
111
- } as unknown as Pick<IMessageRepository, "delete" | "updateForMove">,
127
+ } as unknown as Pick<IMessageRepository, "delete" | "transitionPlacement">,
112
128
  threadMessageService: {
113
129
  findAllByMessageId: async () => [...threadMessages.values()],
114
130
  deleteMany: async (
@@ -204,9 +220,42 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
204
220
  assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
205
221
  });
206
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
+
207
256
  // Issue #1005: a give-up that never writes `status` leaves the row naming
208
- // the destination with the source's uid — a pair `bindsForeignUid` refuses,
209
- // which made the message undeletable and unmovable for good.
257
+ // the destination with the source's uid — a pair every dependent mutation
258
+ // refuses, which made the message undeletable and unmovable for good.
210
259
  it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
211
260
  const repos = buildRepositories(pendingMoveRow());
212
261
  const { log } = buildLogger();
@@ -218,8 +267,8 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
218
267
 
219
268
  assert.equal(
220
269
  placementBindingOf(pendingMoveRow() as never),
221
- "abandoned",
222
- "the row starts in the state the delete route refuses as unverified",
270
+ "in_flight",
271
+ "the row starts on a pair the delete route will not act on",
223
272
  );
224
273
 
225
274
  await resolveExhaustedMessageMoveFailure(deps, {
@@ -257,21 +306,17 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
257
306
  });
258
307
 
259
308
  // The resolver's contract is that it is never re-thrown, so the caller can
260
- // ack the record and emit its resync. A row another path deleted while the
261
- // probe was in flight has nothing left to restore, and the same NotFound
262
- // wraps an ElectroDB composites miss on the listing row.
263
- it("BROKEN: a row deleted underneath the probe settles instead of throwing", async () => {
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 () => {
264
314
  const repos = buildRepositories(pendingMoveRow());
265
- const notFound = Object.assign(new Error("Message not found: msg-1"), {
266
- name: "NotFoundError",
267
- });
268
315
  const { log } = buildLogger();
269
316
  const deps: ResolveExhaustedMessageMoveDeps = {
270
317
  messageService: {
271
318
  ...repos.messageService,
272
- updateForMove: async () => {
273
- throw notFound;
274
- },
319
+ transitionPlacement: async () => undefined,
275
320
  } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
276
321
  threadMessageService: repos.threadMessageService,
277
322
  log,
@@ -320,7 +365,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
320
365
  const deps: ResolveExhaustedMessageMoveDeps = {
321
366
  messageService: {
322
367
  ...repos.messageService,
323
- updateForMove: async () => {
368
+ transitionPlacement: async () => {
324
369
  throw new Error("ProvisionedThroughputExceeded");
325
370
  },
326
371
  } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
@@ -2,14 +2,14 @@ import type {
2
2
  IMessageRepository,
3
3
  IThreadMessageRepository,
4
4
  } from "@remit/data-ports";
5
- import { MessageSyncStatus } from "@remit/domain-enums";
5
+ import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
6
6
  import {
7
7
  type IImapConnection,
8
8
  isMessageGoneFromOpenMailbox,
9
9
  reconcileStaleMessage,
10
+ restoreSourcePlacement,
10
11
  type StaleMessageReconcileDeps,
11
12
  } from "@remit/mailbox-service";
12
- import { restoreSourcePlacement } from "./restore-source-placement.js";
13
13
 
14
14
  export interface MessageMoveTerminalLogger {
15
15
  info(obj: Record<string, unknown>, msg: string): void;
@@ -18,7 +18,7 @@ export interface MessageMoveTerminalLogger {
18
18
 
19
19
  export interface ResolveExhaustedMessageMoveDeps
20
20
  extends StaleMessageReconcileDeps {
21
- messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
21
+ messageService: Pick<IMessageRepository, "delete" | "transitionPlacement">;
22
22
  threadMessageService: Pick<
23
23
  IThreadMessageRepository,
24
24
  "findAllByMessageId" | "deleteMany" | "update"
@@ -149,9 +149,13 @@ export const resolveExhaustedMessageMoveFailure = async (
149
149
  sourceMailboxId,
150
150
  uid,
151
151
  // The probe above just confirmed the pair, so the row is a faithful
152
- // projection of the source again. The move's own failure is the alert, not
153
- // a `failed` left on a row nothing is still trying to move.
154
- syncStatus: MessageSyncStatus.synced,
152
+ // projection of the source again — and the move the user asked for did
153
+ // not happen, with nothing left to try it. `abandoned` is the value that
154
+ // says so without claiming a retry is coming (imap-mutations R3); the
155
+ // alert stays for the operator, and the mutation names itself so the
156
+ // reading pane offers the right way out (issue #1229).
157
+ syncStatus: MessageSyncStatus.abandoned,
158
+ abandonedMutation: MessageMutation.move,
155
159
  });
156
160
 
157
161
  return { outcome: "broken" };
@@ -277,7 +277,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
277
277
  get: async () => [],
278
278
  update: async () => undefined,
279
279
  updateUid: async () => undefined,
280
- updateForMove: async () => undefined,
280
+ transitionPlacement: async () => undefined,
281
281
  },
282
282
  threadMessage: {
283
283
  findAllByMessageId: async () => [],
@@ -410,7 +410,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
410
410
  const arrangePausedMove = async (
411
411
  options: { probeable: boolean } = { probeable: true },
412
412
  ): Promise<{
413
- updateForMoveCalls: unknown[][];
413
+ transitionCalls: unknown[][];
414
414
  updateUidCalls: unknown[][];
415
415
  threadUpdateCalls: unknown[][];
416
416
  }> => {
@@ -424,10 +424,15 @@ describe("handleMessageMove — the move's own pending state gates every attempt
424
424
  },
425
425
  ]);
426
426
  mock.method(client.mailbox, "get", async () => pausedSource());
427
- const updateForMoveCalls: unknown[][] = [];
428
- mock.method(client.message, "updateForMove", async (...args: unknown[]) => {
429
- updateForMoveCalls.push(args);
430
- });
427
+ const transitionCalls: unknown[][] = [];
428
+ mock.method(
429
+ client.message,
430
+ "transitionPlacement",
431
+ async (...args: unknown[]) => {
432
+ transitionCalls.push(args);
433
+ return { messageId: "mm-msg-zzz" };
434
+ },
435
+ );
431
436
  const updateUidCalls: unknown[][] = [];
432
437
  mock.method(client.message, "updateUid", async (...args: unknown[]) => {
433
438
  updateUidCalls.push(args);
@@ -446,7 +451,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
446
451
  mock.method(client.threadMessage, "update", async (...args: unknown[]) => {
447
452
  threadUpdateCalls.push(args);
448
453
  });
449
- return { updateForMoveCalls, updateUidCalls, threadUpdateCalls };
454
+ return { transitionCalls, updateUidCalls, threadUpdateCalls };
450
455
  };
451
456
 
452
457
  // Issue #1203. Acking a paused cursor left the row `moving` with `mailboxId`
@@ -456,7 +461,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
456
461
  // neither folder's set: `placementBindingOf` answered `in_flight` for good
457
462
  // and the message became unmovable and undeletable.
458
463
  it("hands the row back to its source on a first delivery, without connecting", async () => {
459
- const { updateForMoveCalls, threadUpdateCalls } = await arrangePausedMove();
464
+ const { transitionCalls, threadUpdateCalls } = await arrangePausedMove();
460
465
 
461
466
  await handleMessageMove(event, silentLogger, 1, moveDeps());
462
467
 
@@ -466,14 +471,16 @@ describe("handleMessageMove — the move's own pending state gates every attempt
466
471
  "a first delivery has provably issued no MOVE, so it needs no answer from the server",
467
472
  );
468
473
  assert.deepEqual(
469
- updateForMoveCalls[0],
474
+ transitionCalls[0],
470
475
  [
471
476
  "mm-msg-zzz",
477
+ { status: ["moving", "deleting"] },
472
478
  {
473
479
  mailboxId: "mm-src-zzz",
474
480
  uid: 10,
475
481
  status: "active",
476
482
  syncStatus: "synced",
483
+ abandonedMutation: "none",
477
484
  },
478
485
  ],
479
486
  "the row goes back to the source pair, which is the set the rebuild adjudicates",
@@ -509,7 +516,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
509
516
  // binds the row to that copy, and the next permanent delete expunges it:
510
517
  // Archive is not paused, so no rebuild is ever coming to adjudicate there.
511
518
  it("never settles a redelivered paused move as moved off a destination sighting", async () => {
512
- const { updateForMoveCalls, updateUidCalls } = await arrangePausedMove();
519
+ const { transitionCalls, updateUidCalls } = await arrangePausedMove();
513
520
 
514
521
  await handleMessageMove(
515
522
  event,
@@ -525,11 +532,12 @@ describe("handleMessageMove — the move's own pending state gates every attempt
525
532
  );
526
533
  // The source pair is the set its own cursor rebuild walks and adjudicates
527
534
  // by Message-ID, so handing the row back there is the reconcile path.
528
- assert.deepEqual(updateForMoveCalls[0]?.[1], {
535
+ assert.deepEqual(transitionCalls[0]?.[2], {
529
536
  mailboxId: "mm-src-zzz",
530
537
  uid: 10,
531
538
  status: "active",
532
- syncStatus: "failed",
539
+ syncStatus: "abandoned",
540
+ abandonedMutation: "move",
533
541
  });
534
542
  assert.equal(
535
543
  logLines.filter(
@@ -545,7 +553,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
545
553
  // redelivery is broken, not settled. With no Message-ID header neither
546
554
  // folder was asked, and silence is not an answer to write `synced` on.
547
555
  it("settles a redelivered paused move broken when neither folder can be asked", async () => {
548
- const { updateForMoveCalls } = await arrangePausedMove({
556
+ const { transitionCalls } = await arrangePausedMove({
549
557
  probeable: false,
550
558
  });
551
559
 
@@ -557,8 +565,8 @@ describe("handleMessageMove — the move's own pending state gates every attempt
557
565
  );
558
566
 
559
567
  assert.equal(
560
- (updateForMoveCalls[0]?.[1] as { syncStatus?: string })?.syncStatus,
561
- "failed",
568
+ (transitionCalls[0]?.[2] as { syncStatus?: string })?.syncStatus,
569
+ "abandoned",
562
570
  );
563
571
  assert.equal(
564
572
  logLines.filter(
@@ -589,7 +597,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
589
597
  // this message. The source is asked first for exactly that reason, and its
590
598
  // answer ends it.
591
599
  it("never binds a redelivered paused move to an older copy while the source still answers", async () => {
592
- const { updateForMoveCalls, updateUidCalls } = await arrangePausedMove();
600
+ const { transitionCalls, updateUidCalls } = await arrangePausedMove();
593
601
 
594
602
  await handleMessageMove(
595
603
  event,
@@ -599,11 +607,12 @@ describe("handleMessageMove — the move's own pending state gates every attempt
599
607
  );
600
608
 
601
609
  assert.equal(updateUidCalls.length, 0);
602
- assert.deepEqual(updateForMoveCalls[0]?.[1], {
610
+ assert.deepEqual(transitionCalls[0]?.[2], {
603
611
  mailboxId: "mm-src-zzz",
604
612
  uid: 10,
605
613
  status: "active",
606
614
  syncStatus: "synced",
615
+ abandonedMutation: "none",
607
616
  });
608
617
  });
609
618
 
@@ -1,25 +1,28 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
3
- import { MessageSyncStatus } from "@remit/domain-enums";
3
+ import { isNotFoundError } from "@remit/data-ports/errors";
4
+ import {
5
+ MessageMutation,
6
+ MessageStatus,
7
+ MessageSyncStatus,
8
+ } from "@remit/domain-enums";
4
9
  import type { Logger } from "@remit/logger-lambda";
5
10
  import { recordImapFailure } from "@remit/logger-lambda";
6
11
  import {
7
12
  guardConnectionCursor,
8
13
  type IImapConnection,
9
14
  isCursorRebuildNeeded,
10
- isPlacementUnsettled,
11
15
  MailboxCursorPausedError,
16
+ restoreSourcePlacement,
12
17
  } from "@remit/mailbox-service";
13
18
  import { attemptBudget } from "@remit/sqs-client/attempt-budget";
14
19
  import { isAccountDeleted } from "../account-check.js";
15
20
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
16
21
  import { emitEvent } from "../emit.js";
17
22
  import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
18
- import { isNotFoundError } from "../is-not-found.js";
19
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
20
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
21
25
  import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
22
- import { restoreSourcePlacement } from "./restore-source-placement.js";
23
26
 
24
27
  export const getMessageMoveMaxAttempts = (
25
28
  processEnv: NodeJS.ProcessEnv = process.env,
@@ -323,7 +326,12 @@ export const handleMessageMove = async (
323
326
  // "gone" as grounds to reconcile away a row that is correct. There is no
324
327
  // marker to find missing (unlike FLAG_PUSH and PLACEMENT_MOVE_PUSH), so the
325
328
  // row's own pending marker is what stands in for one.
326
- if (!isPlacementUnsettled(message)) {
329
+ //
330
+ // `moving` specifically, not the shared unsettled predicate: the question is
331
+ // whether THIS move is still outstanding, and a row a delete has since
332
+ // claimed is `deleting` — work that belongs to MESSAGE_DELETE, and a state
333
+ // this move must not read as its own to finish.
334
+ if (message.status !== MessageStatus.moving) {
327
335
  log.info(
328
336
  { accountId, messageId, uid: message.uid, status: message.status },
329
337
  "Skipping MESSAGE_MOVE: the move already settled against confirmed IMAP state",
@@ -430,6 +438,13 @@ export const handleMessageMove = async (
430
438
  sourceMailboxId,
431
439
  uid,
432
440
  syncStatus,
441
+ // Every hand-back this handler makes is a move's. Naming it on
442
+ // the row is what stops the reading pane calling a move that
443
+ // gave up a failed delete (issue #1229).
444
+ abandonedMutation:
445
+ syncStatus === MessageSyncStatus.abandoned
446
+ ? MessageMutation.move
447
+ : MessageMutation.none,
433
448
  },
434
449
  );
435
450
  };
@@ -451,7 +466,7 @@ export const handleMessageMove = async (
451
466
  mailbox,
452
467
  );
453
468
 
454
- // A paused cursor is never acked on the optimistic row: `updateForMove`
469
+ // A paused cursor is never acked on the optimistic row: the transition
455
470
  // has pointed it at the destination while `uid` still names the source,
456
471
  // and that pair strands the row for good — the cursor rebuild matches
457
472
  // rows by `(accountConfigId, mailboxId)`, so a row naming the
@@ -499,7 +514,7 @@ export const handleMessageMove = async (
499
514
 
500
515
  if (unproven) {
501
516
  await handBackToSource(
502
- MessageSyncStatus.failed,
517
+ MessageSyncStatus.abandoned,
503
518
  { alert: "message_move_paused_placement_unproven" },
504
519
  placement.kind === "at-destination"
505
520
  ? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
@@ -637,9 +652,11 @@ export const handleMessageMove = async (
637
652
  // alarm; queue redelivery retries, and `failed` marks the row
638
653
  // as unsettled meanwhile. It is not a terminal signal: only the
639
654
  // resolver below settles anything.
640
- await messageService.update(messageId, {
641
- syncStatus: MessageSyncStatus.failed,
642
- });
655
+ await messageService.transitionPlacement(
656
+ messageId,
657
+ { status: MessageStatus.moving },
658
+ { syncStatus: MessageSyncStatus.failed },
659
+ );
643
660
  throw error;
644
661
  }
645
662
 
@@ -1,4 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
3
  import type { Logger } from "@remit/logger-lambda";
3
4
  import { recordImapFailure } from "@remit/logger-lambda";
4
5
  import {
@@ -15,7 +16,6 @@ import { isAccountDeleted } from "../account-check.js";
15
16
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
16
17
  import { emitEvent } from "../emit.js";
17
18
  import type { PlacementMovePushEvent } from "../events.js";
18
- import { isNotFoundError } from "../is-not-found.js";
19
19
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
20
20
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
21
21
  import {
@@ -399,6 +399,7 @@ export const handlePlacementMovePush = async (
399
399
  accountConfigId,
400
400
  messageId,
401
401
  uid: message.uid,
402
+ sourceMailboxId: marker.sourceMailboxId,
402
403
  sourceMailboxPath: sourceMailbox.fullPath,
403
404
  getConnection: scope.getConnection,
404
405
  },
@@ -1,4 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
2
3
  import type { Logger } from "@remit/logger-lambda";
3
4
  import { recordImapFailure } from "@remit/logger-lambda";
4
5
  import {
@@ -11,6 +12,7 @@ import {
11
12
  QuarantineService,
12
13
  resolveExhaustedBodySyncFailures,
13
14
  } from "@remit/mailbox-service";
15
+ import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
14
16
  import { attemptBudget } from "@remit/sqs-client/attempt-budget";
15
17
  import { env } from "expect-env";
16
18
  import { isAccountDeleted } from "../account-check.js";
@@ -20,8 +22,6 @@ import {
20
22
  createConnectionScopeWithCredentials,
21
23
  } from "../connection-scope.js";
22
24
  import type { SyncMessageBodyEvent } from "../events.js";
23
- import { buildFilterConfig } from "../filter-config.js";
24
- import { isNotFoundError } from "../is-not-found.js";
25
25
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
26
26
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
27
27
  import { workerVersion } from "../worker-version.js";
@@ -13,6 +13,7 @@ import type {
13
13
  IThreadMessageRepository,
14
14
  IUnitOfWork,
15
15
  } from "@remit/data-ports";
16
+ import { isNotFoundError } from "@remit/data-ports/errors";
16
17
  import { SyncPhase } from "@remit/domain-enums";
17
18
  import { type Logger, recordImapFailure } from "@remit/logger-lambda";
18
19
  import { RefreshTokenError } from "@remit/mail-oauth-service";
@@ -33,7 +34,6 @@ import type {
33
34
  SyncMessageBodyEvent,
34
35
  SyncMessagesEvent,
35
36
  } from "../events.js";
36
- import { isNotFoundError } from "../is-not-found.js";
37
37
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
38
38
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
39
39
  import { workerVersion } from "../worker-version.js";