@remit/imap-worker 0.0.67 → 0.0.69

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.
@@ -1,5 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import type { ThreadMessageItem } from "@remit/data-ports";
2
+ import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
3
3
  import { MessageSyncStatus } from "@remit/domain-enums";
4
4
  import type { Logger } from "@remit/logger-lambda";
5
5
  import { recordImapFailure } from "@remit/logger-lambda";
@@ -19,6 +19,7 @@ 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 { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
22
+ import { restoreSourcePlacement } from "./restore-source-placement.js";
22
23
 
23
24
  export const getMessageMoveMaxAttempts = (
24
25
  processEnv: NodeJS.ProcessEnv = process.env,
@@ -52,6 +53,18 @@ export const emitMoveResync = async (
52
53
  );
53
54
  };
54
55
 
56
+ /**
57
+ * The one-folder half of {@link emitMoveResync}, for a mutation that has no
58
+ * destination — a permanent delete removed local rows the server still holds,
59
+ * and only a resync of that folder puts them back.
60
+ */
61
+ export const emitMailboxResync = async (
62
+ emit: EmitSyncMessages,
63
+ params: { accountId: string; mailboxId: string },
64
+ ): Promise<void> => {
65
+ await emit({ type: "SYNC_MESSAGES", ...params });
66
+ };
67
+
55
68
  /**
56
69
  * SEARCH a mailbox for copies of a message identified by its RFC822 Message-ID
57
70
  * header and answer the HIGHEST matching uid. Read-only (EXAMINE, not SELECT) —
@@ -87,6 +100,79 @@ export const searchMailboxForHighestMessageIdUid = async (
87
100
  return uids.reduce((highest, uid) => (uid > highest ? uid : highest));
88
101
  };
89
102
 
103
+ /**
104
+ * Which side of a paused mutation the server actually holds the message on.
105
+ * `gone` is an answer both folders gave; `unprobeable` is the row that carries
106
+ * no Message-ID header, where the server was never asked and its silence says
107
+ * nothing.
108
+ */
109
+ export type PausedPlacement =
110
+ | { kind: "at-source" }
111
+ | { kind: "at-destination"; uid: number }
112
+ | { kind: "gone" }
113
+ | { kind: "unprobeable" };
114
+
115
+ /**
116
+ * Ask the server, by Message-ID, which folder holds a message whose mutation a
117
+ * paused cursor interrupted (issue #1203).
118
+ *
119
+ * Reached only on a redelivery, where "the command never left" has stopped
120
+ * being provable: the earlier attempt's tagged OK can be lost with the
121
+ * connection, so restoring the source pair on that assumption would write a
122
+ * settled placement naming a folder the server has already moved the mail out
123
+ * of. `message-copy.ts` asks the same question for the same reason.
124
+ *
125
+ * The SOURCE is asked first and a hit ends it, the gate `confirmTrashMoveUid`
126
+ * documents (#1122): where the mutation never ran, every hit at the destination
127
+ * is an older copy of the same Message-ID — a sieve `fileinto` + `keep`, a
128
+ * multi-label store, a resend — that `deriveMessageId` folds into this one local
129
+ * row, and binding to it hands a later delete somebody else's uid.
130
+ *
131
+ * The source's silence, however, is not the mirror image of its hit. Every
132
+ * caller here reaches this on a paused cursor, which means the source's
133
+ * UIDVALIDITY has moved and the folder may have been recreated holding nothing
134
+ * it held before — so "not at the source" is an answer about the folder, not
135
+ * about our mutation, and `at-destination` is a sighting rather than a
136
+ * confirmation. Callers settle it as unproven; see `settlePausedMove`.
137
+ *
138
+ * Identity, not position, is what makes this askable at all: a paused cursor
139
+ * means the source's UIDVALIDITY has moved, so every stored uid for it names
140
+ * something else, but a SEARCH by header is independent of the axis and answers
141
+ * the same question the cursor rebuild itself matches rows by. For that reason
142
+ * the caller passes the UNGUARDED connection — a `guardConnectionCursor` wrap
143
+ * refuses to open the mailbox at all, and it is bound to the source's snapshot
144
+ * anyway, so opening the destination through it would trip that mailbox too.
145
+ */
146
+ export const probePausedPlacement = async (
147
+ connection: IImapConnection,
148
+ input: {
149
+ messageIdHeader: string | undefined;
150
+ sourceMailboxPath: string;
151
+ destinationMailboxPath: string | undefined;
152
+ },
153
+ ): Promise<PausedPlacement> => {
154
+ const { messageIdHeader, sourceMailboxPath, destinationMailboxPath } = input;
155
+ if (!messageIdHeader) return { kind: "unprobeable" };
156
+
157
+ const atSource = await searchMailboxForHighestMessageIdUid(
158
+ connection,
159
+ sourceMailboxPath,
160
+ messageIdHeader,
161
+ );
162
+ if (atSource !== null) return { kind: "at-source" };
163
+
164
+ if (!destinationMailboxPath) return { kind: "gone" };
165
+
166
+ const atDestination = await searchMailboxForHighestMessageIdUid(
167
+ connection,
168
+ destinationMailboxPath,
169
+ messageIdHeader,
170
+ );
171
+ return atDestination === null
172
+ ? { kind: "gone" }
173
+ : { kind: "at-destination", uid: atDestination };
174
+ };
175
+
90
176
  /**
91
177
  * Resync the affected folders only once the IMAP move has resolved. A move that
92
178
  * fails (or is retried) must not refresh counts off a move that didn't happen,
@@ -140,6 +226,22 @@ export const buildThreadMessageMoveUpdate = (
140
226
  },
141
227
  });
142
228
 
229
+ export interface MessageMoveDeps {
230
+ getClient: typeof getClient;
231
+ buildLifecycleDeps: typeof buildLifecycleDeps;
232
+ withOAuthLifecycle: typeof withOAuthLifecycle;
233
+ createConnectionScope: typeof createConnectionScopeWithCredentials;
234
+ emitEvent: typeof emitEvent;
235
+ }
236
+
237
+ const defaultDeps: MessageMoveDeps = {
238
+ getClient,
239
+ buildLifecycleDeps,
240
+ withOAuthLifecycle,
241
+ createConnectionScope: createConnectionScopeWithCredentials,
242
+ emitEvent,
243
+ };
244
+
143
245
  /**
144
246
  * Handle MESSAGE_MOVE events.
145
247
  * Executes IMAP MOVE command and updates local state with new UID.
@@ -155,7 +257,16 @@ export const handleMessageMove = async (
155
257
  event: MessageMoveEvent,
156
258
  log: Logger,
157
259
  receiveCount = 1,
260
+ deps: MessageMoveDeps = defaultDeps,
158
261
  ): Promise<void> => {
262
+ const {
263
+ getClient,
264
+ buildLifecycleDeps,
265
+ withOAuthLifecycle,
266
+ createConnectionScope: createConnectionScopeWithCredentials,
267
+ emitEvent,
268
+ } = deps;
269
+
159
270
  const {
160
271
  account: accountService,
161
272
  message: messageService,
@@ -244,18 +355,190 @@ export const handleMessageMove = async (
244
355
  return;
245
356
  }
246
357
 
358
+ const settleMoved = async (newUid: number): Promise<void> => {
359
+ await messageService.updateUid(messageId, newUid, destinationMailboxId);
360
+
361
+ const threadMessage = await threadMessageService.findByMessageId(
362
+ account.accountConfigId,
363
+ messageId,
364
+ );
365
+ if (threadMessage) {
366
+ const args = buildThreadMessageMoveUpdate(
367
+ threadMessage,
368
+ newUid,
369
+ destinationMailboxId,
370
+ );
371
+ await threadMessageService.update(
372
+ threadMessage.accountConfigId,
373
+ threadMessage.threadMessageId,
374
+ args.set,
375
+ { composites: args.composites },
376
+ );
377
+ }
378
+
379
+ log.info(
380
+ {
381
+ messageId,
382
+ oldUid: uid,
383
+ newUid,
384
+ destination: destinationMailboxPath,
385
+ },
386
+ "Message moved successfully",
387
+ );
388
+ };
389
+
390
+ /**
391
+ * Put the row back on the pair it was moved off, and say so once.
392
+ *
393
+ * `metric` is the routine give-up the guard doc calls never-a-fault:
394
+ * the user's move was silently dropped, which is worth counting and
395
+ * not worth waking anyone for. `alert` is the give-up the server did
396
+ * not settle — the row carries `failed` and an operator adjudicates.
397
+ */
398
+ const handBackToSource = async (
399
+ syncStatus: MessageItem["syncStatus"],
400
+ signal: { alert: string } | { metric: string },
401
+ reason: string,
402
+ ): Promise<void> => {
403
+ const context = {
404
+ ...signal,
405
+ accountId,
406
+ messageId,
407
+ uid,
408
+ sourceMailboxPath,
409
+ destinationMailboxPath,
410
+ receiveCount,
411
+ reason,
412
+ };
413
+ if ("alert" in signal) {
414
+ log.error(
415
+ context,
416
+ "Move given up without a settled placement; row handed back to its source for adjudication",
417
+ );
418
+ } else {
419
+ log.warn(
420
+ context,
421
+ "Move not pushed to the server; row handed back to its source",
422
+ );
423
+ }
424
+
425
+ await restoreSourcePlacement(
426
+ { messageService, threadMessageService },
427
+ {
428
+ accountConfigId: account.accountConfigId,
429
+ messageId,
430
+ sourceMailboxId,
431
+ uid,
432
+ syncStatus,
433
+ },
434
+ );
435
+ };
436
+
437
+ const scope = createConnectionScopeWithCredentials(account, credentials);
438
+
439
+ // The terminal resolver opens the source itself, and its answer now
440
+ // WRITES a placement, so it has to reach IMAP through the same
441
+ // UIDVALIDITY choke point every other outbound operation does. An
442
+ // unguarded probe on a folder that was deleted and recreated finds
443
+ // whatever the server has renumbered onto this uid, reads it as "still
444
+ // at source", and binds the row to a stranger the next permanent delete
445
+ // would expunge.
446
+ const getGuardedConnection = async (): Promise<IImapConnection> =>
447
+ guardConnectionCursor(
448
+ await scope.getConnection(),
449
+ { mailboxService },
450
+ accountId,
451
+ mailbox,
452
+ );
453
+
454
+ // A paused cursor is never acked on the optimistic row: `updateForMove`
455
+ // has pointed it at the destination while `uid` still names the source,
456
+ // and that pair strands the row for good — the cursor rebuild matches
457
+ // rows by `(accountConfigId, mailboxId)`, so a row naming the
458
+ // destination is in neither folder's set, and nothing re-enqueues a
459
+ // MESSAGE_MOVE (issue #1203).
460
+ //
461
+ // This move therefore reconciles rather than waits (R2,
462
+ // docs/architecture/imap-mutations.md): the row is settled onto
463
+ // whichever pair the server can be shown to hold, and the resync below
464
+ // plus the source's own cursor rebuild are its repair path.
465
+ //
466
+ // Putting the row back is an undo of this product's own write only where
467
+ // the MOVE provably never left. `commandIssued` says what this round
468
+ // did; `receiveCount` adds what an earlier one may have done and lost
469
+ // with its connection. Inferring the first from the second is what
470
+ // `settlePausedDelete` got wrong — attempt 1 reaches an exhausted settle
471
+ // too, with the MOVE already issued.
472
+ const settlePausedMove = async (
473
+ commandIssued: boolean,
474
+ ): Promise<void> => {
475
+ const mayHaveIssued = commandIssued || receiveCount > 1;
476
+
477
+ const placement: PausedPlacement = mayHaveIssued
478
+ ? await probePausedPlacement(await scope.getConnection(), {
479
+ messageIdHeader: message.messageIdHeader,
480
+ sourceMailboxPath,
481
+ destinationMailboxPath,
482
+ })
483
+ : { kind: "at-source" };
484
+
485
+ // A sighting at the destination is not proof this move ran. The pause
486
+ // means the source's UIDVALIDITY has moved, so its "no" can be a
487
+ // recreated folder answering about mail it never held, and the hit can
488
+ // be an older copy of the same Message-ID that `deriveMessageId` folds
489
+ // into this row — settling `moved` on it points the row at mail a
490
+ // later permanent delete would expunge. `unprobeable` is silence, and
491
+ // silence settles nothing either (the rule `message-copy.ts` already
492
+ // applies). Both hand the row back on the source pair carrying
493
+ // `failed`: that is the set the source's own cursor rebuild walks and
494
+ // adjudicates by Message-ID, which is the reconcile path (R2) the
495
+ // destination — not paused, no rebuild coming — does not have.
496
+ const unproven =
497
+ placement.kind === "at-destination" ||
498
+ placement.kind === "unprobeable";
499
+
500
+ if (unproven) {
501
+ await handBackToSource(
502
+ MessageSyncStatus.failed,
503
+ { alert: "message_move_paused_placement_unproven" },
504
+ placement.kind === "at-destination"
505
+ ? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
506
+ : "no Message-ID header to ask either folder with",
507
+ );
508
+ } else {
509
+ // `gone` lands here with `at-source`: the source pair is the set the
510
+ // rebuild walks, and a row it cannot match against a fresh envelope
511
+ // snapshot is the one thing it reconciles away.
512
+ await handBackToSource(
513
+ MessageSyncStatus.synced,
514
+ { metric: "message_move_dropped_on_pause" },
515
+ `source mailbox cursor paused, placement ${placement.kind}`,
516
+ );
517
+ }
518
+
519
+ await emitMoveResync(emitEvent, {
520
+ accountId,
521
+ sourceMailboxId,
522
+ destinationMailboxId,
523
+ });
524
+ };
525
+
247
526
  // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
248
- // paused never even opens a connection. Optimization only — the
249
- // guardConnectionCursor openBox wrap below is the structural guarantee.
527
+ // paused never opens a connection on a first delivery. Optimization
528
+ // only — the guardConnectionCursor openBox wrap below is the structural
529
+ // guarantee.
250
530
  if (isCursorRebuildNeeded(mailbox.cursorState)) {
251
531
  log.info(
252
532
  { accountId, messageId, mailboxId: sourceMailboxId },
253
- "Mailbox cursor not normal; pausing outbound move this round",
533
+ "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
254
534
  );
535
+ await settlePausedMove(false).finally(() => scope.disconnect());
255
536
  return;
256
537
  }
257
538
 
258
- const scope = createConnectionScopeWithCredentials(account, credentials);
539
+ // Flipped the instant the MOVE leaves, so every settle downstream of it
540
+ // knows the server was asked.
541
+ let commandIssued = false;
259
542
 
260
543
  await scope
261
544
  .getConnection()
@@ -275,6 +558,7 @@ export const handleMessageMove = async (
275
558
  await connection.openBox(sourceMailboxPath, false);
276
559
 
277
560
  // Execute IMAP MOVE
561
+ commandIssued = true;
278
562
  const result = await connection.moveMessages(
279
563
  [uid],
280
564
  destinationMailboxPath,
@@ -304,41 +588,7 @@ export const handleMessageMove = async (
304
588
  );
305
589
  }
306
590
 
307
- // Update message with new UID
308
- await messageService.updateUid(
309
- messageId,
310
- newUid,
311
- destinationMailboxId,
312
- );
313
-
314
- // Update ThreadMessage UID and mailboxId
315
- const threadMessage = await threadMessageService.findByMessageId(
316
- account.accountConfigId,
317
- messageId,
318
- );
319
- if (threadMessage) {
320
- const args = buildThreadMessageMoveUpdate(
321
- threadMessage,
322
- newUid,
323
- destinationMailboxId,
324
- );
325
- await threadMessageService.update(
326
- threadMessage.accountConfigId,
327
- threadMessage.threadMessageId,
328
- args.set,
329
- { composites: args.composites },
330
- );
331
- }
332
-
333
- log.info(
334
- {
335
- messageId,
336
- oldUid: uid,
337
- newUid,
338
- destination: destinationMailboxPath,
339
- },
340
- "Message moved successfully",
341
- );
591
+ await settleMoved(newUid);
342
592
  },
343
593
  () =>
344
594
  emitMoveResync(emitEvent, {
@@ -357,8 +607,9 @@ export const handleMessageMove = async (
357
607
  mailboxId: sourceMailboxId,
358
608
  cursorState: error.state,
359
609
  },
360
- "Mailbox cursor not normal; pausing outbound move this round",
610
+ "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
361
611
  );
612
+ await settlePausedMove(commandIssued);
362
613
  return;
363
614
  }
364
615
 
@@ -395,36 +646,64 @@ export const handleMessageMove = async (
395
646
  // Redelivery budget exhausted: resolve into exactly one of the two
396
647
  // terminal outcomes instead of dead-lettering with no diagnosis,
397
648
  // and never by inferring the server's state from our own failures.
398
- const { outcome } = await resolveExhaustedMessageMoveFailure(
649
+ const settled = await resolveExhaustedMessageMoveFailure(
399
650
  { messageService, threadMessageService, log },
400
651
  {
401
652
  accountId,
402
653
  accountConfigId: account.accountConfigId,
403
654
  messageId,
655
+ sourceMailboxId,
404
656
  uid,
405
657
  sourceMailboxPath,
406
- getConnection: scope.getConnection,
658
+ getConnection: getGuardedConnection,
407
659
  },
408
- );
660
+ ).catch(async (settleError: unknown) => {
661
+ // The guarded probe found a mailbox whose UIDVALIDITY has moved.
662
+ // Nothing may be settled off a uid on a dead axis, and the pause
663
+ // is the routine skip `guardMailboxCursor` documents, not a fault
664
+ // to re-throw out of this catch. The row is still settled, on the
665
+ // identity axis instead: `settlePausedMove` asks by Message-ID,
666
+ // which a UIDVALIDITY change does not invalidate.
667
+ if (settleError instanceof MailboxCursorPausedError) {
668
+ log.info(
669
+ {
670
+ accountId,
671
+ messageId,
672
+ mailboxId: sourceMailboxId,
673
+ cursorState: settleError.state,
674
+ },
675
+ "Mailbox cursor not normal; settling the exhausted move against the server by Message-ID",
676
+ );
677
+ await settlePausedMove(commandIssued);
678
+ return null;
679
+ }
680
+ throw settleError;
681
+ });
409
682
 
410
- if (outcome === "reconciled") {
411
- // Whichever folder the message actually sits in re-projects it
412
- // with the server's own UID.
413
- await emitMoveResync(emitEvent, {
414
- accountId,
415
- sourceMailboxId,
416
- destinationMailboxId,
417
- });
418
- return;
683
+ if (!settled) return;
684
+
685
+ if (settled.outcome === "broken") {
686
+ // Terminal and never re-thrown, so the handler-outcome series
687
+ // records this record as a success. Counted here or it is
688
+ // invisible.
689
+ recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
690
+ log.error(
691
+ { error: errorMessage },
692
+ "Message move retry exhausted; message still exists at its source",
693
+ );
419
694
  }
420
695
 
421
- // Terminal and never re-thrown, so the handler-outcome series
422
- // records this record as a success. Counted here or it is invisible.
423
- recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
424
- log.error(
425
- { error: errorMessage },
426
- "Message move retry exhausted; message still exists at its source",
427
- );
696
+ // Both verdicts end in a row the server has contradicted, so this
697
+ // move reconciles rather than waits (R2). RECONCILED, the local rows
698
+ // are gone and whichever folder actually holds the message
699
+ // re-projects it with the server's own UID. BROKEN, the row has just
700
+ // been put back at the source the server confirmed, and the resync
701
+ // is what carries any drift either folder has picked up since.
702
+ await emitMoveResync(emitEvent, {
703
+ accountId,
704
+ sourceMailboxId,
705
+ destinationMailboxId,
706
+ });
428
707
  // Terminal — never re-thrown, so the caller acks either way.
429
708
  })
430
709
  .finally(() => scope.disconnect());
@@ -0,0 +1,135 @@
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ MessageItem,
5
+ ThreadMessageItem,
6
+ } from "@remit/data-ports";
7
+ import { MessageStatus } from "@remit/domain-enums";
8
+ import { isNotFoundError } from "../is-not-found.js";
9
+ import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
10
+
11
+ export interface RestoreSourcePlacementDeps {
12
+ messageService: Pick<IMessageRepository, "updateForMove">;
13
+ threadMessageService: Pick<
14
+ IThreadMessageRepository,
15
+ "findAllByMessageId" | "update"
16
+ >;
17
+ }
18
+
19
+ export interface RestoreSourcePlacementInput {
20
+ accountConfigId: string;
21
+ messageId: string;
22
+ sourceMailboxId: string;
23
+ uid: number;
24
+ /**
25
+ * What the row records about the mutation that will now never happen.
26
+ * `synced` where the row is a faithful projection of the source again — the
27
+ * server never received the command, or has just said the message is still
28
+ * there. `failed` where the product refused an operation the user asked for,
29
+ * which is a failure the row has to carry.
30
+ */
31
+ syncStatus: MessageItem["syncStatus"];
32
+ /**
33
+ * The listing rows, where the caller has already read them to pick this
34
+ * outcome. Omitted, they are read here.
35
+ */
36
+ threadMessages?: ThreadMessageItem[];
37
+ }
38
+
39
+ /**
40
+ * Put a row back on the pre-mutation source pair and hand its listing rows back
41
+ * with it.
42
+ *
43
+ * `status: moving` has to be cleared here or the row is stuck: a give-up that
44
+ * writes `syncStatus: failed` and returns leaves the row naming the destination
45
+ * with the source's uid — the pair `bindsForeignUid` calls a lie and
46
+ * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
47
+ * (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
48
+ * on a UIDVALIDITY change), so the message is undeletable and unmovable for good
49
+ * (issue #1005).
50
+ *
51
+ * `updateForMove`, not `updateUid`: both write the same settled pair, but
52
+ * `updateUid` also appends a `message.moved` outbox row so the search index can
53
+ * refresh the mailbox it stores per message. Nothing here moved. The optimistic
54
+ * `updateForMove` that pointed the row at the destination never enqueued one
55
+ * either, so the index still holds the source and a revert has nothing to
56
+ * correct — the re-index would be a forced pass over an unchanged document.
57
+ *
58
+ * The caller must have evidence, not an inference: this writes a placement, and
59
+ * a placement written on a guess binds live mail to a uid that names somebody
60
+ * else's message. Two kinds of caller qualify, and nothing else does.
61
+ *
62
+ * A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
63
+ * answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
64
+ * this the server's own answer rather than a revert on ambiguity (what PR #652
65
+ * was pulled for).
66
+ *
67
+ * A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
68
+ * openBox guard threw before the outbound command was issued (issue #1203), and
69
+ * restores the pre-mutation pair off its own event. On a FIRST delivery that is
70
+ * not an inference but an undo: the command provably never left, so the row is
71
+ * put back exactly as it was found. A redelivery cannot claim that — the
72
+ * earlier attempt's tagged OK can be lost with the connection — so it asks
73
+ * `probePausedPlacement` which folder holds the message and only reaches here
74
+ * once the answer rules the destination out. Either way the source mailbox is
75
+ * by definition awaiting a cursor rebuild, which matches its rows by Message-ID
76
+ * and adjudicates the uid: handing the row back is what puts it in the set the
77
+ * rebuild walks, since a row still naming the destination is in neither
78
+ * folder's.
79
+ *
80
+ * A row another path deleted while the probe was in flight has nothing left to
81
+ * restore, and a thread row whose composites have moved on is being rewritten
82
+ * by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
83
+ * the conditional-check miss as one — and both are settled states here, not
84
+ * faults. Throwing on them would escape the caller's `.catch()` and cost the
85
+ * record its ack and its resync, which is the contract the terminal resolvers
86
+ * state: never re-thrown.
87
+ */
88
+ export const restoreSourcePlacement = async (
89
+ deps: RestoreSourcePlacementDeps,
90
+ input: RestoreSourcePlacementInput,
91
+ ): Promise<void> => {
92
+ const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
93
+ input;
94
+
95
+ const skipNotFound = (error: unknown): void => {
96
+ if (isNotFoundError(error)) return;
97
+ throw error;
98
+ };
99
+
100
+ const messageRestored = await deps.messageService
101
+ .updateForMove(messageId, {
102
+ mailboxId: sourceMailboxId,
103
+ uid,
104
+ status: MessageStatus.active,
105
+ syncStatus,
106
+ })
107
+ .then(() => true)
108
+ .catch((error: unknown) => {
109
+ skipNotFound(error);
110
+ return false;
111
+ });
112
+ if (!messageRestored) return;
113
+
114
+ const threadMessages =
115
+ input.threadMessages ??
116
+ (await deps.threadMessageService.findAllByMessageId(
117
+ accountConfigId,
118
+ messageId,
119
+ ));
120
+ for (const threadMessage of threadMessages) {
121
+ const args = buildThreadMessageMoveRevert(
122
+ threadMessage,
123
+ uid,
124
+ sourceMailboxId,
125
+ );
126
+ await deps.threadMessageService
127
+ .update(
128
+ threadMessage.accountConfigId,
129
+ threadMessage.threadMessageId,
130
+ args.set,
131
+ { composites: args.composites },
132
+ )
133
+ .catch(skipNotFound);
134
+ }
135
+ };