@remit/mailbox-service 0.0.91 → 0.0.92
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 +13 -0
- 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
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -193,6 +193,7 @@ export {
|
|
|
193
193
|
type SyncAccountInfo,
|
|
194
194
|
} from "./mailbox-sync.js";
|
|
195
195
|
export {
|
|
196
|
+
type DeleteMessagesOutcome,
|
|
196
197
|
type DeleteOptions,
|
|
197
198
|
type MessageMoveConfig,
|
|
198
199
|
type MessageMoveLogger,
|
|
@@ -267,6 +268,12 @@ export {
|
|
|
267
268
|
resolveMailboxRole,
|
|
268
269
|
shapeFromMessageData,
|
|
269
270
|
} from "./quarantine.js";
|
|
271
|
+
export {
|
|
272
|
+
type RestoreSourcePlacementDeps,
|
|
273
|
+
type RestoreSourcePlacementInput,
|
|
274
|
+
type RestoreSourcePlacementOutcome,
|
|
275
|
+
restoreSourcePlacement,
|
|
276
|
+
} from "./restore-source-placement.js";
|
|
270
277
|
export {
|
|
271
278
|
extractSnippetFromEmail,
|
|
272
279
|
generateSnippet,
|
|
@@ -286,6 +293,12 @@ export {
|
|
|
286
293
|
type StaleMessageReconcileDeps,
|
|
287
294
|
type StaleMessageReconcileResult,
|
|
288
295
|
} from "./stale-message-reconcile.js";
|
|
296
|
+
export {
|
|
297
|
+
buildThreadMessageMoveRevert,
|
|
298
|
+
buildThreadMessageTrashUpdate,
|
|
299
|
+
buildThreadMessageUndelete,
|
|
300
|
+
type ThreadMessageRowState,
|
|
301
|
+
} from "./thread-message-rows.js";
|
|
289
302
|
export type {
|
|
290
303
|
FlatMailboxInfo,
|
|
291
304
|
IImapConnection,
|
|
@@ -30,8 +30,11 @@ const buildWorld = (trashExists: boolean) => {
|
|
|
30
30
|
get: async () => [message],
|
|
31
31
|
update: async (_id: string, patch: Record<string, unknown>) =>
|
|
32
32
|
Object.assign(message, patch),
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
transitionPlacement: async (
|
|
34
|
+
_id: string,
|
|
35
|
+
_expected: Record<string, unknown>,
|
|
36
|
+
patch: Record<string, unknown>,
|
|
37
|
+
) => Object.assign(message, patch),
|
|
35
38
|
} as unknown as IMessageRepository;
|
|
36
39
|
|
|
37
40
|
const threadMessageService = {
|
|
@@ -52,7 +52,11 @@ const buildWorld = (
|
|
|
52
52
|
patches.push(patch);
|
|
53
53
|
return Object.assign(message, patch);
|
|
54
54
|
},
|
|
55
|
-
|
|
55
|
+
transitionPlacement: async (
|
|
56
|
+
_id: string,
|
|
57
|
+
_expected: Record<string, unknown>,
|
|
58
|
+
patch: Record<string, unknown>,
|
|
59
|
+
) => {
|
|
56
60
|
patches.push(patch);
|
|
57
61
|
return Object.assign(message, patch);
|
|
58
62
|
},
|
|
@@ -68,11 +68,20 @@ const movingRow = () => ({
|
|
|
68
68
|
originalUid: INBOX_UID,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* A row a delete gave up on. R3 defines the give-up as a hand-back: the row
|
|
73
|
+
* goes back on the placement the mail server confirmed, and only then carries
|
|
74
|
+
* `abandoned`. The pair is honest, so nothing downstream refuses it — the
|
|
75
|
+
* failure is a statement the reading pane makes, never a gate.
|
|
76
|
+
*/
|
|
72
77
|
const strandedRow = () => ({
|
|
73
|
-
...movingRow(),
|
|
74
78
|
messageId: STRANDED_ID,
|
|
75
|
-
|
|
79
|
+
mailboxId: INBOX,
|
|
80
|
+
uid: INBOX_UID,
|
|
81
|
+
status: "active",
|
|
82
|
+
syncStatus: "abandoned",
|
|
83
|
+
abandonedMutation: "delete",
|
|
84
|
+
originalMailboxId: INBOX,
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
/** An ordinary settled row. */
|
|
@@ -127,7 +136,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
|
|
|
127
136
|
patches.push({ messageId: id, patch });
|
|
128
137
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
129
138
|
},
|
|
130
|
-
|
|
139
|
+
transitionPlacement: async (
|
|
140
|
+
id: string,
|
|
141
|
+
_expected: Record<string, unknown>,
|
|
142
|
+
patch: Record<string, unknown>,
|
|
143
|
+
) => {
|
|
131
144
|
patches.push({ messageId: id, patch });
|
|
132
145
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
133
146
|
},
|
|
@@ -329,23 +342,18 @@ describe("the gate refuses only a uid that names somebody else (#845.3)", () =>
|
|
|
329
342
|
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
330
343
|
});
|
|
331
344
|
|
|
332
|
-
it("
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
345
|
+
it("acts on a row a delete gave up on, rather than refusing it", async () => {
|
|
346
|
+
// A give-up hands the row back to the placement the server confirmed
|
|
347
|
+
// before it stops (R3), so the "Delete again" the reading pane offers is
|
|
348
|
+
// an ordinary delete. Refusing it — which is what a give-up left `moving`
|
|
349
|
+
// used to earn — made that button a dead end.
|
|
337
350
|
const { service, events } = buildWorld([strandedRow()]);
|
|
338
351
|
|
|
339
352
|
const startedAt = Date.now();
|
|
340
|
-
await
|
|
341
|
-
() => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
|
|
342
|
-
(error: unknown) =>
|
|
343
|
-
error instanceof MessagePlacementUnsettledError &&
|
|
344
|
-
error.publicApiError?.details?.reason === "unverified",
|
|
345
|
-
);
|
|
353
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT);
|
|
346
354
|
|
|
347
|
-
assert.
|
|
348
|
-
assert.ok(Date.now() - startedAt < 100, "
|
|
355
|
+
assert.equal(events.length, 1);
|
|
356
|
+
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
349
357
|
});
|
|
350
358
|
});
|
|
351
359
|
|
|
@@ -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
|
});
|