@remit/mailbox-service 0.0.88 → 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
|
@@ -48,7 +48,8 @@ const buildWorld = () => {
|
|
|
48
48
|
]);
|
|
49
49
|
|
|
50
50
|
const messageService = {
|
|
51
|
-
get: async () =>
|
|
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
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
|
|
672
|
+
source,
|
|
640
673
|
destinationMailboxId,
|
|
641
674
|
accountId,
|
|
642
675
|
);
|
package/src/spam-report.test.ts
CHANGED
|
@@ -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
|
|