@remit/imap-worker 0.0.86 → 0.0.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +16 -18
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +115 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -0,0 +1,291 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import type { RemitClient } from "@remit/backend/client";
4
+ import {
5
+ MailboxCursorState,
6
+ MailboxSpecialUse,
7
+ MessageMutation,
8
+ MessageStatus,
9
+ MessageSyncStatus,
10
+ } from "@remit/domain-enums";
11
+ import type { MessageDeleteEvent } from "../events.js";
12
+ import { mail, openStore, type World } from "../test-helpers/test-world.js";
13
+ import { handleMessageDelete } from "./message-delete.js";
14
+
15
+ let store: Awaited<ReturnType<typeof openStore>>;
16
+
17
+ before(async () => {
18
+ store = await openStore();
19
+ });
20
+
21
+ after(() => store.close());
22
+
23
+ interface Arranged {
24
+ w: World;
25
+ messageId: string;
26
+ uid: number;
27
+ messageIdHeader: string;
28
+ inboxId: string;
29
+ trashId: string;
30
+ event: MessageDeleteEvent;
31
+ }
32
+
33
+ /**
34
+ * A message in INBOX, deleted through the service the API calls, so the row
35
+ * the handler finds and the event it gets are the ones a person's delete
36
+ * leaves behind.
37
+ */
38
+ const arrange = async (
39
+ options: { permanent?: boolean; olderCopyInTrash?: boolean } = {},
40
+ ): Promise<Arranged> => {
41
+ const w = await store.world();
42
+ const inbox = await w.folder("INBOX");
43
+ const trash = await w.folder("Trash", {
44
+ specialUse: MailboxSpecialUse.Trash,
45
+ });
46
+ const seeded = await w.message(inbox);
47
+ if (options.olderCopyInTrash)
48
+ w.imap.deliver("Trash", mail(seeded.messageIdHeader));
49
+
50
+ await w.client.messageMove.deleteMessage(
51
+ w.account.accountConfigId,
52
+ seeded.messageId,
53
+ w.account.accountId,
54
+ options.permanent ? { permanent: true } : { toTrash: true },
55
+ );
56
+ const event = w
57
+ .queued()
58
+ .find(
59
+ (body) => body.type === "MESSAGE_DELETE",
60
+ ) as unknown as MessageDeleteEvent;
61
+ assert.ok(event, "the delete enqueued a MESSAGE_DELETE");
62
+
63
+ return {
64
+ w,
65
+ messageId: seeded.messageId,
66
+ uid: seeded.uid,
67
+ messageIdHeader: seeded.messageIdHeader,
68
+ inboxId: inbox.mailboxId,
69
+ trashId: trash.mailboxId,
70
+ event,
71
+ };
72
+ };
73
+
74
+ const run = (a: Arranged, receiveCount = 1, event = a.event) =>
75
+ handleMessageDelete(event, a.w.log, receiveCount, a.w.scope);
76
+
77
+ const resyncs = (w: World): unknown[] =>
78
+ w
79
+ .queued()
80
+ .filter((body) => body.type === "SYNC_MESSAGES")
81
+ .map((body) => body.mailboxId);
82
+
83
+ const client = (): RemitClient => store.client;
84
+
85
+ describe("a move to Trash", () => {
86
+ it("lands in Trash and settles the row on the uid the server gave it", async () => {
87
+ const a = await arrange();
88
+
89
+ await run(a);
90
+
91
+ assert.deepEqual(a.w.imap.uids("INBOX"), []);
92
+ const [trashUid] = a.w.imap.uids("Trash", a.messageIdHeader);
93
+ const row = await a.w.row(a.messageId);
94
+ assert.equal(row?.mailboxId, a.trashId);
95
+ assert.equal(row?.uid, trashUid);
96
+ assert.equal(row?.status, MessageStatus.active);
97
+ assert.deepEqual(await a.w.threadRows(a.messageId), [
98
+ { mailboxId: a.trashId, uid: trashUid, isDeleted: true },
99
+ ]);
100
+ });
101
+
102
+ // Issue #1122. Without UIDPLUS the destination is asked by Message-ID, and
103
+ // Trash already holds an older copy: binding the row to it lets Empty Trash
104
+ // expunge the wrong one and leaves the fresh copy unreachable.
105
+ it("settles on the copy this move delivered when the server sends no COPYUID", async () => {
106
+ const a = await arrange({ olderCopyInTrash: true });
107
+ a.w.imap.uidplus = false;
108
+
109
+ await run(a);
110
+
111
+ const copies = a.w.imap.uids("Trash", a.messageIdHeader);
112
+ assert.equal(copies.length, 2);
113
+ assert.equal((await a.w.row(a.messageId))?.uid, Math.max(...copies));
114
+ });
115
+
116
+ // One Message-ID can have several server copies. A source that still holds
117
+ // the uid proves the MOVE never ran, so the older copy in Trash is not this
118
+ // message and must never settle the row.
119
+ it("never settles on an older copy in Trash while the source still holds the message", async () => {
120
+ const a = await arrange({ olderCopyInTrash: true });
121
+ a.w.imap.uidplus = false;
122
+ a.w.imap.ignoreNext.add("MOVE");
123
+
124
+ await assert.rejects(run(a), /unconfirmed/);
125
+
126
+ const row = await a.w.row(a.messageId);
127
+ assert.equal(row?.uid, a.uid);
128
+ assert.equal(row?.status, MessageStatus.moving);
129
+ assert.equal(row?.syncStatus, MessageSyncStatus.failed);
130
+ });
131
+
132
+ it("issues nothing when a redelivery finds the delete already settled", async () => {
133
+ const a = await arrange();
134
+ await run(a);
135
+ const settled = await a.w.row(a.messageId);
136
+ const commands = a.w.imap.log.length;
137
+
138
+ await run(a);
139
+
140
+ assert.equal(
141
+ a.w.imap.log.length,
142
+ commands,
143
+ "no IMAP command on redelivery",
144
+ );
145
+ assert.deepEqual(await a.w.row(a.messageId), settled);
146
+ });
147
+
148
+ it("marks the row failed and redelivers when the connection drops mid-move", async () => {
149
+ const a = await arrange();
150
+ a.w.imap.failNext.MOVE = new Error("Connection reset");
151
+
152
+ await assert.rejects(run(a), /Connection reset/);
153
+
154
+ const row = await a.w.row(a.messageId);
155
+ assert.equal(row?.status, MessageStatus.moving);
156
+ assert.equal(row?.syncStatus, MessageSyncStatus.failed);
157
+ assert.deepEqual(a.w.imap.uids("INBOX"), [a.uid]);
158
+ });
159
+
160
+ // Issue #980. At the ceiling the server is asked where the message is, and
161
+ // a message that never left INBOX puts the row back there.
162
+ it("puts the row back on its source at the ceiling when the message never left", async () => {
163
+ const a = await arrange();
164
+ a.w.imap.failNext.MOVE = new Error("Connection reset");
165
+
166
+ await run(a, 3);
167
+
168
+ const row = await a.w.row(a.messageId);
169
+ assert.equal(row?.mailboxId, a.inboxId);
170
+ assert.equal(row?.uid, a.uid);
171
+ assert.notEqual(row?.status, MessageStatus.moving);
172
+ assert.deepEqual(resyncs(a.w), [a.inboxId, a.trashId]);
173
+ });
174
+
175
+ it("hands the row back without creating a Trash the server does not have", async () => {
176
+ const a = await arrange();
177
+ a.w.imap.removeFolder("Trash");
178
+
179
+ await run(a);
180
+
181
+ assert.deepEqual(a.w.imap.commands("CREATE"), []);
182
+ const row = await a.w.row(a.messageId);
183
+ assert.equal(row?.mailboxId, a.inboxId);
184
+ assert.equal(row?.uid, a.uid);
185
+ assert.equal(row?.syncStatus, MessageSyncStatus.abandoned);
186
+ assert.equal(row?.abandonedMutation, MessageMutation.delete);
187
+ });
188
+
189
+ it("hands the row back without connecting while the source cursor is rebuilding", async () => {
190
+ const a = await arrange();
191
+ await client().mailbox.update(a.w.account.accountId, a.inboxId, {
192
+ cursorState: MailboxCursorState.rebuilding,
193
+ });
194
+
195
+ await run(a);
196
+
197
+ assert.equal(a.w.imap.connections, 0);
198
+ const row = await a.w.row(a.messageId);
199
+ assert.equal(row?.mailboxId, a.inboxId);
200
+ assert.equal(row?.status, MessageStatus.active);
201
+ assert.equal(
202
+ a.w.logs.filter(
203
+ (line) => line.fields.metric === "message_delete_dropped_on_pause",
204
+ ).length,
205
+ 1,
206
+ );
207
+ assert.deepEqual(resyncs(a.w), [a.inboxId, a.trashId]);
208
+ });
209
+
210
+ // The folder was deleted and recreated on the server: uid 1 now names
211
+ // whatever the server put there. Nothing may be moved by it.
212
+ it("moves nothing when the source's UIDVALIDITY changed under it", async () => {
213
+ const a = await arrange();
214
+ a.w.imap.setUidValidity("INBOX", 2);
215
+
216
+ await run(a);
217
+
218
+ assert.deepEqual(a.w.imap.commands("MOVE"), []);
219
+ assert.equal((await a.w.row(a.messageId))?.mailboxId, a.inboxId);
220
+ const inbox = await client().mailbox.get(a.w.account.accountId, a.inboxId);
221
+ assert.notEqual(inbox.cursorState, MailboxCursorState.normal);
222
+ });
223
+
224
+ it("moves from the folder's current path when it was renamed after enqueue", async () => {
225
+ const a = await arrange();
226
+ a.w.imap.renameFolder("INBOX", "Old/Inbox");
227
+ await client().mailbox.update(a.w.account.accountId, a.inboxId, {
228
+ fullPath: "Old/Inbox",
229
+ });
230
+
231
+ await run(a);
232
+
233
+ assert.deepEqual(a.w.imap.uids("Old/Inbox"), []);
234
+ assert.equal(a.w.imap.uids("Trash", a.messageIdHeader).length, 1);
235
+ });
236
+
237
+ it("acks without connecting when the source mailbox was deleted", async () => {
238
+ const a = await arrange();
239
+ await client().mailbox.delete(a.w.account.accountId, a.inboxId);
240
+
241
+ await run(a);
242
+
243
+ assert.equal(a.w.imap.connections, 0);
244
+ });
245
+
246
+ it("refuses an event minted under an unknown contract and hands the row back", async () => {
247
+ const a = await arrange();
248
+
249
+ await run(a, 1, {
250
+ ...a.event,
251
+ schemaVersion: 99,
252
+ } as unknown as MessageDeleteEvent);
253
+
254
+ assert.equal(a.w.imap.connections, 0);
255
+ const row = await a.w.row(a.messageId);
256
+ assert.equal(row?.mailboxId, a.inboxId);
257
+ assert.equal(row?.syncStatus, MessageSyncStatus.abandoned);
258
+ });
259
+ });
260
+
261
+ describe("a permanent delete", () => {
262
+ it("expunges the message and removes every local row", async () => {
263
+ const a = await arrange({ permanent: true });
264
+
265
+ await run(a);
266
+
267
+ assert.deepEqual(a.w.imap.uids("INBOX"), []);
268
+ assert.equal(await a.w.row(a.messageId), undefined);
269
+ assert.deepEqual(await a.w.threadRows(a.messageId), []);
270
+ });
271
+
272
+ it("cleans up locally when another client already expunged it", async () => {
273
+ const a = await arrange({ permanent: true });
274
+ a.w.imap.empty("INBOX");
275
+ a.w.imap.failNext.EXPUNGE = new Error("Message not found (NONEXISTENT)");
276
+
277
+ await run(a);
278
+
279
+ assert.equal(await a.w.row(a.messageId), undefined);
280
+ });
281
+
282
+ it("keeps the row when the server still holds the message after a failed expunge", async () => {
283
+ const a = await arrange({ permanent: true });
284
+ a.w.imap.failNext.EXPUNGE = new Error("Message not found (NONEXISTENT)");
285
+
286
+ await assert.rejects(run(a), /NONEXISTENT/);
287
+
288
+ assert.equal((await a.w.row(a.messageId))?.status, MessageStatus.deleting);
289
+ assert.deepEqual(a.w.imap.uids("INBOX"), [a.uid]);
290
+ });
291
+ });
@@ -10,8 +10,8 @@ import {
10
10
  MessageStatus,
11
11
  MessageSyncStatus,
12
12
  } from "@remit/domain-enums";
13
- import type { Logger } from "@remit/logger-lambda";
14
- import { recordImapFailure } from "@remit/logger-lambda";
13
+ import type { Logger } from "@remit/logger";
14
+ import { recordImapFailure } from "@remit/logger-lambda/metrics";
15
15
  import {
16
16
  buildThreadMessageTrashUpdate,
17
17
  guardConnectionCursor,
@@ -37,6 +37,7 @@ import {
37
37
  probePausedPlacement,
38
38
  searchMailboxForHighestMessageIdUid,
39
39
  } from "./message-move.js";
40
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
40
41
 
41
42
  interface FolderPaths {
42
43
  source: string;
@@ -109,6 +110,8 @@ const confirmTrashMoveUid = async (
109
110
  sourceConnection: IImapConnection,
110
111
  destinationConnection: IImapConnection,
111
112
  messageService: Pick<IMessageRepository, "get">,
113
+ threadMessageService: Pick<IThreadMessageRepository, "findByMessageId">,
114
+ accountConfigId: string,
112
115
  messageId: string,
113
116
  sourceMailboxPath: string,
114
117
  destinationMailboxPath: string,
@@ -121,34 +124,23 @@ const confirmTrashMoveUid = async (
121
124
 
122
125
  const [message] = await messageService.get([messageId]);
123
126
  if (!message) return { outcome: "row-gone" };
124
- if (!message.messageIdHeader) return { outcome: "unprobeable" };
127
+ const messageIdHeader = await probeableMessageIdHeader(
128
+ threadMessageService,
129
+ accountConfigId,
130
+ messageId,
131
+ );
132
+ if (!messageIdHeader) return { outcome: "unprobeable" };
125
133
 
126
134
  const probedUid = await searchMailboxForHighestMessageIdUid(
127
135
  destinationConnection,
128
136
  destinationMailboxPath,
129
- message.messageIdHeader,
137
+ messageIdHeader,
130
138
  );
131
139
  return probedUid === null
132
140
  ? { outcome: "unconfirmed" }
133
141
  : { outcome: "confirmed", uid: probedUid };
134
142
  };
135
143
 
136
- export interface MessageDeleteDeps {
137
- getClient: typeof getClient;
138
- buildLifecycleDeps: typeof buildLifecycleDeps;
139
- withOAuthLifecycle: typeof withOAuthLifecycle;
140
- createConnectionScope: typeof createConnectionScopeWithCredentials;
141
- emitEvent: typeof emitEvent;
142
- }
143
-
144
- const defaultDeps: MessageDeleteDeps = {
145
- getClient,
146
- buildLifecycleDeps,
147
- withOAuthLifecycle,
148
- createConnectionScope: createConnectionScopeWithCredentials,
149
- emitEvent,
150
- };
151
-
152
144
  /**
153
145
  * Handle MESSAGE_DELETE events.
154
146
  * Either moves to Trash (IMAP MOVE) or permanently deletes (IMAP DELETE).
@@ -162,16 +154,8 @@ export const handleMessageDelete = async (
162
154
  event: MessageDeleteEvent,
163
155
  log: Logger,
164
156
  receiveCount = 1,
165
- deps: MessageDeleteDeps = defaultDeps,
157
+ createConnectionScope = createConnectionScopeWithCredentials,
166
158
  ): Promise<void> => {
167
- const {
168
- getClient,
169
- buildLifecycleDeps,
170
- withOAuthLifecycle,
171
- createConnectionScope: createConnectionScopeWithCredentials,
172
- emitEvent,
173
- } = deps;
174
-
175
159
  const {
176
160
  account: accountService,
177
161
  message: messageService,
@@ -446,8 +430,11 @@ export const handleMessageDelete = async (
446
430
 
447
431
  const placement: PausedPlacement = isProbeableTrashMove
448
432
  ? await probePausedPlacement(await getRawConnection(), {
449
- messageIdHeader: (await messageService.get([messageId]))[0]
450
- ?.messageIdHeader,
433
+ messageIdHeader: await probeableMessageIdHeader(
434
+ threadMessageService,
435
+ account.accountConfigId,
436
+ messageId,
437
+ ),
451
438
  sourceMailboxPath: paths.source,
452
439
  destinationMailboxPath,
453
440
  })
@@ -498,6 +485,11 @@ export const handleMessageDelete = async (
498
485
  const settled = await resolveExhaustedMessageDeleteFailure(
499
486
  { messageService, threadMessageService, log },
500
487
  {
488
+ placement: {
489
+ status: message.status,
490
+ mailboxId: message.mailboxId,
491
+ uid: message.uid,
492
+ },
501
493
  accountId,
502
494
  accountConfigId,
503
495
  messageId,
@@ -642,7 +634,7 @@ export const handleMessageDelete = async (
642
634
  destination: destinationMailboxPath,
643
635
  };
644
636
 
645
- const scope = createConnectionScopeWithCredentials(account, credentials);
637
+ const scope = createConnectionScope(account, credentials);
646
638
 
647
639
  // The terminal resolver opens the source itself, and its answer now
648
640
  // WRITES a placement, so it has to reach IMAP through the same
@@ -737,6 +729,8 @@ export const handleMessageDelete = async (
737
729
  connection,
738
730
  rawConnection,
739
731
  messageService,
732
+ threadMessageService,
733
+ account.accountConfigId,
740
734
  messageId,
741
735
  mailboxPath,
742
736
  destinationMailboxPath,
@@ -1,14 +1,13 @@
1
1
  import type {
2
2
  IMessageRepository,
3
3
  IThreadMessageRepository,
4
+ MessageItem,
4
5
  } from "@remit/data-ports";
5
6
  import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
6
7
  import {
7
8
  type IImapConnection,
8
9
  isMessageGoneFromOpenMailbox,
9
- reconcileStaleMessage,
10
10
  restoreSourcePlacement,
11
- type StaleMessageReconcileDeps,
12
11
  } from "@remit/mailbox-service";
13
12
 
14
13
  export interface MessageMoveTerminalLogger {
@@ -16,9 +15,11 @@ export interface MessageMoveTerminalLogger {
16
15
  error(obj: Record<string, unknown>, msg: string): void;
17
16
  }
18
17
 
19
- export interface ResolveExhaustedMessageMoveDeps
20
- extends StaleMessageReconcileDeps {
21
- messageService: Pick<IMessageRepository, "delete" | "transitionPlacement">;
18
+ export interface ResolveExhaustedMessageMoveDeps {
19
+ messageService: Pick<
20
+ IMessageRepository,
21
+ "deleteIfPlacement" | "transitionPlacement"
22
+ >;
22
23
  threadMessageService: Pick<
23
24
  IThreadMessageRepository,
24
25
  "findAllByMessageId" | "deleteMany" | "update"
@@ -27,6 +28,8 @@ export interface ResolveExhaustedMessageMoveDeps
27
28
  }
28
29
 
29
30
  export interface ResolveExhaustedMessageMoveInput {
31
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
32
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
30
33
  accountId: string;
31
34
  accountConfigId: string;
32
35
  messageId: string;
@@ -58,7 +61,7 @@ export interface ResolveExhaustedMessageMoveResult {
58
61
  * the connection dropped before the tagged OK was read, or a foreign client
59
62
  * moved or expunged the message; from here those are indistinguishable and
60
63
  * have the same answer. The stale Message/ThreadMessage rows are deleted
61
- * via {@link reconcileStaleMessage} and the caller resyncs both folders, so
64
+ * via `deleteIfPlacement` and the caller resyncs both folders, so
62
65
  * whichever folder actually holds the message re-projects it with the
63
66
  * server's own UID. Metric only, no alarm — routine.
64
67
  * 2. BROKEN — the message is still at the source, so the move never took
@@ -110,10 +113,9 @@ export const resolveExhaustedMessageMoveFailure = async (
110
113
  await connection.openBox(sourceMailboxPath, true);
111
114
 
112
115
  if (await isMessageGoneFromOpenMailbox(connection, uid)) {
113
- const { threadMessagesDeleted } = await reconcileStaleMessage(
114
- deps,
115
- accountConfigId,
116
+ const reconciled = await deps.messageService.deleteIfPlacement(
116
117
  messageId,
118
+ input.placement,
117
119
  );
118
120
  deps.log.info(
119
121
  {
@@ -123,7 +125,7 @@ export const resolveExhaustedMessageMoveFailure = async (
123
125
  messageId,
124
126
  uid,
125
127
  sourceMailboxPath,
126
- threadMessagesDeleted,
128
+ reconciled,
127
129
  },
128
130
  "Message no longer at its move source after retry exhaustion (move landed server-side, or an external delete or move); stale row reconciled",
129
131
  );