@remit/mailbox-service 0.0.76 → 0.0.78
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 +1 -1
- package/src/message-delete-unsettled-placement.test.ts +388 -0
- package/src/message-move.ts +117 -11
- package/src/placement-settled.ts +50 -1
- package/src/spam-report.ts +14 -33
package/package.json
CHANGED
|
@@ -0,0 +1,388 @@
|
|
|
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
|
+
import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
21
|
+
|
|
22
|
+
const ACCOUNT = "acc-1";
|
|
23
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
24
|
+
const INBOX = "mbx-inbox";
|
|
25
|
+
const ARCHIVE = "mbx-archive";
|
|
26
|
+
const TRASH = "mbx-trash";
|
|
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
|
+
type TrashMailbox = { mailboxId: string; fullPath: string };
|
|
38
|
+
|
|
39
|
+
const flaggedTrash: RoleResolution<TrashMailbox> = {
|
|
40
|
+
kind: "flagged",
|
|
41
|
+
mailbox: { mailboxId: TRASH, fullPath: "INBOX/Trash" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
interface CapturedEvent {
|
|
45
|
+
type: string;
|
|
46
|
+
messageId?: string;
|
|
47
|
+
operation?: string;
|
|
48
|
+
mailboxId?: string;
|
|
49
|
+
mailboxPath?: string;
|
|
50
|
+
uid?: number;
|
|
51
|
+
sourceMailboxPath?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mailboxes = [
|
|
55
|
+
{ mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
56
|
+
{ mailboxId: ARCHIVE, fullPath: "Archive", accountId: ACCOUNT },
|
|
57
|
+
{ mailboxId: TRASH, fullPath: "INBOX/Trash", accountId: ACCOUNT },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** A row mid-move: Archive is already written, the uid is still INBOX's. */
|
|
61
|
+
const movingRow = () => ({
|
|
62
|
+
messageId: MOVING_ID,
|
|
63
|
+
mailboxId: ARCHIVE,
|
|
64
|
+
uid: INBOX_UID,
|
|
65
|
+
status: "moving",
|
|
66
|
+
syncStatus: "pending",
|
|
67
|
+
originalMailboxId: INBOX,
|
|
68
|
+
originalUid: INBOX_UID,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/** The same row after the trash-move handler gave up without confirming. */
|
|
72
|
+
const strandedRow = () => ({
|
|
73
|
+
...movingRow(),
|
|
74
|
+
messageId: STRANDED_ID,
|
|
75
|
+
syncStatus: "failed",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** An ordinary settled row. */
|
|
79
|
+
const settledRow = () => ({
|
|
80
|
+
messageId: SETTLED_ID,
|
|
81
|
+
mailboxId: INBOX,
|
|
82
|
+
uid: SETTLED_UID,
|
|
83
|
+
status: "active",
|
|
84
|
+
syncStatus: "synced",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A freshly copied row: `moving` until COPYUID lands, with no server-side uid
|
|
89
|
+
* at all and no move behind it. Its uid is not ready; it does not name anyone.
|
|
90
|
+
*/
|
|
91
|
+
const freshCopyRow = () => ({
|
|
92
|
+
messageId: COPY_ID,
|
|
93
|
+
mailboxId: ARCHIVE,
|
|
94
|
+
uid: 0,
|
|
95
|
+
status: "moving",
|
|
96
|
+
syncStatus: "pending",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const buildWorld = (seed: Array<Record<string, unknown>>) => {
|
|
100
|
+
const patches: Array<{ messageId: string; patch: Record<string, unknown> }> =
|
|
101
|
+
[];
|
|
102
|
+
const events: CapturedEvent[] = [];
|
|
103
|
+
const rows = new Map<string, Record<string, unknown>>(
|
|
104
|
+
seed.map((row): [string, Record<string, unknown>] => [
|
|
105
|
+
String(row.messageId),
|
|
106
|
+
row,
|
|
107
|
+
]),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const settle = (messageId: string) => {
|
|
111
|
+
Object.assign(rows.get(messageId) ?? {}, {
|
|
112
|
+
uid: ARCHIVE_UID,
|
|
113
|
+
status: "active",
|
|
114
|
+
syncStatus: "synced",
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const messageService = {
|
|
119
|
+
get: async (ids: string | string[]) =>
|
|
120
|
+
Array.isArray(ids)
|
|
121
|
+
? ids.flatMap((id) => {
|
|
122
|
+
const row = rows.get(id);
|
|
123
|
+
return row ? [row] : [];
|
|
124
|
+
})
|
|
125
|
+
: rows.get(ids),
|
|
126
|
+
update: async (id: string, patch: Record<string, unknown>) => {
|
|
127
|
+
patches.push({ messageId: id, patch });
|
|
128
|
+
return Object.assign(rows.get(id) ?? {}, patch);
|
|
129
|
+
},
|
|
130
|
+
updateForMove: async (id: string, patch: Record<string, unknown>) => {
|
|
131
|
+
patches.push({ messageId: id, patch });
|
|
132
|
+
return Object.assign(rows.get(id) ?? {}, patch);
|
|
133
|
+
},
|
|
134
|
+
upsert: async (row: Record<string, unknown>) => row,
|
|
135
|
+
} as unknown as IMessageRepository;
|
|
136
|
+
|
|
137
|
+
const threadMessageService = {
|
|
138
|
+
findAllByMessageId: async () => [],
|
|
139
|
+
getByMessageId: async (_cfg: string, messageId: string) => ({
|
|
140
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
141
|
+
threadMessageId: `tm-${messageId}`,
|
|
142
|
+
messageId,
|
|
143
|
+
threadId: "thr-1",
|
|
144
|
+
mailboxId: rows.get(messageId)?.mailboxId,
|
|
145
|
+
sentDate: 1,
|
|
146
|
+
isRead: false,
|
|
147
|
+
isDeleted: false,
|
|
148
|
+
hasStars: false,
|
|
149
|
+
hasAttachment: false,
|
|
150
|
+
}),
|
|
151
|
+
create: async () => {},
|
|
152
|
+
update: async () => {},
|
|
153
|
+
delete: async () => {},
|
|
154
|
+
} as unknown as IThreadMessageRepository;
|
|
155
|
+
|
|
156
|
+
const mailboxService = {
|
|
157
|
+
get: async (_accountId: string, ids: string | string[]) =>
|
|
158
|
+
Array.isArray(ids)
|
|
159
|
+
? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
|
|
160
|
+
: mailboxes.find((mailbox) => mailbox.mailboxId === ids),
|
|
161
|
+
} as unknown as IMailboxRepository;
|
|
162
|
+
|
|
163
|
+
const mailboxSpecialUseService = {
|
|
164
|
+
resolveTrashRole: async () => flaggedTrash,
|
|
165
|
+
findTrashMailbox: async () => flaggedTrash.mailbox,
|
|
166
|
+
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
167
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
168
|
+
|
|
169
|
+
const addressService = {
|
|
170
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
171
|
+
} as unknown as IAddressRepository;
|
|
172
|
+
|
|
173
|
+
const service = new MessageMoveService({
|
|
174
|
+
messageService,
|
|
175
|
+
mailboxService,
|
|
176
|
+
mailboxSpecialUseService,
|
|
177
|
+
threadMessageService,
|
|
178
|
+
addressService,
|
|
179
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
180
|
+
moveSettleTimeoutMs: 200,
|
|
181
|
+
moveSettlePollMs: 10,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const stubs = service as unknown as {
|
|
185
|
+
enqueueEventsBatch: (batch: CapturedEvent[]) => Promise<void>;
|
|
186
|
+
enqueueEvent: (event: CapturedEvent) => Promise<void>;
|
|
187
|
+
};
|
|
188
|
+
stubs.enqueueEventsBatch = async (batch) => {
|
|
189
|
+
events.push(...batch);
|
|
190
|
+
};
|
|
191
|
+
stubs.enqueueEvent = async (event) => {
|
|
192
|
+
events.push(event);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return { service, patches, events, rows, settle };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
describe("a delete never binds the folder/uid pair of an in-flight move (#845.3)", () => {
|
|
199
|
+
it("binds the confirmed pair once the move settles", async () => {
|
|
200
|
+
const { service, events, settle } = buildWorld([movingRow()]);
|
|
201
|
+
|
|
202
|
+
setTimeout(() => settle(MOVING_ID), 30);
|
|
203
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MOVING_ID], ACCOUNT);
|
|
204
|
+
|
|
205
|
+
assert.equal(events.length, 1);
|
|
206
|
+
assert.equal(events[0].operation, "move_to_trash");
|
|
207
|
+
assert.equal(
|
|
208
|
+
events[0].mailboxPath,
|
|
209
|
+
"Archive",
|
|
210
|
+
"the folder the confirmed move left the message in",
|
|
211
|
+
);
|
|
212
|
+
assert.equal(
|
|
213
|
+
events[0].uid,
|
|
214
|
+
ARCHIVE_UID,
|
|
215
|
+
"and that folder's own uid, never the source folder's",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses the delete, and touches nothing, when the move never settles", async () => {
|
|
220
|
+
const { service, patches, events, rows } = buildWorld([movingRow()]);
|
|
221
|
+
|
|
222
|
+
await assert.rejects(
|
|
223
|
+
() => service.deleteMessages(ACCOUNT_CONFIG, [MOVING_ID], ACCOUNT),
|
|
224
|
+
(error: unknown) =>
|
|
225
|
+
error instanceof MessagePlacementUnsettledError &&
|
|
226
|
+
error.statusCode === 409 &&
|
|
227
|
+
error.publicApiError?.code === "message_placement_unsettled" &&
|
|
228
|
+
error.publicApiError?.details?.messageId === MOVING_ID &&
|
|
229
|
+
error.publicApiError?.details?.accountId === ACCOUNT &&
|
|
230
|
+
error.publicApiError?.details?.reason === "in_flight",
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
assert.deepEqual(events, [], "nothing was enqueued");
|
|
234
|
+
assert.deepEqual(patches, [], "and no local write was made");
|
|
235
|
+
assert.equal(rows.get(MOVING_ID)?.uid, INBOX_UID);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("expunges no part of a mixed batch when one row is in flight", async () => {
|
|
239
|
+
// All-or-nothing: the settled row is deletable on its own, and would be
|
|
240
|
+
// expunged first by a per-row gate. One unverifiable pair refuses the
|
|
241
|
+
// batch it arrived in.
|
|
242
|
+
const { service, patches, events } = buildWorld([
|
|
243
|
+
settledRow(),
|
|
244
|
+
movingRow(),
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
await assert.rejects(
|
|
248
|
+
() =>
|
|
249
|
+
service.deleteMessages(
|
|
250
|
+
ACCOUNT_CONFIG,
|
|
251
|
+
[SETTLED_ID, MOVING_ID],
|
|
252
|
+
ACCOUNT,
|
|
253
|
+
{ permanent: true },
|
|
254
|
+
),
|
|
255
|
+
MessagePlacementUnsettledError,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
assert.deepEqual(
|
|
259
|
+
events,
|
|
260
|
+
[],
|
|
261
|
+
"the settled row was not expunged alongside the refusal",
|
|
262
|
+
);
|
|
263
|
+
assert.deepEqual(patches, []);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("deletes the whole batch once the in-flight row settles", async () => {
|
|
267
|
+
const { service, events, settle } = buildWorld([settledRow(), movingRow()]);
|
|
268
|
+
|
|
269
|
+
setTimeout(() => settle(MOVING_ID), 30);
|
|
270
|
+
await service.deleteMessages(
|
|
271
|
+
ACCOUNT_CONFIG,
|
|
272
|
+
[SETTLED_ID, MOVING_ID],
|
|
273
|
+
ACCOUNT,
|
|
274
|
+
{ permanent: true },
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
assert.deepEqual(
|
|
278
|
+
events.map((event) => [event.messageId, event.uid]).sort(),
|
|
279
|
+
[
|
|
280
|
+
[MOVING_ID, ARCHIVE_UID],
|
|
281
|
+
[SETTLED_ID, SETTLED_UID],
|
|
282
|
+
].sort(),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("waits one row's ceiling for a batch, not one per row", async () => {
|
|
287
|
+
// The gate is concurrent. Three in-flight rows that never settle must
|
|
288
|
+
// refuse at roughly one ceiling — a sequential gate would spend three,
|
|
289
|
+
// and at the client's 100-id chunk cap that is a gateway timeout.
|
|
290
|
+
const rows = [movingRow(), movingRow(), movingRow()].map((row, index) => ({
|
|
291
|
+
...row,
|
|
292
|
+
messageId: `${MOVING_ID}-${index}`,
|
|
293
|
+
}));
|
|
294
|
+
const { service } = buildWorld(rows);
|
|
295
|
+
|
|
296
|
+
const startedAt = Date.now();
|
|
297
|
+
await assert.rejects(
|
|
298
|
+
() =>
|
|
299
|
+
service.deleteMessages(
|
|
300
|
+
ACCOUNT_CONFIG,
|
|
301
|
+
rows.map((row) => row.messageId),
|
|
302
|
+
ACCOUNT,
|
|
303
|
+
{ permanent: true },
|
|
304
|
+
),
|
|
305
|
+
MessagePlacementUnsettledError,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
assert.ok(
|
|
309
|
+
Date.now() - startedAt < 500,
|
|
310
|
+
"three rows refused within one 200ms ceiling, not three",
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("the gate refuses only a uid that names somebody else (#845.3)", () => {
|
|
316
|
+
it("deletes a freshly copied row rather than waiting on it", async () => {
|
|
317
|
+
// A copy is `moving` with no server-side uid yet. That uid is not ready,
|
|
318
|
+
// which is not the same as naming another folder's message, and the
|
|
319
|
+
// delete of a copy is an ordinary flow that must not stall or refuse.
|
|
320
|
+
const { service, events } = buildWorld([freshCopyRow()]);
|
|
321
|
+
|
|
322
|
+
const startedAt = Date.now();
|
|
323
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [COPY_ID], ACCOUNT, {
|
|
324
|
+
permanent: true,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
assert.equal(events.length, 1);
|
|
328
|
+
assert.equal(events[0].operation, "permanent_delete");
|
|
329
|
+
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("refuses a row stranded by a move that gave up, without spending the ceiling", async () => {
|
|
333
|
+
// `syncStatus: failed` with `status: moving` is the shape every handler's
|
|
334
|
+
// give-up path leaves behind, and only `updateUid` clears it. The pair is
|
|
335
|
+
// still a lie, so the delete is still refused — but under a reason whose
|
|
336
|
+
// remedy is a resync, and without a wait that could never succeed.
|
|
337
|
+
const { service, events } = buildWorld([strandedRow()]);
|
|
338
|
+
|
|
339
|
+
const startedAt = Date.now();
|
|
340
|
+
await assert.rejects(
|
|
341
|
+
() => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
|
|
342
|
+
(error: unknown) =>
|
|
343
|
+
error instanceof MessagePlacementUnsettledError &&
|
|
344
|
+
error.publicApiError?.details?.reason === "unverified",
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
assert.deepEqual(events, []);
|
|
348
|
+
assert.ok(Date.now() - startedAt < 100, "refused without waiting");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("a copy binds the same pair and takes the same gate (#845.3)", () => {
|
|
353
|
+
it("copies from the confirmed pair once the move settles", async () => {
|
|
354
|
+
const { service, events, settle } = buildWorld([movingRow()]);
|
|
355
|
+
|
|
356
|
+
setTimeout(() => settle(MOVING_ID), 30);
|
|
357
|
+
await service.copyMessage(ACCOUNT_CONFIG, MOVING_ID, INBOX, ACCOUNT);
|
|
358
|
+
|
|
359
|
+
assert.equal(events.length, 1);
|
|
360
|
+
assert.equal(events[0].type, "MESSAGE_COPY");
|
|
361
|
+
assert.equal(events[0].sourceMailboxPath, "Archive");
|
|
362
|
+
assert.equal(events[0].uid, ARCHIVE_UID);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("writes no part of a batch copy when one row is in flight", async () => {
|
|
366
|
+
// The gate runs over the whole batch before the first upsert, so a
|
|
367
|
+
// refusal cannot leave copies already committed and enqueued.
|
|
368
|
+
const { service, events } = buildWorld([settledRow(), movingRow()]);
|
|
369
|
+
|
|
370
|
+
const startedAt = Date.now();
|
|
371
|
+
await assert.rejects(
|
|
372
|
+
() =>
|
|
373
|
+
service.copyMessages(
|
|
374
|
+
ACCOUNT_CONFIG,
|
|
375
|
+
[SETTLED_ID, MOVING_ID],
|
|
376
|
+
TRASH,
|
|
377
|
+
ACCOUNT,
|
|
378
|
+
),
|
|
379
|
+
MessagePlacementUnsettledError,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
assert.deepEqual(events, [], "no copy was enqueued");
|
|
383
|
+
assert.ok(
|
|
384
|
+
Date.now() - startedAt < 500,
|
|
385
|
+
"and the batch spent one ceiling, not one per row",
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
292
|
-
if (
|
|
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
|
-
|
|
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
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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.
|
package/src/placement-settled.ts
CHANGED
|
@@ -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;
|
package/src/spam-report.ts
CHANGED
|
@@ -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
|
|
312
|
-
|
|
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
|
|