@remit/mailbox-service 0.0.40 → 0.0.42
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/body-sync-placement-flags.test.ts +55 -6
- package/src/body-sync.ts +31 -7
- package/src/list-id-backfill.test.ts +158 -1
- package/src/list-id-backfill.ts +32 -13
package/package.json
CHANGED
|
@@ -69,7 +69,7 @@ const MAILBOXES = {
|
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
const buildHarness = (
|
|
72
|
-
message: { messageId: string; mailboxId: string },
|
|
72
|
+
message: { messageId: string; mailboxId: string; movedByRemit?: boolean },
|
|
73
73
|
flags: AddressItem["flags"],
|
|
74
74
|
): Harness => {
|
|
75
75
|
const moves: MoveCall[] = [];
|
|
@@ -79,11 +79,7 @@ const buildHarness = (
|
|
|
79
79
|
}> = [];
|
|
80
80
|
|
|
81
81
|
const messageService = {
|
|
82
|
-
get: async () => ({
|
|
83
|
-
messageId: message.messageId,
|
|
84
|
-
mailboxId: message.mailboxId,
|
|
85
|
-
uid: 1,
|
|
86
|
-
}),
|
|
82
|
+
get: async () => ({ ...message, uid: 1 }),
|
|
87
83
|
update: async (messageId: string, input: UpdateMessageInput) => {
|
|
88
84
|
messageUpdates.push({ messageId, input });
|
|
89
85
|
},
|
|
@@ -300,3 +296,56 @@ describe("Address.flags.autoArchive drives placement (issue #300)", () => {
|
|
|
300
296
|
]);
|
|
301
297
|
});
|
|
302
298
|
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Issue #398: not every `leave` verdict means "nothing confident to say".
|
|
302
|
+
* `already-moved-by-remit` is the guard against re-deciding a settled
|
|
303
|
+
* placement, and mail sitting in Junk gets a `leave` because the demote branch
|
|
304
|
+
* only fires outside Junk. Auto-archive is a filing preference relative to the
|
|
305
|
+
* Inbox and must yield to both, so these drive `BodySyncService` end-to-end and
|
|
306
|
+
* assert on the absence of a move.
|
|
307
|
+
*/
|
|
308
|
+
describe("autoArchive respects a leave verdict that decided something (issue #398)", () => {
|
|
309
|
+
it("leaves a blocked autoArchive sender's message in Junk", async () => {
|
|
310
|
+
const harness = buildHarness(
|
|
311
|
+
{ messageId: "m-1", mailboxId: MAILBOXES.junk.mailboxId },
|
|
312
|
+
{
|
|
313
|
+
blocked: { value: true, setAt: 1_000 },
|
|
314
|
+
autoArchive: { value: true, setAt: 1_000 },
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await readBody(harness.service, "Junk");
|
|
319
|
+
|
|
320
|
+
assert.deepEqual(harness.moves, []);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("leaves an autoArchive sender's provider-junked message in Junk", async () => {
|
|
324
|
+
const harness = buildHarness(
|
|
325
|
+
{ messageId: "m-1", mailboxId: MAILBOXES.junk.mailboxId },
|
|
326
|
+
{ autoArchive: { value: true, setAt: 1_000 } },
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// Provider-spam + dmarc-pass from an untrusted sender is an unsure
|
|
330
|
+
// `leave`: too weak to rescue, and auto-archive is not a second chance
|
|
331
|
+
// at leaving Junk.
|
|
332
|
+
await readBody(harness.service, "Junk", SPAM_FLAGGED_DMARC_PASS_EML);
|
|
333
|
+
|
|
334
|
+
assert.deepEqual(harness.moves, []);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("leaves a message Remit already placed alone on a re-entrant pass", async () => {
|
|
338
|
+
const harness = buildHarness(
|
|
339
|
+
{
|
|
340
|
+
messageId: "m-1",
|
|
341
|
+
mailboxId: MAILBOXES.inbox.mailboxId,
|
|
342
|
+
movedByRemit: true,
|
|
343
|
+
},
|
|
344
|
+
{ autoArchive: { value: true, setAt: 1_000 } },
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await readBody(harness.service);
|
|
348
|
+
|
|
349
|
+
assert.deepEqual(harness.moves, []);
|
|
350
|
+
});
|
|
351
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import {
|
|
49
49
|
classifyPlacement,
|
|
50
50
|
type FolderPlacement,
|
|
51
|
+
type PlacementVerdict,
|
|
51
52
|
resolveBlockedVsTrust,
|
|
52
53
|
} from "./heuristics/classifyPlacement.js";
|
|
53
54
|
import type { PlacementMoveService } from "./placement-move.js";
|
|
@@ -199,6 +200,26 @@ const hasDecidedCategory = (
|
|
|
199
200
|
const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
|
|
200
201
|
placementDecidedAt !== undefined;
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Issue #398: `flags.autoArchive` is a filing preference relative to the Inbox.
|
|
205
|
+
* A `leave` verdict reaches it for two unrelated reasons and only one of them
|
|
206
|
+
* means "nothing confident to say":
|
|
207
|
+
*
|
|
208
|
+
* - `already-moved-by-remit` is {@link classifyPlacement}'s guard against
|
|
209
|
+
* re-deciding a settled placement, and auto-archive is a re-decision. The
|
|
210
|
+
* re-entrant paths `hasDecidedPlacement` names reach it on messages Remit
|
|
211
|
+
* itself placed, or that a user moved back afterwards.
|
|
212
|
+
* - A message already in Junk gets an unsure `leave` whatever the sender's
|
|
213
|
+
* `blocked` flag says, since the demote branch only fires outside Junk.
|
|
214
|
+
* Moving mail out of Junk is a rescue, which this file reserves for a
|
|
215
|
+
* confident signal plus sender trust.
|
|
216
|
+
*/
|
|
217
|
+
const autoArchiveApplies = (
|
|
218
|
+
verdict: PlacementVerdict,
|
|
219
|
+
placement: FolderPlacement,
|
|
220
|
+
): boolean =>
|
|
221
|
+
placement !== "junk" && !verdict.reasons.includes("already-moved-by-remit");
|
|
222
|
+
|
|
202
223
|
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
203
224
|
text: parsed.text ?? null,
|
|
204
225
|
html: typeof parsed.html === "string" ? parsed.html : null,
|
|
@@ -1459,12 +1480,14 @@ export class BodySyncService {
|
|
|
1459
1480
|
// junk/inbox verdict computed above. Still marked decided (issue #383):
|
|
1460
1481
|
// "leave" is itself a verdict, not "not yet evaluated".
|
|
1461
1482
|
if (verdict.action === "leave") {
|
|
1462
|
-
const outcome =
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1483
|
+
const outcome = autoArchiveApplies(verdict, placement)
|
|
1484
|
+
? await this.resolveAutoArchive(
|
|
1485
|
+
mailboxSpecialUseService,
|
|
1486
|
+
message,
|
|
1487
|
+
accountId,
|
|
1488
|
+
signals.autoArchive,
|
|
1489
|
+
)
|
|
1490
|
+
: {};
|
|
1468
1491
|
return { ...outcome, placementDecidedAt: Date.now() };
|
|
1469
1492
|
}
|
|
1470
1493
|
|
|
@@ -1526,7 +1549,8 @@ export class BodySyncService {
|
|
|
1526
1549
|
* straight to Archive, skipping Inbox. Only reached from
|
|
1527
1550
|
* {@link computePlacement} when {@link classifyPlacement} had no confident
|
|
1528
1551
|
* junk/inbox verdict of its own — `blocked`/DKIM/DMARC always take priority
|
|
1529
|
-
* over this filing preference
|
|
1552
|
+
* over this filing preference — and only for the `leave` verdicts
|
|
1553
|
+
* {@link autoArchiveApplies} admits.
|
|
1530
1554
|
*
|
|
1531
1555
|
* No {@link MessagePlacementVerdict} is recorded: `PlacementAction` (the
|
|
1532
1556
|
* audit enum) has only `MoveToInbox`/`MoveToJunk` — issue #300 is scoped to
|
|
@@ -24,6 +24,7 @@ import type { StorageService } from "@remit/storage-service";
|
|
|
24
24
|
import {
|
|
25
25
|
backfillListIds,
|
|
26
26
|
type ListIdBackfillCheckpoint,
|
|
27
|
+
type ListIdBackfillCheckpointStore,
|
|
27
28
|
type ListIdBackfillProgress,
|
|
28
29
|
} from "./list-id-backfill.js";
|
|
29
30
|
|
|
@@ -99,6 +100,22 @@ const message = (overrides: Partial<MessageItem>): MessageItem =>
|
|
|
99
100
|
...overrides,
|
|
100
101
|
}) as unknown as MessageItem;
|
|
101
102
|
|
|
103
|
+
const asAccount = (accountConfigId: string): AccountConfigItem =>
|
|
104
|
+
({ accountConfigId }) as unknown as AccountConfigItem;
|
|
105
|
+
|
|
106
|
+
const inMemoryCheckpointStore = (): ListIdBackfillCheckpointStore => {
|
|
107
|
+
let checkpoint: ListIdBackfillCheckpoint | undefined;
|
|
108
|
+
return {
|
|
109
|
+
load: async () => checkpoint,
|
|
110
|
+
save: async (next) => {
|
|
111
|
+
checkpoint = next;
|
|
112
|
+
},
|
|
113
|
+
clear: async () => {
|
|
114
|
+
checkpoint = undefined;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
102
119
|
interface Harness {
|
|
103
120
|
accountConfigService: Pick<IAccountConfigRepository, "listAll">;
|
|
104
121
|
threadMessageService: Pick<
|
|
@@ -351,6 +368,37 @@ describe("backfillListIds", () => {
|
|
|
351
368
|
assert.equal(harness.updates.length, 2);
|
|
352
369
|
});
|
|
353
370
|
|
|
371
|
+
it("scans accounts in account-id order, whatever order listAll returns", async () => {
|
|
372
|
+
const accountIds = ["acc-1", "acc-2", "acc-3"];
|
|
373
|
+
const rows = accountIds.map((accountConfigId) =>
|
|
374
|
+
row({
|
|
375
|
+
threadMessageId: `tm-${accountConfigId}`,
|
|
376
|
+
messageId: `m-${accountConfigId}`,
|
|
377
|
+
accountConfigId,
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
const harness = buildHarness({
|
|
381
|
+
accounts: ["acc-3", "acc-1", "acc-2"].map(asAccount),
|
|
382
|
+
rows,
|
|
383
|
+
messages: rows.map((r) =>
|
|
384
|
+
message({
|
|
385
|
+
messageId: r.messageId,
|
|
386
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
387
|
+
}),
|
|
388
|
+
),
|
|
389
|
+
});
|
|
390
|
+
const progress: ListIdBackfillProgress[] = [];
|
|
391
|
+
|
|
392
|
+
await backfillListIds(harness, {
|
|
393
|
+
onProgress: (p) => progress.push({ ...p }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
assert.deepEqual(
|
|
397
|
+
progress.map((p) => p.accountConfigId),
|
|
398
|
+
accountIds,
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
354
402
|
it("checkpoints after each page and clears it on completion", async () => {
|
|
355
403
|
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
356
404
|
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
@@ -380,6 +428,7 @@ describe("backfillListIds", () => {
|
|
|
380
428
|
});
|
|
381
429
|
|
|
382
430
|
assert.equal(saved.length, 2);
|
|
431
|
+
assert.equal(saved[0].accountConfigId, "acc-1");
|
|
383
432
|
assert.equal(saved[0].continuationToken, "2");
|
|
384
433
|
assert.equal(saved[1].continuationToken, undefined);
|
|
385
434
|
assert.equal(cleared, true);
|
|
@@ -400,7 +449,10 @@ describe("backfillListIds", () => {
|
|
|
400
449
|
const result = await backfillListIds(harness, {
|
|
401
450
|
batchSize: 2,
|
|
402
451
|
checkpointStore: {
|
|
403
|
-
load: async () => ({
|
|
452
|
+
load: async () => ({
|
|
453
|
+
accountConfigId: "acc-1",
|
|
454
|
+
continuationToken: "2",
|
|
455
|
+
}),
|
|
404
456
|
save: async () => {},
|
|
405
457
|
clear: async () => {},
|
|
406
458
|
},
|
|
@@ -413,4 +465,109 @@ describe("backfillListIds", () => {
|
|
|
413
465
|
["tm-2"],
|
|
414
466
|
);
|
|
415
467
|
});
|
|
468
|
+
|
|
469
|
+
it("resumes the account it was working through after an earlier account is removed", async () => {
|
|
470
|
+
const accountIds = ["acc-1", "acc-2", "acc-3"];
|
|
471
|
+
const rows = [
|
|
472
|
+
row({
|
|
473
|
+
threadMessageId: "tm-1",
|
|
474
|
+
messageId: "m-1",
|
|
475
|
+
accountConfigId: "acc-1",
|
|
476
|
+
}),
|
|
477
|
+
row({
|
|
478
|
+
threadMessageId: "tm-2a",
|
|
479
|
+
messageId: "m-2a",
|
|
480
|
+
accountConfigId: "acc-2",
|
|
481
|
+
}),
|
|
482
|
+
row({
|
|
483
|
+
threadMessageId: "tm-2b",
|
|
484
|
+
messageId: "m-2b",
|
|
485
|
+
accountConfigId: "acc-2",
|
|
486
|
+
}),
|
|
487
|
+
row({
|
|
488
|
+
threadMessageId: "tm-3",
|
|
489
|
+
messageId: "m-3",
|
|
490
|
+
accountConfigId: "acc-3",
|
|
491
|
+
}),
|
|
492
|
+
];
|
|
493
|
+
const messages = rows.map((r) =>
|
|
494
|
+
message({
|
|
495
|
+
messageId: r.messageId,
|
|
496
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const store = inMemoryCheckpointStore();
|
|
501
|
+
|
|
502
|
+
const interrupted = buildHarness({
|
|
503
|
+
accounts: accountIds.map(asAccount),
|
|
504
|
+
rows,
|
|
505
|
+
messages,
|
|
506
|
+
pageSize: 1,
|
|
507
|
+
});
|
|
508
|
+
const listByAccount = interrupted.threadMessageService.listByAccount;
|
|
509
|
+
interrupted.threadMessageService.listByAccount = async (
|
|
510
|
+
accountConfigId,
|
|
511
|
+
opts,
|
|
512
|
+
) => {
|
|
513
|
+
if (accountConfigId === "acc-2" && opts?.continuationToken) {
|
|
514
|
+
throw new Error("interrupted");
|
|
515
|
+
}
|
|
516
|
+
return listByAccount(accountConfigId, opts);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
await assert.rejects(
|
|
520
|
+
backfillListIds(interrupted, { batchSize: 1, checkpointStore: store }),
|
|
521
|
+
/interrupted/,
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const resumed = buildHarness({
|
|
525
|
+
accounts: ["acc-2", "acc-3"].map(asAccount),
|
|
526
|
+
rows,
|
|
527
|
+
messages,
|
|
528
|
+
pageSize: 1,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const result = await backfillListIds(resumed, {
|
|
532
|
+
batchSize: 1,
|
|
533
|
+
checkpointStore: store,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
assert.deepEqual(
|
|
537
|
+
resumed.updates.map((u) => u.threadMessageId),
|
|
538
|
+
["tm-2b", "tm-3"],
|
|
539
|
+
);
|
|
540
|
+
assert.equal(result.backfilled, 2);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("restarts the pass when the checkpointed account no longer exists", async () => {
|
|
544
|
+
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
545
|
+
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
546
|
+
);
|
|
547
|
+
const messages = rows.map((r) =>
|
|
548
|
+
message({
|
|
549
|
+
messageId: r.messageId,
|
|
550
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
554
|
+
|
|
555
|
+
const result = await backfillListIds(harness, {
|
|
556
|
+
batchSize: 2,
|
|
557
|
+
checkpointStore: {
|
|
558
|
+
load: async () => ({
|
|
559
|
+
accountConfigId: "acc-removed",
|
|
560
|
+
continuationToken: "2",
|
|
561
|
+
}),
|
|
562
|
+
save: async () => {},
|
|
563
|
+
clear: async () => {},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
assert.equal(result.scanned, 3);
|
|
568
|
+
assert.deepEqual(
|
|
569
|
+
harness.updates.map((u) => u.threadMessageId),
|
|
570
|
+
["tm-0", "tm-1", "tm-2"],
|
|
571
|
+
);
|
|
572
|
+
});
|
|
416
573
|
});
|
package/src/list-id-backfill.ts
CHANGED
|
@@ -11,14 +11,14 @@ import { extractListId } from "./filters/list-id.js";
|
|
|
11
11
|
const DEFAULT_BATCH_SIZE = 200;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Where the full-corpus pass left off: the
|
|
15
|
-
*
|
|
16
|
-
* account
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Where the full-corpus pass left off: the identity of the account it was
|
|
15
|
+
* working through, and the page cursor within that account. Resuming looks the
|
|
16
|
+
* account up by id and re-opens it at its saved cursor, rather than re-scanning
|
|
17
|
+
* the whole corpus from the top after an interruption. A checkpoint naming no
|
|
18
|
+
* configured account restarts the pass.
|
|
19
19
|
*/
|
|
20
20
|
export interface ListIdBackfillCheckpoint {
|
|
21
|
-
|
|
21
|
+
accountConfigId: string;
|
|
22
22
|
continuationToken?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -138,7 +138,8 @@ const deriveAndApplyListId = async (
|
|
|
138
138
|
* sync path will populate `listId` for it once the body lands.
|
|
139
139
|
*
|
|
140
140
|
* Chunked by `listByAccount`'s existing keyset pagination, one account at a
|
|
141
|
-
* time in
|
|
141
|
+
* time in account-id order, so an interrupted run and its resume walk the same
|
|
142
|
+
* sequence. A failure reading or parsing one message's
|
|
142
143
|
* stored body is contained to that message — logged, counted, and the pass
|
|
143
144
|
* continues — the same containment `BodySyncService`'s classification
|
|
144
145
|
* backfill uses for the same reason: one unreadable object must not strand
|
|
@@ -151,9 +152,29 @@ export const backfillListIds = async (
|
|
|
151
152
|
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
152
153
|
const { logger, checkpointStore } = options;
|
|
153
154
|
|
|
154
|
-
const accounts = await deps.accountConfigService.listAll()
|
|
155
|
+
const accounts = [...(await deps.accountConfigService.listAll())].sort(
|
|
156
|
+
(left, right) => left.accountConfigId.localeCompare(right.accountConfigId),
|
|
157
|
+
);
|
|
155
158
|
const startingCheckpoint = await checkpointStore?.load();
|
|
156
|
-
const
|
|
159
|
+
const checkpointedIndex = startingCheckpoint
|
|
160
|
+
? accounts.findIndex(
|
|
161
|
+
(account) =>
|
|
162
|
+
account.accountConfigId === startingCheckpoint.accountConfigId,
|
|
163
|
+
)
|
|
164
|
+
: -1;
|
|
165
|
+
|
|
166
|
+
if (startingCheckpoint && checkpointedIndex === -1) {
|
|
167
|
+
logger?.info(
|
|
168
|
+
{ accountConfigId: startingCheckpoint.accountConfigId },
|
|
169
|
+
"ListId backfill checkpoint names no configured account; restarting the pass",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const startIndex = checkpointedIndex === -1 ? 0 : checkpointedIndex;
|
|
174
|
+
const startContinuationToken =
|
|
175
|
+
checkpointedIndex === -1
|
|
176
|
+
? undefined
|
|
177
|
+
: startingCheckpoint?.continuationToken;
|
|
157
178
|
|
|
158
179
|
const totals = emptyTotals();
|
|
159
180
|
const failedThreadMessageIds: string[] = [];
|
|
@@ -165,9 +186,7 @@ export const backfillListIds = async (
|
|
|
165
186
|
) {
|
|
166
187
|
const account = accounts[accountIndex];
|
|
167
188
|
let continuationToken: string | undefined =
|
|
168
|
-
accountIndex === startIndex
|
|
169
|
-
? startingCheckpoint?.continuationToken
|
|
170
|
-
: undefined;
|
|
189
|
+
accountIndex === startIndex ? startContinuationToken : undefined;
|
|
171
190
|
|
|
172
191
|
do {
|
|
173
192
|
const page = await deps.threadMessageService.listByAccount(
|
|
@@ -231,7 +250,7 @@ export const backfillListIds = async (
|
|
|
231
250
|
|
|
232
251
|
continuationToken = page.continuationToken;
|
|
233
252
|
await checkpointStore?.save({
|
|
234
|
-
|
|
253
|
+
accountConfigId: account.accountConfigId,
|
|
235
254
|
continuationToken,
|
|
236
255
|
});
|
|
237
256
|
|