@remit/mailbox-service 0.0.91 → 0.0.93
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 +16 -2
- package/src/mailbox-management.test.ts +38 -20
- package/src/mailbox-management.ts +47 -22
- package/src/mailbox-presence.test.ts +72 -0
- package/src/mailbox-presence.ts +49 -27
- package/src/mailbox-queue.ts +34 -15
- package/src/mailbox-rename-intent.test.ts +143 -0
- package/src/mailbox-rename-subtree.test.ts +22 -1
- package/src/mailbox-sync.test.ts +170 -28
- package/src/mailbox-sync.ts +57 -18
- package/src/message-delete-junk-reconcile.test.ts +5 -2
- package/src/message-delete-no-trash.test.ts +5 -1
- package/src/message-delete-unsettled-placement.test.ts +25 -17
- package/src/message-move-copy.test.ts +28 -1
- package/src/message-move-empty-trash.test.ts +48 -1
- package/src/message-move-same-mailbox.test.ts +5 -2
- package/src/message-move-unsettled-placement.test.ts +56 -17
- package/src/message-move.ts +189 -62
- package/src/message-sync-repoint.test.ts +22 -2
- package/src/message-sync.ts +9 -10
- package/src/placement-move-terminal.test.ts +198 -133
- package/src/placement-move-terminal.ts +47 -14
- package/src/placement-move-unsettled.test.ts +18 -4
- package/src/placement-move.ts +65 -9
- package/src/placement-settled.test.ts +36 -6
- package/src/placement-settled.ts +28 -25
- package/src/placement-transition-race.test.ts +482 -0
- package/src/restore-source-placement.ts +157 -0
- package/src/spam-report.test.ts +13 -10
- package/src/spam-report.ts +28 -2
- package/src/thread-message-rows.ts +77 -0
|
@@ -69,6 +69,11 @@ const buildWorld = () => {
|
|
|
69
69
|
category: "primary",
|
|
70
70
|
classificationState: "Examined",
|
|
71
71
|
hasListUnsubscribe: false,
|
|
72
|
+
// Settled, which is what a delete's predicate asks for: a row every
|
|
73
|
+
// repository read carries a status, and leaving it off made the
|
|
74
|
+
// fixture answer for a state no row is ever in.
|
|
75
|
+
status: "active",
|
|
76
|
+
syncStatus: "synced",
|
|
72
77
|
},
|
|
73
78
|
],
|
|
74
79
|
]);
|
|
@@ -130,6 +135,19 @@ const buildWorld = () => {
|
|
|
130
135
|
if (m) Object.assign(m, patch);
|
|
131
136
|
return m;
|
|
132
137
|
},
|
|
138
|
+
transitionPlacement: async (
|
|
139
|
+
id: string,
|
|
140
|
+
expected: Record<string, unknown>,
|
|
141
|
+
patch: Record<string, unknown>,
|
|
142
|
+
) => {
|
|
143
|
+
const m = messages.get(id) as Record<string, unknown> | undefined;
|
|
144
|
+
if (!m) return undefined;
|
|
145
|
+
for (const [field, want] of Object.entries(expected)) {
|
|
146
|
+
if (want !== undefined && m[field] !== want) return undefined;
|
|
147
|
+
}
|
|
148
|
+
Object.assign(m, patch);
|
|
149
|
+
return m;
|
|
150
|
+
},
|
|
133
151
|
} as unknown as IMessageRepository;
|
|
134
152
|
|
|
135
153
|
const threadMessageService = {
|
|
@@ -457,7 +475,7 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
|
|
|
457
475
|
});
|
|
458
476
|
|
|
459
477
|
it("copy then delete of the copy removes the copy row and leaves the original", async () => {
|
|
460
|
-
const { service, threadRows } = buildWorld();
|
|
478
|
+
const { service, threadRows, messages } = buildWorld();
|
|
461
479
|
|
|
462
480
|
await service.copyMessages(
|
|
463
481
|
ACCOUNT_CONFIG,
|
|
@@ -467,6 +485,15 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
|
|
|
467
485
|
);
|
|
468
486
|
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
469
487
|
|
|
488
|
+
// A fresh copy row is `moving` until the server hands back its COPYUID, and
|
|
489
|
+
// a delete against it would name uid 0. Settle it the way `updateUid`
|
|
490
|
+
// does, which is also the only state in which a user can see it to delete.
|
|
491
|
+
Object.assign(messages.get(copyId) ?? {}, {
|
|
492
|
+
uid: 77,
|
|
493
|
+
status: "active",
|
|
494
|
+
syncStatus: "synced",
|
|
495
|
+
});
|
|
496
|
+
|
|
470
497
|
await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
|
|
471
498
|
permanent: true,
|
|
472
499
|
});
|
|
@@ -79,9 +79,12 @@ const buildWorld = (
|
|
|
79
79
|
trashContents: TrashMessage[] = [
|
|
80
80
|
{ messageId: "junk-1", syncStatus: "synced" },
|
|
81
81
|
],
|
|
82
|
+
/** Message ids whose placement another lane changed after the read. */
|
|
83
|
+
transitionsLost: string[] = [],
|
|
82
84
|
) => {
|
|
83
85
|
const emptied: string[] = [];
|
|
84
86
|
const markedDeleting: string[] = [];
|
|
87
|
+
const markPredicates = new Map<string, unknown>();
|
|
85
88
|
const events: EnqueuedEvent[] = [];
|
|
86
89
|
const messagesByMailbox = new Map<string, TrashMessage[]>([
|
|
87
90
|
[DELETED_FOLDER, [{ messageId: "keepsake-1", syncStatus: "synced" }]],
|
|
@@ -96,6 +99,12 @@ const buildWorld = (
|
|
|
96
99
|
update: async (messageId: string) => {
|
|
97
100
|
markedDeleting.push(messageId);
|
|
98
101
|
},
|
|
102
|
+
transitionPlacement: async (messageId: string, expected: unknown) => {
|
|
103
|
+
markPredicates.set(messageId, expected);
|
|
104
|
+
if (transitionsLost.includes(messageId)) return undefined;
|
|
105
|
+
markedDeleting.push(messageId);
|
|
106
|
+
return { messageId };
|
|
107
|
+
},
|
|
99
108
|
} as unknown as IMessageRepository;
|
|
100
109
|
|
|
101
110
|
const threadMessageService = {
|
|
@@ -135,7 +144,7 @@ const buildWorld = (
|
|
|
135
144
|
events.push(event);
|
|
136
145
|
};
|
|
137
146
|
|
|
138
|
-
return { service, emptied, markedDeleting, events };
|
|
147
|
+
return { service, emptied, markedDeleting, markPredicates, events };
|
|
139
148
|
};
|
|
140
149
|
|
|
141
150
|
describe("MessageMoveService.emptyTrash", () => {
|
|
@@ -245,6 +254,44 @@ describe("MessageMoveService.emptyTrash", () => {
|
|
|
245
254
|
assert.equal(deletedCount, 1);
|
|
246
255
|
});
|
|
247
256
|
|
|
257
|
+
it("predicates each mark on the row it just read, and drops the ones it loses", async () => {
|
|
258
|
+
// The rows are held by nothing between the listing and the mark, and
|
|
259
|
+
// PLACEMENT_MOVE_PUSH rides a standard queue this account's FIFO group
|
|
260
|
+
// does not order (imap-mutations R3). A row that moved under the sweep
|
|
261
|
+
// must not be marked `deleting` for an expunge that would bind a uid it
|
|
262
|
+
// no longer has.
|
|
263
|
+
const { service, markedDeleting, markPredicates } = buildWorld(
|
|
264
|
+
appointedTrash,
|
|
265
|
+
[
|
|
266
|
+
{
|
|
267
|
+
messageId: "settled-1",
|
|
268
|
+
syncStatus: "synced",
|
|
269
|
+
status: "active",
|
|
270
|
+
mailboxId: REAL_TRASH,
|
|
271
|
+
uid: 10,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
messageId: "raced-1",
|
|
275
|
+
syncStatus: "synced",
|
|
276
|
+
status: "active",
|
|
277
|
+
mailboxId: REAL_TRASH,
|
|
278
|
+
uid: 11,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
["raced-1"],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
285
|
+
|
|
286
|
+
assert.deepEqual(markedDeleting, ["settled-1"]);
|
|
287
|
+
assert.equal(deletedCount, 1, "a row that lost is not counted either");
|
|
288
|
+
assert.deepEqual(markPredicates.get("settled-1"), {
|
|
289
|
+
status: "active",
|
|
290
|
+
mailboxId: REAL_TRASH,
|
|
291
|
+
uid: 10,
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
248
295
|
it("reports the same count when pressed twice before the worker runs", async () => {
|
|
249
296
|
// The rows it marked are still in that folder, so N stays true and the
|
|
250
297
|
// re-mark is idempotent. Reporting 0 the second time while still
|
|
@@ -52,8 +52,11 @@ const buildWorld = () => {
|
|
|
52
52
|
Array.isArray(ids) ? [message] : message,
|
|
53
53
|
update: async (_id: string, patch: Record<string, unknown>) =>
|
|
54
54
|
Object.assign(message, patch),
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
transitionPlacement: async (
|
|
56
|
+
_id: string,
|
|
57
|
+
_expected: Record<string, unknown>,
|
|
58
|
+
patch: Record<string, unknown>,
|
|
59
|
+
) => Object.assign(message, patch),
|
|
57
60
|
} as unknown as IMessageRepository;
|
|
58
61
|
|
|
59
62
|
const threadMessageService = {
|
|
@@ -28,6 +28,7 @@ const ARCHIVE = "mbx-archive";
|
|
|
28
28
|
const MOVING_ID = "msg-moving";
|
|
29
29
|
const SETTLED_ID = "msg-settled";
|
|
30
30
|
const STRANDED_ID = "msg-stranded";
|
|
31
|
+
const DELETING_ID = "msg-deleting";
|
|
31
32
|
const COPY_ID = "msg-copy";
|
|
32
33
|
|
|
33
34
|
const INBOX_UID = 42;
|
|
@@ -58,11 +59,36 @@ const movingRow = () => ({
|
|
|
58
59
|
originalUid: INBOX_UID,
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* A row a mutation gave up on. R3 defines the give-up as a hand-back: the row
|
|
64
|
+
* goes back on the placement the mail server confirmed, and only then carries
|
|
65
|
+
* `abandoned`. The pair is honest, so nothing downstream refuses it — the
|
|
66
|
+
* failure is a statement the reading pane makes, never a gate.
|
|
67
|
+
*/
|
|
62
68
|
const strandedRow = () => ({
|
|
63
|
-
...movingRow(),
|
|
64
69
|
messageId: STRANDED_ID,
|
|
65
|
-
|
|
70
|
+
mailboxId: INBOX,
|
|
71
|
+
uid: INBOX_UID,
|
|
72
|
+
status: "active",
|
|
73
|
+
syncStatus: "abandoned",
|
|
74
|
+
abandonedMutation: "move",
|
|
75
|
+
originalMailboxId: INBOX,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The same shape a delete leaves: Trash is already written, the uid is still
|
|
80
|
+
* INBOX's, and `deleting` is what says so. R3 calls this in flight exactly as
|
|
81
|
+
* `moving` is — reading only `moving` let a move enqueued behind a delete bind
|
|
82
|
+
* this pair and be dropped when the delete settled.
|
|
83
|
+
*/
|
|
84
|
+
const deletingRow = () => ({
|
|
85
|
+
messageId: DELETING_ID,
|
|
86
|
+
mailboxId: ARCHIVE,
|
|
87
|
+
uid: INBOX_UID,
|
|
88
|
+
status: "deleting",
|
|
89
|
+
syncStatus: "pending",
|
|
90
|
+
originalMailboxId: INBOX,
|
|
91
|
+
originalUid: INBOX_UID,
|
|
66
92
|
});
|
|
67
93
|
|
|
68
94
|
/** An ordinary settled row. */
|
|
@@ -118,7 +144,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
|
|
|
118
144
|
patches.push({ messageId: id, patch });
|
|
119
145
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
120
146
|
},
|
|
121
|
-
|
|
147
|
+
transitionPlacement: async (
|
|
148
|
+
id: string,
|
|
149
|
+
_expected: Record<string, unknown>,
|
|
150
|
+
patch: Record<string, unknown>,
|
|
151
|
+
) => {
|
|
122
152
|
patches.push({ messageId: id, patch });
|
|
123
153
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
124
154
|
},
|
|
@@ -318,22 +348,31 @@ describe("the move gate refuses only a uid that names somebody else (#665)", ()
|
|
|
318
348
|
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
319
349
|
});
|
|
320
350
|
|
|
321
|
-
it("
|
|
322
|
-
|
|
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()]);
|
|
351
|
+
it("waits out a delete in flight rather than binding its borrowed uid", async () => {
|
|
352
|
+
const { service, events, patches } = buildWorld([deletingRow()]);
|
|
327
353
|
|
|
328
|
-
const startedAt = Date.now();
|
|
329
354
|
await assert.rejects(
|
|
330
|
-
() => service.moveMessage(ACCOUNT_CONFIG,
|
|
331
|
-
(error: unknown) =>
|
|
332
|
-
error instanceof MessagePlacementUnsettledError &&
|
|
333
|
-
error.publicApiError?.details?.reason === "unverified",
|
|
355
|
+
() => service.moveMessage(ACCOUNT_CONFIG, DELETING_ID, INBOX, ACCOUNT),
|
|
356
|
+
(error: unknown) => error instanceof MessagePlacementUnsettledError,
|
|
334
357
|
);
|
|
335
358
|
|
|
336
|
-
assert.deepEqual(events, []);
|
|
337
|
-
assert.
|
|
359
|
+
assert.deepEqual(events, [], "nothing was enqueued");
|
|
360
|
+
assert.deepEqual(patches, [], "and no local write was made");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("acts on a row a move gave up on, rather than refusing it", async () => {
|
|
364
|
+
// A give-up hands the row back to the placement the server confirmed
|
|
365
|
+
// before it stops (R3), so the folder and uid name the same message and
|
|
366
|
+
// the user's next move is an ordinary one. Refusing it — which is what a
|
|
367
|
+
// give-up left `moving` used to earn — made the message unmovable for
|
|
368
|
+
// good, with nothing routine to repair it.
|
|
369
|
+
const { service, events } = buildWorld([strandedRow()]);
|
|
370
|
+
|
|
371
|
+
const startedAt = Date.now();
|
|
372
|
+
await service.moveMessage(ACCOUNT_CONFIG, STRANDED_ID, ARCHIVE, ACCOUNT);
|
|
373
|
+
|
|
374
|
+
assert.equal(events.length, 1);
|
|
375
|
+
assert.equal(events[0].type, "MESSAGE_MOVE");
|
|
376
|
+
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
338
377
|
});
|
|
339
378
|
});
|
package/src/message-move.ts
CHANGED
|
@@ -29,17 +29,28 @@ import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
|
29
29
|
import { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
|
|
30
30
|
import {
|
|
31
31
|
CanonicalMailboxRole,
|
|
32
|
+
MessageMutation,
|
|
32
33
|
MessageStatus,
|
|
33
34
|
MessageSyncStatus,
|
|
34
35
|
} from "@remit/domain-enums";
|
|
35
36
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
36
37
|
import {
|
|
37
|
-
|
|
38
|
-
type PlacementBinding,
|
|
38
|
+
carriesForeignUid,
|
|
39
39
|
placementBindingOf,
|
|
40
40
|
waitForPlacementToSettle,
|
|
41
41
|
} from "./placement-settled.js";
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* What a delete batch did not claim. A row whose placement changed between the
|
|
45
|
+
* read that decided this delete and the mark that recorded it belongs to
|
|
46
|
+
* whoever changed it (docs/architecture/imap-mutations.md R3), so this delete
|
|
47
|
+
* leaves it alone — and says which, because the client removed it optimistically
|
|
48
|
+
* and a row that reappears unexplained is a failure the user cannot act on.
|
|
49
|
+
*/
|
|
50
|
+
export interface DeleteMessagesOutcome {
|
|
51
|
+
refusedMessageIds: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* Event types for message move/delete operations.
|
|
45
56
|
* These match the worker event types in remit-imap-worker/events.ts.
|
|
@@ -288,9 +299,8 @@ export class MessageMoveService {
|
|
|
288
299
|
messageId: string,
|
|
289
300
|
accountId: string,
|
|
290
301
|
options: DeleteOptions = { toTrash: true },
|
|
291
|
-
): Promise<
|
|
292
|
-
|
|
293
|
-
};
|
|
302
|
+
): Promise<DeleteMessagesOutcome> =>
|
|
303
|
+
this.deleteMessages(accountConfigId, [messageId], accountId, options);
|
|
294
304
|
|
|
295
305
|
/**
|
|
296
306
|
* Delete multiple messages using batch operations.
|
|
@@ -305,12 +315,12 @@ export class MessageMoveService {
|
|
|
305
315
|
messageIds: string[],
|
|
306
316
|
accountId: string,
|
|
307
317
|
options: DeleteOptions = { toTrash: true },
|
|
308
|
-
): Promise<
|
|
309
|
-
if (messageIds.length === 0) return;
|
|
318
|
+
): Promise<DeleteMessagesOutcome> => {
|
|
319
|
+
if (messageIds.length === 0) return { refusedMessageIds: [] };
|
|
310
320
|
|
|
311
321
|
// Batch get all messages
|
|
312
322
|
const rows = await this.messageService.get(messageIds);
|
|
313
|
-
if (rows.length === 0) return;
|
|
323
|
+
if (rows.length === 0) return { refusedMessageIds: [] };
|
|
314
324
|
|
|
315
325
|
// Ahead of the Trash gates below, which compare `mailboxId` against the
|
|
316
326
|
// Trash folder: an in-flight move makes that comparison answer for a
|
|
@@ -381,20 +391,36 @@ export class MessageMoveService {
|
|
|
381
391
|
}
|
|
382
392
|
|
|
383
393
|
// Group messages by operation type
|
|
384
|
-
|
|
394
|
+
type DeleteEntry = {
|
|
385
395
|
messageId: string;
|
|
386
396
|
message: { mailboxId: string; uid: number };
|
|
387
397
|
sourceMailbox: { mailboxId: string; fullPath: string };
|
|
388
|
-
}
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
398
|
+
};
|
|
399
|
+
const moveToTrashMessages: DeleteEntry[] = [];
|
|
400
|
+
const permanentDeleteMessages: DeleteEntry[] = [];
|
|
401
|
+
|
|
402
|
+
// Rows whose placement changed between the read above and the mark below,
|
|
403
|
+
// so this delete never claimed them. Reported rather than skipped in
|
|
404
|
+
// silence: the client removed them optimistically, and a row that
|
|
405
|
+
// reappears with nothing said is the dead-button failure again (#1229).
|
|
406
|
+
const refused: string[] = [];
|
|
394
407
|
|
|
395
408
|
for (const message of messages) {
|
|
396
409
|
const sourceMailbox = mailboxMap.get(message.mailboxId);
|
|
397
|
-
if (!sourceMailbox)
|
|
410
|
+
if (!sourceMailbox) {
|
|
411
|
+
// The folder lookup is scoped to one account, so a selection that
|
|
412
|
+
// spans accounts — the daily brief lists every account's mail —
|
|
413
|
+
// leaves this row's mailbox unresolved. Nothing about it was
|
|
414
|
+
// written and no event was enqueued, so it is a refusal like any
|
|
415
|
+
// other; dropping it silently answered "deleted" over a message
|
|
416
|
+
// still sitting where it was.
|
|
417
|
+
refused.push(message.messageId);
|
|
418
|
+
this.log.info(
|
|
419
|
+
{ accountId, messageId: message.messageId },
|
|
420
|
+
"Refused delete: this message's folder does not belong to this account",
|
|
421
|
+
);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
398
424
|
|
|
399
425
|
const isInTrash =
|
|
400
426
|
trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
|
|
@@ -428,14 +454,41 @@ export class MessageMoveService {
|
|
|
428
454
|
accountConfigId,
|
|
429
455
|
);
|
|
430
456
|
for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
457
|
+
// The optimistic write is a transition off the row this call read
|
|
458
|
+
// (imap-mutations R3). Several folder lookups stand between the read
|
|
459
|
+
// and here, and PLACEMENT_MOVE_PUSH runs on a queue this account's
|
|
460
|
+
// FIFO group does not order, so the row can settle onto a different
|
|
461
|
+
// folder and uid in between. Writing anyway would stamp `moving` and
|
|
462
|
+
// the OLD uid over the new placement, and the event enqueued below
|
|
463
|
+
// would name a uid the source no longer holds — a delete aimed at
|
|
464
|
+
// whatever message has since taken that number.
|
|
465
|
+
const marked = await this.messageService.transitionPlacement(
|
|
466
|
+
messageId,
|
|
467
|
+
{
|
|
468
|
+
// `active`, not whatever the row was read as: a row already
|
|
469
|
+
// `moving` or `deleting` is mid-mutation, and marking it again
|
|
470
|
+
// would enqueue a second event against the same uid.
|
|
471
|
+
status: MessageStatus.active,
|
|
472
|
+
mailboxId: message.mailboxId,
|
|
473
|
+
uid: message.uid,
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
mailboxId: trashMailbox.mailboxId,
|
|
477
|
+
status: MessageStatus.moving,
|
|
478
|
+
syncStatus: MessageSyncStatus.pending,
|
|
479
|
+
abandonedMutation: MessageMutation.none,
|
|
480
|
+
originalMailboxId: sourceMailbox.mailboxId,
|
|
481
|
+
originalUid: message.uid,
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
if (!marked) {
|
|
485
|
+
refused.push(messageId);
|
|
486
|
+
this.log.info(
|
|
487
|
+
{ accountId, messageId },
|
|
488
|
+
"Refused delete: this message's placement changed after it was read",
|
|
489
|
+
);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
439
492
|
|
|
440
493
|
// Update ThreadMessage
|
|
441
494
|
await this.updateThreadMessageForMove(
|
|
@@ -481,11 +534,36 @@ export class MessageMoveService {
|
|
|
481
534
|
message,
|
|
482
535
|
sourceMailbox,
|
|
483
536
|
} of permanentDeleteMessages) {
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
537
|
+
// A transition off the row this call read, like the trash path above
|
|
538
|
+
// (imap-mutations R3). The expunge below is aimed at a specific uid in
|
|
539
|
+
// a specific folder, so a row that has moved since the read must not be
|
|
540
|
+
// marked for it: a restore out of Trash settling in between would be
|
|
541
|
+
// overwritten with `deleting`, and the event would carry the uid Trash
|
|
542
|
+
// no longer holds.
|
|
543
|
+
const marked = await this.messageService.transitionPlacement(
|
|
544
|
+
messageId,
|
|
545
|
+
{
|
|
546
|
+
// `active` for the same reason as the trash path above: a second
|
|
547
|
+
// press on a row already `deleting` would enqueue a duplicate
|
|
548
|
+
// expunge rather than refusing.
|
|
549
|
+
status: MessageStatus.active,
|
|
550
|
+
mailboxId: message.mailboxId,
|
|
551
|
+
uid: message.uid,
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
status: MessageStatus.deleting,
|
|
555
|
+
syncStatus: MessageSyncStatus.pending,
|
|
556
|
+
abandonedMutation: MessageMutation.none,
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
if (!marked) {
|
|
560
|
+
refused.push(messageId);
|
|
561
|
+
this.log.info(
|
|
562
|
+
{ accountId, messageId },
|
|
563
|
+
"Refused permanent delete: this message's placement changed after it was read",
|
|
564
|
+
);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
489
567
|
|
|
490
568
|
// Delete ThreadMessage rows up-front (one row per mailbox copy).
|
|
491
569
|
// The IMAP worker also deletes them once the IMAP DELETE succeeds —
|
|
@@ -517,6 +595,8 @@ export class MessageMoveService {
|
|
|
517
595
|
|
|
518
596
|
// Batch send events to SQS
|
|
519
597
|
await this.enqueueEventsBatch(events);
|
|
598
|
+
|
|
599
|
+
return { refusedMessageIds: refused };
|
|
520
600
|
};
|
|
521
601
|
|
|
522
602
|
/**
|
|
@@ -591,14 +671,41 @@ export class MessageMoveService {
|
|
|
591
671
|
trashMailbox && message.mailboxId === trashMailbox.mailboxId,
|
|
592
672
|
);
|
|
593
673
|
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
674
|
+
// The optimistic write is a transition off the row this call read
|
|
675
|
+
// (imap-mutations R3). Three mailbox lookups stand between the read and
|
|
676
|
+
// here, and PLACEMENT_MOVE_PUSH runs on a queue this account's FIFO group
|
|
677
|
+
// does not order, so the row can settle onto a different folder and uid in
|
|
678
|
+
// between. A blind write would stamp `moving` and the OLD uid over that
|
|
679
|
+
// placement, leaving a pair `carriesForeignUid` calls consistent, and the
|
|
680
|
+
// event below would send the worker after whatever now holds the old uid.
|
|
681
|
+
const moved = await this.messageService.transitionPlacement(
|
|
682
|
+
messageId,
|
|
683
|
+
{
|
|
684
|
+
status: MessageStatus.active,
|
|
685
|
+
mailboxId: message.mailboxId,
|
|
686
|
+
uid: message.uid,
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
mailboxId: destinationMailboxId,
|
|
690
|
+
status: MessageStatus.moving,
|
|
691
|
+
syncStatus: MessageSyncStatus.pending,
|
|
692
|
+
abandonedMutation: MessageMutation.none,
|
|
693
|
+
originalMailboxId: sourceMailbox.mailboxId,
|
|
694
|
+
originalUid: message.uid,
|
|
695
|
+
},
|
|
696
|
+
);
|
|
697
|
+
if (!moved) {
|
|
698
|
+
this.log.error(
|
|
699
|
+
{ accountId, messageId, destinationMailboxId },
|
|
700
|
+
"Refused: this message's placement changed while the move was being prepared",
|
|
701
|
+
);
|
|
702
|
+
throw new MessagePlacementUnsettledError(
|
|
703
|
+
`Message ${messageId} was not moved: something else changed where it is while this move was being prepared`,
|
|
704
|
+
accountId,
|
|
705
|
+
messageId,
|
|
706
|
+
"in_flight",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
602
709
|
|
|
603
710
|
// Update ThreadMessage
|
|
604
711
|
await this.updateThreadMessageForMove(
|
|
@@ -928,18 +1035,45 @@ export class MessageMoveService {
|
|
|
928
1035
|
// says so: its own MESSAGE_MOVE then reads the row as settled and returns
|
|
929
1036
|
// without moving anything, and the worker's expunge binds that borrowed
|
|
930
1037
|
// uid against whatever Trash really holds at it. Left as it stands, that
|
|
931
|
-
// move
|
|
932
|
-
// the
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1038
|
+
// move settles the row on its own, so the message is in Trash by the time
|
|
1039
|
+
// the sweep reaches it and goes with the rest of this same press — only
|
|
1040
|
+
// the count below is one short of what the sweep ends up removing.
|
|
1041
|
+
//
|
|
1042
|
+
// `carriesForeignUid`, the same predicate the sweep applies (#1217).
|
|
1043
|
+
// The binding form reads `status` too, and `status` is exactly what this
|
|
1044
|
+
// mark is about to overwrite, so the two halves of one gate would have
|
|
1045
|
+
// disagreed the moment the mark landed.
|
|
1046
|
+
const candidates = rows.filter((message) => !carriesForeignUid(message));
|
|
1047
|
+
|
|
1048
|
+
// Each mark is a transition off the row as it was just read
|
|
1049
|
+
// (imap-mutations R3), not a blind write onto a snapshot. The rows are
|
|
1050
|
+
// held by nothing between the listing and here, and PLACEMENT_MOVE_PUSH
|
|
1051
|
+
// runs on a standard queue that this account's FIFO group does not order,
|
|
1052
|
+
// so a row can move out from under the sweep. A lost predicate means
|
|
1053
|
+
// another lane won: the row is left alone and not counted, rather than
|
|
1054
|
+
// marked `deleting` for an expunge that would bind somebody else's uid.
|
|
1055
|
+
const messages: MessageItem[] = [];
|
|
1056
|
+
for (const message of candidates) {
|
|
1057
|
+
const marked = await this.messageService.transitionPlacement(
|
|
1058
|
+
message.messageId,
|
|
1059
|
+
{
|
|
1060
|
+
status: message.status,
|
|
1061
|
+
mailboxId: message.mailboxId,
|
|
1062
|
+
uid: message.uid,
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
status: MessageStatus.deleting,
|
|
1066
|
+
syncStatus: MessageSyncStatus.pending,
|
|
1067
|
+
},
|
|
1068
|
+
);
|
|
1069
|
+
if (!marked) {
|
|
1070
|
+
this.log.info(
|
|
1071
|
+
{ accountId, messageId: message.messageId },
|
|
1072
|
+
"Empty Trash skipped a message whose placement changed while the folder was being read",
|
|
1073
|
+
);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
messages.push(marked);
|
|
943
1077
|
await this.updateThreadMessageDeleted(
|
|
944
1078
|
accountConfigId,
|
|
945
1079
|
message.messageId,
|
|
@@ -952,6 +1086,7 @@ export class MessageMoveService {
|
|
|
952
1086
|
accountId,
|
|
953
1087
|
trashMailboxId: trashMailbox.mailboxId,
|
|
954
1088
|
count: messages.length,
|
|
1089
|
+
skipped: rows.length - messages.length,
|
|
955
1090
|
},
|
|
956
1091
|
"Marked all trash messages for deletion (local)",
|
|
957
1092
|
);
|
|
@@ -976,17 +1111,16 @@ export class MessageMoveService {
|
|
|
976
1111
|
private refusePlacement = (
|
|
977
1112
|
message: MessageItem,
|
|
978
1113
|
accountId: string,
|
|
979
|
-
binding: Exclude<PlacementBinding, "consistent">,
|
|
980
1114
|
): never => {
|
|
981
1115
|
this.log.error(
|
|
982
|
-
{ accountId, messageId: message.messageId
|
|
1116
|
+
{ accountId, messageId: message.messageId },
|
|
983
1117
|
"Refused: this message's folder and uid do not name the same message",
|
|
984
1118
|
);
|
|
985
1119
|
throw new MessagePlacementUnsettledError(
|
|
986
1120
|
`Message ${message.messageId} was not acted on: its folder and uid do not name the same message`,
|
|
987
1121
|
accountId,
|
|
988
1122
|
message.messageId,
|
|
989
|
-
|
|
1123
|
+
"in_flight",
|
|
990
1124
|
);
|
|
991
1125
|
};
|
|
992
1126
|
|
|
@@ -994,13 +1128,7 @@ export class MessageMoveService {
|
|
|
994
1128
|
message: MessageItem,
|
|
995
1129
|
accountId: string,
|
|
996
1130
|
): Promise<MessageItem> => {
|
|
997
|
-
|
|
998
|
-
if (binding === "consistent") return message;
|
|
999
|
-
|
|
1000
|
-
// An abandoned move is never coming back, so the ceiling would be spent
|
|
1001
|
-
// only to reach the same answer.
|
|
1002
|
-
if (binding === "abandoned")
|
|
1003
|
-
return this.refusePlacement(message, accountId, binding);
|
|
1131
|
+
if (placementBindingOf(message) === "consistent") return message;
|
|
1004
1132
|
|
|
1005
1133
|
const settled = await waitForPlacementToSettle(
|
|
1006
1134
|
this.messageService,
|
|
@@ -1010,9 +1138,8 @@ export class MessageMoveService {
|
|
|
1010
1138
|
pollMs: this.moveSettlePollMs,
|
|
1011
1139
|
},
|
|
1012
1140
|
);
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
return this.refusePlacement(settled, accountId, settledBinding);
|
|
1141
|
+
if (placementBindingOf(settled) === "consistent") return settled;
|
|
1142
|
+
return this.refusePlacement(settled, accountId);
|
|
1016
1143
|
};
|
|
1017
1144
|
|
|
1018
1145
|
/**
|
|
@@ -336,13 +336,33 @@ describe("repointsOnSighting", () => {
|
|
|
336
336
|
);
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
-
|
|
339
|
+
/**
|
|
340
|
+
* The row `abandonDelete` hands back: reader refused the delete, put the
|
|
341
|
+
* message back where the server still has it, and nothing else is coming
|
|
342
|
+
* for the row. Reader shares its mailboxes, so the user moving that same
|
|
343
|
+
* message in another client is ordinary — and before R3 the sighting was
|
|
344
|
+
* refused on `syncStatus` alone and the row never followed the move.
|
|
345
|
+
*/
|
|
346
|
+
it("accepts a row whose delete was abandoned, so a move made elsewhere still lands", () => {
|
|
347
|
+
assert.equal(
|
|
348
|
+
repointsOnSighting(
|
|
349
|
+
JUNK,
|
|
350
|
+
storedIn(INBOX, {
|
|
351
|
+
status: MessageStatus.active,
|
|
352
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
true,
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("accepts a row left `failed` by a transient attempt that has since settled", () => {
|
|
340
360
|
assert.equal(
|
|
341
361
|
repointsOnSighting(
|
|
342
362
|
JUNK,
|
|
343
363
|
storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
|
|
344
364
|
),
|
|
345
|
-
|
|
365
|
+
true,
|
|
346
366
|
);
|
|
347
367
|
});
|
|
348
368
|
|