@remit/mailbox-service 0.0.87 → 0.0.89

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.87",
3
+ "version": "0.0.89",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -8,6 +8,7 @@ import {
8
8
  cosineSimilarity,
9
9
  type FilterMessage,
10
10
  literalClausesMatch,
11
+ literalClauseTerms,
11
12
  selectMoveWinner,
12
13
  } from "./match.js";
13
14
 
@@ -287,3 +288,106 @@ describe("buildMatchText", () => {
287
288
  assert.equal(text.length, 512);
288
289
  });
289
290
  });
291
+
292
+ describe("literalClauseTerms (reader #459)", () => {
293
+ it("narrows From and Subject onto the columns that carry them", () => {
294
+ assert.deepEqual(
295
+ literalClauseTerms(
296
+ [
297
+ clause(FilterClauseField.From, " alice@acme.example "),
298
+ clause(FilterClauseField.Subject, "invoice"),
299
+ ],
300
+ FilterMatchOperator.And,
301
+ ),
302
+ {
303
+ terms: [
304
+ { field: "sender", contains: "alice@acme.example" },
305
+ { field: "subject", contains: "invoice" },
306
+ ],
307
+ operator: "and",
308
+ },
309
+ );
310
+ });
311
+
312
+ it("narrows FromDomain to the registrable domain, which the sender always contains", () => {
313
+ const narrowing = literalClauseTerms(
314
+ [clause(FilterClauseField.FromDomain, "billing@mail.acme.example")],
315
+ FilterMatchOperator.And,
316
+ );
317
+
318
+ assert.deepEqual(narrowing?.terms, [
319
+ { field: "sender", contains: "acme.example" },
320
+ ]);
321
+ assert.equal(
322
+ clauseMatches(
323
+ clause(FilterClauseField.FromDomain, "billing@mail.acme.example"),
324
+ message(),
325
+ ),
326
+ true,
327
+ );
328
+ });
329
+
330
+ it("narrows ListId to its canonical form", () => {
331
+ assert.deepEqual(
332
+ literalClauseTerms(
333
+ [clause(FilterClauseField.ListId, "News <Weekly.News.Example.com>")],
334
+ FilterMatchOperator.And,
335
+ )?.terms,
336
+ [{ field: "listId", contains: "weekly.news.example.com" }],
337
+ );
338
+ });
339
+
340
+ it("returns null for clauses nothing can satisfy, so no query is worth running", () => {
341
+ assert.equal(
342
+ literalClauseTerms(
343
+ [clause(FilterClauseField.From, " ")],
344
+ FilterMatchOperator.And,
345
+ ),
346
+ null,
347
+ );
348
+ assert.equal(
349
+ literalClauseTerms(
350
+ [clause(FilterClauseField.FromDomain, "not a domain")],
351
+ FilterMatchOperator.Or,
352
+ ),
353
+ null,
354
+ );
355
+ });
356
+
357
+ it("drops an unsatisfiable clause from an Or instead of losing the rest", () => {
358
+ assert.deepEqual(
359
+ literalClauseTerms(
360
+ [
361
+ clause(FilterClauseField.From, ""),
362
+ clause(FilterClauseField.Subject, "invoice"),
363
+ ],
364
+ FilterMatchOperator.Or,
365
+ ),
366
+ { terms: [{ field: "subject", contains: "invoice" }], operator: "or" },
367
+ );
368
+ });
369
+
370
+ it("narrows nothing when an Or reaches a clause no column carries", () => {
371
+ assert.deepEqual(
372
+ literalClauseTerms(
373
+ [
374
+ clause(FilterClauseField.HasWords, "quarter"),
375
+ clause(FilterClauseField.Subject, "invoice"),
376
+ ],
377
+ FilterMatchOperator.Or,
378
+ ),
379
+ { terms: [], operator: "or" },
380
+ );
381
+ });
382
+
383
+ it("narrows nothing for no clauses, matching the vacuous pass of literalClausesMatch", () => {
384
+ assert.deepEqual(literalClauseTerms([], FilterMatchOperator.And), {
385
+ terms: [],
386
+ operator: "and",
387
+ });
388
+ assert.equal(
389
+ literalClausesMatch([], FilterMatchOperator.And, message()),
390
+ true,
391
+ );
392
+ });
393
+ });
@@ -1,4 +1,8 @@
1
- import { FILTER_NO_ACTION, type FilterItem } from "@remit/data-ports";
1
+ import {
2
+ FILTER_NO_ACTION,
3
+ type FilterItem,
4
+ type ThreadMessageFieldTerm,
5
+ } from "@remit/data-ports";
2
6
  import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
3
7
  import { getDomain } from "tldts";
4
8
  import { normalizeListId } from "./list-id.js";
@@ -108,6 +112,87 @@ export const literalClausesMatch = (
108
112
  : clauses.every((clause) => clauseMatches(clause, msg));
109
113
  };
110
114
 
115
+ /**
116
+ * What a store can be asked for on behalf of one clause: a column term, or the
117
+ * two answers a term cannot express — a clause nothing can satisfy, and a
118
+ * clause selecting on something no ThreadMessage column carries.
119
+ */
120
+ type ClauseNarrowing =
121
+ | { kind: "Term"; term: ThreadMessageFieldTerm }
122
+ | { kind: "NeverMatches" }
123
+ | { kind: "Unnarrowable" };
124
+
125
+ const clauseNarrowing = (clause: FilterClause): ClauseNarrowing => {
126
+ const value = clause.value.trim();
127
+ if (value === "") return { kind: "NeverMatches" };
128
+ switch (clause.field) {
129
+ case FilterClauseField.From:
130
+ return { kind: "Term", term: { field: "sender", contains: value } };
131
+ case FilterClauseField.Subject:
132
+ return { kind: "Term", term: { field: "subject", contains: value } };
133
+ case FilterClauseField.ListId: {
134
+ const target = normalizeListId(value);
135
+ if (target === "") return { kind: "NeverMatches" };
136
+ return { kind: "Term", term: { field: "listId", contains: target } };
137
+ }
138
+ case FilterClauseField.FromDomain: {
139
+ const target = registrableDomain(value);
140
+ if (target === null) return { kind: "NeverMatches" };
141
+ return { kind: "Term", term: { field: "sender", contains: target } };
142
+ }
143
+ default:
144
+ return { kind: "Unnarrowable" };
145
+ }
146
+ };
147
+
148
+ /**
149
+ * The terms to query with, and how to combine them. Empty terms narrow nothing
150
+ * — the caller reads the whole corpus and refines it.
151
+ */
152
+ export interface LiteralClauseNarrowing {
153
+ terms: ThreadMessageFieldTerm[];
154
+ operator: "and" | "or";
155
+ }
156
+
157
+ /**
158
+ * The store-side half of {@link literalClausesMatch}: the column terms a query
159
+ * can evaluate on behalf of these clauses, or `null` when they can match
160
+ * nothing at all and there is no query worth running.
161
+ *
162
+ * A term is deliberately WIDER than the clause it stands for — a `FromDomain`
163
+ * clause narrows to the registrable domain appearing anywhere in the sender,
164
+ * so `github.com.evil.example` still comes back — and a `HasWords` clause
165
+ * cannot be narrowed at all, since no row column carries the body. The rows a
166
+ * term selects are candidates; `literalClausesMatch` still decides. Pushing the
167
+ * terms down is what makes the caller's bound a bound on RESULTS: filtering a
168
+ * date-ordered page instead answers "matches among the newest N messages"
169
+ * (#459).
170
+ */
171
+ export const literalClauseTerms = (
172
+ clauses: readonly FilterClause[],
173
+ operator: FilterItem["matchOperator"],
174
+ ): LiteralClauseNarrowing | null => {
175
+ const combine = operator === FilterMatchOperator.Or ? "or" : "and";
176
+ if (clauses.length === 0) return { terms: [], operator: combine };
177
+ const narrowings = clauses.map(clauseNarrowing);
178
+ const terms = narrowings.flatMap((narrowing) =>
179
+ narrowing.kind === "Term" ? [narrowing.term] : [],
180
+ );
181
+ if (combine === "and") {
182
+ if (narrowings.some((narrowing) => narrowing.kind === "NeverMatches")) {
183
+ return null;
184
+ }
185
+ return { terms, operator: "and" };
186
+ }
187
+ if (narrowings.every((narrowing) => narrowing.kind === "NeverMatches")) {
188
+ return null;
189
+ }
190
+ if (narrowings.some((narrowing) => narrowing.kind === "Unnarrowable")) {
191
+ return { terms: [], operator: "or" };
192
+ }
193
+ return { terms, operator: "or" };
194
+ };
195
+
111
196
  /**
112
197
  * Cosine similarity of two equal-length vectors. Throws on a dimension mismatch
113
198
  * rather than silently scoring incomparable vectors — a mismatch means a stale
package/src/index.ts CHANGED
@@ -76,7 +76,9 @@ export {
76
76
  cosineSimilarity,
77
77
  DEFAULT_SEMANTIC_MATCH_THRESHOLD,
78
78
  type FilterMessage,
79
+ type LiteralClauseNarrowing,
79
80
  literalClausesMatch,
81
+ literalClauseTerms,
80
82
  NO_ACTION,
81
83
  selectMoveWinner,
82
84
  } from "./filters/match.js";
@@ -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
+ });
@@ -521,6 +521,12 @@ export class MessageMoveService {
521
521
  /**
522
522
  * Move a message to another mailbox.
523
523
  *
524
+ * Waits, never reconciles (imap-mutations R2): the folder and uid go to the
525
+ * server as one pair, and once the worker has moved the destination's own
526
+ * message at that uid there is no reference left to repair. The person is
527
+ * looking at the result, so blocking for the sub-second a move takes is the
528
+ * cheap half; on timeout the move refuses ahead of any local write.
529
+ *
524
530
  * @param messageId - Message to move
525
531
  * @param destinationMailboxId - Destination mailbox ID
526
532
  * @param accountId - Account ID
@@ -531,13 +537,32 @@ export class MessageMoveService {
531
537
  destinationMailboxId: string,
532
538
  accountId: string,
533
539
  ): Promise<void> => {
534
- const message = await this.messageService.get(messageId);
540
+ await this.moveSettledMessage(
541
+ accountConfigId,
542
+ await this.settledPlacement(
543
+ await this.messageService.get(messageId),
544
+ accountId,
545
+ ),
546
+ destinationMailboxId,
547
+ accountId,
548
+ );
549
+ };
550
+
551
+ private moveSettledMessage = async (
552
+ accountConfigId: string,
553
+ message: MessageItem,
554
+ destinationMailboxId: string,
555
+ accountId: string,
556
+ ): Promise<void> => {
557
+ const messageId = message.messageId;
535
558
 
536
559
  // IMAP has no same-mailbox MOVE: the server copies the message, expunges
537
560
  // the original and hands back a fresh UID, so a request that asks for the
538
561
  // mailbox the message is already in destroys its identity to reach the
539
562
  // state it was already in. The requested end state holds, so there is
540
- // nothing to record as pending and nothing to enqueue.
563
+ // nothing to record as pending and nothing to enqueue. The row is settled
564
+ // by the time this compares, so the folder it names is one the message has
565
+ // actually reached rather than one an in-flight move wrote optimistically.
541
566
  if (message.mailboxId === destinationMailboxId) {
542
567
  this.log.info(
543
568
  { messageId, mailboxId: destinationMailboxId },
@@ -633,10 +658,18 @@ export class MessageMoveService {
633
658
  destinationMailboxId: string,
634
659
  accountId: string,
635
660
  ): Promise<void> => {
636
- for (const messageId of messageIds) {
637
- await this.moveMessage(
661
+ // Every row is settled before the first move is written, so the batch's
662
+ // wait is one row's ceiling rather than the sum across it, and a refusal
663
+ // lands before any part of the batch has committed.
664
+ const sources = await this.settledPlacements(
665
+ await this.messageService.get(messageIds),
666
+ accountId,
667
+ );
668
+
669
+ for (const source of sources) {
670
+ await this.moveSettledMessage(
638
671
  accountConfigId,
639
- messageId,
672
+ source,
640
673
  destinationMailboxId,
641
674
  accountId,
642
675
  );
@@ -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