@remit/mailbox-service 0.0.91 → 0.0.93

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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * The three optimistic writers, against a row that moves under them
3
+ * (docs/architecture/imap-mutations.md R3).
4
+ *
5
+ * Each reads a message, does several lookups, then writes where it decided to
6
+ * put it. Between the read and the write, PLACEMENT_MOVE_PUSH can settle the
7
+ * same row: that lane rides a standard queue, which the account's FIFO group
8
+ * does not order against. A blind write then stamps `moving` and the OLD uid
9
+ * over a folder and uid the mail server has just confirmed — a pair
10
+ * `carriesForeignUid` reads as consistent, so nothing downstream catches it —
11
+ * and the event that follows sends the worker after whatever now holds the old
12
+ * number.
13
+ *
14
+ * The repository honours the predicate here the way the real one does, so a
15
+ * losing write returns `undefined` and lands nothing at all.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { describe, it } from "node:test";
20
+ import type {
21
+ IAddressRepository,
22
+ IMailboxRepository,
23
+ IMailboxSpecialUseRepository,
24
+ IMessagePlacementMoveRepository,
25
+ IMessageRepository,
26
+ IThreadMessageRepository,
27
+ PlacementPredicate,
28
+ PlacementTransitionInput,
29
+ } from "@remit/data-ports";
30
+ import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
31
+ import { MessageMoveService } from "./message-move.js";
32
+ import { PlacementMoveService } from "./placement-move.js";
33
+ import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
34
+
35
+ const ACCOUNT = "acc-1";
36
+ const ACCOUNT_CONFIG = "cfg-1";
37
+ const INBOX = "mbx-inbox";
38
+ const ARCHIVE = "mbx-archive";
39
+ const TRASH = "mbx-trash";
40
+ const JUNK = "mbx-junk";
41
+ const MESSAGE_ID = "msg-1";
42
+
43
+ /** What the row is when each writer reads it. */
44
+ const READ_UID = 42;
45
+
46
+ /** What the placement push settles it to while the writer is still deciding. */
47
+ const SETTLED_UID = 907;
48
+
49
+ const mailboxes = [
50
+ { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT, uidValidity: 1 },
51
+ {
52
+ mailboxId: ARCHIVE,
53
+ fullPath: "Archive",
54
+ accountId: ACCOUNT,
55
+ uidValidity: 1,
56
+ },
57
+ { mailboxId: TRASH, fullPath: "Trash", accountId: ACCOUNT, uidValidity: 1 },
58
+ { mailboxId: JUNK, fullPath: "Junk", accountId: ACCOUNT, uidValidity: 1 },
59
+ ];
60
+
61
+ interface CapturedEvent {
62
+ type: string;
63
+ messageId?: string;
64
+ uid?: number;
65
+ }
66
+
67
+ interface Row {
68
+ messageId: string;
69
+ accountConfigId: string;
70
+ mailboxId: string;
71
+ uid: number;
72
+ status: string;
73
+ syncStatus: string;
74
+ abandonedMutation: string;
75
+ originalMailboxId?: string;
76
+ originalUid?: number;
77
+ }
78
+
79
+ const freshRow = (): Row => ({
80
+ messageId: MESSAGE_ID,
81
+ accountConfigId: ACCOUNT_CONFIG,
82
+ mailboxId: INBOX,
83
+ uid: READ_UID,
84
+ status: "active",
85
+ syncStatus: "synced",
86
+ abandonedMutation: "none",
87
+ });
88
+
89
+ interface World {
90
+ row: Row;
91
+ events: CapturedEvent[];
92
+ writes: PlacementTransitionInput[];
93
+ /** Destination folders the listing row was actually moved to. */
94
+ threadMoves: string[];
95
+ /** Stand in for the push settling the row onto Archive with Archive's uid. */
96
+ settleElsewhere: () => void;
97
+ messageService: IMessageRepository;
98
+ threadMessageService: IThreadMessageRepository;
99
+ mailboxService: IMailboxRepository;
100
+ mailboxSpecialUseService: IMailboxSpecialUseRepository;
101
+ addressService: IAddressRepository;
102
+ }
103
+
104
+ const buildWorld = (onLookup?: (world: World) => void): World => {
105
+ const row = freshRow();
106
+ const events: CapturedEvent[] = [];
107
+ const writes: PlacementTransitionInput[] = [];
108
+
109
+ const threadMoves: string[] = [];
110
+ const world = { row, events, writes, threadMoves } as World;
111
+
112
+ world.settleElsewhere = () => {
113
+ row.mailboxId = ARCHIVE;
114
+ row.uid = SETTLED_UID;
115
+ row.status = "active";
116
+ row.syncStatus = "synced";
117
+ row.originalUid = undefined;
118
+ };
119
+
120
+ world.messageService = {
121
+ // A snapshot, the way a repository read is one. Handing back the live
122
+ // object would let the caller's `message` follow the concurrent settle,
123
+ // and the predicate it builds from it would match by accident.
124
+ get: async (ids: string | string[]) =>
125
+ Array.isArray(ids) ? [{ ...row }] : ({ ...row } as unknown),
126
+ update: async () => row,
127
+ transitionPlacement: async (
128
+ _messageId: string,
129
+ expected: PlacementPredicate,
130
+ next: PlacementTransitionInput,
131
+ ) => {
132
+ if (expected.status !== undefined && expected.status !== row.status) {
133
+ return undefined;
134
+ }
135
+ if (
136
+ expected.mailboxId !== undefined &&
137
+ expected.mailboxId !== row.mailboxId
138
+ ) {
139
+ return undefined;
140
+ }
141
+ if (expected.uid !== undefined && expected.uid !== row.uid) {
142
+ return undefined;
143
+ }
144
+ writes.push(next);
145
+ Object.assign(row, next);
146
+ return row;
147
+ },
148
+ } as unknown as IMessageRepository;
149
+
150
+ world.threadMessageService = {
151
+ getByMessageId: async () => ({
152
+ accountConfigId: ACCOUNT_CONFIG,
153
+ threadMessageId: "tm-1",
154
+ messageId: MESSAGE_ID,
155
+ mailboxId: row.mailboxId,
156
+ sentDate: 1,
157
+ isRead: false,
158
+ isDeleted: false,
159
+ hasStars: false,
160
+ hasAttachment: false,
161
+ }),
162
+ findAllByMessageId: async () => [],
163
+ update: async (
164
+ _accountConfigId: string,
165
+ _threadMessageId: string,
166
+ patch: { mailboxId?: string },
167
+ ) => {
168
+ if (patch.mailboxId) threadMoves.push(patch.mailboxId);
169
+ },
170
+ } as unknown as IThreadMessageRepository;
171
+
172
+ // Every writer resolves folders between reading the row and writing it, so
173
+ // this is where the concurrent settle is made to land.
174
+ world.mailboxService = {
175
+ get: async (_accountId: string, ids: string | string[]) => {
176
+ onLookup?.(world);
177
+ return Array.isArray(ids)
178
+ ? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
179
+ : mailboxes.find((mailbox) => mailbox.mailboxId === ids);
180
+ },
181
+ } as unknown as IMailboxRepository;
182
+
183
+ world.mailboxSpecialUseService = {
184
+ findTrashMailbox: async () => mailboxes[2],
185
+ resolveTrashRole: async () => ({ kind: "flagged", mailbox: mailboxes[2] }),
186
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
187
+ } as unknown as IMailboxSpecialUseRepository;
188
+
189
+ world.addressService = {
190
+ reconcileJunkOnlyForMessage: async () => {},
191
+ } as unknown as IAddressRepository;
192
+
193
+ return world;
194
+ };
195
+
196
+ const buildMoveService = (world: World): MessageMoveService => {
197
+ const service = new MessageMoveService({
198
+ messageService: world.messageService,
199
+ mailboxService: world.mailboxService,
200
+ mailboxSpecialUseService: world.mailboxSpecialUseService,
201
+ threadMessageService: world.threadMessageService,
202
+ addressService: world.addressService,
203
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
204
+ moveSettleTimeoutMs: 100,
205
+ moveSettlePollMs: 10,
206
+ });
207
+ (
208
+ service as unknown as {
209
+ enqueueEvent: (e: CapturedEvent) => Promise<void>;
210
+ enqueueEventsBatch: (e: CapturedEvent[]) => Promise<void>;
211
+ }
212
+ ).enqueueEvent = async (event) => {
213
+ world.events.push(event);
214
+ };
215
+ (
216
+ service as unknown as {
217
+ enqueueEventsBatch: (e: CapturedEvent[]) => Promise<void>;
218
+ }
219
+ ).enqueueEventsBatch = async (batch) => {
220
+ world.events.push(...batch);
221
+ };
222
+ return service;
223
+ };
224
+
225
+ describe("a user move writes only against the placement it read (R3)", () => {
226
+ it("moves the message when nothing else touched the row", async () => {
227
+ const world = buildWorld();
228
+ const service = buildMoveService(world);
229
+
230
+ await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, ARCHIVE, ACCOUNT);
231
+
232
+ assert.equal(world.events.length, 1);
233
+ assert.equal(world.row.status, "moving");
234
+ assert.equal(world.row.mailboxId, ARCHIVE);
235
+ });
236
+
237
+ it("refuses, and writes nothing, when the row settles elsewhere mid-decision", async () => {
238
+ let settled = false;
239
+ const world = buildWorld((w) => {
240
+ if (settled) return;
241
+ settled = true;
242
+ w.settleElsewhere();
243
+ });
244
+ const service = buildMoveService(world);
245
+
246
+ await assert.rejects(
247
+ () => service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT),
248
+ (error: unknown) =>
249
+ error instanceof MessagePlacementUnsettledError &&
250
+ error.statusCode === 409,
251
+ );
252
+
253
+ assert.deepEqual(world.writes, [], "no placement was written");
254
+ assert.deepEqual(world.events, [], "and nothing was enqueued");
255
+ assert.equal(
256
+ world.row.mailboxId,
257
+ ARCHIVE,
258
+ "the winner's placement stands exactly as it left it",
259
+ );
260
+ assert.equal(world.row.uid, SETTLED_UID);
261
+ assert.equal(world.row.status, "active");
262
+ });
263
+ });
264
+
265
+ describe("a delete writes only against the placement it read (R3)", () => {
266
+ it("marks the message when nothing else touched the row", async () => {
267
+ const world = buildWorld();
268
+ const service = buildMoveService(world);
269
+
270
+ await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
271
+
272
+ assert.equal(world.events.length, 1);
273
+ assert.equal(world.row.status, "moving");
274
+ assert.equal(world.row.mailboxId, TRASH);
275
+ });
276
+
277
+ it("skips the message, and enqueues nothing for it, when the row settles elsewhere", async () => {
278
+ let settled = false;
279
+ const world = buildWorld((w) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ w.settleElsewhere();
283
+ });
284
+ const service = buildMoveService(world);
285
+
286
+ const { refusedMessageIds } = await service.deleteMessages(
287
+ ACCOUNT_CONFIG,
288
+ [MESSAGE_ID],
289
+ ACCOUNT,
290
+ );
291
+
292
+ assert.deepEqual(
293
+ refusedMessageIds,
294
+ [MESSAGE_ID],
295
+ "the caller is told which rows this delete never claimed",
296
+ );
297
+ assert.deepEqual(world.writes, [], "no placement was written");
298
+ assert.deepEqual(
299
+ world.events,
300
+ [],
301
+ "and no delete was enqueued against a uid the source no longer holds",
302
+ );
303
+ assert.equal(world.row.mailboxId, ARCHIVE);
304
+ assert.equal(world.row.uid, SETTLED_UID);
305
+ });
306
+ });
307
+
308
+ // The folder lookup is scoped to one account. The daily brief lists every
309
+ // account's mail, so a selection made there carries rows this lookup cannot
310
+ // resolve — and dropping them silently answered "deleted" over messages still
311
+ // sitting where they were.
312
+ describe("a delete reports a row whose folder it could not resolve", () => {
313
+ it("refuses it rather than counting it as deleted", async () => {
314
+ const world = buildWorld();
315
+ world.row.mailboxId = "mbx-another-account";
316
+ const service = buildMoveService(world);
317
+
318
+ const { refusedMessageIds } = await service.deleteMessages(
319
+ ACCOUNT_CONFIG,
320
+ [MESSAGE_ID],
321
+ ACCOUNT,
322
+ );
323
+
324
+ assert.deepEqual(refusedMessageIds, [MESSAGE_ID]);
325
+ assert.deepEqual(world.writes, [], "nothing was written");
326
+ assert.deepEqual(world.events, [], "and nothing was enqueued");
327
+ });
328
+ });
329
+
330
+ describe("a permanent delete writes only against the placement it read (R3)", () => {
331
+ // The row is already in Trash, so the batch takes the expunge path rather
332
+ // than the move-to-Trash one — a different writer, and the fourth of the
333
+ // four. Its event names one uid in one folder, so a row that has moved since
334
+ // the read must not be marked for it: a restore out of Trash settling in
335
+ // between was overwritten with `deleting`, and the expunge went after the
336
+ // uid Trash no longer holds.
337
+ const inTrash = (world: World): void => {
338
+ world.row.mailboxId = TRASH;
339
+ };
340
+
341
+ it("marks the message when nothing else touched the row", async () => {
342
+ const world = buildWorld();
343
+ inTrash(world);
344
+ const service = buildMoveService(world);
345
+
346
+ const { refusedMessageIds } = await service.deleteMessages(
347
+ ACCOUNT_CONFIG,
348
+ [MESSAGE_ID],
349
+ ACCOUNT,
350
+ { permanent: true },
351
+ );
352
+
353
+ assert.deepEqual(refusedMessageIds, []);
354
+ assert.equal(world.row.status, "deleting");
355
+ assert.equal(world.events.length, 1);
356
+ });
357
+
358
+ it("reports the message, and enqueues nothing, when the row settles elsewhere", async () => {
359
+ let settled = false;
360
+ const world = buildWorld((w) => {
361
+ if (settled) return;
362
+ settled = true;
363
+ w.settleElsewhere();
364
+ });
365
+ inTrash(world);
366
+ const service = buildMoveService(world);
367
+
368
+ const { refusedMessageIds } = await service.deleteMessages(
369
+ ACCOUNT_CONFIG,
370
+ [MESSAGE_ID],
371
+ ACCOUNT,
372
+ { permanent: true },
373
+ );
374
+
375
+ // Reported, never silent: the client removed the row optimistically, and
376
+ // one that reappears with nothing said is a failure the user cannot act
377
+ // on — the same class as a button that does nothing (#1229).
378
+ assert.deepEqual(refusedMessageIds, [MESSAGE_ID]);
379
+ assert.deepEqual(world.writes, [], "no placement was written");
380
+ assert.deepEqual(world.events, [], "and no expunge was enqueued");
381
+ assert.equal(world.row.mailboxId, ARCHIVE);
382
+ assert.equal(world.row.status, "active");
383
+ });
384
+ });
385
+
386
+ describe("a classification filing writes only against the placement it read (R3)", () => {
387
+ const buildPlacementService = (world: World, onPut?: () => void) => {
388
+ const markers = new Map<string, unknown>();
389
+ const pushed: string[] = [];
390
+ const markerService = {
391
+ find: async () => markers.get(MESSAGE_ID) ?? null,
392
+ // The last step between reading the row and writing it, so this is
393
+ // where a concurrent settle is staged.
394
+ put: async (input: Record<string, unknown>) => {
395
+ onPut?.();
396
+ const marker = { ...input, state: "pending", createdAt: Date.now() };
397
+ markers.set(MESSAGE_ID, marker);
398
+ return marker;
399
+ },
400
+ updateState: async () => markers.get(MESSAGE_ID),
401
+ delete: async () => {
402
+ markers.delete(MESSAGE_ID);
403
+ },
404
+ } as unknown as IMessagePlacementMoveRepository;
405
+
406
+ const service = new PlacementMoveService({
407
+ messageService: world.messageService,
408
+ threadMessageService: world.threadMessageService,
409
+ markerService,
410
+ addressService: world.addressService,
411
+ mailboxSpecialUseService: world.mailboxSpecialUseService,
412
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-message-mgmt",
413
+ moveSettleTimeoutMs: 100,
414
+ moveSettlePollMs: 10,
415
+ });
416
+ (service as unknown as { enqueuePush: () => Promise<void> }).enqueuePush =
417
+ async () => {
418
+ pushed.push(MESSAGE_ID);
419
+ };
420
+ return { service, markers, pushed };
421
+ };
422
+
423
+ it("files the message when nothing else touched the row", async () => {
424
+ const world = buildWorld();
425
+ const { service, markers, pushed } = buildPlacementService(world);
426
+
427
+ await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
428
+
429
+ assert.equal(world.row.mailboxId, JUNK);
430
+ assert.deepEqual(
431
+ world.threadMoves,
432
+ [JUNK],
433
+ "and the listing row follows the message it describes",
434
+ );
435
+ assert.deepEqual(pushed, [MESSAGE_ID]);
436
+ assert.ok(markers.get(MESSAGE_ID));
437
+ });
438
+
439
+ // The Message row is written first, so a loser leaves nothing behind. Moving
440
+ // the listing row ahead of the transition made the loser diverge instead: the
441
+ // listing showed the classification folder while the Message row sat where
442
+ // the winner put it, and nothing routine reconciles that.
443
+ it("leaves the listing row alone too when the row settles elsewhere", async () => {
444
+ const world = buildWorld();
445
+ const { service, markers, pushed } = buildPlacementService(world, () =>
446
+ world.settleElsewhere(),
447
+ );
448
+
449
+ await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
450
+
451
+ assert.deepEqual(world.writes, [], "no placement was written");
452
+ assert.deepEqual(
453
+ world.threadMoves,
454
+ [],
455
+ "and the listing row never claimed a folder the message is not in",
456
+ );
457
+ assert.deepEqual(pushed, [], "and no push was enqueued");
458
+ assert.equal(
459
+ markers.get(MESSAGE_ID),
460
+ undefined,
461
+ "the marker goes with it: nothing owes IMAP this move",
462
+ );
463
+ assert.equal(world.row.mailboxId, ARCHIVE);
464
+ });
465
+
466
+ // A permanent delete has already claimed the row. Waiting the ceiling out and
467
+ // then throwing drove an ordinary sequence toward the DLQ; there is nothing
468
+ // left to file, so this skips.
469
+ it("skips a message a delete has claimed, rather than burning the ceiling", async () => {
470
+ const world = buildWorld();
471
+ world.row.status = "deleting";
472
+ const { service, markers, pushed } = buildPlacementService(world);
473
+
474
+ const startedAt = Date.now();
475
+ await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
476
+
477
+ assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
478
+ assert.deepEqual(world.writes, []);
479
+ assert.deepEqual(pushed, []);
480
+ assert.equal(markers.get(MESSAGE_ID), undefined);
481
+ });
482
+ });
@@ -0,0 +1,157 @@
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ MessageItem,
5
+ ThreadMessageItem,
6
+ } from "@remit/data-ports";
7
+ import { isNotFoundError } from "@remit/data-ports/errors";
8
+ import { MessageStatus } from "@remit/domain-enums";
9
+ import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
10
+
11
+ export interface RestoreSourcePlacementDeps {
12
+ messageService: Pick<IMessageRepository, "transitionPlacement">;
13
+ threadMessageService: Pick<
14
+ IThreadMessageRepository,
15
+ "findAllByMessageId" | "update"
16
+ >;
17
+ }
18
+
19
+ /** Whether the row was still owed a restore when this ran. */
20
+ export type RestoreSourcePlacementOutcome = "restored" | "superseded";
21
+
22
+ export interface RestoreSourcePlacementInput {
23
+ accountConfigId: string;
24
+ messageId: string;
25
+ sourceMailboxId: string;
26
+ uid: number;
27
+ /**
28
+ * What the row records about the mutation that will now never happen.
29
+ * `synced` where the row is a faithful projection of the source again and
30
+ * nothing was refused. `abandoned` where a mutation the user asked for gave
31
+ * up, which is a failure the row has to carry (imap-mutations R3).
32
+ */
33
+ syncStatus: MessageItem["syncStatus"];
34
+ /**
35
+ * Which mutation gave up, for the row to carry alongside `abandoned`. The
36
+ * hand-back is about to set `status` back to `active`, and `status` was the
37
+ * only field naming it — without this the client can see that something was
38
+ * abandoned and not what, which is how a move that handed back was reported
39
+ * as a failed delete (issue #1229). `none` accompanies every other
40
+ * `syncStatus`, so the field never outlives its gate.
41
+ */
42
+ abandonedMutation: MessageItem["abandonedMutation"];
43
+ /**
44
+ * The listing rows, where the caller has already read them to pick this
45
+ * outcome. Omitted, they are read here.
46
+ */
47
+ threadMessages?: ThreadMessageItem[];
48
+ }
49
+
50
+ /**
51
+ * Put a row back on the pre-mutation source pair and hand its listing rows back
52
+ * with it.
53
+ *
54
+ * `status: moving` has to be cleared here or the row is stuck: a give-up that
55
+ * writes a sync status and returns leaves the row naming the destination with
56
+ * the source's uid — the pair `placementBindingOf` calls a lie and
57
+ * `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs it
58
+ * (sync does not touch `status`, and the cursor rebuild's `updateUid` runs only
59
+ * on a UIDVALIDITY change), so the message is undeletable and unmovable for good
60
+ * (issue #1005).
61
+ *
62
+ * A transition, not a plain update (imap-mutations R3): the restore is only
63
+ * owed while a mutation is still outstanding on the row, so the predicate is
64
+ * `moving` or `deleting`. A row another lane has already settled — the
65
+ * placement-move push runs off a standard queue and is ordered against nothing
66
+ * on the account's FIFO — is the winner, and dragging it back to a source it
67
+ * has since left is the corruption this predicate exists to refuse. The loser
68
+ * writes nothing at all, listing rows included, and answers `superseded`.
69
+ *
70
+ * No `message.moved` outbox row: nothing here moved. The optimistic write that
71
+ * pointed the row at the destination never enqueued one either, so the search
72
+ * index still holds the source and a revert has nothing to correct.
73
+ *
74
+ * The caller must have evidence, not an inference: this writes a placement, and
75
+ * a placement written on a guess binds live mail to a uid that names somebody
76
+ * else's message. Two kinds of caller qualify, and nothing else does.
77
+ *
78
+ * A terminal resolver reaches here from {@link isMessageGoneFromOpenMailbox}
79
+ * answering that the message is STILL at `sourceMailboxId`/`uid`, which makes
80
+ * this the server's own answer rather than a revert on ambiguity (what PR #652
81
+ * was pulled for).
82
+ *
83
+ * A paused-cursor caller reaches here from a `MailboxCursorPausedError` the
84
+ * openBox guard threw before the outbound command was issued (issue #1203), and
85
+ * restores the pre-mutation pair off its own event. On a FIRST delivery that is
86
+ * not an inference but an undo: the command provably never left, so the row is
87
+ * put back exactly as it was found. A redelivery cannot claim that — the
88
+ * earlier attempt's tagged OK can be lost with the connection — so it asks
89
+ * `probePausedPlacement` which folder holds the message and only reaches here
90
+ * once the answer rules the destination out. Either way the source mailbox is
91
+ * by definition awaiting a cursor rebuild, which matches its rows by Message-ID
92
+ * and adjudicates the uid: handing the row back is what puts it in the set the
93
+ * rebuild walks, since a row still naming the destination is in neither
94
+ * folder's.
95
+ *
96
+ * A row another path deleted while the probe was in flight has nothing left to
97
+ * restore, and it fails the predicate like any other row that is no longer
98
+ * owed one. A listing row that other path has already rewritten is its to
99
+ * finish; the update for it comes back `NotFoundError`, and that is a settled
100
+ * state here rather than a fault. Throwing on it would escape the caller's
101
+ * `.catch()` and cost the record its ack and its resync, which is the contract
102
+ * the terminal resolvers state: never re-thrown.
103
+ */
104
+ export const restoreSourcePlacement = async (
105
+ deps: RestoreSourcePlacementDeps,
106
+ input: RestoreSourcePlacementInput,
107
+ ): Promise<RestoreSourcePlacementOutcome> => {
108
+ const {
109
+ accountConfigId,
110
+ messageId,
111
+ sourceMailboxId,
112
+ uid,
113
+ syncStatus,
114
+ abandonedMutation,
115
+ } = input;
116
+
117
+ const skipNotFound = (error: unknown): void => {
118
+ if (isNotFoundError(error)) return;
119
+ throw error;
120
+ };
121
+
122
+ const restored = await deps.messageService.transitionPlacement(
123
+ messageId,
124
+ { status: [MessageStatus.moving, MessageStatus.deleting] },
125
+ {
126
+ mailboxId: sourceMailboxId,
127
+ uid,
128
+ status: MessageStatus.active,
129
+ syncStatus,
130
+ abandonedMutation,
131
+ },
132
+ );
133
+ if (!restored) return "superseded";
134
+
135
+ const threadMessages =
136
+ input.threadMessages ??
137
+ (await deps.threadMessageService.findAllByMessageId(
138
+ accountConfigId,
139
+ messageId,
140
+ ));
141
+ for (const threadMessage of threadMessages) {
142
+ const args = buildThreadMessageMoveRevert(
143
+ threadMessage,
144
+ uid,
145
+ sourceMailboxId,
146
+ );
147
+ await deps.threadMessageService
148
+ .update(
149
+ threadMessage.accountConfigId,
150
+ threadMessage.threadMessageId,
151
+ args.set,
152
+ { composites: args.composites },
153
+ )
154
+ .catch(skipNotFound);
155
+ }
156
+ return "restored";
157
+ };
@@ -174,9 +174,20 @@ const buildWorld = (
174
174
  if (m) Object.assign(m, patch);
175
175
  return m;
176
176
  },
177
- updateForMove: async (id: string, patch: Record<string, unknown>) => {
177
+ transitionPlacement: async (
178
+ id: string,
179
+ _expected: Record<string, unknown>,
180
+ patch: Record<string, unknown>,
181
+ ) => {
178
182
  const m = messages.get(id);
179
- if (m) Object.assign(m, patch);
183
+ if (!m) return undefined;
184
+ for (const [field, value] of Object.entries(patch)) {
185
+ if (value === null) {
186
+ delete m[field as keyof typeof m];
187
+ continue;
188
+ }
189
+ Object.assign(m, { [field]: value });
190
+ }
180
191
  return m;
181
192
  },
182
193
  clearSpamReport: async (id: string) => {
@@ -184,14 +195,6 @@ const buildWorld = (
184
195
  if (m) delete m.spamReport;
185
196
  return m;
186
197
  },
187
- clearOriginalMailboxId: async (id: string) => {
188
- const m = messages.get(id);
189
- if (m) {
190
- delete m.originalMailboxId;
191
- delete m.originalUid;
192
- }
193
- return m;
194
- },
195
198
  } as unknown as IMessageRepository;
196
199
 
197
200
  const addressService = {