@remit/mailbox-service 0.0.88 → 0.0.90

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.88",
3
+ "version": "0.0.90",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -252,6 +252,7 @@ export {
252
252
  resolveExhaustedPlacementMoveFailure,
253
253
  } from "./placement-move-terminal.js";
254
254
  export {
255
+ carriesForeignUid,
255
256
  isPlacementUnsettled,
256
257
  type PlacementBinding,
257
258
  placementBindingOf,
@@ -46,8 +46,27 @@ const proposedDeletedFolder: RoleResolution<TrashMailbox> = {
46
46
  interface TrashMessage {
47
47
  messageId: string;
48
48
  syncStatus: string;
49
+ status?: string;
50
+ mailboxId?: string;
51
+ uid?: number;
52
+ originalMailboxId?: string;
53
+ originalUid?: number;
49
54
  }
50
55
 
56
+ /**
57
+ * A row mid-move into Trash: the folder is already written, the uid is still
58
+ * the inbox's, and only `status: moving` says so.
59
+ */
60
+ const movingIntoTrash = (messageId: string, uid: number): TrashMessage => ({
61
+ messageId,
62
+ syncStatus: "pending",
63
+ status: "moving",
64
+ mailboxId: REAL_TRASH,
65
+ uid,
66
+ originalMailboxId: "mbx-inbox",
67
+ originalUid: uid,
68
+ });
69
+
51
70
  interface EnqueuedEvent {
52
71
  type: string;
53
72
  schemaVersion?: number;
@@ -194,22 +213,38 @@ describe("MessageMoveService.emptyTrash", () => {
194
213
  assert.equal(deletedCount, 3);
195
214
  });
196
215
 
197
- it("marks and counts a message whose move to Trash has not settled", async () => {
198
- // The user saw the message in Trash and asked for the folder to be
199
- // emptied. Skipping it reports a number the folder contradicts, and the
200
- // queue is per-account FIFO, so the move has landed on the server before
201
- // the expunge is even delivered.
216
+ it("marks and counts a message whose sync is merely pending", async () => {
217
+ // `syncStatus: pending` is where every freshly synced inbound row sits
218
+ // forever; it says nothing about where the message is. The user saw it in
219
+ // Trash and asked for the folder to be emptied, and skipping it would
220
+ // report a number the folder contradicts.
202
221
  const { service, markedDeleting } = buildWorld(appointedTrash, [
203
222
  { messageId: "settled-1", syncStatus: "synced" },
204
- { messageId: "still-moving-1", syncStatus: "pending" },
223
+ { messageId: "pending-1", syncStatus: "pending" },
205
224
  ]);
206
225
 
207
226
  const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
208
227
 
209
- assert.deepEqual(markedDeleting, ["settled-1", "still-moving-1"]);
228
+ assert.deepEqual(markedDeleting, ["settled-1", "pending-1"]);
210
229
  assert.equal(deletedCount, 2);
211
230
  });
212
231
 
232
+ it("leaves a row whose move into Trash has not settled unmarked and uncounted", async () => {
233
+ // Issue #1217. Marking it `deleting` overwrites the `moving` its own
234
+ // MESSAGE_MOVE reads to decide there is still work to do, so that move
235
+ // returns without touching IMAP and the worker's expunge then binds the
236
+ // inbox's uid against whatever Trash really holds at it.
237
+ const { service, markedDeleting } = buildWorld(appointedTrash, [
238
+ { messageId: "settled-1", syncStatus: "synced" },
239
+ movingIntoTrash("moving-1", 10),
240
+ ]);
241
+
242
+ const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
243
+
244
+ assert.deepEqual(markedDeleting, ["settled-1"]);
245
+ assert.equal(deletedCount, 1);
246
+ });
247
+
213
248
  it("reports the same count when pressed twice before the worker runs", async () => {
214
249
  // The rows it marked are still in that folder, so N stays true and the
215
250
  // re-mark is idempotent. Reporting 0 the second time while still
@@ -48,7 +48,8 @@ const buildWorld = () => {
48
48
  ]);
49
49
 
50
50
  const messageService = {
51
- get: async () => message,
51
+ get: async (ids: string | string[]) =>
52
+ Array.isArray(ids) ? [message] : message,
52
53
  update: async (_id: string, patch: Record<string, unknown>) =>
53
54
  Object.assign(message, patch),
54
55
  updateForMove: async (_id: string, patch: Record<string, unknown>) =>
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Issue #665. While a placement move is in flight the Message row names the
3
+ * destination folder but still carries the SOURCE folder's uid, so a user move
4
+ * that binds the pair as it stands sends the worker to `openBox(destination)`
5
+ * and `moveMessages([uid])` — the destination folder's own message at that uid,
6
+ * carried off to wherever the user pointed. The reachable press is Undo on the
7
+ * auto-moved badge, inside the seconds before the placement push confirms.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import type {
13
+ IAddressRepository,
14
+ IMailboxRepository,
15
+ IMailboxSpecialUseRepository,
16
+ IMessageRepository,
17
+ IThreadMessageRepository,
18
+ } from "@remit/data-ports";
19
+ import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
20
+ import { MessageMoveService } from "./message-move.js";
21
+ import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
22
+
23
+ const ACCOUNT = "acc-1";
24
+ const ACCOUNT_CONFIG = "cfg-1";
25
+ const INBOX = "mbx-inbox";
26
+ const ARCHIVE = "mbx-archive";
27
+
28
+ const MOVING_ID = "msg-moving";
29
+ const SETTLED_ID = "msg-settled";
30
+ const STRANDED_ID = "msg-stranded";
31
+ const COPY_ID = "msg-copy";
32
+
33
+ const INBOX_UID = 42;
34
+ const ARCHIVE_UID = 907;
35
+ const SETTLED_UID = 11;
36
+
37
+ interface CapturedEvent {
38
+ type: string;
39
+ messageId?: string;
40
+ uid?: number;
41
+ sourceMailboxPath?: string;
42
+ destinationMailboxPath?: string;
43
+ }
44
+
45
+ const mailboxes = [
46
+ { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
47
+ { mailboxId: ARCHIVE, fullPath: "Archive", accountId: ACCOUNT },
48
+ ];
49
+
50
+ /** A row mid-move: Archive is already written, the uid is still INBOX's. */
51
+ const movingRow = () => ({
52
+ messageId: MOVING_ID,
53
+ mailboxId: ARCHIVE,
54
+ uid: INBOX_UID,
55
+ status: "moving",
56
+ syncStatus: "pending",
57
+ originalMailboxId: INBOX,
58
+ originalUid: INBOX_UID,
59
+ });
60
+
61
+ /** The same row after the mover gave up without confirming. */
62
+ const strandedRow = () => ({
63
+ ...movingRow(),
64
+ messageId: STRANDED_ID,
65
+ syncStatus: "failed",
66
+ });
67
+
68
+ /** An ordinary settled row. */
69
+ const settledRow = () => ({
70
+ messageId: SETTLED_ID,
71
+ mailboxId: INBOX,
72
+ uid: SETTLED_UID,
73
+ status: "active",
74
+ syncStatus: "synced",
75
+ });
76
+
77
+ /**
78
+ * A freshly copied row: `moving` until COPYUID lands, with no server-side uid
79
+ * at all and no move behind it. Its uid is not ready; it does not name anyone.
80
+ */
81
+ const freshCopyRow = () => ({
82
+ messageId: COPY_ID,
83
+ mailboxId: ARCHIVE,
84
+ uid: 0,
85
+ status: "moving",
86
+ syncStatus: "pending",
87
+ });
88
+
89
+ const buildWorld = (seed: Array<Record<string, unknown>>) => {
90
+ const patches: Array<{ messageId: string; patch: Record<string, unknown> }> =
91
+ [];
92
+ const events: CapturedEvent[] = [];
93
+ const rows = new Map<string, Record<string, unknown>>(
94
+ seed.map((row): [string, Record<string, unknown>] => [
95
+ String(row.messageId),
96
+ row,
97
+ ]),
98
+ );
99
+
100
+ /** What `updateUid` writes when the placement move confirms. */
101
+ const settle = (messageId: string) => {
102
+ Object.assign(rows.get(messageId) ?? {}, {
103
+ uid: ARCHIVE_UID,
104
+ status: "active",
105
+ syncStatus: "synced",
106
+ });
107
+ };
108
+
109
+ const messageService = {
110
+ get: async (ids: string | string[]) =>
111
+ Array.isArray(ids)
112
+ ? ids.flatMap((id) => {
113
+ const row = rows.get(id);
114
+ return row ? [row] : [];
115
+ })
116
+ : rows.get(ids),
117
+ update: async (id: string, patch: Record<string, unknown>) => {
118
+ patches.push({ messageId: id, patch });
119
+ return Object.assign(rows.get(id) ?? {}, patch);
120
+ },
121
+ updateForMove: async (id: string, patch: Record<string, unknown>) => {
122
+ patches.push({ messageId: id, patch });
123
+ return Object.assign(rows.get(id) ?? {}, patch);
124
+ },
125
+ } as unknown as IMessageRepository;
126
+
127
+ const threadMessageService = {
128
+ getByMessageId: async (_cfg: string, messageId: string) => ({
129
+ accountConfigId: ACCOUNT_CONFIG,
130
+ threadMessageId: `tm-${messageId}`,
131
+ messageId,
132
+ threadId: "thr-1",
133
+ mailboxId: rows.get(messageId)?.mailboxId,
134
+ sentDate: 1,
135
+ isRead: false,
136
+ isDeleted: false,
137
+ hasStars: false,
138
+ hasAttachment: false,
139
+ }),
140
+ update: async () => {},
141
+ } as unknown as IThreadMessageRepository;
142
+
143
+ const mailboxService = {
144
+ get: async (_accountId: string, ids: string | string[]) =>
145
+ Array.isArray(ids)
146
+ ? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
147
+ : mailboxes.find((mailbox) => mailbox.mailboxId === ids),
148
+ } as unknown as IMailboxRepository;
149
+
150
+ const mailboxSpecialUseService = {
151
+ findTrashMailbox: async () => null,
152
+ resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
153
+ } as unknown as IMailboxSpecialUseRepository;
154
+
155
+ const addressService = {
156
+ reconcileJunkOnlyForMessage: async () => {},
157
+ } as unknown as IAddressRepository;
158
+
159
+ const service = new MessageMoveService({
160
+ messageService,
161
+ mailboxService,
162
+ mailboxSpecialUseService,
163
+ threadMessageService,
164
+ addressService,
165
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
166
+ moveSettleTimeoutMs: 200,
167
+ moveSettlePollMs: 10,
168
+ });
169
+
170
+ (
171
+ service as unknown as { enqueueEvent: (e: CapturedEvent) => Promise<void> }
172
+ ).enqueueEvent = async (event) => {
173
+ events.push(event);
174
+ };
175
+
176
+ return { service, patches, events, rows, settle };
177
+ };
178
+
179
+ describe("a move never binds the folder/uid pair of an in-flight move (#665)", () => {
180
+ it("binds the confirmed pair once the placement move settles", async () => {
181
+ const { service, events, settle } = buildWorld([movingRow()]);
182
+
183
+ setTimeout(() => settle(MOVING_ID), 30);
184
+ await service.moveMessage(ACCOUNT_CONFIG, MOVING_ID, INBOX, ACCOUNT);
185
+
186
+ assert.equal(events.length, 1);
187
+ assert.equal(events[0].type, "MESSAGE_MOVE");
188
+ assert.equal(
189
+ events[0].sourceMailboxPath,
190
+ "Archive",
191
+ "the folder the confirmed move left the message in",
192
+ );
193
+ assert.equal(
194
+ events[0].uid,
195
+ ARCHIVE_UID,
196
+ "and that folder's own uid, never the source folder's",
197
+ );
198
+ assert.equal(events[0].destinationMailboxPath, "INBOX");
199
+ });
200
+
201
+ it("refuses the move, and touches nothing, when the placement never settles", async () => {
202
+ const { service, patches, events, rows } = buildWorld([movingRow()]);
203
+
204
+ await assert.rejects(
205
+ () => service.moveMessage(ACCOUNT_CONFIG, MOVING_ID, INBOX, ACCOUNT),
206
+ (error: unknown) =>
207
+ error instanceof MessagePlacementUnsettledError &&
208
+ error.statusCode === 409 &&
209
+ error.publicApiError?.code === "message_placement_unsettled" &&
210
+ error.publicApiError?.details?.messageId === MOVING_ID &&
211
+ error.publicApiError?.details?.accountId === ACCOUNT &&
212
+ error.publicApiError?.details?.reason === "in_flight",
213
+ );
214
+
215
+ assert.deepEqual(events, [], "nothing was enqueued");
216
+ assert.deepEqual(patches, [], "and no local write was made");
217
+ assert.equal(rows.get(MOVING_ID)?.mailboxId, ARCHIVE);
218
+ assert.equal(rows.get(MOVING_ID)?.uid, INBOX_UID);
219
+ });
220
+
221
+ it("never answers the same-mailbox skip from a folder the message has not reached", async () => {
222
+ // The skip compares the row's `mailboxId` against the destination. An
223
+ // in-flight move has already written that folder optimistically, so the
224
+ // comparison would report the message home and return a silent success
225
+ // for a move the server has not made.
226
+ const { service, events } = buildWorld([movingRow()]);
227
+
228
+ await assert.rejects(
229
+ () => service.moveMessage(ACCOUNT_CONFIG, MOVING_ID, ARCHIVE, ACCOUNT),
230
+ MessagePlacementUnsettledError,
231
+ );
232
+
233
+ assert.deepEqual(events, []);
234
+ });
235
+
236
+ it("moves no part of a batch when one row is in flight", async () => {
237
+ // All-or-nothing: the settled row is movable on its own and a per-row
238
+ // gate would have moved it before reaching the refusal.
239
+ const { service, patches, events } = buildWorld([
240
+ settledRow(),
241
+ movingRow(),
242
+ ]);
243
+
244
+ await assert.rejects(
245
+ () =>
246
+ service.moveMessages(
247
+ ACCOUNT_CONFIG,
248
+ [SETTLED_ID, MOVING_ID],
249
+ ARCHIVE,
250
+ ACCOUNT,
251
+ ),
252
+ MessagePlacementUnsettledError,
253
+ );
254
+
255
+ assert.deepEqual(
256
+ events,
257
+ [],
258
+ "the settled row was not moved alongside the refusal",
259
+ );
260
+ assert.deepEqual(patches, []);
261
+ });
262
+
263
+ it("moves the whole batch once the in-flight row settles", async () => {
264
+ const { service, events, settle } = buildWorld([settledRow(), movingRow()]);
265
+
266
+ setTimeout(() => settle(MOVING_ID), 30);
267
+ await service.moveMessages(
268
+ ACCOUNT_CONFIG,
269
+ [SETTLED_ID, MOVING_ID],
270
+ INBOX,
271
+ ACCOUNT,
272
+ );
273
+
274
+ assert.deepEqual(
275
+ events.map((event) => [event.messageId, event.uid]),
276
+ [[MOVING_ID, ARCHIVE_UID]],
277
+ "the settled row was already in INBOX and skipped; the moved row went with the confirmed uid",
278
+ );
279
+ });
280
+
281
+ it("waits one row's ceiling for a batch, not one per row", async () => {
282
+ const rows = [movingRow(), movingRow(), movingRow()].map((row, index) => ({
283
+ ...row,
284
+ messageId: `${MOVING_ID}-${index}`,
285
+ }));
286
+ const { service } = buildWorld(rows);
287
+
288
+ const startedAt = Date.now();
289
+ await assert.rejects(
290
+ () =>
291
+ service.moveMessages(
292
+ ACCOUNT_CONFIG,
293
+ rows.map((row) => row.messageId),
294
+ INBOX,
295
+ ACCOUNT,
296
+ ),
297
+ MessagePlacementUnsettledError,
298
+ );
299
+
300
+ assert.ok(
301
+ Date.now() - startedAt < 500,
302
+ "three rows refused within one 200ms ceiling, not three",
303
+ );
304
+ });
305
+ });
306
+
307
+ describe("the move gate refuses only a uid that names somebody else (#665)", () => {
308
+ it("moves a freshly copied row rather than waiting on it", async () => {
309
+ // A copy is `moving` with no server-side uid yet. That uid is not ready,
310
+ // which is not the same as naming another folder's message.
311
+ const { service, events } = buildWorld([freshCopyRow()]);
312
+
313
+ const startedAt = Date.now();
314
+ await service.moveMessage(ACCOUNT_CONFIG, COPY_ID, INBOX, ACCOUNT);
315
+
316
+ assert.equal(events.length, 1);
317
+ assert.equal(events[0].type, "MESSAGE_MOVE");
318
+ assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
319
+ });
320
+
321
+ it("refuses a row stranded by a move that gave up, without spending the ceiling", async () => {
322
+ // `syncStatus: failed` with `status: moving` is what every handler's
323
+ // give-up path leaves behind, and only `updateUid` clears it. The pair is
324
+ // still a lie, so the move is still refused — under a reason whose remedy
325
+ // is a resync, and without a wait that could never succeed.
326
+ const { service, events } = buildWorld([strandedRow()]);
327
+
328
+ const startedAt = Date.now();
329
+ await assert.rejects(
330
+ () => service.moveMessage(ACCOUNT_CONFIG, STRANDED_ID, INBOX, ACCOUNT),
331
+ (error: unknown) =>
332
+ error instanceof MessagePlacementUnsettledError &&
333
+ error.publicApiError?.details?.reason === "unverified",
334
+ );
335
+
336
+ assert.deepEqual(events, []);
337
+ assert.ok(Date.now() - startedAt < 100, "refused without waiting");
338
+ });
339
+ });
@@ -34,6 +34,7 @@ import {
34
34
  } from "@remit/domain-enums";
35
35
  import { createQueueProducer } from "@remit/sqs-client/producer";
36
36
  import {
37
+ bindsForeignUid,
37
38
  type PlacementBinding,
38
39
  placementBindingOf,
39
40
  waitForPlacementToSettle,
@@ -521,6 +522,12 @@ export class MessageMoveService {
521
522
  /**
522
523
  * Move a message to another mailbox.
523
524
  *
525
+ * Waits, never reconciles (imap-mutations R2): the folder and uid go to the
526
+ * server as one pair, and once the worker has moved the destination's own
527
+ * message at that uid there is no reference left to repair. The person is
528
+ * looking at the result, so blocking for the sub-second a move takes is the
529
+ * cheap half; on timeout the move refuses ahead of any local write.
530
+ *
524
531
  * @param messageId - Message to move
525
532
  * @param destinationMailboxId - Destination mailbox ID
526
533
  * @param accountId - Account ID
@@ -531,13 +538,32 @@ export class MessageMoveService {
531
538
  destinationMailboxId: string,
532
539
  accountId: string,
533
540
  ): Promise<void> => {
534
- const message = await this.messageService.get(messageId);
541
+ await this.moveSettledMessage(
542
+ accountConfigId,
543
+ await this.settledPlacement(
544
+ await this.messageService.get(messageId),
545
+ accountId,
546
+ ),
547
+ destinationMailboxId,
548
+ accountId,
549
+ );
550
+ };
551
+
552
+ private moveSettledMessage = async (
553
+ accountConfigId: string,
554
+ message: MessageItem,
555
+ destinationMailboxId: string,
556
+ accountId: string,
557
+ ): Promise<void> => {
558
+ const messageId = message.messageId;
535
559
 
536
560
  // IMAP has no same-mailbox MOVE: the server copies the message, expunges
537
561
  // the original and hands back a fresh UID, so a request that asks for the
538
562
  // mailbox the message is already in destroys its identity to reach the
539
563
  // state it was already in. The requested end state holds, so there is
540
- // nothing to record as pending and nothing to enqueue.
564
+ // nothing to record as pending and nothing to enqueue. The row is settled
565
+ // by the time this compares, so the folder it names is one the message has
566
+ // actually reached rather than one an in-flight move wrote optimistically.
541
567
  if (message.mailboxId === destinationMailboxId) {
542
568
  this.log.info(
543
569
  { messageId, mailboxId: destinationMailboxId },
@@ -633,10 +659,18 @@ export class MessageMoveService {
633
659
  destinationMailboxId: string,
634
660
  accountId: string,
635
661
  ): Promise<void> => {
636
- for (const messageId of messageIds) {
637
- await this.moveMessage(
662
+ // Every row is settled before the first move is written, so the batch's
663
+ // wait is one row's ceiling rather than the sum across it, and a refusal
664
+ // lands before any part of the batch has committed.
665
+ const sources = await this.settledPlacements(
666
+ await this.messageService.get(messageIds),
667
+ accountId,
668
+ );
669
+
670
+ for (const source of sources) {
671
+ await this.moveSettledMessage(
638
672
  accountConfigId,
639
- messageId,
673
+ source,
640
674
  destinationMailboxId,
641
675
  accountId,
642
676
  );
@@ -884,10 +918,22 @@ export class MessageMoveService {
884
918
  trashMailbox.mailboxId,
885
919
  );
886
920
 
887
- const messages = await this.messageService.listAllByMailbox(
921
+ const rows = await this.messageService.listAllByMailbox(
888
922
  trashMailbox.mailboxId,
889
923
  );
890
924
 
925
+ // Reconciles, never waits (imap-mutations R2). A row whose move into
926
+ // Trash has not settled names this folder while still carrying the SOURCE
927
+ // folder's uid, and marking it `deleting` overwrites the only marker that
928
+ // says so: its own MESSAGE_MOVE then reads the row as settled and returns
929
+ // without moving anything, and the worker's expunge binds that borrowed
930
+ // uid against whatever Trash really holds at it. Left as it stands, that
931
+ // move runs ahead of this expunge in the account's FIFO group and settles
932
+ // the row, so the message is in Trash by the time the sweep reaches it and
933
+ // goes with the rest of this same press — only the count below is one
934
+ // short of what the sweep ends up removing.
935
+ const messages = rows.filter((message) => !bindsForeignUid(message));
936
+
891
937
  // Mark all as deleting locally
892
938
  for (const message of messages) {
893
939
  await this.messageService.update(message.messageId, {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
3
  import type { MessageItem } from "@remit/data-ports";
4
4
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
5
- import { placementBindingOf } from "./placement-settled.js";
5
+ import { carriesForeignUid, placementBindingOf } from "./placement-settled.js";
6
6
 
7
7
  /**
8
8
  * Pins `placementBindingOf` directly. It is the guard #1098/#1143 depend on and
@@ -70,3 +70,48 @@ describe("placementBindingOf", () => {
70
70
  );
71
71
  });
72
72
  });
73
+
74
+ /**
75
+ * The same question with the `status` gate off, for callers that resolve a uid
76
+ * off a row some other operation may have re-marked on its way past (#1217).
77
+ */
78
+ describe("carriesForeignUid", () => {
79
+ test("holds whatever an operation has since written to `status`", () => {
80
+ for (const status of [
81
+ MessageStatus.moving,
82
+ MessageStatus.deleting,
83
+ MessageStatus.active,
84
+ ]) {
85
+ assert.equal(carriesForeignUid(row({ status })), true, status);
86
+ }
87
+ });
88
+
89
+ test("is false once `updateUid` settles the move", () => {
90
+ // The settle writes the destination's own uid and drops `originalUid` in
91
+ // the same statement, so a destination counter that hands back the
92
+ // source's number cannot leave the row reading foreign forever.
93
+ assert.equal(
94
+ carriesForeignUid(
95
+ row({
96
+ status: MessageStatus.active,
97
+ syncStatus: MessageSyncStatus.synced,
98
+ originalUid: undefined,
99
+ }),
100
+ ),
101
+ false,
102
+ );
103
+ });
104
+
105
+ test("is false for a row that was never moved", () => {
106
+ assert.equal(
107
+ carriesForeignUid(
108
+ row({ originalUid: undefined, originalMailboxId: undefined }),
109
+ ),
110
+ false,
111
+ );
112
+ });
113
+
114
+ test("is false once a restore points the row back at that folder", () => {
115
+ assert.equal(carriesForeignUid(row({ mailboxId: "mbx-src" })), false);
116
+ });
117
+ });
@@ -29,19 +29,33 @@ export const isPlacementUnsettled = (
29
29
  * never moved, so its uid names nothing but itself — a freshly copied row is
30
30
  * `moving` with `uid: 0` until COPYUID lands, which is a uid that is not ready,
31
31
  * not a uid that names somebody else's message.
32
+ *
33
+ * Answered without reference to `status`, because `status` is not what clears
34
+ * it: `updateUid` does, dropping `originalUid` in the same statement that
35
+ * writes the destination's own, and a restore does by pointing `mailboxId`
36
+ * back at `originalMailboxId`. An operation that rewrites `status` on its way
37
+ * past — Empty Trash marking a whole folder `deleting` — leaves the pair
38
+ * untouched, so anything that resolves a uid off a row it did not settle
39
+ * itself asks this rather than the binding below (issue #1217).
32
40
  */
33
- export const bindsForeignUid = (
41
+ export const carriesForeignUid = (
34
42
  message: Pick<
35
43
  MessageItem,
36
- "status" | "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
44
+ "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
37
45
  >,
38
46
  ): boolean =>
39
- isPlacementUnsettled(message) &&
40
47
  message.originalUid !== undefined &&
41
48
  message.originalUid === message.uid &&
42
49
  message.originalMailboxId !== undefined &&
43
50
  message.originalMailboxId !== message.mailboxId;
44
51
 
52
+ export const bindsForeignUid = (
53
+ message: Pick<
54
+ MessageItem,
55
+ "status" | "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
56
+ >,
57
+ ): boolean => isPlacementUnsettled(message) && carriesForeignUid(message);
58
+
45
59
  /**
46
60
  * `in_flight` — a mover is still working on the row, so waiting resolves it.
47
61
  * `abandoned` — the last attempt failed (`syncStatus: failed`) and the row is
@@ -10,6 +10,7 @@ import type {
10
10
  IMessageRepository,
11
11
  IThreadMessageRepository,
12
12
  } from "@remit/data-ports";
13
+ import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
13
14
  import { deriveAddressId } from "@remit/data-ports/id";
14
15
  import { AddressRole } from "@remit/domain-enums";
15
16
  import { FlagPushService } from "./flag-push.js";
@@ -279,6 +280,8 @@ const buildWorld = (
279
280
  threadMessageService,
280
281
  addressService,
281
282
  sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
283
+ moveSettleTimeoutMs: 30,
284
+ moveSettlePollMs: 5,
282
285
  });
283
286
  (
284
287
  messageMoveService as unknown as {
@@ -394,13 +397,14 @@ describe("SpamReportService.reportSpam", () => {
394
397
  });
395
398
 
396
399
  it("is idempotent under a double press", async () => {
397
- const { service, sent } = buildWorld();
400
+ const { service, messages, sent } = buildWorld();
398
401
 
399
402
  await service.reportSpam({
400
403
  accountConfigId: ACCOUNT_CONFIG,
401
404
  accountId: ACCOUNT,
402
405
  messageId: MESSAGE_ID,
403
406
  });
407
+ settleMove(messages);
404
408
  await service.reportSpam({
405
409
  accountConfigId: ACCOUNT_CONFIG,
406
410
  accountId: ACCOUNT,
@@ -409,6 +413,37 @@ describe("SpamReportService.reportSpam", () => {
409
413
 
410
414
  // The second call's move is a no-op: MessageMoveService.moveMessage sees
411
415
  // the local mailboxId already equals Junk and skips without enqueueing.
416
+ // The skip is read off the settled row — a press inside the first move's
417
+ // flight waits for it rather than answering from the folder that move
418
+ // wrote optimistically (#665).
419
+ assert.equal(moveEvents(sent).length, 1);
420
+ });
421
+
422
+ it("refuses a second press inside the first move's flight", async () => {
423
+ // No settleMove between the presses: the row names Junk while still
424
+ // carrying the inbox's uid, so acting on it would address whatever Junk
425
+ // holds at that uid — somebody else's message (#665).
426
+ const { service, sent } = buildWorld();
427
+
428
+ await service.reportSpam({
429
+ accountConfigId: ACCOUNT_CONFIG,
430
+ accountId: ACCOUNT,
431
+ messageId: MESSAGE_ID,
432
+ });
433
+
434
+ await assert.rejects(
435
+ () =>
436
+ service.reportSpam({
437
+ accountConfigId: ACCOUNT_CONFIG,
438
+ accountId: ACCOUNT,
439
+ messageId: MESSAGE_ID,
440
+ }),
441
+ (error: unknown) =>
442
+ error instanceof MessagePlacementUnsettledError &&
443
+ error.statusCode === 409 &&
444
+ error.publicApiError?.details?.reason === "in_flight",
445
+ );
446
+
412
447
  assert.equal(moveEvents(sent).length, 1);
413
448
  });
414
449