@remit/mailbox-service 0.0.76 → 0.0.77

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.76",
3
+ "version": "0.0.77",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Issue #845 item 3. While a move is in flight the Message row names the
3
+ * destination folder but still carries the SOURCE folder's uid, so a delete
4
+ * that binds the pair as it stands sends the worker to expunge the destination
5
+ * folder's own message at that uid — an unrelated message, destroyed for good.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import type {
11
+ IAddressRepository,
12
+ IMailboxRepository,
13
+ IMailboxSpecialUseRepository,
14
+ IMessageRepository,
15
+ IThreadMessageRepository,
16
+ } from "@remit/data-ports";
17
+ import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
18
+ import type { RoleResolution } from "@remit/data-ports/folder-role";
19
+ import { MessageMoveService } from "./message-move.js";
20
+
21
+ const ACCOUNT = "acc-1";
22
+ const ACCOUNT_CONFIG = "cfg-1";
23
+ const INBOX = "mbx-inbox";
24
+ const ARCHIVE = "mbx-archive";
25
+ const TRASH = "mbx-trash";
26
+
27
+ const MOVING_ID = "msg-moving";
28
+ const SETTLED_ID = "msg-settled";
29
+ const STRANDED_ID = "msg-stranded";
30
+ const COPY_ID = "msg-copy";
31
+
32
+ const INBOX_UID = 42;
33
+ const ARCHIVE_UID = 907;
34
+ const SETTLED_UID = 11;
35
+
36
+ type TrashMailbox = { mailboxId: string; fullPath: string };
37
+
38
+ const flaggedTrash: RoleResolution<TrashMailbox> = {
39
+ kind: "flagged",
40
+ mailbox: { mailboxId: TRASH, fullPath: "INBOX/Trash" },
41
+ };
42
+
43
+ interface CapturedEvent {
44
+ type: string;
45
+ messageId?: string;
46
+ operation?: string;
47
+ mailboxId?: string;
48
+ mailboxPath?: string;
49
+ uid?: number;
50
+ sourceMailboxPath?: string;
51
+ }
52
+
53
+ const mailboxes = [
54
+ { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
55
+ { mailboxId: ARCHIVE, fullPath: "Archive", accountId: ACCOUNT },
56
+ { mailboxId: TRASH, fullPath: "INBOX/Trash", accountId: ACCOUNT },
57
+ ];
58
+
59
+ /** A row mid-move: Archive is already written, the uid is still INBOX's. */
60
+ const movingRow = () => ({
61
+ messageId: MOVING_ID,
62
+ mailboxId: ARCHIVE,
63
+ uid: INBOX_UID,
64
+ status: "moving",
65
+ syncStatus: "pending",
66
+ originalMailboxId: INBOX,
67
+ originalUid: INBOX_UID,
68
+ });
69
+
70
+ /** The same row after the trash-move handler gave up without confirming. */
71
+ const strandedRow = () => ({
72
+ ...movingRow(),
73
+ messageId: STRANDED_ID,
74
+ syncStatus: "failed",
75
+ });
76
+
77
+ /** An ordinary settled row. */
78
+ const settledRow = () => ({
79
+ messageId: SETTLED_ID,
80
+ mailboxId: INBOX,
81
+ uid: SETTLED_UID,
82
+ status: "active",
83
+ syncStatus: "synced",
84
+ });
85
+
86
+ /**
87
+ * A freshly copied row: `moving` until COPYUID lands, with no server-side uid
88
+ * at all and no move behind it. Its uid is not ready; it does not name anyone.
89
+ */
90
+ const freshCopyRow = () => ({
91
+ messageId: COPY_ID,
92
+ mailboxId: ARCHIVE,
93
+ uid: 0,
94
+ status: "moving",
95
+ syncStatus: "pending",
96
+ });
97
+
98
+ const buildWorld = (seed: Array<Record<string, unknown>>) => {
99
+ const patches: Array<{ messageId: string; patch: Record<string, unknown> }> =
100
+ [];
101
+ const events: CapturedEvent[] = [];
102
+ const rows = new Map<string, Record<string, unknown>>(
103
+ seed.map((row): [string, Record<string, unknown>] => [
104
+ String(row.messageId),
105
+ row,
106
+ ]),
107
+ );
108
+
109
+ const settle = (messageId: string) => {
110
+ Object.assign(rows.get(messageId) ?? {}, {
111
+ uid: ARCHIVE_UID,
112
+ status: "active",
113
+ syncStatus: "synced",
114
+ });
115
+ };
116
+
117
+ const messageService = {
118
+ get: async (ids: string | string[]) =>
119
+ Array.isArray(ids)
120
+ ? ids.flatMap((id) => {
121
+ const row = rows.get(id);
122
+ return row ? [row] : [];
123
+ })
124
+ : rows.get(ids),
125
+ update: async (id: string, patch: Record<string, unknown>) => {
126
+ patches.push({ messageId: id, patch });
127
+ return Object.assign(rows.get(id) ?? {}, patch);
128
+ },
129
+ updateForMove: async (id: string, patch: Record<string, unknown>) => {
130
+ patches.push({ messageId: id, patch });
131
+ return Object.assign(rows.get(id) ?? {}, patch);
132
+ },
133
+ upsert: async (row: Record<string, unknown>) => row,
134
+ } as unknown as IMessageRepository;
135
+
136
+ const threadMessageService = {
137
+ findAllByMessageId: async () => [],
138
+ getByMessageId: async (_cfg: string, messageId: string) => ({
139
+ accountConfigId: ACCOUNT_CONFIG,
140
+ threadMessageId: `tm-${messageId}`,
141
+ messageId,
142
+ threadId: "thr-1",
143
+ mailboxId: rows.get(messageId)?.mailboxId,
144
+ sentDate: 1,
145
+ isRead: false,
146
+ isDeleted: false,
147
+ hasStars: false,
148
+ hasAttachment: false,
149
+ }),
150
+ create: async () => {},
151
+ update: async () => {},
152
+ delete: async () => {},
153
+ } as unknown as IThreadMessageRepository;
154
+
155
+ const mailboxService = {
156
+ get: async (_accountId: string, ids: string | string[]) =>
157
+ Array.isArray(ids)
158
+ ? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
159
+ : mailboxes.find((mailbox) => mailbox.mailboxId === ids),
160
+ } as unknown as IMailboxRepository;
161
+
162
+ const mailboxSpecialUseService = {
163
+ resolveTrashRole: async () => flaggedTrash,
164
+ findTrashMailbox: async () => flaggedTrash.mailbox,
165
+ } as unknown as IMailboxSpecialUseRepository;
166
+
167
+ const addressService = {
168
+ reconcileJunkOnlyForMessage: async () => {},
169
+ } as unknown as IAddressRepository;
170
+
171
+ const service = new MessageMoveService({
172
+ messageService,
173
+ mailboxService,
174
+ mailboxSpecialUseService,
175
+ threadMessageService,
176
+ addressService,
177
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
178
+ moveSettleTimeoutMs: 200,
179
+ moveSettlePollMs: 10,
180
+ });
181
+
182
+ const stubs = service as unknown as {
183
+ enqueueEventsBatch: (batch: CapturedEvent[]) => Promise<void>;
184
+ enqueueEvent: (event: CapturedEvent) => Promise<void>;
185
+ };
186
+ stubs.enqueueEventsBatch = async (batch) => {
187
+ events.push(...batch);
188
+ };
189
+ stubs.enqueueEvent = async (event) => {
190
+ events.push(event);
191
+ };
192
+
193
+ return { service, patches, events, rows, settle };
194
+ };
195
+
196
+ describe("a delete never binds the folder/uid pair of an in-flight move (#845.3)", () => {
197
+ it("binds the confirmed pair once the move settles", async () => {
198
+ const { service, events, settle } = buildWorld([movingRow()]);
199
+
200
+ setTimeout(() => settle(MOVING_ID), 30);
201
+ await service.deleteMessages(ACCOUNT_CONFIG, [MOVING_ID], ACCOUNT);
202
+
203
+ assert.equal(events.length, 1);
204
+ assert.equal(events[0].operation, "move_to_trash");
205
+ assert.equal(
206
+ events[0].mailboxPath,
207
+ "Archive",
208
+ "the folder the confirmed move left the message in",
209
+ );
210
+ assert.equal(
211
+ events[0].uid,
212
+ ARCHIVE_UID,
213
+ "and that folder's own uid, never the source folder's",
214
+ );
215
+ });
216
+
217
+ it("refuses the delete, and touches nothing, when the move never settles", async () => {
218
+ const { service, patches, events, rows } = buildWorld([movingRow()]);
219
+
220
+ await assert.rejects(
221
+ () => service.deleteMessages(ACCOUNT_CONFIG, [MOVING_ID], ACCOUNT),
222
+ (error: unknown) =>
223
+ error instanceof MessagePlacementUnsettledError &&
224
+ error.statusCode === 409 &&
225
+ error.publicApiError?.code === "message_placement_unsettled" &&
226
+ error.publicApiError?.details?.messageId === MOVING_ID &&
227
+ error.publicApiError?.details?.accountId === ACCOUNT &&
228
+ error.publicApiError?.details?.reason === "in_flight",
229
+ );
230
+
231
+ assert.deepEqual(events, [], "nothing was enqueued");
232
+ assert.deepEqual(patches, [], "and no local write was made");
233
+ assert.equal(rows.get(MOVING_ID)?.uid, INBOX_UID);
234
+ });
235
+
236
+ it("expunges no part of a mixed batch when one row is in flight", async () => {
237
+ // All-or-nothing: the settled row is deletable on its own, and would be
238
+ // expunged first by a per-row gate. One unverifiable pair refuses the
239
+ // batch it arrived in.
240
+ const { service, patches, events } = buildWorld([
241
+ settledRow(),
242
+ movingRow(),
243
+ ]);
244
+
245
+ await assert.rejects(
246
+ () =>
247
+ service.deleteMessages(
248
+ ACCOUNT_CONFIG,
249
+ [SETTLED_ID, MOVING_ID],
250
+ ACCOUNT,
251
+ { permanent: true },
252
+ ),
253
+ MessagePlacementUnsettledError,
254
+ );
255
+
256
+ assert.deepEqual(
257
+ events,
258
+ [],
259
+ "the settled row was not expunged alongside the refusal",
260
+ );
261
+ assert.deepEqual(patches, []);
262
+ });
263
+
264
+ it("deletes the whole batch once the in-flight row settles", async () => {
265
+ const { service, events, settle } = buildWorld([settledRow(), movingRow()]);
266
+
267
+ setTimeout(() => settle(MOVING_ID), 30);
268
+ await service.deleteMessages(
269
+ ACCOUNT_CONFIG,
270
+ [SETTLED_ID, MOVING_ID],
271
+ ACCOUNT,
272
+ { permanent: true },
273
+ );
274
+
275
+ assert.deepEqual(
276
+ events.map((event) => [event.messageId, event.uid]).sort(),
277
+ [
278
+ [MOVING_ID, ARCHIVE_UID],
279
+ [SETTLED_ID, SETTLED_UID],
280
+ ].sort(),
281
+ );
282
+ });
283
+
284
+ it("waits one row's ceiling for a batch, not one per row", async () => {
285
+ // The gate is concurrent. Three in-flight rows that never settle must
286
+ // refuse at roughly one ceiling — a sequential gate would spend three,
287
+ // and at the client's 100-id chunk cap that is a gateway timeout.
288
+ const rows = [movingRow(), movingRow(), movingRow()].map((row, index) => ({
289
+ ...row,
290
+ messageId: `${MOVING_ID}-${index}`,
291
+ }));
292
+ const { service } = buildWorld(rows);
293
+
294
+ const startedAt = Date.now();
295
+ await assert.rejects(
296
+ () =>
297
+ service.deleteMessages(
298
+ ACCOUNT_CONFIG,
299
+ rows.map((row) => row.messageId),
300
+ ACCOUNT,
301
+ { permanent: true },
302
+ ),
303
+ MessagePlacementUnsettledError,
304
+ );
305
+
306
+ assert.ok(
307
+ Date.now() - startedAt < 500,
308
+ "three rows refused within one 200ms ceiling, not three",
309
+ );
310
+ });
311
+ });
312
+
313
+ describe("the gate refuses only a uid that names somebody else (#845.3)", () => {
314
+ it("deletes a freshly copied row rather than waiting on it", async () => {
315
+ // A copy is `moving` with no server-side uid yet. That uid is not ready,
316
+ // which is not the same as naming another folder's message, and the
317
+ // delete of a copy is an ordinary flow that must not stall or refuse.
318
+ const { service, events } = buildWorld([freshCopyRow()]);
319
+
320
+ const startedAt = Date.now();
321
+ await service.deleteMessages(ACCOUNT_CONFIG, [COPY_ID], ACCOUNT, {
322
+ permanent: true,
323
+ });
324
+
325
+ assert.equal(events.length, 1);
326
+ assert.equal(events[0].operation, "permanent_delete");
327
+ assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
328
+ });
329
+
330
+ it("refuses a row stranded by a move that gave up, without spending the ceiling", async () => {
331
+ // `syncStatus: failed` with `status: moving` is the shape every handler's
332
+ // give-up path leaves behind, and only `updateUid` clears it. The pair is
333
+ // still a lie, so the delete is still refused — but under a reason whose
334
+ // remedy is a resync, and without a wait that could never succeed.
335
+ const { service, events } = buildWorld([strandedRow()]);
336
+
337
+ const startedAt = Date.now();
338
+ await assert.rejects(
339
+ () => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
340
+ (error: unknown) =>
341
+ error instanceof MessagePlacementUnsettledError &&
342
+ error.publicApiError?.details?.reason === "unverified",
343
+ );
344
+
345
+ assert.deepEqual(events, []);
346
+ assert.ok(Date.now() - startedAt < 100, "refused without waiting");
347
+ });
348
+ });
349
+
350
+ describe("a copy binds the same pair and takes the same gate (#845.3)", () => {
351
+ it("copies from the confirmed pair once the move settles", async () => {
352
+ const { service, events, settle } = buildWorld([movingRow()]);
353
+
354
+ setTimeout(() => settle(MOVING_ID), 30);
355
+ await service.copyMessage(ACCOUNT_CONFIG, MOVING_ID, INBOX, ACCOUNT);
356
+
357
+ assert.equal(events.length, 1);
358
+ assert.equal(events[0].type, "MESSAGE_COPY");
359
+ assert.equal(events[0].sourceMailboxPath, "Archive");
360
+ assert.equal(events[0].uid, ARCHIVE_UID);
361
+ });
362
+
363
+ it("writes no part of a batch copy when one row is in flight", async () => {
364
+ // The gate runs over the whole batch before the first upsert, so a
365
+ // refusal cannot leave copies already committed and enqueued.
366
+ const { service, events } = buildWorld([settledRow(), movingRow()]);
367
+
368
+ const startedAt = Date.now();
369
+ await assert.rejects(
370
+ () =>
371
+ service.copyMessages(
372
+ ACCOUNT_CONFIG,
373
+ [SETTLED_ID, MOVING_ID],
374
+ TRASH,
375
+ ACCOUNT,
376
+ ),
377
+ MessagePlacementUnsettledError,
378
+ );
379
+
380
+ assert.deepEqual(events, [], "no copy was enqueued");
381
+ assert.ok(
382
+ Date.now() - startedAt < 500,
383
+ "and the batch spent one ceiling, not one per row",
384
+ );
385
+ });
386
+ });
@@ -10,8 +10,12 @@ import type {
10
10
  IMailboxSpecialUseRepository,
11
11
  IMessageRepository,
12
12
  IThreadMessageRepository,
13
+ MessageItem,
13
14
  } from "@remit/data-ports";
14
- import { FolderRoleUnresolvedError } from "@remit/data-ports/errors";
15
+ import {
16
+ FolderRoleUnresolvedError,
17
+ MessagePlacementUnsettledError,
18
+ } from "@remit/data-ports/errors";
15
19
  import {
16
20
  type FolderRoleUnresolvedReason,
17
21
  NO_TRASH_FOLDER_REASON,
@@ -29,6 +33,11 @@ import {
29
33
  MessageSyncStatus,
30
34
  } from "@remit/domain-enums";
31
35
  import { createQueueProducer } from "@remit/sqs-client/producer";
36
+ import {
37
+ type PlacementBinding,
38
+ placementBindingOf,
39
+ waitForPlacementToSettle,
40
+ } from "./placement-settled.js";
32
41
 
33
42
  /**
34
43
  * Event types for message move/delete operations.
@@ -120,8 +129,13 @@ export interface MessageMoveConfig {
120
129
  sqsQueueUrl: string;
121
130
  sqsEndpoint?: string;
122
131
  logger?: MessageMoveLogger;
132
+ moveSettleTimeoutMs?: number;
133
+ moveSettlePollMs?: number;
123
134
  }
124
135
 
136
+ const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
137
+ const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
138
+
125
139
  /**
126
140
  * The account resolves no Trash folder, so a move-to-Trash delete has nowhere
127
141
  * to put the mail and an Empty Trash has nothing it may empty. A designed,
@@ -239,6 +253,8 @@ export class MessageMoveService {
239
253
  private sqs: SQSClient;
240
254
  private queueUrl: string;
241
255
  private log: MessageMoveLogger;
256
+ private moveSettleTimeoutMs: number;
257
+ private moveSettlePollMs: number;
242
258
 
243
259
  constructor(config: MessageMoveConfig) {
244
260
  this.messageService = config.messageService;
@@ -248,6 +264,10 @@ export class MessageMoveService {
248
264
  this.addressService = config.addressService;
249
265
  this.queueUrl = config.sqsQueueUrl;
250
266
  this.log = config.logger ?? noopLogger;
267
+ this.moveSettleTimeoutMs =
268
+ config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
269
+ this.moveSettlePollMs =
270
+ config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
251
271
 
252
272
  this.sqs = createQueueProducer({
253
273
  queueUrl: config.sqsQueueUrl,
@@ -288,8 +308,13 @@ export class MessageMoveService {
288
308
  if (messageIds.length === 0) return;
289
309
 
290
310
  // Batch get all messages
291
- const messages = await this.messageService.get(messageIds);
292
- if (messages.length === 0) return;
311
+ const rows = await this.messageService.get(messageIds);
312
+ if (rows.length === 0) return;
313
+
314
+ // Ahead of the Trash gates below, which compare `mailboxId` against the
315
+ // Trash folder: an in-flight move makes that comparison answer for a
316
+ // folder the message has not reached.
317
+ const messages = await this.settledPlacements(rows, accountId);
293
318
 
294
319
  // Get unique mailbox IDs and batch fetch mailboxes
295
320
  const uniqueMailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
@@ -633,7 +658,24 @@ export class MessageMoveService {
633
658
  destinationMailboxId: string,
634
659
  accountId: string,
635
660
  ): Promise<string> => {
636
- const sourceMessage = await this.messageService.get(messageId);
661
+ return this.copySettledMessage(
662
+ accountConfigId,
663
+ await this.settledPlacement(
664
+ await this.messageService.get(messageId),
665
+ accountId,
666
+ ),
667
+ destinationMailboxId,
668
+ accountId,
669
+ );
670
+ };
671
+
672
+ private copySettledMessage = async (
673
+ accountConfigId: string,
674
+ sourceMessage: MessageItem,
675
+ destinationMailboxId: string,
676
+ accountId: string,
677
+ ): Promise<string> => {
678
+ const messageId = sourceMessage.messageId;
637
679
  const sourceMailbox = await this.mailboxService.get(
638
680
  accountId,
639
681
  sourceMessage.mailboxId,
@@ -750,15 +792,24 @@ export class MessageMoveService {
750
792
  destinationMailboxId: string,
751
793
  accountId: string,
752
794
  ): Promise<string[]> => {
795
+ // Every row is settled before the first copy is written, so the batch's
796
+ // wait is one row's ceiling rather than the sum across it, and a refusal
797
+ // lands before any part of the batch has committed.
798
+ const sources = await this.settledPlacements(
799
+ await this.messageService.get(messageIds),
800
+ accountId,
801
+ );
802
+
753
803
  const newMessageIds: string[] = [];
754
- for (const messageId of messageIds) {
755
- const newId = await this.copyMessage(
756
- accountConfigId,
757
- messageId,
758
- destinationMailboxId,
759
- accountId,
804
+ for (const source of sources) {
805
+ newMessageIds.push(
806
+ await this.copySettledMessage(
807
+ accountConfigId,
808
+ source,
809
+ destinationMailboxId,
810
+ accountId,
811
+ ),
760
812
  );
761
- newMessageIds.push(newId);
762
813
  }
763
814
  return newMessageIds;
764
815
  };
@@ -867,6 +918,61 @@ export class MessageMoveService {
867
918
  return { deletedCount: messages.length };
868
919
  };
869
920
 
921
+ private refusePlacement = (
922
+ message: MessageItem,
923
+ accountId: string,
924
+ binding: Exclude<PlacementBinding, "consistent">,
925
+ ): never => {
926
+ this.log.error(
927
+ { accountId, messageId: message.messageId, binding },
928
+ "Refused: this message's folder and uid do not name the same message",
929
+ );
930
+ throw new MessagePlacementUnsettledError(
931
+ `Message ${message.messageId} was not acted on: its folder and uid do not name the same message`,
932
+ accountId,
933
+ message.messageId,
934
+ binding === "abandoned" ? "unverified" : "in_flight",
935
+ );
936
+ };
937
+
938
+ private settledPlacement = async (
939
+ message: MessageItem,
940
+ accountId: string,
941
+ ): Promise<MessageItem> => {
942
+ const binding = placementBindingOf(message);
943
+ if (binding === "consistent") return message;
944
+
945
+ // An abandoned move is never coming back, so the ceiling would be spent
946
+ // only to reach the same answer.
947
+ if (binding === "abandoned")
948
+ return this.refusePlacement(message, accountId, binding);
949
+
950
+ const settled = await waitForPlacementToSettle(
951
+ this.messageService,
952
+ message.messageId,
953
+ {
954
+ timeoutMs: this.moveSettleTimeoutMs,
955
+ pollMs: this.moveSettlePollMs,
956
+ },
957
+ );
958
+ const settledBinding = placementBindingOf(settled);
959
+ if (settledBinding === "consistent") return settled;
960
+ return this.refusePlacement(settled, accountId, settledBinding);
961
+ };
962
+
963
+ /**
964
+ * Settle a whole batch before any of it is acted on, so one unsettled row
965
+ * refuses the batch whole rather than leaving part of it expunged, and the
966
+ * batch's total wait is one row's ceiling rather than the sum across it.
967
+ */
968
+ private settledPlacements = async (
969
+ messages: MessageItem[],
970
+ accountId: string,
971
+ ): Promise<MessageItem[]> =>
972
+ Promise.all(
973
+ messages.map((message) => this.settledPlacement(message, accountId)),
974
+ );
975
+
870
976
  /**
871
977
  * Update ThreadMessage for move operations.
872
978
  * Updates mailboxId and optionally isDeleted.
@@ -1,5 +1,5 @@
1
1
  import type { MessageItem } from "@remit/data-ports";
2
- import { MessageStatus } from "@remit/domain-enums";
2
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
3
3
 
4
4
  /**
5
5
  * Whether the message row's placement is still an unconfirmed local write
@@ -19,6 +19,55 @@ export const isPlacementUnsettled = (
19
19
  message: Pick<MessageItem, "status">,
20
20
  ): boolean => message.status === MessageStatus.moving;
21
21
 
22
+ /**
23
+ * Whether the row's `uid` belongs to a folder other than the one `mailboxId`
24
+ * names — the state that makes the pair a lie rather than merely incomplete.
25
+ *
26
+ * `updateForMove` is the only writer of this shape: it points the row at the
27
+ * destination and records the pre-move pair, leaving `uid` untouched until the
28
+ * server confirms and `updateUid` replaces it. A row with no `originalUid` was
29
+ * never moved, so its uid names nothing but itself — a freshly copied row is
30
+ * `moving` with `uid: 0` until COPYUID lands, which is a uid that is not ready,
31
+ * not a uid that names somebody else's message.
32
+ */
33
+ export const bindsForeignUid = (
34
+ message: Pick<
35
+ MessageItem,
36
+ "status" | "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
37
+ >,
38
+ ): boolean =>
39
+ isPlacementUnsettled(message) &&
40
+ message.originalUid !== undefined &&
41
+ message.originalUid === message.uid &&
42
+ message.originalMailboxId !== undefined &&
43
+ message.originalMailboxId !== message.mailboxId;
44
+
45
+ /**
46
+ * `in_flight` — a mover is still working on the row, so waiting resolves it.
47
+ * `abandoned` — the mover gave up without confirming (`syncStatus: failed`,
48
+ * written by every handler's give-up path while `status` stays `moving`, since
49
+ * only `updateUid` clears it). Nothing routine settles such a row, so waiting
50
+ * on one is a wait that never ends, and the pair stays a lie either way.
51
+ */
52
+ export type PlacementBinding = "consistent" | "in_flight" | "abandoned";
53
+
54
+ export const placementBindingOf = (
55
+ message: Pick<
56
+ MessageItem,
57
+ | "status"
58
+ | "syncStatus"
59
+ | "mailboxId"
60
+ | "uid"
61
+ | "originalMailboxId"
62
+ | "originalUid"
63
+ >,
64
+ ): PlacementBinding => {
65
+ if (!bindsForeignUid(message)) return "consistent";
66
+ return message.syncStatus === MessageSyncStatus.failed
67
+ ? "abandoned"
68
+ : "in_flight";
69
+ };
70
+
22
71
  export interface PlacementSettleOptions {
23
72
  timeoutMs: number;
24
73
  pollMs: number;
@@ -3,16 +3,15 @@ import type {
3
3
  IAddressRepository,
4
4
  IMailboxSpecialUseRepository,
5
5
  IMessageRepository,
6
- MessageItem,
7
6
  } from "@remit/data-ports";
8
7
  import { deriveAddressId } from "@remit/data-ports/id";
9
- import {
10
- AddressRole,
11
- MessageKeywordFlag,
12
- MessageStatus,
13
- } from "@remit/domain-enums";
8
+ import { AddressRole, MessageKeywordFlag } from "@remit/domain-enums";
14
9
  import type { FlagPushService } from "./flag-push.js";
15
10
  import type { MessageMoveService } from "./message-move.js";
11
+ import {
12
+ isPlacementUnsettled,
13
+ waitForPlacementToSettle,
14
+ } from "./placement-settled.js";
16
15
 
17
16
  export interface SpamReportLogger {
18
17
  info(obj: Record<string, unknown>, msg: string): void;
@@ -185,31 +184,6 @@ export class SpamReportService {
185
184
  });
186
185
  };
187
186
 
188
- /**
189
- * R2 wait (docs/architecture/imap-mutations.md): `notSpam`'s restore is a
190
- * dependent write against report-spam's own move — enqueuing it while that
191
- * move is still in flight (`status === moving`) would carry the message's
192
- * pre-move `uid` (only a CONFIRMED move updates it, via `updateUid`) and
193
- * risk acting on the wrong server-side message once both moves are
194
- * in-flight at once. Cheap to block per the doc's default guidance: a move
195
- * ordinarily settles in well under a second. On timeout the dependent
196
- * write is not made — the caller is told to retry, and retrying is safe
197
- * (this whole flow is idempotent).
198
- */
199
- private waitForMoveToSettle = async (
200
- messageId: string,
201
- ): Promise<MessageItem> => {
202
- const deadline = Date.now() + this.moveSettleTimeoutMs;
203
- let message = await this.messageService.get(messageId);
204
- while (message.status === MessageStatus.moving && Date.now() < deadline) {
205
- await new Promise((resolve) =>
206
- setTimeout(resolve, this.moveSettlePollMs),
207
- );
208
- message = await this.messageService.get(messageId);
209
- }
210
- return message;
211
- };
212
-
213
187
  reportSpam = async (params: SpamReportParams): Promise<void> => {
214
188
  const { accountConfigId, accountId, messageId, setBy } = params;
215
189
  const now = Date.now();
@@ -308,8 +282,15 @@ export class SpamReportService {
308
282
  // Only wait on the move's settlement when there is actually a
309
283
  // dependent write to make (the restore below) — a move in flight for
310
284
  // an unrelated reason is not this operation's concern.
311
- const settled = await this.waitForMoveToSettle(messageId);
312
- if (settled.status === MessageStatus.moving) {
285
+ const settled = await waitForPlacementToSettle(
286
+ this.messageService,
287
+ messageId,
288
+ {
289
+ timeoutMs: this.moveSettleTimeoutMs,
290
+ pollMs: this.moveSettlePollMs,
291
+ },
292
+ );
293
+ if (isPlacementUnsettled(settled)) {
313
294
  throw new MoveNotSettledError(messageId);
314
295
  }
315
296