@remit/imap-worker 0.0.68 → 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,16 +355,84 @@ export const handleMessageMove = async (
244
355
  return;
245
356
  }
246
357
 
247
- // 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.
250
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
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
+
251
379
  log.info(
252
- { accountId, messageId, mailboxId: sourceMailboxId },
253
- "Mailbox cursor not normal; pausing outbound move this round",
380
+ {
381
+ messageId,
382
+ oldUid: uid,
383
+ newUid,
384
+ destination: destinationMailboxPath,
385
+ },
386
+ "Message moved successfully",
254
387
  );
255
- return;
256
- }
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
+ };
257
436
 
258
437
  const scope = createConnectionScopeWithCredentials(account, credentials);
259
438
 
@@ -272,6 +451,95 @@ export const handleMessageMove = async (
272
451
  mailbox,
273
452
  );
274
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
+
526
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
527
+ // paused never opens a connection on a first delivery. Optimization
528
+ // only — the guardConnectionCursor openBox wrap below is the structural
529
+ // guarantee.
530
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
531
+ log.info(
532
+ { accountId, messageId, mailboxId: sourceMailboxId },
533
+ "Mailbox cursor not normal; pausing outbound move this round and settling the row against the server",
534
+ );
535
+ await settlePausedMove(false).finally(() => scope.disconnect());
536
+ return;
537
+ }
538
+
539
+ // Flipped the instant the MOVE leaves, so every settle downstream of it
540
+ // knows the server was asked.
541
+ let commandIssued = false;
542
+
275
543
  await scope
276
544
  .getConnection()
277
545
  .then((rawConnection) => {
@@ -290,6 +558,7 @@ export const handleMessageMove = async (
290
558
  await connection.openBox(sourceMailboxPath, false);
291
559
 
292
560
  // Execute IMAP MOVE
561
+ commandIssued = true;
293
562
  const result = await connection.moveMessages(
294
563
  [uid],
295
564
  destinationMailboxPath,
@@ -319,41 +588,7 @@ export const handleMessageMove = async (
319
588
  );
320
589
  }
321
590
 
322
- // Update message with new UID
323
- await messageService.updateUid(
324
- messageId,
325
- newUid,
326
- destinationMailboxId,
327
- );
328
-
329
- // Update ThreadMessage UID and mailboxId
330
- const threadMessage = await threadMessageService.findByMessageId(
331
- account.accountConfigId,
332
- messageId,
333
- );
334
- if (threadMessage) {
335
- const args = buildThreadMessageMoveUpdate(
336
- threadMessage,
337
- newUid,
338
- destinationMailboxId,
339
- );
340
- await threadMessageService.update(
341
- threadMessage.accountConfigId,
342
- threadMessage.threadMessageId,
343
- args.set,
344
- { composites: args.composites },
345
- );
346
- }
347
-
348
- log.info(
349
- {
350
- messageId,
351
- oldUid: uid,
352
- newUid,
353
- destination: destinationMailboxPath,
354
- },
355
- "Message moved successfully",
356
- );
591
+ await settleMoved(newUid);
357
592
  },
358
593
  () =>
359
594
  emitMoveResync(emitEvent, {
@@ -372,8 +607,9 @@ export const handleMessageMove = async (
372
607
  mailboxId: sourceMailboxId,
373
608
  cursorState: error.state,
374
609
  },
375
- "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",
376
611
  );
612
+ await settlePausedMove(commandIssued);
377
613
  return;
378
614
  }
379
615
 
@@ -421,12 +657,13 @@ export const handleMessageMove = async (
421
657
  sourceMailboxPath,
422
658
  getConnection: getGuardedConnection,
423
659
  },
424
- ).catch((settleError: unknown) => {
660
+ ).catch(async (settleError: unknown) => {
425
661
  // The guarded probe found a mailbox whose UIDVALIDITY has moved.
426
- // Every stored uid for this folder now names something else, so
427
- // there is nothing here that may be settled off them, and the
428
- // pause is the routine skip `guardMailboxCursor` documents, not
429
- // a fault to re-throw out of this catch.
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.
430
667
  if (settleError instanceof MailboxCursorPausedError) {
431
668
  log.info(
432
669
  {
@@ -435,8 +672,9 @@ export const handleMessageMove = async (
435
672
  mailboxId: sourceMailboxId,
436
673
  cursorState: settleError.state,
437
674
  },
438
- "Mailbox cursor not normal; leaving the exhausted move for the cursor rebuild",
675
+ "Mailbox cursor not normal; settling the exhausted move against the server by Message-ID",
439
676
  );
677
+ await settlePausedMove(commandIssued);
440
678
  return null;
441
679
  }
442
680
  throw settleError;
@@ -1,12 +1,15 @@
1
1
  import type {
2
2
  IMessageRepository,
3
3
  IThreadMessageRepository,
4
+ MessageItem,
5
+ ThreadMessageItem,
4
6
  } from "@remit/data-ports";
7
+ import { MessageStatus } from "@remit/domain-enums";
5
8
  import { isNotFoundError } from "../is-not-found.js";
6
9
  import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
7
10
 
8
11
  export interface RestoreSourcePlacementDeps {
9
- messageService: Pick<IMessageRepository, "updateUid">;
12
+ messageService: Pick<IMessageRepository, "updateForMove">;
10
13
  threadMessageService: Pick<
11
14
  IThreadMessageRepository,
12
15
  "findAllByMessageId" | "update"
@@ -18,26 +21,61 @@ export interface RestoreSourcePlacementInput {
18
21
  messageId: string;
19
22
  sourceMailboxId: string;
20
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[];
21
37
  }
22
38
 
23
39
  /**
24
- * Put a row back on the source pair a presence probe has just confirmed, and
25
- * hand its listing rows back with it.
40
+ * Put a row back on the pre-mutation source pair and hand its listing rows back
41
+ * with it.
26
42
  *
27
- * Only `updateUid` clears `status: moving`, so a give-up that writes
28
- * `syncStatus: failed` and returns leaves the row naming the destination with
29
- * the source's uid — the pair `bindsForeignUid` calls a lie and
30
- * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs
31
- * it (sync does not touch `status`, and the cursor rebuild's `updateUid` runs
32
- * only on a UIDVALIDITY change), so the message is undeletable and unmovable
33
- * for good (issue #1005).
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.
34
57
  *
35
58
  * The caller must have evidence, not an inference: this writes a placement, and
36
59
  * a placement written on a guess binds live mail to a uid that names somebody
37
- * else's message. Every caller reaches here from
38
- * {@link isMessageGoneFromOpenMailbox} answering that the message is STILL at
39
- * `sourceMailboxId`/`uid`, which makes this the server's own answer rather than
40
- * a revert on ambiguity (what PR #652 was pulled for).
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.
41
79
  *
42
80
  * A row another path deleted while the probe was in flight has nothing left to
43
81
  * restore, and a thread row whose composites have moved on is being rewritten
@@ -51,7 +89,8 @@ export const restoreSourcePlacement = async (
51
89
  deps: RestoreSourcePlacementDeps,
52
90
  input: RestoreSourcePlacementInput,
53
91
  ): Promise<void> => {
54
- const { accountConfigId, messageId, sourceMailboxId, uid } = input;
92
+ const { accountConfigId, messageId, sourceMailboxId, uid, syncStatus } =
93
+ input;
55
94
 
56
95
  const skipNotFound = (error: unknown): void => {
57
96
  if (isNotFoundError(error)) return;
@@ -59,7 +98,12 @@ export const restoreSourcePlacement = async (
59
98
  };
60
99
 
61
100
  const messageRestored = await deps.messageService
62
- .updateUid(messageId, uid, sourceMailboxId)
101
+ .updateForMove(messageId, {
102
+ mailboxId: sourceMailboxId,
103
+ uid,
104
+ status: MessageStatus.active,
105
+ syncStatus,
106
+ })
63
107
  .then(() => true)
64
108
  .catch((error: unknown) => {
65
109
  skipNotFound(error);
@@ -67,10 +111,12 @@ export const restoreSourcePlacement = async (
67
111
  });
68
112
  if (!messageRestored) return;
69
113
 
70
- const threadMessages = await deps.threadMessageService.findAllByMessageId(
71
- accountConfigId,
72
- messageId,
73
- );
114
+ const threadMessages =
115
+ input.threadMessages ??
116
+ (await deps.threadMessageService.findAllByMessageId(
117
+ accountConfigId,
118
+ messageId,
119
+ ));
74
120
  for (const threadMessage of threadMessages) {
75
121
  const args = buildThreadMessageMoveRevert(
76
122
  threadMessage,