@remit/mailbox-service 0.0.23 → 0.0.25
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 +2 -1
- package/src/body-sync.ts +15 -5
- package/src/filters/list-id.test.ts +50 -0
- package/src/filters/list-id.ts +29 -0
- package/src/filters/match.test.ts +88 -0
- package/src/filters/match.ts +29 -1
- package/src/filters/pipeline.test.ts +103 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mailbox-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"mailparser": "^3.9.14",
|
|
40
40
|
"nodemailer": "^9.0.3",
|
|
41
41
|
"p-map": "^7.0.4",
|
|
42
|
+
"tldts": "^7.4.9",
|
|
42
43
|
"@remit/data-ports": "*",
|
|
43
44
|
"@remit/domain-enums": "*",
|
|
44
45
|
"@remit/mail-oauth-service": "*",
|
package/src/body-sync.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { type ParsedMail, simpleParser } from "mailparser";
|
|
|
30
30
|
import pMap from "p-map";
|
|
31
31
|
import { BodyParseError, parseMessageBody } from "./body-parse.js";
|
|
32
32
|
import { mapBodyPartsToContent } from "./body-part-mapper.js";
|
|
33
|
+
import { extractListId } from "./filters/list-id.js";
|
|
33
34
|
import type { FilterMessage } from "./filters/match.js";
|
|
34
35
|
import {
|
|
35
36
|
type FilterConfig,
|
|
@@ -134,6 +135,7 @@ const toFilterMessage = (parsed: ParsedMail): FilterMessage => ({
|
|
|
134
135
|
fromName: parsed.from?.value?.[0]?.name ?? "",
|
|
135
136
|
subject: parsed.subject ?? "",
|
|
136
137
|
text: parsed.text ?? "",
|
|
138
|
+
listId: extractListId(parsed),
|
|
137
139
|
});
|
|
138
140
|
|
|
139
141
|
export const toParsedBody = (parsed: ParsedMail): ParsedBody => ({
|
|
@@ -1423,10 +1425,11 @@ export class BodySyncService {
|
|
|
1423
1425
|
}
|
|
1424
1426
|
|
|
1425
1427
|
/**
|
|
1426
|
-
* Extract snippet
|
|
1427
|
-
* onto the ThreadMessage. `category` mirrors the Message:
|
|
1428
|
-
* `uncategorized` at metadata-sync and set to the classified value
|
|
1429
|
-
*
|
|
1428
|
+
* Extract snippet, header category and the normalized `List-Id` from the body
|
|
1429
|
+
* and denormalize them onto the ThreadMessage. `category` mirrors the Message:
|
|
1430
|
+
* created as `uncategorized` at metadata-sync and set to the classified value
|
|
1431
|
+
* here; `listId` is written so the back-apply corpus projection can match a
|
|
1432
|
+
* `ListId` clause vector-free, off the same row the list/search path reads.
|
|
1430
1433
|
* Returns the parsed mail so callers can reuse it (e.g., to write the
|
|
1431
1434
|
* parsed-body cache) without paying for mailparser twice.
|
|
1432
1435
|
*/
|
|
@@ -1450,12 +1453,14 @@ export class BodySyncService {
|
|
|
1450
1453
|
);
|
|
1451
1454
|
|
|
1452
1455
|
const category = classifyByHeaders(parsed);
|
|
1456
|
+
const listId = extractListId(parsed);
|
|
1453
1457
|
|
|
1454
1458
|
await this.denormalizeCategory(
|
|
1455
1459
|
accountConfigId,
|
|
1456
1460
|
messageId,
|
|
1457
1461
|
category,
|
|
1458
1462
|
snippet,
|
|
1463
|
+
listId,
|
|
1459
1464
|
);
|
|
1460
1465
|
|
|
1461
1466
|
this.log.debug?.(
|
|
@@ -1482,6 +1487,7 @@ export class BodySyncService {
|
|
|
1482
1487
|
messageId: string,
|
|
1483
1488
|
category: ThreadMessageCategory,
|
|
1484
1489
|
snippet?: string,
|
|
1490
|
+
listId?: string,
|
|
1485
1491
|
): Promise<void> {
|
|
1486
1492
|
const threadMessage = await this.threadMessageService.getByMessageId(
|
|
1487
1493
|
accountConfigId,
|
|
@@ -1491,7 +1497,11 @@ export class BodySyncService {
|
|
|
1491
1497
|
await this.threadMessageService.update(
|
|
1492
1498
|
accountConfigId,
|
|
1493
1499
|
threadMessage.threadMessageId,
|
|
1494
|
-
{
|
|
1500
|
+
{
|
|
1501
|
+
category,
|
|
1502
|
+
...(snippet ? { snippet } : {}),
|
|
1503
|
+
...(listId ? { listId } : {}),
|
|
1504
|
+
},
|
|
1495
1505
|
{
|
|
1496
1506
|
composites: {
|
|
1497
1507
|
sentDate: threadMessage.sentDate,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { simpleParser } from "mailparser";
|
|
4
|
+
import { extractListId, normalizeListId } from "./list-id.js";
|
|
5
|
+
|
|
6
|
+
const parse = async (lines: string[]) =>
|
|
7
|
+
simpleParser(Buffer.from(lines.join("\r\n")));
|
|
8
|
+
|
|
9
|
+
describe("normalizeListId", () => {
|
|
10
|
+
it("extracts the bracketed identifier and folds case", () => {
|
|
11
|
+
assert.equal(
|
|
12
|
+
normalizeListId("Weekly News <Weekly.News.Example.COM>"),
|
|
13
|
+
"weekly.news.example.com",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps a bare value and folds case", () => {
|
|
18
|
+
assert.equal(
|
|
19
|
+
normalizeListId(" Weekly.News.Example.COM "),
|
|
20
|
+
"weekly.news.example.com",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("normalizes an empty value to the empty string", () => {
|
|
25
|
+
assert.equal(normalizeListId(" "), "");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("extractListId", () => {
|
|
30
|
+
it("reads and normalizes a List-Id header", async () => {
|
|
31
|
+
const parsed = await parse([
|
|
32
|
+
"From: list@example.com",
|
|
33
|
+
"Subject: hi",
|
|
34
|
+
"List-Id: Weekly News <weekly.news.example.com>",
|
|
35
|
+
"",
|
|
36
|
+
"body",
|
|
37
|
+
]);
|
|
38
|
+
assert.equal(extractListId(parsed), "weekly.news.example.com");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns the empty string when there is no List-Id header", async () => {
|
|
42
|
+
const parsed = await parse([
|
|
43
|
+
"From: alice@example.com",
|
|
44
|
+
"Subject: hi",
|
|
45
|
+
"",
|
|
46
|
+
"body",
|
|
47
|
+
]);
|
|
48
|
+
assert.equal(extractListId(parsed), "");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ParsedMail } from "mailparser";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical form of a `List-Id` value for exact comparison: the bracketed
|
|
5
|
+
* identifier when the header carries the RFC 2919 `Name <list.id>` shape,
|
|
6
|
+
* otherwise the whole value, trimmed and case-folded. Both the stored copy and
|
|
7
|
+
* a `ListId` clause pass through this, so `<weekly.news.example.com>` and
|
|
8
|
+
* `weekly.news.example.com` are one list. An empty input normalizes to `""`.
|
|
9
|
+
*/
|
|
10
|
+
export const normalizeListId = (value: string): string => {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
const bracketed = trimmed.match(/<([^>]+)>/);
|
|
13
|
+
return (bracketed ? bracketed[1] : trimmed).trim().toLowerCase();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The normalized `List-Id` header value of a parsed message, or `""` when the
|
|
18
|
+
* message carries no `List-Id`. Read from the raw header line so the exact
|
|
19
|
+
* value survives regardless of how the parser structures the header.
|
|
20
|
+
*/
|
|
21
|
+
export const extractListId = (parsed: ParsedMail): string => {
|
|
22
|
+
const line = parsed.headerLines.find(
|
|
23
|
+
(header) => header.key.toLowerCase() === "list-id",
|
|
24
|
+
);
|
|
25
|
+
if (!line) return "";
|
|
26
|
+
const colon = line.line.indexOf(":");
|
|
27
|
+
if (colon < 0) return "";
|
|
28
|
+
return normalizeListId(line.line.slice(colon + 1));
|
|
29
|
+
};
|
|
@@ -18,6 +18,7 @@ const message = (overrides: Partial<FilterMessage> = {}): FilterMessage => ({
|
|
|
18
18
|
fromName: "Alice Example",
|
|
19
19
|
subject: "Q3 invoice attached",
|
|
20
20
|
text: "Please find the invoice for the quarter attached.",
|
|
21
|
+
listId: "",
|
|
21
22
|
...overrides,
|
|
22
23
|
});
|
|
23
24
|
|
|
@@ -69,6 +70,93 @@ describe("clauseMatches", () => {
|
|
|
69
70
|
false,
|
|
70
71
|
);
|
|
71
72
|
});
|
|
73
|
+
|
|
74
|
+
it("matches ListId exactly, never as a substring", () => {
|
|
75
|
+
const msg = message({ listId: "weekly.news.example.com" });
|
|
76
|
+
assert.equal(
|
|
77
|
+
clauseMatches(
|
|
78
|
+
clause(FilterClauseField.ListId, "weekly.news.example.com"),
|
|
79
|
+
msg,
|
|
80
|
+
),
|
|
81
|
+
true,
|
|
82
|
+
);
|
|
83
|
+
assert.equal(
|
|
84
|
+
clauseMatches(clause(FilterClauseField.ListId, "news.example.com"), msg),
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
clauseMatches(
|
|
89
|
+
clause(FilterClauseField.ListId, "weekly.news.example.com.other"),
|
|
90
|
+
msg,
|
|
91
|
+
),
|
|
92
|
+
false,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("normalizes ListId brackets and case on both sides", () => {
|
|
97
|
+
const msg = message({ listId: "weekly.news.example.com" });
|
|
98
|
+
assert.equal(
|
|
99
|
+
clauseMatches(
|
|
100
|
+
clause(FilterClauseField.ListId, "<Weekly.News.Example.COM>"),
|
|
101
|
+
msg,
|
|
102
|
+
),
|
|
103
|
+
true,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("never matches ListId on a message with no List-Id", () => {
|
|
108
|
+
assert.equal(
|
|
109
|
+
clauseMatches(
|
|
110
|
+
clause(FilterClauseField.ListId, "weekly.news.example.com"),
|
|
111
|
+
message({ listId: "" }),
|
|
112
|
+
),
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("matches FromDomain on the registrable domain, including subdomains", () => {
|
|
118
|
+
assert.equal(
|
|
119
|
+
clauseMatches(
|
|
120
|
+
clause(FilterClauseField.FromDomain, "github.com"),
|
|
121
|
+
message({ from: "notifications@github.com" }),
|
|
122
|
+
),
|
|
123
|
+
true,
|
|
124
|
+
);
|
|
125
|
+
assert.equal(
|
|
126
|
+
clauseMatches(
|
|
127
|
+
clause(FilterClauseField.FromDomain, "github.com"),
|
|
128
|
+
message({ from: "notifications@sub.github.com" }),
|
|
129
|
+
),
|
|
130
|
+
true,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("never matches FromDomain on a look-alike subdomain (public-suffix aware)", () => {
|
|
135
|
+
assert.equal(
|
|
136
|
+
clauseMatches(
|
|
137
|
+
clause(FilterClauseField.FromDomain, "github.com"),
|
|
138
|
+
message({ from: "attacker@github.com.evil.example" }),
|
|
139
|
+
),
|
|
140
|
+
false,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("matches FromDomain across multi-level public suffixes", () => {
|
|
145
|
+
assert.equal(
|
|
146
|
+
clauseMatches(
|
|
147
|
+
clause(FilterClauseField.FromDomain, "example.co.uk"),
|
|
148
|
+
message({ from: "hr@mail.example.co.uk" }),
|
|
149
|
+
),
|
|
150
|
+
true,
|
|
151
|
+
);
|
|
152
|
+
assert.equal(
|
|
153
|
+
clauseMatches(
|
|
154
|
+
clause(FilterClauseField.FromDomain, "example.co.uk"),
|
|
155
|
+
message({ from: "hr@example.co.uk.evil.example" }),
|
|
156
|
+
),
|
|
157
|
+
false,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
72
160
|
});
|
|
73
161
|
|
|
74
162
|
describe("literalClausesMatch", () => {
|
package/src/filters/match.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { FilterItem } from "@remit/data-ports";
|
|
2
2
|
import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
|
|
3
|
+
import { getDomain } from "tldts";
|
|
4
|
+
import { normalizeListId } from "./list-id.js";
|
|
3
5
|
|
|
4
6
|
type FilterClause = FilterItem["literalClauses"][number];
|
|
5
7
|
|
|
@@ -36,15 +38,32 @@ export interface FilterMessage {
|
|
|
36
38
|
fromName: string;
|
|
37
39
|
subject: string;
|
|
38
40
|
text: string;
|
|
41
|
+
/** Normalized `List-Id` header value (see `normalizeListId`); `""` when absent. */
|
|
42
|
+
listId: string;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
const includesFold = (haystack: string, needle: string): boolean =>
|
|
42
46
|
haystack.toLowerCase().includes(needle.toLowerCase());
|
|
43
47
|
|
|
48
|
+
const hostOf = (address: string): string => {
|
|
49
|
+
const at = address.lastIndexOf("@");
|
|
50
|
+
return at >= 0 ? address.slice(at + 1) : address;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The registrable, public-suffix-aware domain of an email address or host, or
|
|
55
|
+
* `null` when none resolves. `getDomain` folds case and applies the ICANN
|
|
56
|
+
* suffix list, so `github.com.evil.example` yields `evil.example`, never
|
|
57
|
+
* `github.com` — a `FromDomain` clause cannot be spoofed by a crafted subdomain.
|
|
58
|
+
*/
|
|
59
|
+
const registrableDomain = (addressOrHost: string): string | null =>
|
|
60
|
+
getDomain(hostOf(addressOrHost.trim()));
|
|
61
|
+
|
|
44
62
|
/**
|
|
45
63
|
* Whether one literal clause matches the message. From matches against the
|
|
46
64
|
* sender address and display name; Subject against the subject; HasWords against
|
|
47
|
-
* subject or body
|
|
65
|
+
* subject or body; ListId against the exact normalized `List-Id`; FromDomain
|
|
66
|
+
* against the sender's registrable domain. An empty clause value never matches.
|
|
48
67
|
*/
|
|
49
68
|
export const clauseMatches = (
|
|
50
69
|
clause: FilterClause,
|
|
@@ -59,6 +78,15 @@ export const clauseMatches = (
|
|
|
59
78
|
return includesFold(msg.subject, value);
|
|
60
79
|
case FilterClauseField.HasWords:
|
|
61
80
|
return includesFold(msg.subject, value) || includesFold(msg.text, value);
|
|
81
|
+
case FilterClauseField.ListId: {
|
|
82
|
+
const target = normalizeListId(value);
|
|
83
|
+
return target !== "" && normalizeListId(msg.listId) === target;
|
|
84
|
+
}
|
|
85
|
+
case FilterClauseField.FromDomain: {
|
|
86
|
+
const target = registrableDomain(value);
|
|
87
|
+
if (target === null) return false;
|
|
88
|
+
return registrableDomain(msg.from) === target;
|
|
89
|
+
}
|
|
62
90
|
default:
|
|
63
91
|
return false;
|
|
64
92
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The index-time filter pass over an anchorless literal filter — the shape the
|
|
3
|
+
* organize widen creates on a deployment without the vector pipeline: `From`
|
|
4
|
+
* clauses combined with `Or`, `hasAnchor: false`. This is what keeps a standing
|
|
5
|
+
* "move all mail from these senders" filter working on future mail with no
|
|
6
|
+
* vectors, so it is pinned directly: a matching sender resolves the move, a
|
|
7
|
+
* non-matching one resolves nothing, and the embedder is never touched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import type {
|
|
13
|
+
FilterItem,
|
|
14
|
+
IFilterAnchorRepository,
|
|
15
|
+
IFilterRepository,
|
|
16
|
+
IMessageLabelRepository,
|
|
17
|
+
} from "@remit/data-ports";
|
|
18
|
+
import {
|
|
19
|
+
FilterClauseField,
|
|
20
|
+
FilterMatchOperator,
|
|
21
|
+
FilterState,
|
|
22
|
+
} from "@remit/domain-enums";
|
|
23
|
+
import type { PlacementMoveService } from "../placement-move.js";
|
|
24
|
+
import { NO_ACTION } from "./match.js";
|
|
25
|
+
import { type FilterConfig, FilterPipeline } from "./pipeline.js";
|
|
26
|
+
|
|
27
|
+
const senderFilter = (destinationMailboxId: string): FilterItem =>
|
|
28
|
+
({
|
|
29
|
+
filterId: "flt-senders",
|
|
30
|
+
accountConfigId: "cfg-1",
|
|
31
|
+
name: "npm barrage",
|
|
32
|
+
scope: "Standing",
|
|
33
|
+
state: FilterState.Active,
|
|
34
|
+
hasAnchor: false,
|
|
35
|
+
ruleChangedAt: 1,
|
|
36
|
+
matchOperator: FilterMatchOperator.Or,
|
|
37
|
+
literalClauses: [
|
|
38
|
+
{ field: FilterClauseField.From, value: "npm@github.com" },
|
|
39
|
+
{ field: FilterClauseField.From, value: "notifications@github.com" },
|
|
40
|
+
],
|
|
41
|
+
actionLabelId: NO_ACTION,
|
|
42
|
+
actionMailboxId: destinationMailboxId,
|
|
43
|
+
createdAt: 1,
|
|
44
|
+
updatedAt: 1,
|
|
45
|
+
}) as unknown as FilterItem;
|
|
46
|
+
|
|
47
|
+
const buildPipeline = (filter: FilterItem) => {
|
|
48
|
+
const state = { embedCalls: 0 };
|
|
49
|
+
const config: FilterConfig = {
|
|
50
|
+
filterService: {
|
|
51
|
+
listByAccountAndState: async () => [filter],
|
|
52
|
+
refreshExpiry: async (f: FilterItem) => f,
|
|
53
|
+
} as unknown as IFilterRepository,
|
|
54
|
+
filterAnchorService: {
|
|
55
|
+
get: async () => undefined,
|
|
56
|
+
} as unknown as IFilterAnchorRepository,
|
|
57
|
+
messageLabelService: {} as unknown as IMessageLabelRepository,
|
|
58
|
+
placementMoveService: {} as unknown as PlacementMoveService,
|
|
59
|
+
embedder: {
|
|
60
|
+
embed: async () => {
|
|
61
|
+
state.embedCalls += 1;
|
|
62
|
+
return [];
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
return { pipeline: new FilterPipeline(config, { info: () => {} }), state };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe("FilterPipeline — anchorless From/Or filter at index time", () => {
|
|
70
|
+
it("resolves the move for a matching sender and never embeds", async () => {
|
|
71
|
+
const { pipeline, state } = buildPipeline(senderFilter("mbx-archive"));
|
|
72
|
+
const decision = await pipeline.evaluate("cfg-1", "m-1", {
|
|
73
|
+
from: "npm@github.com",
|
|
74
|
+
fromName: "npm",
|
|
75
|
+
subject: "A new version of left-pad is available",
|
|
76
|
+
text: "body",
|
|
77
|
+
listId: "",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(decision.move, {
|
|
81
|
+
destinationMailboxId: "mbx-archive",
|
|
82
|
+
filterId: "flt-senders",
|
|
83
|
+
});
|
|
84
|
+
// A literal-only filter is decided by its clauses alone — the embedder is
|
|
85
|
+
// never constructed or called.
|
|
86
|
+
assert.equal(state.embedCalls, 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("resolves nothing for a sender none of the clauses match", async () => {
|
|
90
|
+
const { pipeline, state } = buildPipeline(senderFilter("mbx-archive"));
|
|
91
|
+
const decision = await pipeline.evaluate("cfg-1", "m-2", {
|
|
92
|
+
from: "hello@stripe.com",
|
|
93
|
+
fromName: "Stripe",
|
|
94
|
+
subject: "Your receipt",
|
|
95
|
+
text: "body",
|
|
96
|
+
listId: "",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.equal(decision.move, undefined);
|
|
100
|
+
assert.deepEqual(decision.labels, []);
|
|
101
|
+
assert.equal(state.embedCalls, 0);
|
|
102
|
+
});
|
|
103
|
+
});
|