@remit/mailbox-service 1.0.0 → 2.0.0
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/dist/body-sync.js +22 -10
- package/dist/filters/match.d.ts +3 -3
- package/dist/filters/match.js +5 -5
- package/dist/filters/pipeline.d.ts +13 -0
- package/dist/filters/pipeline.js +13 -0
- package/dist/test-helpers/fake-imap.d.ts +1 -0
- package/dist/test-helpers/fake-imap.js +1 -1
- package/package.json +1 -1
package/dist/body-sync.js
CHANGED
|
@@ -15,7 +15,7 @@ import { mapMessagePartsToContent, parsePartSource, } from "./body-part-mapper.j
|
|
|
15
15
|
import { calendarParts } from "./calendar-parts.js";
|
|
16
16
|
import { classifyParsedMessage, extractPrimaryFromEmail, } from "./classify-message.js";
|
|
17
17
|
import { extractListId } from "./filters/list-id.js";
|
|
18
|
-
import { FilterPipeline, } from "./filters/pipeline.js";
|
|
18
|
+
import { FilterPipeline, filtersForArrival, } from "./filters/pipeline.js";
|
|
19
19
|
import { placementOf, resolveFolderRoleMailboxes, } from "./folder-placement.js";
|
|
20
20
|
import { classifyPlacement, resolveSenderPlacement, } from "./heuristics/classifyPlacement.js";
|
|
21
21
|
import { denormalizeMessageCategory, hasDecidedCategory, } from "./message-category.js";
|
|
@@ -119,6 +119,13 @@ export const toParsedBody = (parsed) => ({
|
|
|
119
119
|
const noopLogger = {
|
|
120
120
|
info: () => { },
|
|
121
121
|
};
|
|
122
|
+
const internalDateOf = (internalDates, messageId) => {
|
|
123
|
+
const internalDate = internalDates.get(messageId);
|
|
124
|
+
if (internalDate === undefined) {
|
|
125
|
+
throw new Error(`${messageId} was fetched but never read from the batch`);
|
|
126
|
+
}
|
|
127
|
+
return internalDate;
|
|
128
|
+
};
|
|
122
129
|
// A failed load is forgotten, so the next message retries it rather than the
|
|
123
130
|
// rest of the batch inheriting the failure.
|
|
124
131
|
const once = (load) => {
|
|
@@ -188,6 +195,7 @@ export class BodySyncService {
|
|
|
188
195
|
// decision re-run. A message with no stored body inside a forced batch
|
|
189
196
|
// is still a first store and takes the full decision pass.
|
|
190
197
|
const reStoredMessageIds = new Set();
|
|
198
|
+
const internalDates = new Map();
|
|
191
199
|
// One read per round, not per message (issue #72). The list is small by
|
|
192
200
|
// design and almost always empty, so a lookup per message would put a
|
|
193
201
|
// query on the hot path for a state that is nearly never set.
|
|
@@ -228,6 +236,7 @@ export class BodySyncService {
|
|
|
228
236
|
if (message.bodyStorageKey)
|
|
229
237
|
reStoredMessageIds.add(messageId);
|
|
230
238
|
pending.set(message.uid, messageId);
|
|
239
|
+
internalDates.set(messageId, message.internalDate);
|
|
231
240
|
}
|
|
232
241
|
if (pending.size === 0) {
|
|
233
242
|
return this.buildResult([], skippedCount, []);
|
|
@@ -274,7 +283,10 @@ export class BodySyncService {
|
|
|
274
283
|
}
|
|
275
284
|
const stored = await pMap(fetchedBodies(), async ({ uid, messageId, body }) => {
|
|
276
285
|
// A body larger than the whole budget takes all of it and goes alone.
|
|
277
|
-
const outcome = await budget.runExclusive(() => this.storeFetchedBody(messageId, accountId, accountConfigId, body,
|
|
286
|
+
const outcome = await budget.runExclusive(() => this.storeFetchedBody(messageId, accountId, accountConfigId, body, {
|
|
287
|
+
placement: folder.placement,
|
|
288
|
+
internalDate: internalDateOf(internalDates, messageId),
|
|
289
|
+
}, reStoredMessageIds.has(messageId), lookups, location), Math.min(body.length, bytesInFlight));
|
|
278
290
|
if (outcome.kind === "Failed")
|
|
279
291
|
stream.failed = true;
|
|
280
292
|
return { uid, messageId, outcome };
|
|
@@ -355,7 +367,7 @@ export class BodySyncService {
|
|
|
355
367
|
failedMessageIds,
|
|
356
368
|
};
|
|
357
369
|
}
|
|
358
|
-
async storeFetchedBody(messageId, accountId, accountConfigId, body,
|
|
370
|
+
async storeFetchedBody(messageId, accountId, accountConfigId, body, arrival, isReStore, lookups, location) {
|
|
359
371
|
const stored = await this.storageService
|
|
360
372
|
.storeMessageBody({
|
|
361
373
|
accountConfigId,
|
|
@@ -363,7 +375,7 @@ export class BodySyncService {
|
|
|
363
375
|
messageId,
|
|
364
376
|
content: body,
|
|
365
377
|
})
|
|
366
|
-
.then((ref) => this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri },
|
|
378
|
+
.then((ref) => this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, arrival, isReStore, lookups))
|
|
367
379
|
.then(() => ({ kind: "Stored" }), (error) => ({ kind: "Failed", error }));
|
|
368
380
|
if (stored.kind !== "Failed")
|
|
369
381
|
return stored;
|
|
@@ -404,7 +416,7 @@ export class BodySyncService {
|
|
|
404
416
|
* Returns the parsed mail so callers that already need it (both do) don't
|
|
405
417
|
* pay for mailparser twice.
|
|
406
418
|
*/
|
|
407
|
-
async applyPostStoreSteps(messageId, accountId, accountConfigId, body, bodyRef,
|
|
419
|
+
async applyPostStoreSteps(messageId, accountId, accountConfigId, body, bodyRef, arrival,
|
|
408
420
|
/**
|
|
409
421
|
* Whether this call is a re-store of an already-classified message
|
|
410
422
|
* rather than a first store. The two re-entrant paths (`force: true`
|
|
@@ -453,14 +465,14 @@ export class BodySyncService {
|
|
|
453
465
|
// `flags.category` override when one applies (issue #299). The derived
|
|
454
466
|
// fields are folded into the single Message update below — they are NOT
|
|
455
467
|
// written here.
|
|
456
|
-
const classification = await this.classifyMessage(accountConfigId, parsed, placement);
|
|
468
|
+
const classification = await this.classifyMessage(accountConfigId, parsed, arrival.placement);
|
|
457
469
|
// Decide the placement move from the in-memory classification before the
|
|
458
470
|
// write, so its `movedByRemit` flag and audit verdict join the same
|
|
459
471
|
// UpdateItem. The verdict is recorded whenever Remit decided to act
|
|
460
472
|
// (action != leave); the move is enqueued only for a confident,
|
|
461
473
|
// actionable verdict.
|
|
462
474
|
const resolved = await this.resolvePlacement(lookups, messageId, accountId, accountConfigId, parsed, classification);
|
|
463
|
-
const filterDecision = await this.evaluateFilters(lookups, messageId, parsed);
|
|
475
|
+
const filterDecision = await this.evaluateFilters(lookups, messageId, parsed, arrival);
|
|
464
476
|
// Store the parsed-body cache BEFORE persisting bodyStorageKey. The skip
|
|
465
477
|
// guard in syncBodies treats a stored bodyStorageKey as "fully synced",
|
|
466
478
|
// so if we wrote it first and the parsed-cache write then failed, the
|
|
@@ -651,7 +663,7 @@ export class BodySyncService {
|
|
|
651
663
|
// a stored body — the NoSuchKey fallback above; a row with no
|
|
652
664
|
// bodyStorageKey is a genuine first store and takes the full decision
|
|
653
665
|
// pass.
|
|
654
|
-
parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, folder.placement, message.bodyStorageKey != null, this.batchLookups(accountConfigId, accountId));
|
|
666
|
+
parsed = await this.applyPostStoreSteps(messageId, accountId, accountConfigId, body, { uri: ref.uri }, { placement: folder.placement, internalDate: message.internalDate }, message.bodyStorageKey != null, this.batchLookups(accountConfigId, accountId));
|
|
655
667
|
}
|
|
656
668
|
else {
|
|
657
669
|
parsed = await simpleParser(body);
|
|
@@ -917,13 +929,13 @@ export class BodySyncService {
|
|
|
917
929
|
* precedent). The applies of the returned decision are the caller's and are
|
|
918
930
|
* deliberately unswallowed.
|
|
919
931
|
*/
|
|
920
|
-
async evaluateFilters(lookups, messageId, parsed) {
|
|
932
|
+
async evaluateFilters(lookups, messageId, parsed, arrival) {
|
|
921
933
|
const { filterPipeline } = this;
|
|
922
934
|
if (!filterPipeline)
|
|
923
935
|
return { labels: [] };
|
|
924
936
|
return lookups
|
|
925
937
|
.usableFilters()
|
|
926
|
-
.then((usable) => filterPipeline.decide(usable, toFilterMessage(parsed)))
|
|
938
|
+
.then((usable) => filterPipeline.decide(filtersForArrival(usable, arrival), toFilterMessage(parsed)))
|
|
927
939
|
.catch((error) => {
|
|
928
940
|
this.log.error?.({
|
|
929
941
|
alert: "body_sync_filter_match_failed",
|
package/dist/filters/match.d.ts
CHANGED
|
@@ -27,9 +27,9 @@ export interface FilterMessage {
|
|
|
27
27
|
*/
|
|
28
28
|
export declare const clauseMatches: (clause: FilterClause, msg: FilterMessage) => boolean;
|
|
29
29
|
/**
|
|
30
|
-
* Whether the literal clauses match under their combine operator.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Whether the literal clauses match under their combine operator. A filter with
|
|
31
|
+
* no clauses states nothing about the message, so it matches nothing: read as
|
|
32
|
+
* a vacuous pass it would file every message in every folder.
|
|
33
33
|
*/
|
|
34
34
|
export declare const literalClausesMatch: (clauses: readonly FilterClause[], operator: FilterItem["matchOperator"], msg: FilterMessage) => boolean;
|
|
35
35
|
/**
|
package/dist/filters/match.js
CHANGED
|
@@ -65,13 +65,13 @@ export const clauseMatches = (clause, msg) => {
|
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
/**
|
|
68
|
-
* Whether the literal clauses match under their combine operator.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Whether the literal clauses match under their combine operator. A filter with
|
|
69
|
+
* no clauses states nothing about the message, so it matches nothing: read as
|
|
70
|
+
* a vacuous pass it would file every message in every folder.
|
|
71
71
|
*/
|
|
72
72
|
export const literalClausesMatch = (clauses, operator, msg) => {
|
|
73
73
|
if (clauses.length === 0)
|
|
74
|
-
return
|
|
74
|
+
return false;
|
|
75
75
|
return operator === FilterMatchOperator.Or
|
|
76
76
|
? clauses.some((clause) => clauseMatches(clause, msg))
|
|
77
77
|
: clauses.every((clause) => clauseMatches(clause, msg));
|
|
@@ -118,7 +118,7 @@ const clauseNarrowing = (clause) => {
|
|
|
118
118
|
export const literalClauseTerms = (clauses, operator) => {
|
|
119
119
|
const combine = operator === FilterMatchOperator.Or ? "or" : "and";
|
|
120
120
|
if (clauses.length === 0)
|
|
121
|
-
return
|
|
121
|
+
return null;
|
|
122
122
|
const narrowings = clauses.map(clauseNarrowing);
|
|
123
123
|
const terms = narrowings.flatMap((narrowing) => narrowing.kind === "Term" ? [narrowing.term] : []);
|
|
124
124
|
if (combine === "and") {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FilterItem, IFilterRepository, IMailboxRepository, IMessageLabelRepository } from "@remit/data-ports";
|
|
2
|
+
import type { FolderPlacement } from "../heuristics/classifyPlacement.js";
|
|
2
3
|
import type { PlacementMoveService } from "../placement-move.js";
|
|
3
4
|
import { type FilterMessage } from "./match.js";
|
|
4
5
|
export interface FilterLogger {
|
|
@@ -60,3 +61,15 @@ export declare class FilterPipeline {
|
|
|
60
61
|
private appliesTo;
|
|
61
62
|
prepare(accountConfigId: string, accountId: string, active: readonly FilterItem[]): Promise<PreparedFilters>;
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The filters a stored body is matched against. A filter acts on new mail:
|
|
66
|
+
* mail that arrived in Inbox or Junk, the folders mail is delivered to, after
|
|
67
|
+
* its rule or action was last set. Mail filed anywhere else, or delivered
|
|
68
|
+
* before the rule existed, is never moved by a body sync, which also runs for
|
|
69
|
+
* every old message a resync fetches again; applying a rule to existing mail
|
|
70
|
+
* is the organize job's.
|
|
71
|
+
*/
|
|
72
|
+
export declare const filtersForArrival: (prepared: PreparedFilters, arrival: {
|
|
73
|
+
placement: FolderPlacement;
|
|
74
|
+
internalDate: number;
|
|
75
|
+
}) => PreparedFilters;
|
package/dist/filters/pipeline.js
CHANGED
|
@@ -81,4 +81,17 @@ export class FilterPipeline {
|
|
|
81
81
|
return filters;
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The filters a stored body is matched against. A filter acts on new mail:
|
|
86
|
+
* mail that arrived in Inbox or Junk, the folders mail is delivered to, after
|
|
87
|
+
* its rule or action was last set. Mail filed anywhere else, or delivered
|
|
88
|
+
* before the rule existed, is never moved by a body sync, which also runs for
|
|
89
|
+
* every old message a resync fetches again; applying a rule to existing mail
|
|
90
|
+
* is the organize job's.
|
|
91
|
+
*/
|
|
92
|
+
export const filtersForArrival = (prepared, arrival) => {
|
|
93
|
+
if (arrival.placement === "other")
|
|
94
|
+
return [];
|
|
95
|
+
return prepared.filter((filter) => arrival.internalDate >= filter.actionChangedAt * 1000);
|
|
96
|
+
};
|
|
84
97
|
const filterMatches = (filter, msg) => literalClausesMatch(filter.literalClauses, filter.matchOperator, msg);
|
|
@@ -101,7 +101,7 @@ export class FakeImapServer {
|
|
|
101
101
|
raw,
|
|
102
102
|
flags: new Set(options.flags ?? []),
|
|
103
103
|
modseq: this.modseq++,
|
|
104
|
-
internalDate: new Date(1_700_000_000_000 + uid * 1000),
|
|
104
|
+
internalDate: options.internalDate ?? new Date(1_700_000_000_000 + uid * 1000),
|
|
105
105
|
bodyStructure: options.bodyStructure,
|
|
106
106
|
});
|
|
107
107
|
return uid;
|