@remit/mailbox-service 0.0.99 → 0.0.100
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.ts +16 -113
- package/src/classification-backfill.test.ts +543 -0
- package/src/classification-backfill.ts +345 -0
- package/src/classify-message.test.ts +146 -0
- package/src/classify-message.ts +113 -0
- package/src/index.ts +16 -1
- package/src/message-category.ts +18 -0
package/package.json
CHANGED
package/src/body-sync.ts
CHANGED
|
@@ -23,7 +23,6 @@ import { deriveAddressId, deriveBodyPartId } from "@remit/data-ports/id";
|
|
|
23
23
|
import { isBulkSender } from "@remit/data-ports/wellknown";
|
|
24
24
|
import {
|
|
25
25
|
FilterState,
|
|
26
|
-
MessageCategory,
|
|
27
26
|
MessageClassificationState,
|
|
28
27
|
PlacementAction,
|
|
29
28
|
PlacementConfidence,
|
|
@@ -40,6 +39,10 @@ import pMap from "p-map";
|
|
|
40
39
|
import { BodyParseError, parseMessageBody } from "./body-parse.js";
|
|
41
40
|
import { mapBodyPartsToContent } from "./body-part-mapper.js";
|
|
42
41
|
import { calendarParts } from "./calendar-parts.js";
|
|
42
|
+
import {
|
|
43
|
+
classifyParsedMessage,
|
|
44
|
+
extractPrimaryFromEmail,
|
|
45
|
+
} from "./classify-message.js";
|
|
43
46
|
import { extractListId } from "./filters/list-id.js";
|
|
44
47
|
import type { FilterMessage } from "./filters/match.js";
|
|
45
48
|
import {
|
|
@@ -48,21 +51,16 @@ import {
|
|
|
48
51
|
FilterPipeline,
|
|
49
52
|
} from "./filters/pipeline.js";
|
|
50
53
|
import type { FlagQueueService } from "./flag-queue.js";
|
|
51
|
-
import {
|
|
52
|
-
classifyByHeaders,
|
|
53
|
-
extractAuthenticity,
|
|
54
|
-
extractAuthResult,
|
|
55
|
-
extractHasListUnsubscribe,
|
|
56
|
-
extractProviderSpam,
|
|
57
|
-
} from "./heuristics/classifyByHeaders.js";
|
|
58
54
|
import {
|
|
59
55
|
classifyPlacement,
|
|
60
56
|
type FolderPlacement,
|
|
61
57
|
type PlacementVerdict,
|
|
62
58
|
resolveSenderPlacement,
|
|
63
59
|
} from "./heuristics/classifyPlacement.js";
|
|
64
|
-
import {
|
|
65
|
-
|
|
60
|
+
import {
|
|
61
|
+
denormalizeMessageCategory,
|
|
62
|
+
hasDecidedCategory,
|
|
63
|
+
} from "./message-category.js";
|
|
66
64
|
import type { PlacementMoveService } from "./placement-move.js";
|
|
67
65
|
import { type QuarantineService, shapeFromMessageData } from "./quarantine.js";
|
|
68
66
|
import { extractSnippetFromEmail } from "./snippet.js";
|
|
@@ -133,14 +131,6 @@ const isConnectionDrop = (error: unknown): boolean => {
|
|
|
133
131
|
return code === "EConnectionClosed" || code === "NoConnection";
|
|
134
132
|
};
|
|
135
133
|
|
|
136
|
-
export const extractPrimaryFromEmail = (parsed: ParsedMail): string | null => {
|
|
137
|
-
const from = parsed.from;
|
|
138
|
-
if (!from || !from.value || from.value.length === 0) return null;
|
|
139
|
-
const address = from.value[0]?.address;
|
|
140
|
-
if (!address) return null;
|
|
141
|
-
return address.toLowerCase();
|
|
142
|
-
};
|
|
143
|
-
|
|
144
134
|
/**
|
|
145
135
|
* Project a parsed message onto the fields a filter matches against (RFC 034) —
|
|
146
136
|
* the literal-clause targets plus the text a semantic anchor embeds. Kept
|
|
@@ -158,32 +148,16 @@ const SNIPPET_LENGTH = 256;
|
|
|
158
148
|
|
|
159
149
|
/**
|
|
160
150
|
* The snippet the list row shows, from whichever body part carries text.
|
|
151
|
+
* Exported for the classification backfill (issue #1197), which denormalizes
|
|
152
|
+
* the same fields the body-store path does, from the same bytes.
|
|
161
153
|
*/
|
|
162
|
-
const extractSnippet = (parsed: ParsedMail): string =>
|
|
154
|
+
export const extractSnippet = (parsed: ParsedMail): string =>
|
|
163
155
|
extractSnippetFromEmail(
|
|
164
156
|
parsed.text,
|
|
165
157
|
typeof parsed.html === "string" ? parsed.html : undefined,
|
|
166
158
|
SNIPPET_LENGTH,
|
|
167
159
|
);
|
|
168
160
|
|
|
169
|
-
/**
|
|
170
|
-
/**
|
|
171
|
-
* RFC 034 Decision 3.1: `Message.category` is written once and never mutated
|
|
172
|
-
* after — RFC 030's message-list GSI sort key depends on it never churning.
|
|
173
|
-
* "Already decided" is any real category, so a message that carries one is
|
|
174
|
-
* never re-categorized by a re-entrant pass.
|
|
175
|
-
*
|
|
176
|
-
* `uncategorized` fails this test whether or not the classifier has run, which
|
|
177
|
-
* is deliberate: this asks what the row holds, not what was done to it. Whether
|
|
178
|
-
* the classifier has run is {@link Message.classificationState}, and it is the
|
|
179
|
-
* skip guard in {@link BodySyncService.syncBodies} — not this one — that keeps
|
|
180
|
-
* a declined message from being examined twice.
|
|
181
|
-
*/
|
|
182
|
-
const hasDecidedCategory = (
|
|
183
|
-
category: ThreadMessageCategory | undefined,
|
|
184
|
-
): boolean =>
|
|
185
|
-
category !== undefined && category !== MessageCategory.uncategorized;
|
|
186
|
-
|
|
187
161
|
/**
|
|
188
162
|
* Issue #499: whether a completed body-sync pass has already stored this
|
|
189
163
|
* message's body. `bodyStorageKey` is written last, once every derivation of
|
|
@@ -1115,87 +1089,16 @@ export class BodySyncService {
|
|
|
1115
1089
|
/**
|
|
1116
1090
|
* Header classification, with the sender's `Address.flags.category`
|
|
1117
1091
|
* override (issue #299, RFC 039 Decision 3) substituted for the
|
|
1118
|
-
* header-derived category when one is set.
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
* `undefined`.
|
|
1123
|
-
*
|
|
1124
|
-
* The override wins outright rather than blending with the heuristic — RFC
|
|
1125
|
-
* 039 Decision 3 treats a direct reclassification as final, the same as
|
|
1126
|
-
* `flags.blocked`/`vip` already override placement. The caller
|
|
1127
|
-
* (`applyPostStoreSteps`) gates on `hasDecidedCategory` before this result
|
|
1128
|
-
* reaches a write, so a message that already carries a real category is
|
|
1129
|
-
* never re-touched regardless of what this returns.
|
|
1092
|
+
* header-derived category when one is set. Shared with the classification
|
|
1093
|
+
* backfill (issue #1197) as {@link classifyParsedMessage}, so the one-time
|
|
1094
|
+
* pass and the body-store path can never classify the same bytes
|
|
1095
|
+
* differently.
|
|
1130
1096
|
*/
|
|
1131
1097
|
private async classifyMessage(
|
|
1132
1098
|
accountConfigId: string,
|
|
1133
1099
|
parsed: ParsedMail,
|
|
1134
1100
|
): Promise<UpdateMessageInput & { category: ThreadMessageCategory }> {
|
|
1135
|
-
|
|
1136
|
-
const authenticity = extractAuthenticity(parsed);
|
|
1137
|
-
const authResult = extractAuthResult(parsed);
|
|
1138
|
-
const providerSpam = extractProviderSpam(parsed);
|
|
1139
|
-
const hasListUnsubscribe = extractHasListUnsubscribe(parsed);
|
|
1140
|
-
|
|
1141
|
-
// A passing SPF/DKIM/DMARC check proves the sending domain, not the
|
|
1142
|
-
// identity the message claims — on a shared-tenant host the verified
|
|
1143
|
-
// subdomain belongs to whoever signed up. These two comparisons say
|
|
1144
|
-
// whether the claim holds, and run only over mail the provider already
|
|
1145
|
-
// called spam.
|
|
1146
|
-
const senderMismatch =
|
|
1147
|
-
authenticity === null
|
|
1148
|
-
? {}
|
|
1149
|
-
: extractSenderMismatch(parsed, {
|
|
1150
|
-
fromDomain: authenticity.fromDomain,
|
|
1151
|
-
spamClassified: providerSpam?.classified === true,
|
|
1152
|
-
bulkSender: hasListUnsubscribe,
|
|
1153
|
-
});
|
|
1154
|
-
|
|
1155
|
-
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1156
|
-
const categoryOverride = fromEmail
|
|
1157
|
-
? await this.resolveCategoryOverride(accountConfigId, fromEmail)
|
|
1158
|
-
: undefined;
|
|
1159
|
-
|
|
1160
|
-
return {
|
|
1161
|
-
category: categoryOverride ?? headerCategory,
|
|
1162
|
-
hasListUnsubscribe,
|
|
1163
|
-
...(authenticity !== null
|
|
1164
|
-
? { authenticity: { ...authenticity, ...senderMismatch } }
|
|
1165
|
-
: {}),
|
|
1166
|
-
...(authResult !== null ? { authResult } : {}),
|
|
1167
|
-
...(providerSpam !== null ? { providerSpam } : {}),
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* The one-sided half of `Address.flags.category` (issue #299, RFC 039
|
|
1173
|
-
* Decision 3): a real value here overrides `classifyByHeaders` outright.
|
|
1174
|
-
* A second, separate `Address` read from `deriveSenderPlacementSignals`'s
|
|
1175
|
-
* (issue #300) — sharing one fetch was not practical, because the two have
|
|
1176
|
-
* different failure contracts: placement's read is best-effort (a failure
|
|
1177
|
-
* is caught and logged, never failing the sync), while this one feeds the
|
|
1178
|
-
* write-once `Message.category` at the moment it is decided, where a
|
|
1179
|
-
* masked infra failure would silently classify by headers when the user
|
|
1180
|
-
* asked for something else. Only a genuinely-absent Address is "no
|
|
1181
|
-
* override" — any other failure (throttle, infra) propagates, matching the
|
|
1182
|
-
* existing `deriveSenderPlacementSignals` convention.
|
|
1183
|
-
*/
|
|
1184
|
-
private async resolveCategoryOverride(
|
|
1185
|
-
accountConfigId: string,
|
|
1186
|
-
fromEmail: string,
|
|
1187
|
-
): Promise<ThreadMessageCategory | undefined> {
|
|
1188
|
-
try {
|
|
1189
|
-
const addressId = deriveAddressId(accountConfigId, fromEmail);
|
|
1190
|
-
const address = await this.addressService.getAddress(
|
|
1191
|
-
accountConfigId,
|
|
1192
|
-
addressId,
|
|
1193
|
-
);
|
|
1194
|
-
return address.flags?.category?.value;
|
|
1195
|
-
} catch (err) {
|
|
1196
|
-
if (!(err instanceof NotFoundError)) throw err;
|
|
1197
|
-
return undefined;
|
|
1198
|
-
}
|
|
1101
|
+
return classifyParsedMessage(this.addressService, accountConfigId, parsed);
|
|
1199
1102
|
}
|
|
1200
1103
|
|
|
1201
1104
|
private async incrementInboundCount(
|