@bobfrankston/rmfmail 1.2.285 → 1.2.286

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.
Files changed (32) hide show
  1. package/.commitmsg +37 -31
  2. package/TODO.md +2 -0
  3. package/client/android-bootstrap.bundle.js +202 -19
  4. package/client/android-bootstrap.bundle.js.map +2 -2
  5. package/client/app.bundle.js +7 -4
  6. package/client/app.bundle.js.map +2 -2
  7. package/client/components/message-viewer.js +32 -9
  8. package/client/components/message-viewer.js.map +1 -1
  9. package/client/components/message-viewer.ts +36 -10
  10. package/client/styles/components.css +9 -0
  11. package/docs/spam-false-positives.md +208 -0
  12. package/npmchanges.md +40 -0
  13. package/package.json +1 -1
  14. package/packages/mailx-imap/package-lock.json +2 -2
  15. package/packages/mailx-imap/package.json +1 -1
  16. package/packages/mailx-service/index.ts +20 -3
  17. package/packages/mailx-service/package.json +1 -1
  18. package/packages/mailx-settings/docs/spam-false-positives.md +208 -0
  19. package/packages/mailx-settings/package.json +1 -1
  20. package/packages/mailx-store/package.json +1 -1
  21. package/packages/mailx-store/store.d.ts.map +1 -1
  22. package/packages/mailx-store/store.js +7 -3
  23. package/packages/mailx-store/store.js.map +1 -1
  24. package/packages/mailx-store/store.ts +7 -3
  25. package/packages/mailx-store-web/package.json +1 -1
  26. package/packages/mailx-types/package.json +1 -1
  27. package/packages/mailx-types/trust.d.ts +16 -3
  28. package/packages/mailx-types/trust.d.ts.map +1 -1
  29. package/packages/mailx-types/trust.js +262 -23
  30. package/packages/mailx-types/trust.js.map +1 -1
  31. package/packages/mailx-types/trust.ts +343 -25
  32. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-85316 → node_modules.npmglobalize-stash-50896}/.package-lock.json +0 -0
@@ -34,8 +34,10 @@ export interface TrustFinding {
34
34
  /** Stable identifier — for tests, logs, and any future suppression UI. */
35
35
  id: "server-spam-verdict" | "self-spoof" | "relay-auth-mismatch"
36
36
  | "zero-width-obfuscation" | "hidden-link-overlay" | "redirector-link";
37
- /** `danger`: near-certain forgery. `caution`: worth a second look. */
38
- severity: "danger" | "caution";
37
+ /** `danger`: near-certain forgery. `caution`: worth a second look.
38
+ * `info`: something the server said, in a register that is not an
39
+ * accusation — a bulk-mail verdict on a sender DMARC proved. */
40
+ severity: "danger" | "caution" | "info";
39
41
  /** One line, naming what was found. Never "this message is suspicious". */
40
42
  text: string;
41
43
  /** The specific evidence — a header value, a host, a score. */
@@ -89,7 +91,270 @@ function bare(addr: string): string {
89
91
  }
90
92
 
91
93
  /**
92
- * Did the receiving server's spam filter already say yes?
94
+ * What SpamAssassin's rules are evidence OF — which is not what its score is.
95
+ *
96
+ * The score answers "should this be delivered". The banner asks a different
97
+ * question, "is this forged", and the `tests=` list that separates the two
98
+ * sits in the same header and was never read. Four messages Bob subscribed to
99
+ * (Constant Contact, Adobe MAX, Mokin, ActBlue) scored 2.2 to 10.9 and two of
100
+ * them raised the app's strongest banner; every rule that pushed them there
101
+ * was a bulk-mail test or a third-party blocklist entry about a host the
102
+ * message merely LINKS TO. Mokin's 6.0 — more than the whole 4.5 threshold on
103
+ * its own — was `URIBL_SBL` on the IP of the nameserver of a linked domain:
104
+ * fourth-order guilt by association, saying nothing whatever about whether
105
+ * Mokin sent the mail. All four passed DMARC aligned, so on all four the
106
+ * headline "This message is not what it says it is" was factually false.
107
+ * Full diagnosis: docs/spam-false-positives.md. (Claude Code 2026-08-27)
108
+ *
109
+ * Note what is NOT here: any tuning of a threshold. A rule's class follows
110
+ * from what it tests, never from how much it scored.
111
+ */
112
+ type RuleClass = "forgery" | "association" | "bulk" | "neutral" | "other";
113
+
114
+ /** Rules that say THIS message lies about who sent it. The only class that
115
+ * earns the red banner. `KAM_DMARC_STATUS` is deliberately absent — it is a
116
+ * 0.0-scoring status marker that fired on Mokin, a message the receiving
117
+ * server had just recorded as `dmarc=pass`. */
118
+ const FORGERY_RULES: RegExp[] = [
119
+ /^SPF_(HELO_)?(FAIL|SOFTFAIL)$/,
120
+ /^(KAM_)?DMARC_(FAIL|REJECT|QUAR)/,
121
+ /^DKIM_ADSP_(ALL|DISCARD|NXDOMAIN)$/,
122
+ /^FORGED_/,
123
+ /SPOOF/,
124
+ /_PHISH/, /PHISHING/, /_MALW/, /MALWARE/,
125
+ ];
126
+
127
+ /** Reputation a third party holds about some host the message mentions or
128
+ * travelled through. Real evidence the mail is unwanted; no evidence at all
129
+ * that the From line is false — the listed party is usually not the sender. */
130
+ const ASSOCIATION_RULES: RegExp[] = [
131
+ /^URIBL_(SBL|BLACK|RED|DBL|ABUSE)/,
132
+ /^SH_DBL/,
133
+ /^SEM_URIBL/,
134
+ /^SPAMHAUS_/,
135
+ /^RCVD_IN_(SBL|XBL|PBL|BL_SPAMCOP|SORBS|BRBL|VALIDITY|MSPIKE_[LZ])/,
136
+ ];
137
+
138
+ /** The shape of mass mailing: bulk-detection networks, marketing markup, list
139
+ * plumbing. A score built entirely from these is a bulk verdict however high
140
+ * it climbs — Adobe MAX reached 3.9 without one rule that looks at identity. */
141
+ const BULK_RULES: RegExp[] = [
142
+ /^DCC_/, /^PYZOR_/, /^RAZOR2_/,
143
+ /^MAILING_LIST/, /^LIST_/,
144
+ // KAM_* by name only, never the whole family: it holds marketing and list
145
+ // rules alongside scam-CONTENT rules (KAM_BENEFICIARY is worth 10.0,
146
+ // KAM_FAKE_NORTONLOW 6.5), and calling those bulk would quiet the loudest
147
+ // true positives in the store.
148
+ /^KAM_(BODY_)?MARKETINGBL/, /^KAM_UNSUB/, /^KAM_TRACKIMAGE$/,
149
+ /^KAM_REALLYHUGEIMGSRC$/, /^KAM_DMARC_STATUS$/, /^KAM_COUPON/,
150
+ /^HTML_/, /^MIME_HTML/, /^WORD_INVIS$/,
151
+ /^URIBL_(GREY|CSS|BLOCKED|CT_SURBL)/, /^SH_BODYURI/,
152
+ /^HEADER_FROM_DIFFERENT_DOMAINS$/, // every ESP on earth trips this
153
+ /^MPART_ALT_DIFF/, /^TVD_/, /^T_REMOTE_IMAGE$/, /^UNPARSEABLE_RELAY$/,
154
+ ];
155
+
156
+ /** Passes, whitelistings, and Bayes' own opinion — informational, and in
157
+ * BAYES_00's case exculpatory. `DKIM_INVALID` lives here rather than in the
158
+ * forgery class because a broken signature is routine on relayed and
159
+ * list-rewritten mail (Mokin, sent via Shopify and SendGrid, carries it while
160
+ * the receiving server records dkim=pass). It is promoted to forgery only
161
+ * when DMARC also failed — see classifyRule. */
162
+ const NEUTRAL_RULES: RegExp[] = [
163
+ /^BAYES_/,
164
+ /^SPF_(PASS|NONE|HELO_(PASS|NONE|NEUTRAL))$/,
165
+ /^DKIM_(SIGNED|VALID|VALID_AU|VALID_EF|INVALID)$/,
166
+ /^DKIMWL_/, /^RCVD_IN_(DNSWL|MSPIKE_H|IADB)/,
167
+ /^SHORTCIRCUIT$/, /^AWL$/, /^NO_RELAYS$/, /^ALL_TRUSTED$/,
168
+ ];
169
+
170
+ function classifyRule(name: string, dmarcFailed: boolean): RuleClass {
171
+ if (dmarcFailed && name === "DKIM_INVALID") return "forgery";
172
+ for (const re of FORGERY_RULES) if (re.test(name)) return "forgery";
173
+ for (const re of ASSOCIATION_RULES) if (re.test(name)) return "association";
174
+ for (const re of NEUTRAL_RULES) if (re.test(name)) return "neutral";
175
+ for (const re of BULK_RULES) if (re.test(name)) return "bulk";
176
+ return "other";
177
+ }
178
+
179
+ /**
180
+ * Did the receiving server prove the From domain?
181
+ *
182
+ * Only the TOPMOST Authentication-Results is read. Headers are prepended, so
183
+ * the first one is the one our own server wrote; every one below it travelled
184
+ * with the message and a forger can write whatever they like there. Same
185
+ * asymmetry serverSpamVerdict relies on, in the other direction: a claim that
186
+ * SILENCES a warning must come from a source the sender cannot control.
187
+ *
188
+ * `dmarc=pass` is aligned by definition (RFC 7489) — SPF or DKIM passed on the
189
+ * same domain the reader sees in From. The `header.from=` it names is checked
190
+ * against the actual From line anyway, because an Authentication-Results about
191
+ * some other message proves nothing about this one.
192
+ */
193
+ function dmarcProof(input: TrustInput): { pass: boolean; failed: boolean; authFailures: string[]; domain: string; policy: string } {
194
+ const auth = headerAll(input.headerLines, "authentication-results")[0] || "";
195
+ if (!auth) return { pass: false, failed: false, authFailures: [], domain: "", policy: "" };
196
+
197
+ // What the server caught failing, by name. An outright authentication
198
+ // failure IS an identity test, whatever the tests= list happens to call
199
+ // it — this is what keeps the 2026-08-25 phishes red under the new
200
+ // classification, since spf=fail is the whole point of them.
201
+ const authFailures = [
202
+ /\bspf=(fail|softfail)\b/i.test(auth) ? "SPF" : "",
203
+ /\bdkim=(fail|permerror)\b/i.test(auth) ? "DKIM" : "",
204
+ /\bdmarc=(fail|permerror)\b/i.test(auth) ? "DMARC" : "",
205
+ ].filter(Boolean);
206
+ const failed = authFailures.includes("DMARC");
207
+ if (!/\bdmarc=pass\b/i.test(auth)) return { pass: false, failed, authFailures, domain: "", policy: "" };
208
+
209
+ const stated = (auth.match(/header\.from=([^\s;,]+)/i)?.[1] || "").toLowerCase();
210
+ const policy = (auth.match(/policy\.dmarc=([^\s;,]+)/i)?.[1] || "").toLowerCase();
211
+ const fromDomain = (bare(input.fromAddress).split("@")[1] || "").toLowerCase();
212
+ if (stated && fromDomain
213
+ && stated !== fromDomain
214
+ && !stated.endsWith("." + fromDomain)
215
+ && !fromDomain.endsWith("." + stated)) {
216
+ return { pass: false, failed, authFailures, domain: "", policy };
217
+ }
218
+ return { pass: true, failed: false, authFailures, domain: stated || fromDomain, policy };
219
+ }
220
+
221
+ /** One rule that fired, with its score and description when the server left a
222
+ * report. `X-Spam-Report` appears only on flagged mail, so score and
223
+ * description are often absent while the name always is not. */
224
+ interface SpamRule {
225
+ name: string;
226
+ score: number;
227
+ description: string;
228
+ cls: RuleClass;
229
+ }
230
+
231
+ /** Unfold a header value the way RFC 5322 folds it: delete the line break,
232
+ * KEEP the whitespace that continues the line. SpamAssassin breaks its tests=
233
+ * list right after a comma, so that continuation whitespace is all that
234
+ * separates two rule names from being glued into one — the list is de-spaced
235
+ * after it has been cut out, not here. X-Spam-Report is NOT unfolded: there
236
+ * the line structure is the record structure. */
237
+ function unfold(value: string): string {
238
+ return value.replace(/\r?\n/g, "");
239
+ }
240
+
241
+ /** Per-rule scores and descriptions out of X-Spam-Report, which reads:
242
+ *
243
+ * * 6.0 URIBL_SBL Contains an URL's NS IP listed in the Spamhaus SBL
244
+ * * blocklist
245
+ * * [URI: ns12.xincache.com/112.80.181.111]
246
+ *
247
+ * The bracketed evidence lines are dropped: they name the third party, and
248
+ * the finding is about this message. */
249
+ function parseSpamReport(report: string): Map<string, { score: number; description: string }> {
250
+ const out = new Map<string, { score: number; description: string }>();
251
+ let current: { score: number; description: string } = null;
252
+ for (const raw of report.split(/\r?\n/)) {
253
+ const line = raw.replace(/^[ \t]*\*?[ \t]*/, "").trimEnd();
254
+ if (!line) continue;
255
+ const start = line.match(/^(-?\d+(?:\.\d+)?)\s+([A-Z0-9_]{3,})\s*(.*)$/);
256
+ if (start) {
257
+ current = { score: Number(start[1]), description: start[3].trim() };
258
+ out.set(start[2], current);
259
+ continue;
260
+ }
261
+ if (current && !line.startsWith("[")) current.description += " " + line;
262
+ }
263
+ for (const rule of out.values())
264
+ rule.description = rule.description
265
+ .replace(/\s+/g, " ")
266
+ .replace(/^(BODY|RAW|HEADER|URI|RBL):\s*/i, "")
267
+ .trim();
268
+ return out;
269
+ }
270
+
271
+ /**
272
+ * What the server said, taken apart: the rules that fired, what each one is
273
+ * evidence of, and whether the From line was proved independently.
274
+ *
275
+ * One mechanism, two consumers — the banner finding below and spamScoreOf's
276
+ * chip — so the banner and the chip can never disagree about what a message is.
277
+ */
278
+ interface ServerSpamAnalysis {
279
+ score: number;
280
+ threshold: number;
281
+ flagged: boolean;
282
+ rules: SpamRule[];
283
+ /** Worst class that actually scored, forgery > association > other > bulk. */
284
+ kind: "forgery" | "association" | "bulk" | "unclassified";
285
+ /** DMARC-aligned pass at the receiving server: the sender is who it says. */
286
+ proved: boolean;
287
+ provedDomain: string;
288
+ provedPolicy: string;
289
+ /** The reader's own trained filter put this at 0-1% spam. */
290
+ bayesHam: boolean;
291
+ /** Authentication the receiving server saw fail, by name: "SPF", "DKIM",
292
+ * "DMARC". Any of these is an identity finding on its own. */
293
+ authFailures: string[];
294
+ /** Top scoring rules, worst first, named for a human to judge in a second. */
295
+ reasons: string[];
296
+ }
297
+
298
+ function analyzeServerSpam(input: TrustInput): ServerSpamAnalysis {
299
+ const status = unfold(header(input.headerLines, "x-spam-status"));
300
+ const flag = header(input.headerLines, "x-spam-flag");
301
+ if (!status && !flag) return null;
302
+
303
+ const score = Number(status.match(/score=(-?[\d.]+)/)?.[1]);
304
+ const threshold = Number(status.match(/required=(-?[\d.]+)/)?.[1]);
305
+ const flagged = /^yes/i.test(status) || /^yes/i.test(flag);
306
+
307
+ const dmarc = dmarcProof(input);
308
+ const report = parseSpamReport(header(input.headerLines, "x-spam-report"));
309
+ // Everything up to the first lowercase key=value — SpamAssassin's trailing
310
+ // shortcircuit=/autolearn=/version= fields. Cutting on "not a rule name"
311
+ // rather than on whitespace is what keeps "...,WORD_INVIS shortcircuit=no"
312
+ // from delivering a rule called WORD_INVISshortcircuit.
313
+ const names = (status.match(/tests=([\s\S]*?)(?=\s+[a-z_]+=|$)/)?.[1] || "")
314
+ .replace(/\s+/g, "").split(",").map(n => n.toUpperCase()).filter(Boolean);
315
+
316
+ const rules: SpamRule[] = names.map(name => {
317
+ const found = report.get(name);
318
+ return {
319
+ name,
320
+ score: found ? found.score : NaN,
321
+ description: found ? found.description : "",
322
+ cls: classifyRule(name, dmarc.failed),
323
+ };
324
+ });
325
+
326
+ // A rule that scored zero or negative did not put this message over any
327
+ // line, so it cannot be the reason the message is here. Unscored rules
328
+ // (no X-Spam-Report, which is most unflagged mail) still count — absence
329
+ // of a report is not evidence the rule was free.
330
+ const scored = (r: SpamRule) => !(r.score <= 0);
331
+ const present = (cls: RuleClass) => rules.some(r => r.cls === cls && scored(r));
332
+ const kind = (dmarc.authFailures.length || present("forgery")) ? "forgery"
333
+ : present("association") ? "association"
334
+ : present("other") ? "unclassified"
335
+ : rules.length ? "bulk"
336
+ : "unclassified";
337
+
338
+ const reasons = rules
339
+ .filter(r => r.cls !== "neutral" && scored(r))
340
+ .sort((a, b) => (b.score || 0) - (a.score || 0))
341
+ .slice(0, 3)
342
+ .map(r => {
343
+ const num = Number.isFinite(r.score) ? `${r.score.toFixed(1)} ` : "";
344
+ return r.description ? `${num}${r.name} (${r.description})` : `${num}${r.name}`;
345
+ });
346
+
347
+ return {
348
+ score, threshold, flagged, rules, kind,
349
+ proved: dmarc.pass, provedDomain: dmarc.domain, provedPolicy: dmarc.policy,
350
+ bayesHam: rules.some(r => r.name === "BAYES_00" || r.name === "BAYES_01"),
351
+ authFailures: dmarc.authFailures,
352
+ reasons,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Did the receiving server's spam filter already say yes — and if so, to WHAT?
93
358
  *
94
359
  * The highest-value signal available, and it costs nothing: the mail server
95
360
  * ran SpamAssassin before delivery and wrote the answer into the message.
@@ -99,23 +364,61 @@ function bare(addr: string): string {
99
364
  * `X-Spam-Flag: NO` into a message they send, and treating that as a clean
100
365
  * bill of health would hand attackers a switch for turning the check off. A
101
366
  * forged YES only flags the forger's own mail, so the asymmetry is safe.
367
+ *
368
+ * The severity comes from the RULES, never from the number (Claude Code
369
+ * 2026-08-27):
370
+ * - a forgery rule fired -> danger. This is what red is for.
371
+ * - the sender is DMARC-proved -> info. The mail may well be unwanted,
372
+ * and every rule that scored is but "not what it says it is" is a
373
+ * one this file can name, or claim its own evidence contradicts.
374
+ * the whole score is bulk rules
375
+ * - anything else -> caution. Flagged, and the reason is
376
+ * not identity — or is a rule this file
377
+ * cannot classify, which is never a
378
+ * reason to go quiet.
102
379
  */
103
- function serverSpamVerdict(lines: TrustInput["headerLines"]): TrustFinding {
104
- const flag = header(lines, "x-spam-flag");
105
- const status = header(lines, "x-spam-status");
106
- if (!/^yes/i.test(flag) && !/^yes/i.test(status)) return null;
107
-
108
- // "Yes, score=6.7 required=4.5 tests=..." — quote the numbers when they
109
- // are there, so the reader sees how emphatic the verdict was.
110
- const score = status.match(/score=([-\d.]+)/)?.[1];
111
- const required = status.match(/required=([-\d.]+)/)?.[1];
380
+ function serverSpamVerdict(input: TrustInput): TrustFinding {
381
+ const a = analyzeServerSpam(input);
382
+ if (!a || !a.flagged) return null;
383
+
384
+ const numbers = Number.isFinite(a.score) && Number.isFinite(a.threshold)
385
+ ? `SpamAssassin score ${a.score} of ${a.threshold}`
386
+ : "flagged by SpamAssassin";
387
+ const why = a.reasons.length ? ` — ${a.reasons.join("; ")}` : "";
388
+ const bayes = a.bayesHam ? "; your own trained filter puts it at 0-1% spam (BAYES_00)" : "";
389
+
390
+ if (a.kind === "forgery")
391
+ return {
392
+ id: "server-spam-verdict",
393
+ severity: "danger",
394
+ text: "Your mail server classified this as spam, and the rules that fired test who sent it.",
395
+ detail: a.authFailures.length
396
+ ? `${a.authFailures.join(" and ")} authentication failed at your server. ${numbers}${why}`
397
+ : `${numbers}${why}`,
398
+ };
399
+
400
+ if (a.proved && a.kind !== "unclassified")
401
+ return {
402
+ id: "server-spam-verdict",
403
+ severity: "info",
404
+ text: `Your mail server scored this as spam, but ${a.provedDomain} proved it sent this.`,
405
+ detail: `DMARC pass, aligned${a.provedPolicy ? `, policy ${a.provedPolicy}` : ""}. ${numbers}${why}${bayes}`,
406
+ };
407
+
408
+ if (a.kind === "bulk")
409
+ return {
410
+ id: "server-spam-verdict",
411
+ severity: "info",
412
+ text: "Your mail server rates this bulk mail — nothing that scored tests who sent it.",
413
+ detail: `${numbers}${why}${bayes}`,
414
+ };
415
+
416
+ const proof = a.proved ? `${a.provedDomain} passed DMARC, so the From line is genuine. ` : "";
112
417
  return {
113
418
  id: "server-spam-verdict",
114
- severity: "danger",
419
+ severity: "caution",
115
420
  text: "Your mail server classified this as spam before delivering it.",
116
- detail: score && required
117
- ? `SpamAssassin score ${score}, threshold ${required}`
118
- : (status || flag || "X-Spam-Flag: YES"),
421
+ detail: `${proof}${numbers}${why}${bayes}`,
119
422
  };
120
423
  }
121
424
 
@@ -259,14 +562,14 @@ function redirectorLink(bodyHtml: string): TrustFinding {
259
562
  */
260
563
  export function assessMessageTrust(input: TrustInput): TrustFinding[] {
261
564
  const findings = [
262
- serverSpamVerdict(input.headerLines),
565
+ serverSpamVerdict(input),
263
566
  selfSpoof(input),
264
567
  relayAuthMismatch(input),
265
568
  zeroWidthObfuscation(input.bodyText || ""),
266
569
  hiddenLinkOverlay(input.bodyHtml || ""),
267
570
  redirectorLink(input.bodyHtml || ""),
268
571
  ].filter(Boolean);
269
- const rank = { danger: 0, caution: 1 };
572
+ const rank = { danger: 0, caution: 1, info: 2 };
270
573
  return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
271
574
  }
272
575
 
@@ -377,13 +680,28 @@ export interface SpamScore {
377
680
  threshold: number;
378
681
  /** The server's own yes/no, which is simply score >= threshold. */
379
682
  flagged: boolean;
683
+ /** What the rules that actually scored are evidence OF. `bulk` and a
684
+ * `proved` sender are the two ways a high number can be honest mail —
685
+ * the chip stays visible and says so, rather than going red at a number
686
+ * and teaching the reader to ignore it. (Claude Code 2026-08-27) */
687
+ kind: "forgery" | "association" | "bulk" | "unclassified";
688
+ /** The From domain passed DMARC, aligned, at the receiving server. */
689
+ proved: boolean;
690
+ /** Top scoring rules, named — "6.0 URIBL_SBL (Contains an URL's NS IP
691
+ * listed in the Spamhaus SBL blocklist)". A number alone is unactionable;
692
+ * this can be judged in a second. */
693
+ reasons: string[];
380
694
  }
381
695
 
382
- export function spamScoreOf(headerLines: TrustInput["headerLines"]): SpamScore {
383
- const status = header(headerLines, "x-spam-status");
384
- if (!status) return null;
385
- const score = Number(status.match(/score=([-\d.]+)/)?.[1]);
386
- const threshold = Number(status.match(/required=([-\d.]+)/)?.[1]);
387
- if (!Number.isFinite(score) || !Number.isFinite(threshold)) return null;
388
- return { score, threshold, flagged: /^yes/i.test(status) };
696
+ export function spamScoreOf(input: TrustInput): SpamScore {
697
+ const a = analyzeServerSpam(input);
698
+ if (!a || !Number.isFinite(a.score) || !Number.isFinite(a.threshold)) return null;
699
+ return {
700
+ score: a.score,
701
+ threshold: a.threshold,
702
+ flagged: a.flagged,
703
+ kind: a.kind,
704
+ proved: a.proved,
705
+ reasons: a.reasons,
706
+ };
389
707
  }