@remit/imap-worker 0.0.68 → 0.0.70

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.
@@ -2,9 +2,10 @@ import { getClient } from "@remit/backend/client";
2
2
  import type {
3
3
  IMessageRepository,
4
4
  IThreadMessageRepository,
5
+ MessageItem,
5
6
  } from "@remit/data-ports";
6
7
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
7
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
8
+ import { MessageSyncStatus } from "@remit/domain-enums";
8
9
  import type { Logger } from "@remit/logger-lambda";
9
10
  import { recordImapFailure } from "@remit/logger-lambda";
10
11
  import {
@@ -24,13 +25,14 @@ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
25
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
25
26
  import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
26
27
  import {
28
+ emitMailboxResync,
27
29
  emitMoveResync,
30
+ type PausedPlacement,
31
+ probePausedPlacement,
28
32
  searchMailboxForHighestMessageIdUid,
29
33
  } from "./message-move.js";
30
- import {
31
- buildThreadMessageMoveRevert,
32
- buildThreadMessageTrashUpdate,
33
- } from "./thread-message-rows.js";
34
+ import { restoreSourcePlacement } from "./restore-source-placement.js";
35
+ import { buildThreadMessageTrashUpdate } from "./thread-message-rows.js";
34
36
 
35
37
  export const getMessageDeleteMaxAttempts = (
36
38
  processEnv: NodeJS.ProcessEnv = process.env,
@@ -194,6 +196,98 @@ export const handleMessageDelete = async (
194
196
  return;
195
197
  }
196
198
 
199
+ /**
200
+ * Re-read the folders this delete touched. A move to trash has two; a
201
+ * permanent delete has only the source, and it is owed one just as much —
202
+ * the local rows are gone while the server copy survives, so with no resync
203
+ * the mail is invisible until the next scheduled sync. SYNC_MESSAGES on a
204
+ * mailbox whose cursor is not normal routes to the rebuild, which is what
205
+ * re-projects the rows this handler removed (`message-sync.ts`).
206
+ */
207
+ const emitSettleResync = async (): Promise<void> => {
208
+ if (destinationMailboxId) {
209
+ await emitMoveResync(emitEvent, {
210
+ accountId,
211
+ sourceMailboxId: mailboxId,
212
+ destinationMailboxId,
213
+ });
214
+ return;
215
+ }
216
+ await emitMailboxResync(emitEvent, { accountId, mailboxId });
217
+ };
218
+
219
+ /**
220
+ * Undo the optimistic local delete, for every reason this handler has to
221
+ * give one up, and say so exactly once. The verdict differs — a refused
222
+ * event is a failure the row carries, a paused cursor is a mutation the
223
+ * server never heard of — but the way back is one rule, and it is the
224
+ * listing rows that pick it.
225
+ *
226
+ * Rows still there is the ordinary move to trash: the row goes back on the
227
+ * source pair, which is also the set the cursor rebuild adjudicates.
228
+ *
229
+ * No rows left is a permanent delete, which drops them at enqueue; they are
230
+ * denormalized off an envelope only the sync path shapes and cannot be
231
+ * rebuilt from here, so restoring the Message alone would leave mail nothing
232
+ * can list — the silent vanish rather than a visible failure. The local
233
+ * removal finishes instead. Nothing was expunged, so the server copy
234
+ * survives and the mailbox's own sync re-projects it.
235
+ *
236
+ * `alert` is a refusal or a placement the server never settled, both of
237
+ * which an operator adjudicates. `metric` is the routine pause the guard doc
238
+ * calls never-a-fault: the user's delete was silently dropped, worth
239
+ * counting and not worth waking anyone for. Either way `localRowsRemoved`
240
+ * keeps the disappearance of local mail visible.
241
+ */
242
+ const handBackDelete = async (
243
+ syncStatus: MessageItem["syncStatus"],
244
+ signal: { alert: string } | { metric: string },
245
+ reason: string,
246
+ ): Promise<void> => {
247
+ const threadMessages = await threadMessageService.findAllByMessageId(
248
+ account.accountConfigId,
249
+ messageId,
250
+ );
251
+ const localRowsRemoved = threadMessages.length === 0;
252
+
253
+ const context = {
254
+ ...signal,
255
+ accountId,
256
+ messageId,
257
+ uid,
258
+ mailboxPath,
259
+ operation,
260
+ receiveCount,
261
+ reason,
262
+ localRowsRemoved,
263
+ };
264
+ const summary = localRowsRemoved
265
+ ? "Delete given up with no listing rows left to restore; the local row was removed and the server copy was not expunged"
266
+ : "Delete given up; the row was handed back to its source pair";
267
+ if ("alert" in signal) {
268
+ log.error(context, summary);
269
+ } else {
270
+ log.warn(context, summary);
271
+ }
272
+
273
+ if (localRowsRemoved) {
274
+ await messageService.delete(messageId);
275
+ return;
276
+ }
277
+
278
+ await restoreSourcePlacement(
279
+ { messageService, threadMessageService },
280
+ {
281
+ accountConfigId: account.accountConfigId,
282
+ messageId,
283
+ sourceMailboxId: mailboxId,
284
+ uid,
285
+ syncStatus,
286
+ threadMessages,
287
+ },
288
+ );
289
+ };
290
+
197
291
  // Only an operation that explicitly says so destroys mail. The event is
198
292
  // `JSON.parse`d and cast in the queue handler with no validation, so a
199
293
  // missing, misspelled or future field must abandon the delete — the
@@ -201,51 +295,27 @@ export const handleMessageDelete = async (
201
295
  // one that destroyed mail in the service, and an unrecoverable EXPUNGE is
202
296
  // not a default. Abandoning hands the row back where the server still has
203
297
  // it: an invisible `failed` on a row the user cannot see is the shape of
204
- // the incident this whole change is about.
205
- const abandonDelete = async (
206
- reason: string,
207
- alert: string,
298
+ // the incident this whole change is about. The row keeps `failed`, because
299
+ // the product refused something the user asked for.
300
+ const abandonDelete = (reason: string, alert: string): Promise<void> =>
301
+ handBackDelete(MessageSyncStatus.failed, { alert }, reason);
302
+
303
+ const settleTrashMoveConfirmed = async (
304
+ newUid: number,
305
+ trashMailboxId: string,
208
306
  ): Promise<void> => {
209
- log.error(
210
- { alert, accountId, messageId, uid, mailboxPath, operation },
211
- reason,
212
- );
307
+ await messageService.updateUid(messageId, newUid, trashMailboxId);
213
308
 
214
- const threadMessages = await threadMessageService.findAllByMessageId(
309
+ const threadMessage = await threadMessageService.findByMessageId(
215
310
  account.accountConfigId,
216
311
  messageId,
217
312
  );
218
-
219
- // A permanent delete removes the listing rows before it enqueues, and
220
- // they cannot be rebuilt from here — the row is denormalized off an
221
- // envelope only the sync path shapes. Restoring the Message alone would
222
- // leave mail nothing can list, which is the silent vanish rather than a
223
- // visible failure, so the local removal finishes instead. The server copy
224
- // survives (nothing was expunged) and a full sync of the mailbox brings
225
- // it back. Reachable only through the rollout window, where a v1 event
226
- // carries no `schemaVersion`.
227
- if (threadMessages.length === 0) {
228
- log.error(
229
- {
230
- alert: "message_delete_abandoned_after_local_cleanup",
231
- accountId,
232
- messageId,
233
- uid,
234
- mailboxPath,
235
- },
236
- "Abandoned delete had no listing rows left to restore; the server copy was not expunged",
313
+ if (threadMessage) {
314
+ const args = buildThreadMessageTrashUpdate(
315
+ threadMessage,
316
+ newUid,
317
+ trashMailboxId,
237
318
  );
238
- await messageService.delete(messageId);
239
- return;
240
- }
241
-
242
- await messageService.updateUid(messageId, uid, mailboxId);
243
- await messageService.update(messageId, {
244
- status: MessageStatus.active,
245
- syncStatus: MessageSyncStatus.failed,
246
- });
247
- for (const threadMessage of threadMessages) {
248
- const args = buildThreadMessageMoveRevert(threadMessage, uid, mailboxId);
249
319
  await threadMessageService.update(
250
320
  threadMessage.accountConfigId,
251
321
  threadMessage.threadMessageId,
@@ -253,11 +323,93 @@ export const handleMessageDelete = async (
253
323
  { composites: args.composites },
254
324
  );
255
325
  }
326
+
327
+ log.info({ messageId, newUid }, "Message moved to trash");
328
+ };
329
+
330
+ /**
331
+ * Settle the optimistic local delete when a paused cursor stops this round
332
+ * from issuing it. Acking on the optimistic row strands it: nothing
333
+ * re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows by
334
+ * `(accountConfigId, mailboxId)`, so it never even sees a move-to-trash row
335
+ * that already names Trash (issue #1203).
336
+ *
337
+ * The delete therefore reconciles rather than waits (R2,
338
+ * docs/architecture/imap-mutations.md): the row is settled onto whichever
339
+ * pair the server can be shown to hold, and the resync plus the source's own
340
+ * cursor rebuild are its repair path.
341
+ *
342
+ * Undoing the local write claims nothing about the server only where the
343
+ * command provably never left. `commandIssued` says what this round did;
344
+ * `receiveCount` adds what an earlier one may have done and lost with its
345
+ * connection. `receiveCount === 1` alone is not that proof: an unprobeable
346
+ * confirmation reaches {@link settleExhaustedDelete} on the first attempt,
347
+ * with the MOVE already issued and the source already saying it no longer
348
+ * holds the message. Where either says the server may have been asked, it is
349
+ * asked again — {@link probePausedPlacement}, on the unguarded handle. A
350
+ * permanent delete has nothing to ask: its answer is the same either way,
351
+ * since the row is unlistable and goes.
352
+ */
353
+ const settlePausedDelete = async (
354
+ getRawConnection: () => Promise<IImapConnection>,
355
+ commandIssued: boolean,
356
+ ): Promise<void> => {
357
+ const mayHaveIssued = commandIssued || receiveCount > 1;
358
+ const isProbeableTrashMove =
359
+ mayHaveIssued &&
360
+ operation === "move_to_trash" &&
361
+ destinationMailboxId !== undefined &&
362
+ destinationMailboxPath !== undefined;
363
+
364
+ const placement: PausedPlacement = isProbeableTrashMove
365
+ ? await probePausedPlacement(await getRawConnection(), {
366
+ messageIdHeader: (await messageService.get([messageId]))[0]
367
+ ?.messageIdHeader,
368
+ sourceMailboxPath: mailboxPath,
369
+ destinationMailboxPath,
370
+ })
371
+ : { kind: "at-source" };
372
+
373
+ // A sighting in Trash is not proof this delete ran. The pause means the
374
+ // source's UIDVALIDITY has moved, so its "no" can be a recreated folder
375
+ // answering about mail it never held, and the hit can be an older copy of
376
+ // the same Message-ID — a sieve `fileinto` + `keep`, a resend — that
377
+ // `deriveMessageId` folds into this row; settling `moved` on it hands
378
+ // Empty Trash a stranger's uid. `unprobeable` is silence, and silence
379
+ // settles nothing either (the rule `message-copy.ts` already applies).
380
+ // Both hand the row back on the source pair carrying `failed`: that is the
381
+ // set the source's own cursor rebuild walks and adjudicates by Message-ID,
382
+ // which is the reconcile path (R2) Trash — not paused, no rebuild coming —
383
+ // does not have.
384
+ const unproven =
385
+ placement.kind === "at-destination" || placement.kind === "unprobeable";
386
+
387
+ if (unproven) {
388
+ await handBackDelete(
389
+ MessageSyncStatus.failed,
390
+ { alert: "message_delete_paused_placement_unproven" },
391
+ placement.kind === "at-destination"
392
+ ? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
393
+ : "no Message-ID header to ask either folder with",
394
+ );
395
+ } else {
396
+ // `gone` lands here with `at-source`: the source pair is the set the
397
+ // rebuild walks, and a row it cannot match against a fresh envelope
398
+ // snapshot is the one thing it reconciles away.
399
+ await handBackDelete(
400
+ MessageSyncStatus.synced,
401
+ { metric: "message_delete_dropped_on_pause" },
402
+ `source mailbox cursor paused, placement ${placement.kind}`,
403
+ );
404
+ }
405
+
406
+ await emitSettleResync();
256
407
  };
257
408
 
258
409
  const settleExhaustedDelete = async (
259
410
  accountConfigId: string,
260
411
  getConnection: () => Promise<IImapConnection>,
412
+ settlePaused: () => Promise<void>,
261
413
  ): Promise<void> => {
262
414
  const settled = await resolveExhaustedMessageDeleteFailure(
263
415
  { messageService, threadMessageService, log },
@@ -270,17 +422,19 @@ export const handleMessageDelete = async (
270
422
  sourceMailboxPath: mailboxPath,
271
423
  getConnection,
272
424
  },
273
- ).catch((settleError: unknown) => {
274
- // The guarded probe found a mailbox whose UIDVALIDITY has moved. Every
275
- // stored uid for this folder now names something else, so there is
276
- // nothing here that may be settled off them, and the pause is the
277
- // routine skip `guardMailboxCursor` documents, not a fault to re-throw
278
- // out of the caller's catch.
425
+ ).catch(async (settleError: unknown) => {
426
+ // The guarded probe found a mailbox whose UIDVALIDITY has moved. Nothing
427
+ // may be settled off a uid on a dead axis, and the pause is the routine
428
+ // skip `guardMailboxCursor` documents, not a fault to re-throw out of
429
+ // the caller's catch. The delete is still settled, on the identity axis
430
+ // instead: the paused settle asks by Message-ID, which a UIDVALIDITY
431
+ // change does not invalidate.
279
432
  if (settleError instanceof MailboxCursorPausedError) {
280
433
  log.info(
281
434
  { accountId, messageId, mailboxId, cursorState: settleError.state },
282
- "Mailbox cursor not normal; leaving the exhausted delete for the cursor rebuild",
435
+ "Mailbox cursor not normal; settling the exhausted delete against the server by Message-ID",
283
436
  );
437
+ await settlePaused();
284
438
  return null;
285
439
  }
286
440
  throw settleError;
@@ -298,13 +452,7 @@ export const handleMessageDelete = async (
298
452
  // rows are gone and the resync rebuilds them. BROKEN, the row has just
299
453
  // been put back at the source the server confirmed, and the resync is
300
454
  // what carries any drift either folder has picked up since.
301
- if (destinationMailboxId) {
302
- await emitMoveResync(emitEvent, {
303
- accountId,
304
- sourceMailboxId: mailboxId,
305
- destinationMailboxId,
306
- });
307
- }
455
+ await emitSettleResync();
308
456
  };
309
457
 
310
458
  /**
@@ -317,6 +465,7 @@ export const handleMessageDelete = async (
317
465
  confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
318
466
  accountConfigId: string,
319
467
  getConnection: () => Promise<IImapConnection>,
468
+ settlePaused: () => Promise<void>,
320
469
  ): Promise<void> => {
321
470
  const context = {
322
471
  accountId,
@@ -342,7 +491,7 @@ export const handleMessageDelete = async (
342
491
  context,
343
492
  "Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
344
493
  );
345
- await settleExhaustedDelete(accountConfigId, getConnection);
494
+ await settleExhaustedDelete(accountConfigId, getConnection, settlePaused);
346
495
  return;
347
496
  }
348
497
 
@@ -383,17 +532,6 @@ export const handleMessageDelete = async (
383
532
  return;
384
533
  }
385
534
 
386
- // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
387
- // paused never even opens a connection. Optimization only — the
388
- // guardConnectionCursor openBox wrap below is the structural guarantee.
389
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
390
- log.info(
391
- { accountId, messageId, mailboxId },
392
- "Mailbox cursor not normal; pausing outbound delete this round",
393
- );
394
- return;
395
- }
396
-
397
535
  const scope = createConnectionScopeWithCredentials(account, credentials);
398
536
 
399
537
  // The terminal resolver opens the source itself, and its answer now
@@ -411,6 +549,26 @@ export const handleMessageDelete = async (
411
549
  mailbox,
412
550
  );
413
551
 
552
+ const settlePaused = (commandIssued: boolean): Promise<void> =>
553
+ settlePausedDelete(scope.getConnection, commandIssued);
554
+
555
+ // Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
556
+ // downstream of it knows the server was asked.
557
+ let commandIssued = false;
558
+
559
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
560
+ // paused never opens a connection on a first delivery. Optimization
561
+ // only — the guardConnectionCursor openBox wrap below is the structural
562
+ // guarantee.
563
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
564
+ log.info(
565
+ { accountId, messageId, mailboxId },
566
+ "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
567
+ );
568
+ await settlePaused(false).finally(() => scope.disconnect());
569
+ return;
570
+ }
571
+
414
572
  await scope
415
573
  .getConnection()
416
574
  .then(async (rawConnection) => {
@@ -450,6 +608,7 @@ export const handleMessageDelete = async (
450
608
  destinationMailboxId
451
609
  ) {
452
610
  // Move to Trash
611
+ commandIssued = true;
453
612
  const result = await connection.moveMessages(
454
613
  [uid],
455
614
  destinationMailboxPath,
@@ -487,34 +646,10 @@ export const handleMessageDelete = async (
487
646
  });
488
647
 
489
648
  if (confirmation.outcome === "confirmed") {
490
- const newUid = confirmation.uid;
491
- // Update message with new UID in Trash
492
- await messageService.updateUid(
493
- messageId,
494
- newUid,
649
+ await settleTrashMoveConfirmed(
650
+ confirmation.uid,
495
651
  destinationMailboxId,
496
652
  );
497
-
498
- // Update ThreadMessage with new UID and isDeleted = true
499
- const threadMessage = await threadMessageService.findByMessageId(
500
- account.accountConfigId,
501
- messageId,
502
- );
503
- if (threadMessage) {
504
- const args = buildThreadMessageTrashUpdate(
505
- threadMessage,
506
- newUid,
507
- destinationMailboxId,
508
- );
509
- await threadMessageService.update(
510
- threadMessage.accountConfigId,
511
- threadMessage.threadMessageId,
512
- args.set,
513
- { composites: args.composites },
514
- );
515
- }
516
-
517
- log.info({ messageId, newUid }, "Message moved to trash");
518
653
  return;
519
654
  }
520
655
 
@@ -522,9 +657,11 @@ export const handleMessageDelete = async (
522
657
  confirmation,
523
658
  account.accountConfigId,
524
659
  getGuardedConnection,
660
+ () => settlePaused(true),
525
661
  );
526
662
  } else {
527
663
  // Permanent delete — reached only by `operation === "permanent_delete"`.
664
+ commandIssued = true;
528
665
  await connection.deleteMessages([uid]);
529
666
 
530
667
  // Delete ThreadMessage rows BEFORE the Message row to collapse the
@@ -551,8 +688,9 @@ export const handleMessageDelete = async (
551
688
  if (error instanceof MailboxCursorPausedError) {
552
689
  log.info(
553
690
  { accountId, messageId, mailboxId, cursorState: error.state },
554
- "Mailbox cursor not normal; pausing outbound delete this round",
691
+ "Mailbox cursor not normal; pausing outbound delete this round and settling the row against the server",
555
692
  );
693
+ await settlePaused(commandIssued);
556
694
  return;
557
695
  }
558
696
 
@@ -626,6 +764,7 @@ export const handleMessageDelete = async (
626
764
  await settleExhaustedDelete(
627
765
  account.accountConfigId,
628
766
  getGuardedConnection,
767
+ () => settlePaused(commandIssued),
629
768
  );
630
769
  log.error(
631
770
  { accountId, messageId, uid, mailboxPath, error: errorMessage },
@@ -75,8 +75,8 @@ interface ThreadRow {
75
75
  * The move's pending state IS the Message row, so these fakes hold real rows
76
76
  * and the assertions read the rows back — a resolver that settled the row on
77
77
  * anything but the server's own answer shows up here as a changed row, not as
78
- * an uncalled mock. `updateUid` writes what the repository writes: the settled
79
- * pair AND the `active`/`synced` that clears `moving`.
78
+ * an uncalled mock. `updateForMove` writes what the repository writes: whatever
79
+ * fields the caller set, and nothing it left out.
80
80
  */
81
81
  const buildRepositories = (row: MessageRow) => {
82
82
  const messages = new Map<string, MessageRow>([[row.messageId, row]]);
@@ -104,22 +104,11 @@ const buildRepositories = (row: MessageRow) => {
104
104
  const current = messages.get(messageId);
105
105
  if (current) messages.set(messageId, { ...current, ...input });
106
106
  },
107
- updateUid: async (
108
- messageId: string,
109
- newUid: number,
110
- newMailboxId: string,
111
- ) => {
107
+ updateForMove: async (messageId: string, set: Partial<MessageRow>) => {
112
108
  const current = messages.get(messageId);
113
- if (current)
114
- messages.set(messageId, {
115
- ...current,
116
- uid: newUid,
117
- mailboxId: newMailboxId,
118
- status: "active",
119
- syncStatus: "synced",
120
- });
109
+ if (current) messages.set(messageId, { ...current, ...set });
121
110
  },
122
- } as unknown as Pick<IMessageRepository, "delete" | "updateUid">,
111
+ } as unknown as Pick<IMessageRepository, "delete" | "updateForMove">,
123
112
  threadMessageService: {
124
113
  findAllByMessageId: async () => [...threadMessages.values()],
125
114
  deleteMany: async (
@@ -215,10 +204,9 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
215
204
  assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
216
205
  });
217
206
 
218
- // Issue #1005: only `updateUid` clears `moving`, so a give-up that returned
219
- // without calling it left the row naming the destination with the source's
220
- // uid — a pair `bindsForeignUid` refuses, which made the message
221
- // undeletable and unmovable for good.
207
+ // 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.
222
210
  it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
223
211
  const repos = buildRepositories(pendingMoveRow());
224
212
  const { log } = buildLogger();
@@ -281,7 +269,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
281
269
  const deps: ResolveExhaustedMessageMoveDeps = {
282
270
  messageService: {
283
271
  ...repos.messageService,
284
- updateUid: async () => {
272
+ updateForMove: async () => {
285
273
  throw notFound;
286
274
  },
287
275
  } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
@@ -332,7 +320,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
332
320
  const deps: ResolveExhaustedMessageMoveDeps = {
333
321
  messageService: {
334
322
  ...repos.messageService,
335
- updateUid: async () => {
323
+ updateForMove: async () => {
336
324
  throw new Error("ProvisionedThroughputExceeded");
337
325
  },
338
326
  } as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
@@ -2,6 +2,7 @@ import type {
2
2
  IMessageRepository,
3
3
  IThreadMessageRepository,
4
4
  } from "@remit/data-ports";
5
+ import { MessageSyncStatus } from "@remit/domain-enums";
5
6
  import {
6
7
  type IImapConnection,
7
8
  isMessageGoneFromOpenMailbox,
@@ -17,7 +18,7 @@ export interface MessageMoveTerminalLogger {
17
18
 
18
19
  export interface ResolveExhaustedMessageMoveDeps
19
20
  extends StaleMessageReconcileDeps {
20
- messageService: Pick<IMessageRepository, "delete" | "updateUid">;
21
+ messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
21
22
  threadMessageService: Pick<
22
23
  IThreadMessageRepository,
23
24
  "findAllByMessageId" | "deleteMany" | "update"
@@ -147,6 +148,10 @@ export const resolveExhaustedMessageMoveFailure = async (
147
148
  messageId,
148
149
  sourceMailboxId,
149
150
  uid,
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,
150
155
  });
151
156
 
152
157
  return { outcome: "broken" };