@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 +1 -1
- package/src/index.ts +1 -0
- package/src/message-move-empty-trash.test.ts +42 -7
- package/src/message-move-same-mailbox.test.ts +2 -1
- package/src/message-move-unsettled-placement.test.ts +339 -0
- package/src/message-move.ts +52 -6
- package/src/placement-settled.test.ts +46 -1
- package/src/placement-settled.ts +17 -3
- package/src/spam-report.test.ts +36 -1
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -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
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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: "
|
|
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", "
|
|
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 () =>
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
package/src/placement-settled.ts
CHANGED
|
@@ -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
|
|
41
|
+
export const carriesForeignUid = (
|
|
34
42
|
message: Pick<
|
|
35
43
|
MessageItem,
|
|
36
|
-
"
|
|
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
|
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
|
|