@bobfrankston/rmfmail 1.2.285 → 1.2.287
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/.commitmsg +28 -32
- package/TODO.md +2 -0
- package/client/android-bootstrap.bundle.js +202 -19
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +7 -4
- package/client/app.bundle.js.map +2 -2
- package/client/components/message-viewer.js +32 -9
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +36 -10
- package/client/compose/compose.bundle.js +106 -38
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/harper-lint.js +125 -42
- package/client/compose/harper-lint.js.map +1 -1
- package/client/compose/harper-lint.ts +120 -40
- package/client/styles/components.css +9 -0
- package/docs/spam-false-positives.md +208 -0
- package/npmchanges.md +86 -0
- package/package.json +6 -6
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +20 -3
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +20 -3
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/docs/spam-false-positives.md +208 -0
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +7 -3
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +7 -3
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/package.json +1 -1
- package/packages/mailx-types/trust.d.ts +16 -3
- package/packages/mailx-types/trust.d.ts.map +1 -1
- package/packages/mailx-types/trust.js +262 -23
- package/packages/mailx-types/trust.js.map +1 -1
- package/packages/mailx-types/trust.ts +343 -25
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-85316 → node_modules.npmglobalize-stash-38644}/.package-lock.json +0 -0
package/.commitmsg
CHANGED
|
@@ -1,37 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
Grammar squiggles: stop inventing words, stop underlining white space
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Three complaints from one compose session (Bob 2026-08-27, forwarding a
|
|
4
|
+
message), all of them the checker arguing with prose that was fine:
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
1. "keypoint". Harper's CompoundNouns rule fires on any noun+noun pair whose
|
|
7
|
+
joined form is in its dictionary, so "One key point in my recent efforts"
|
|
8
|
+
came back as an error suggesting a word he did not write. Disabled in the
|
|
9
|
+
daemon next to AvoidCurses — same objection as that one: the tool
|
|
10
|
+
proofreads, it does not get a vote on word choice. Its siblings (MergeWords,
|
|
11
|
+
MakeupCompoundNoun, OvertimeCompoundNoun, ThereAfterCompound,
|
|
12
|
+
EasyGoingCompoundAdjective) are named in a comment there, one line each to
|
|
13
|
+
turn off if they start nagging.
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
and
|
|
15
|
+
2. A blue dotted line across two blank lines. blockText concatenated a block's
|
|
16
|
+
text nodes with no separator, so the last word of a paragraph was glued to
|
|
17
|
+
the first word of the next: "…of the network" + "To: x@y.com" reached Harper
|
|
18
|
+
as "networkTo", and it reported — correctly, for the string it was given —
|
|
19
|
+
that the word should be split. The span then covered a junction that on
|
|
20
|
+
screen is two empty lines, and getClientRects painted a full-width underline
|
|
21
|
+
over the whitespace. Verified both halves against the real engine: gluing
|
|
22
|
+
produces `networkTo` and `FrankstonSubject` lints that vanish once the
|
|
23
|
+
breaks are there. blockText now emits "\n" at every line-breaking element,
|
|
24
|
+
and spanToRange refuses any span whose ends sit in different line boxes, so
|
|
25
|
+
no squiggle can ever be drawn across a paragraph boundary again.
|
|
14
26
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
popout server and opened its window — several seconds later. Every launch
|
|
18
|
-
arriving inside that gap reads instance.json, sees nothing alive, and starts
|
|
19
|
-
another daemon. Three launches within ten seconds, three daemons.
|
|
27
|
+
3. Lints on the sentence being typed. The unfinished sentence under the caret
|
|
28
|
+
is now exempt — a thought that is not out yet is not an error.
|
|
20
29
|
|
|
21
|
-
The
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
takes the activate path added in the previous release and drops
|
|
26
|
-
pending-activate.json, which the daemon consumes as soon as it starts watching
|
|
27
|
-
— which is exactly why that path checks for a request that arrived while it was
|
|
28
|
-
booting.
|
|
29
|
-
|
|
30
|
-
Also: startedAt was recomputed on EVERY write, and every popup and popout
|
|
31
|
-
window rewrites this file, so the field reported the last window event rather
|
|
32
|
-
than when the daemon started. It now survives childPids updates. That one cost
|
|
33
|
-
a wrong turn while diagnosing this — the file claimed the daemon had started
|
|
34
|
-
seconds ago when it had been up for minutes.
|
|
35
|
-
|
|
36
|
-
Ships separately from the activate fix in 1.2.284 — verified that release did
|
|
37
|
-
not already contain it.
|
|
30
|
+
The pure helpers (collectBlocks, blockText, lineBox, caretOffset,
|
|
31
|
+
sentenceStart, spanToRange) moved to module scope and are exported, because
|
|
32
|
+
they hold the decisions worth testing; tests/dom/harper-lint.test.ts covers all
|
|
33
|
+
three rules in jsdom, using the forward markup client/app.ts actually builds.
|
package/TODO.md
CHANGED
|
@@ -144,6 +144,7 @@ None of these are truly showstoppers — mailx is usable — but they're the nex
|
|
|
144
144
|
|
|
145
145
|
| # | Status | Item |
|
|
146
146
|
|---|---|---|
|
|
147
|
+
| **C165** | PARTIAL | Spam banner fires on subscribed newsletters (Constant Contact / Adobe / Mokin). **Rule classification + DMARC veto shipped v1.2.286** — severity now comes from what the `tests=` rules test, not from the score; red is reserved for forgery. **Still owed: sender trust** via `allowlist.jsonc` + an inline trust action on the banner. **Do not tune the `threshold/2` chip floor.** [`docs/spam-false-positives.md`](spam-false-positives.md). [S] |
|
|
147
148
|
| [**S56**](#ext56) | OPEN | Row-objects own the preview pane — the last open slice of the old "S1 local-first refactor" umbrella. All S1 sub-shipments (tombstones, opaque UUIDs, stable row UUID, Message-ID move-detection, pink rows, viewer-reacts-to-list-replace, unified-inbox pink) are in Done under their version tags; this is what remains. ✓ (can start the `focus()`/`unfocus()` seam; full migration is more than one session) |
|
|
148
149
|
| [**S9**](#ext9) | PARTIAL | Predownloading gaps — 60s prefetch landed v1.0.321; per-account prefetch guard already in mailx-imap (`prefetchingAccounts` Set). Remaining: batch body prefetch (C24) for 10–50× speedup. |
|
|
149
150
|
| [**S51**](#ext51) ❓ | PARTIAL | Calendar Thunderbird-style sidebar. Sidebar UI + visible-by-default + 4-column grid reflow shipped v1.0.375/376. **Still owed**: Google Calendar live fetch via service-side proxy. Open Q: keep the full-screen calendar modal, or retire in favor of sidebar-only? |
|
|
@@ -229,6 +230,7 @@ Items blocked on a design decision. Short-form question here; full context in th
|
|
|
229
230
|
| **C129** | OPEN | **Android prod/dev split via filename/URL channel** — User 2026-05-09 (revised): no separate MAUI project. Same source tree builds; output filenames distinguish channels: Phase 1 (start now) writes `rmfmaildev.apk` alongside today's `mailx-maui.apk`. Phase 2 (when a release is declared) switches to `rmfmail-<version>.apk` versioned + `rmfmaildev.apk` for latest, with promotion = re-pointing `rmfmail.apk` at a chosen version. Single APK contents per build; channel is runtime setting in AppUpdater (Settings toggle for which URL to poll). Side-by-side install on one device NOT supported by this design (single Android package id) — that's a separate need. See `docs/prod-android.md` for full plan. Phase 1 ~30 min; full flow ~2.5 hours when activated. |
|
|
230
231
|
| **C130** | OPEN | **Reset prefetch error budget after fresh sync** — Android: `errors`/`failedThisSession` accumulate within a prefetch session. Already resets on next syncAll cycle (since each `prefetchBodies(account)` call is fresh). Verify this works in practice via logit; if a long sync session burns through budget and stays stuck, add an explicit reset on `bodyAvailable` events that fire while prefetch is still running. |
|
|
231
232
|
| **C164** | RESEARCHED | **Thundermail / JMAP provider** — full protocol survey at [`docs/thundermail-jmap.md`](thundermail-jmap.md), probed live 2026-08-17. Thundermail is Stalwart, so there is no proprietary API: IMAP 993 / SMTP 465 work **today with zero code** (an `accounts.jsonc` entry and nothing else), CardDAV `/dav/card` + CalDAV `/dav/cal` are http-basic, and JMAP is live at `https://mail.thundermail.com/jmap/` advertising core / mail / submission / calendars / contacts / filenode / sieve / quota plus **WebSocket push** (`supportsPush: true`). JMAP is the attractive bet: it kills the IDLE lane, the 5-lane connection semaphore and the Dovecot 20-conn ceiling, needs no transport injection (one path for Android and desktop, cf. C125), and `Email/changes` is native set-difference sync. **From address: resolved — good.** The 15-alias cap is `@thundermail.com`/`@tb.pro` only; custom domains get unlimited aliases plus a catch-all (empty-name alias), and with catch-all on you can send from any prefix unregistered — their own example is the per-vendor pattern already used on `bob.ma`. mailx needs ~nothing here (per-message From identity shipped v1.2.201); only Stalwart-side `MAIL FROM`/DKIM validation for an unregistered prefix is untested. **Still blocking/staged:** JMAP-calendars and `filenode` are unratified drafts; OAuth `scopes_supported` covers mail only (no calendars/contacts scope); invite-only beta so there is nothing to test against; and **no Drive equivalent** — Thunderbird Send is one-shot file *sharing*, not a listable revisable store, so mailx's shared-config dependency (`accounts.jsonc` / `contacts.jsonc` / preferences / allowlist on GDrive) has no home. **C161 is therefore the prerequisite, not JMAP** — the `_rmfmail` hidden-IMAP-folder state channel removes the Drive dependency for every account type at once and keeps working unchanged if JMAP lands later. Until C161 ships, adopting Thundermail is a half-migration that trades a Google dependency for a Thunderbird one. |
|
|
233
|
+
| **C165** | PARTIAL | **Spam-banner false positives — trust the sender, do not tune the threshold** — full diagnosis and a "What shipped" section at [`docs/spam-false-positives.md`](spam-false-positives.md). Bob 2026-08-27: the red "This message is not what it says it is" banner fired on Constant Contact / Adobe MAX / Mokin newsletters he subscribed to, because `serverSpamVerdict()` consumed SpamAssassin’s **bottom line** (a delivery decision) to answer a different question (is this forged) and never parsed the `tests=` list in the same header. **Shipped v1.2.286 (mailx-types 0.1.64, mailx-store 0.1.96):** `tests=` is classified into forgery / association / bulk / neutral / other; `X-Spam-Report` supplies each rule’s score and description so findings name their evidence; the **topmost** `Authentication-Results` is read for a DMARC-aligned pass with `header.from=` checked against the real From. Severity follows the rules — danger only on a forgery rule or a server-recorded SPF/DKIM/DMARC failure (phishing/malware URL listings count); a new neutral `info` severity (no headline, no "do not open" footer, grey chip) when the sender is proved and every scoring rule is classifiable, or the score is all bulk; caution otherwise, **including a proved sender whose rules are unclassified** — unknown is never a reason to go quiet (326 messages in the store). `spamScoreOf()` and the banner now come from one analysis so they cannot disagree. Measured over 20,291 messages: of 1,131 flagged, 294 stay red, 672 caution, 165 quiet; the 2026-08-25 phishes stay red. Tests: all four samples verbatim + both ways the DMARC proof could be gamed. **Still owed:** sender trust — `allowlist.jsonc` already is the approved-sender store (`getAllowlist()` at `mailx-service/index.ts:1980`) and the viewer already has “Trust all senders at ‹domain›”, but nothing consults it when computing `trust`/`spamScore` (`store.ts:586`) and the banner has no inline trust action. Open Qs unchanged: trust key granularity (shared ESP domain vs Reply-To org vs Delivered-To tag), whether trust suppresses the whole banner or only the bulk/association half (leaning: never suppress forgery evidence), and auto-trust on reply/move-out-of-Junk. |
|
|
232
234
|
|
|
233
235
|
<a id="concerns"></a>
|
|
234
236
|
|
|
@@ -2226,18 +2226,141 @@ function bare(addr) {
|
|
|
2226
2226
|
const m = (addr || "").match(/[^\s<>,;]+@[^\s<>,;]+/);
|
|
2227
2227
|
return (m ? m[0] : addr || "").toLowerCase().replace(/[.,;]+$/, "");
|
|
2228
2228
|
}
|
|
2229
|
-
function
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2229
|
+
function classifyRule(name, dmarcFailed) {
|
|
2230
|
+
if (dmarcFailed && name === "DKIM_INVALID")
|
|
2231
|
+
return "forgery";
|
|
2232
|
+
for (const re of FORGERY_RULES)
|
|
2233
|
+
if (re.test(name))
|
|
2234
|
+
return "forgery";
|
|
2235
|
+
for (const re of ASSOCIATION_RULES)
|
|
2236
|
+
if (re.test(name))
|
|
2237
|
+
return "association";
|
|
2238
|
+
for (const re of NEUTRAL_RULES)
|
|
2239
|
+
if (re.test(name))
|
|
2240
|
+
return "neutral";
|
|
2241
|
+
for (const re of BULK_RULES)
|
|
2242
|
+
if (re.test(name))
|
|
2243
|
+
return "bulk";
|
|
2244
|
+
return "other";
|
|
2245
|
+
}
|
|
2246
|
+
function dmarcProof(input) {
|
|
2247
|
+
const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
|
|
2248
|
+
if (!auth)
|
|
2249
|
+
return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
|
|
2250
|
+
const authFailures = [
|
|
2251
|
+
/\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
|
|
2252
|
+
/\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
|
|
2253
|
+
/\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : ""
|
|
2254
|
+
].filter(Boolean);
|
|
2255
|
+
const failed = authFailures.includes("DMARC");
|
|
2256
|
+
if (!/\bdmarc=pass\b/i.test(auth))
|
|
2257
|
+
return { pass: false, failed, authFailures, domain: "", policy: "" };
|
|
2258
|
+
const stated = (auth.match(/header\.from=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
2259
|
+
const policy = (auth.match(/policy\.dmarc=([^\s;,]+)/i)?.[1] || "").toLowerCase();
|
|
2260
|
+
const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
|
|
2261
|
+
if (stated && fromDomain && stated !== fromDomain && !stated.endsWith("." + fromDomain) && !fromDomain.endsWith("." + stated)) {
|
|
2262
|
+
return { pass: false, failed, authFailures, domain: "", policy };
|
|
2263
|
+
}
|
|
2264
|
+
return { pass: true, failed: false, authFailures, domain: stated || fromDomain, policy };
|
|
2265
|
+
}
|
|
2266
|
+
function unfold(value) {
|
|
2267
|
+
return value.replace(/\r?\n/g, "");
|
|
2268
|
+
}
|
|
2269
|
+
function parseSpamReport(report) {
|
|
2270
|
+
const out = /* @__PURE__ */ new Map();
|
|
2271
|
+
let current = null;
|
|
2272
|
+
for (const raw of report.split(/\r?\n/)) {
|
|
2273
|
+
const line = raw.replace(/^[ \t]*\*?[ \t]*/, "").trimEnd();
|
|
2274
|
+
if (!line)
|
|
2275
|
+
continue;
|
|
2276
|
+
const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
|
|
2277
|
+
if (start) {
|
|
2278
|
+
current = { score: Number(start[1]), description: start[3].trim() };
|
|
2279
|
+
out.set(start[2], current);
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
if (current && !line.startsWith("["))
|
|
2283
|
+
current.description += " " + line;
|
|
2284
|
+
}
|
|
2285
|
+
for (const rule of out.values())
|
|
2286
|
+
rule.description = rule.description.replace(/\s+/g, " ").replace(/^(BODY|RAW|HEADER|URI|RBL):\s*/i, "").trim();
|
|
2287
|
+
return out;
|
|
2288
|
+
}
|
|
2289
|
+
function analyzeServerSpam(input) {
|
|
2290
|
+
const status = unfold(header(input.headerLines, "x-spam-status"));
|
|
2291
|
+
const flag = header(input.headerLines, "x-spam-flag");
|
|
2292
|
+
if (!status && !flag)
|
|
2293
|
+
return null;
|
|
2294
|
+
const score = Number(status.match(/score=(-?[\d.]+)/)?.[1]);
|
|
2295
|
+
const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
|
|
2296
|
+
const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
|
|
2297
|
+
const dmarc = dmarcProof(input);
|
|
2298
|
+
const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
|
|
2299
|
+
const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "").replace(/\s+/g, "").split(",").map((n) => n.toUpperCase()).filter(Boolean);
|
|
2300
|
+
const rules = names.map((name) => {
|
|
2301
|
+
const found = report.get(name);
|
|
2302
|
+
return {
|
|
2303
|
+
name,
|
|
2304
|
+
score: found ? found.score : NaN,
|
|
2305
|
+
description: found ? found.description : "",
|
|
2306
|
+
cls: classifyRule(name, dmarc.failed)
|
|
2307
|
+
};
|
|
2308
|
+
});
|
|
2309
|
+
const scored = (r) => !(r.score <= 0);
|
|
2310
|
+
const present = (cls) => rules.some((r) => r.cls === cls && scored(r));
|
|
2311
|
+
const kind = dmarc.authFailures.length || present("forgery") ? "forgery" : present("association") ? "association" : present("other") ? "unclassified" : rules.length ? "bulk" : "unclassified";
|
|
2312
|
+
const reasons = rules.filter((r) => r.cls !== "neutral" && scored(r)).sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, 3).map((r) => {
|
|
2313
|
+
const num = Number.isFinite(r.score) ? `${r.score.toFixed(1)} ` : "";
|
|
2314
|
+
return r.description ? `${num}${r.name} (${r.description})` : `${num}${r.name}`;
|
|
2315
|
+
});
|
|
2316
|
+
return {
|
|
2317
|
+
score,
|
|
2318
|
+
threshold,
|
|
2319
|
+
flagged,
|
|
2320
|
+
rules,
|
|
2321
|
+
kind,
|
|
2322
|
+
proved: dmarc.pass,
|
|
2323
|
+
provedDomain: dmarc.domain,
|
|
2324
|
+
provedPolicy: dmarc.policy,
|
|
2325
|
+
bayesHam: rules.some((r) => r.name === "BAYES_00" || r.name === "BAYES_01"),
|
|
2326
|
+
authFailures: dmarc.authFailures,
|
|
2327
|
+
reasons
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
function serverSpamVerdict(input) {
|
|
2331
|
+
const a = analyzeServerSpam(input);
|
|
2332
|
+
if (!a || !a.flagged)
|
|
2233
2333
|
return null;
|
|
2234
|
-
const
|
|
2235
|
-
const
|
|
2334
|
+
const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold) ? `SpamAssassin score ${a.score} of ${a.threshold}` : "flagged by SpamAssassin";
|
|
2335
|
+
const why = a.reasons.length ? ` \u2014 ${a.reasons.join("; ")}` : "";
|
|
2336
|
+
const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
|
|
2337
|
+
if (a.kind === "forgery")
|
|
2338
|
+
return {
|
|
2339
|
+
id: "server-spam-verdict",
|
|
2340
|
+
severity: "danger",
|
|
2341
|
+
text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
|
|
2342
|
+
detail: a.authFailures.length ? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}` : `${numbers}${why}`
|
|
2343
|
+
};
|
|
2344
|
+
if (a.proved && a.kind !== "unclassified")
|
|
2345
|
+
return {
|
|
2346
|
+
id: "server-spam-verdict",
|
|
2347
|
+
severity: "info",
|
|
2348
|
+
text: `Your mail server scored this as spam, but ${a.provedDomain} proved it sent this.`,
|
|
2349
|
+
detail: `DMARC pass, aligned${a.provedPolicy ? `, policy ${a.provedPolicy}` : ""}. ${numbers}${why}${bayes}`
|
|
2350
|
+
};
|
|
2351
|
+
if (a.kind === "bulk")
|
|
2352
|
+
return {
|
|
2353
|
+
id: "server-spam-verdict",
|
|
2354
|
+
severity: "info",
|
|
2355
|
+
text: "Your mail server rates this bulk mail \u2014 nothing that scored tests who sent it.",
|
|
2356
|
+
detail: `${numbers}${why}${bayes}`
|
|
2357
|
+
};
|
|
2358
|
+
const proof = a.proved ? `${a.provedDomain} passed DMARC, so the From line is genuine. ` : "";
|
|
2236
2359
|
return {
|
|
2237
2360
|
id: "server-spam-verdict",
|
|
2238
|
-
severity: "
|
|
2361
|
+
severity: "caution",
|
|
2239
2362
|
text: "Your mail server classified this as spam before delivering it.",
|
|
2240
|
-
detail:
|
|
2363
|
+
detail: `${proof}${numbers}${why}${bayes}`
|
|
2241
2364
|
};
|
|
2242
2365
|
}
|
|
2243
2366
|
function selfSpoof(input) {
|
|
@@ -2336,14 +2459,14 @@ function redirectorLink(bodyHtml) {
|
|
|
2336
2459
|
}
|
|
2337
2460
|
function assessMessageTrust(input) {
|
|
2338
2461
|
const findings = [
|
|
2339
|
-
serverSpamVerdict(input
|
|
2462
|
+
serverSpamVerdict(input),
|
|
2340
2463
|
selfSpoof(input),
|
|
2341
2464
|
relayAuthMismatch(input),
|
|
2342
2465
|
zeroWidthObfuscation(input.bodyText || ""),
|
|
2343
2466
|
hiddenLinkOverlay(input.bodyHtml || ""),
|
|
2344
2467
|
redirectorLink(input.bodyHtml || "")
|
|
2345
2468
|
].filter(Boolean);
|
|
2346
|
-
const rank = { danger: 0, caution: 1 };
|
|
2469
|
+
const rank = { danger: 0, caution: 1, info: 2 };
|
|
2347
2470
|
return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
2348
2471
|
}
|
|
2349
2472
|
function relayAuthMismatch(input) {
|
|
@@ -2381,20 +2504,80 @@ function zeroWidthObfuscation(bodyText) {
|
|
|
2381
2504
|
detail: `${count} zero-width characters hidden inside words \u2014 legitimate mail has no reason to do this`
|
|
2382
2505
|
};
|
|
2383
2506
|
}
|
|
2384
|
-
function spamScoreOf(
|
|
2385
|
-
const
|
|
2386
|
-
if (!
|
|
2387
|
-
return null;
|
|
2388
|
-
const score = Number(status.match(/score=([-\d.]+)/)?.[1]);
|
|
2389
|
-
const threshold = Number(status.match(/required=([-\d.]+)/)?.[1]);
|
|
2390
|
-
if (!Number.isFinite(score) || !Number.isFinite(threshold))
|
|
2507
|
+
function spamScoreOf(input) {
|
|
2508
|
+
const a = analyzeServerSpam(input);
|
|
2509
|
+
if (!a || !Number.isFinite(a.score) || !Number.isFinite(a.threshold))
|
|
2391
2510
|
return null;
|
|
2392
|
-
return {
|
|
2511
|
+
return {
|
|
2512
|
+
score: a.score,
|
|
2513
|
+
threshold: a.threshold,
|
|
2514
|
+
flagged: a.flagged,
|
|
2515
|
+
kind: a.kind,
|
|
2516
|
+
proved: a.proved,
|
|
2517
|
+
reasons: a.reasons
|
|
2518
|
+
};
|
|
2393
2519
|
}
|
|
2394
|
-
var OVERLAY_MIN_EDGE_PX, RELAY_IDENTITY_HEADERS, ZERO_WIDTH_MIN_OCCURRENCES;
|
|
2520
|
+
var FORGERY_RULES, ASSOCIATION_RULES, BULK_RULES, NEUTRAL_RULES, OVERLAY_MIN_EDGE_PX, RELAY_IDENTITY_HEADERS, ZERO_WIDTH_MIN_OCCURRENCES;
|
|
2395
2521
|
var init_trust = __esm({
|
|
2396
2522
|
"packages/mailx-types/trust.js"() {
|
|
2397
2523
|
"use strict";
|
|
2524
|
+
FORGERY_RULES = [
|
|
2525
|
+
/^SPF_(HELO_)?(FAIL|SOFTFAIL)$/,
|
|
2526
|
+
/^(KAM_)?DMARC_(FAIL|REJECT|QUAR)/,
|
|
2527
|
+
/^DKIM_ADSP_(ALL|DISCARD|NXDOMAIN)$/,
|
|
2528
|
+
/^FORGED_/,
|
|
2529
|
+
/SPOOF/,
|
|
2530
|
+
/_PHISH/,
|
|
2531
|
+
/PHISHING/,
|
|
2532
|
+
/_MALW/,
|
|
2533
|
+
/MALWARE/
|
|
2534
|
+
];
|
|
2535
|
+
ASSOCIATION_RULES = [
|
|
2536
|
+
/^URIBL_(SBL|BLACK|RED|DBL|ABUSE)/,
|
|
2537
|
+
/^SH_DBL/,
|
|
2538
|
+
/^SEM_URIBL/,
|
|
2539
|
+
/^SPAMHAUS_/,
|
|
2540
|
+
/^RCVD_IN_(SBL|XBL|PBL|BL_SPAMCOP|SORBS|BRBL|VALIDITY|MSPIKE_[LZ])/
|
|
2541
|
+
];
|
|
2542
|
+
BULK_RULES = [
|
|
2543
|
+
/^DCC_/,
|
|
2544
|
+
/^PYZOR_/,
|
|
2545
|
+
/^RAZOR2_/,
|
|
2546
|
+
/^MAILING_LIST/,
|
|
2547
|
+
/^LIST_/,
|
|
2548
|
+
// KAM_* by name only, never the whole family: it holds marketing and list
|
|
2549
|
+
// rules alongside scam-CONTENT rules (KAM_BENEFICIARY is worth 10.0,
|
|
2550
|
+
// KAM_FAKE_NORTONLOW 6.5), and calling those bulk would quiet the loudest
|
|
2551
|
+
// true positives in the store.
|
|
2552
|
+
/^KAM_(BODY_)?MARKETINGBL/,
|
|
2553
|
+
/^KAM_UNSUB/,
|
|
2554
|
+
/^KAM_TRACKIMAGE$/,
|
|
2555
|
+
/^KAM_REALLYHUGEIMGSRC$/,
|
|
2556
|
+
/^KAM_DMARC_STATUS$/,
|
|
2557
|
+
/^KAM_COUPON/,
|
|
2558
|
+
/^HTML_/,
|
|
2559
|
+
/^MIME_HTML/,
|
|
2560
|
+
/^WORD_INVIS$/,
|
|
2561
|
+
/^URIBL_(GREY|CSS|BLOCKED|CT_SURBL)/,
|
|
2562
|
+
/^SH_BODYURI/,
|
|
2563
|
+
/^HEADER_FROM_DIFFERENT_DOMAINS$/,
|
|
2564
|
+
// every ESP on earth trips this
|
|
2565
|
+
/^MPART_ALT_DIFF/,
|
|
2566
|
+
/^TVD_/,
|
|
2567
|
+
/^T_REMOTE_IMAGE$/,
|
|
2568
|
+
/^UNPARSEABLE_RELAY$/
|
|
2569
|
+
];
|
|
2570
|
+
NEUTRAL_RULES = [
|
|
2571
|
+
/^BAYES_/,
|
|
2572
|
+
/^SPF_(PASS|NONE|HELO_(PASS|NONE|NEUTRAL))$/,
|
|
2573
|
+
/^DKIM_(SIGNED|VALID|VALID_AU|VALID_EF|INVALID)$/,
|
|
2574
|
+
/^DKIMWL_/,
|
|
2575
|
+
/^RCVD_IN_(DNSWL|MSPIKE_H|IADB)/,
|
|
2576
|
+
/^SHORTCIRCUIT$/,
|
|
2577
|
+
/^AWL$/,
|
|
2578
|
+
/^NO_RELAYS$/,
|
|
2579
|
+
/^ALL_TRUSTED$/
|
|
2580
|
+
];
|
|
2398
2581
|
OVERLAY_MIN_EDGE_PX = 2e3;
|
|
2399
2582
|
RELAY_IDENTITY_HEADERS = [
|
|
2400
2583
|
"x-source-auth",
|