/** * Spam SCORING for the intake handler. Pouya's ruling, 2026-09-04. * * ⚠️ **THIS MODULE NEVER REJECTS ANYTHING, AND THAT IS THE WHOLE DESIGN.** It * returns a score and a list of signal names. The handler stores the record and * sends both emails either way; above the threshold it prefixes the OPERATOR * notification's subject with `[Possible spam] ` and adds one line naming the * signals. Pouya filters in Gmail. His words: *"Nothing is dropped; a false * positive costs him one glance."* * * That asymmetry is why the thresholds below can be tuned aggressively. The cost * of a false positive is a subject-line prefix; the cost of a false negative is * one unlabelled email. Neither loses an inquiry — which a filter that rejected * would, and a legal inquiry lost silently is the one outcome this form must not * produce. * * ⚠️ **NOTHING HERE IS STORED.** The score and the signals do not enter the * DynamoDB item. `/legal/privacy/` publishes what the record holds, field by * field, and adding an attribute would make that list wrong — a disclosure * defect, not a schema change. The label lives only in the operator * notification. ⚠️ **THAT MAILBOX IS DELEGATED, NOT PERSONAL** — §9 Q63 and * `/legal/privacy/` §Who can see it both say so, and an earlier draft of this * comment said the label "lives in an email that only Pouya reads", which is the * exclusivity Q63 struck. It reaches whoever reads `info@smlcompany.ca`. If a * stored score is ever wanted, the page changes first. * * ⚠️ **AND THE INQUIRER NEVER SEES ANY OF THIS.** The confirmation email is * untouched. A person wrongly scored must not be told a machine thought they * were a bot. * * WHY SCORING RATHER THAN MORE REJECTION. The two submissions of 2026-09-04 * (`docs/05` §Observed abuse) passed the honeypot. Every rule that would have * caught them — a foreign phone, a link in the summary, a disposable-looking * address — is a rule some real inquirer also trips: this practice takes * cross-border commercial work, so a `+44` number is a client, not a bot. A * rejecting rule set built from those signals would eventually discard a real * dispute and report success while doing it. */ /** * ⚠️ **NOT MEASURED FROM A CORPUS — THERE IS NO CORPUS.** No genuine inquiry has * arrived through this form yet, so there is nothing to measure a normal summary * length against, and a number presented as measured when it is not is the * defect `AGENTS.md` keeps paying for. * * It is DERIVED, and the derivation is the form's own instruction: the `summary` * field's hint reads *"A few sentences is enough."* This floor sits **below** what * that invites, so it fires on a summary that does not attempt the question * rather than on one that answers it briefly. `[assumed 2026-09-04]` * * ⚠️ **TUNE IT DOWN WHEN IN DOUBT, NEVER UP.** An unlabelled spam costs nothing * that matters; a labelled real inquiry spends the reader's trust in the label. * *"Shareholder dispute, two directors, Ontario CBCA company."* is 57 characters * and is exactly what the hint asks for — a floor above that scores the form's * own instruction as a spam signal. * * **Pouya can replace this with a measurement whenever he likes** — the two spam * records of 2026-09-04 are still in the table, and their `summary` lengths are * the first real data this number could rest on. §9 Q65 records that. */ export const SHORT_SUMMARY_CHARS = 100; /** * Above this, the notification is labelled. Weights below are 1 for a signal a * real inquirer plausibly trips and 2 for one they rarely do, so the threshold * of 2 means: **one strong signal, or two weak ones.** * * Worked, because a threshold nobody has worked through is a guess with a number * on it: * - Ontario counsel, local number, three-line summary → 0, clean * - Cross-border counsel, `+44` number, three-line summary → 1, clean * - Cross-border counsel, `+44` number, one-line summary → 2, LABELLED * - A four-part real name at gmail.com → 1, clean * - Anyone pasting a link to a public tender document → 2, LABELLED * - foreign number + a short scraped summary carrying a link → 4, LABELLED * * The third and fourth rows are the accepted false positives. Both are real * shapes, both cost one glance, and both were preferred to missing the fifth. * * ⚠️ **THE LAST ROW IS A SHAPE, NOT A MEASUREMENT OF THE TWO 2026-09-04 * SUBMISSIONS. THOSE RECORDS WERE NEVER READ.** What the attested signature * guarantees is a non-NANP phone — **one weak signal** — and whether either is * labelled turns on facts only the two rows in the table hold. §9 Q65 records * that they are still there and are the only real data any of these numbers * could rest on. */ export const SPAM_THRESHOLD = 2; /** `https://…` or `www.…` only. A bare `acme.com` is NOT matched: an inquirer * writing "the dispute concerns acme.com's supply contract" is describing a * party, and matching that would label ordinary commercial prose. */ const URL_IN_TEXT = /\b(?:https?:\/\/|www\.)\S/i; /** * NANP: an explicit `+` settles it; otherwise ten digits, or eleven * beginning with 1, after the tail is dropped. * * ⚠️ **A DIGIT COUNT ALONE CANNOT DO THIS.** `416-555-0123 ext 22` is twelve * digits, `416-555-0123 or 416-555-0124` is twenty, and both are ordinary * Toronto numbers that a bare count calls foreign — a signal saying the opposite * of the truth. The tail is dropped at the first extension marker or * second-number separator, and the marker list is deliberately generous. * * **The country code is read FIRST because it is the only unambiguous thing in * the field.** `+44 …` and `+7 …` are settled without counting anything, which * is what a pure shape test cannot do: `+7 912 345 6789` is grouped 3-3-4 * exactly like a NANP number, so matching the shape would call it Canadian. * Only when there is no explicit country code does the digit count run, and then * the tail is dropped at the first extension marker or second-number separator. */ function looksNorthAmerican(phone) { const trimmed = phone.trim(); /* An explicit international prefix is decisive in both directions. */ const cc = trimmed.match(/^\+\s*(\d{1,3})/); if (cc) return cc[1] === '1'; /* Longest alternative FIRST: regex alternation is leftmost-first, so `ext` placed before `extension` matches the first three letters and then relies on backtracking. Ordering it correctly is cheaper than depending on that. `\bx\b` would NOT match the `x` in `x22` — the digit after it is a word character, so there is no boundary — which is how `(416) 555-0123 x22` scored foreign for one round. The marker is matched by what FOLLOWS it. */ const digits = trimmed .split(/\s*(?:extension|extn|ext|x)[.:-]?\s*\d|[#,;]|\bor\b/i)[0] .replace(/\D/g, ''); return digits.length === 10 || (digits.length === 11 && digits[0] === '1'); } /** * The Gmail dot trick: one mailbox, unlimited distinct-looking addresses, * because Gmail ignores dots in the local part. * * ⚠️ **A DOT IS NOT THE SIGNAL, AND TREATING IT AS ONE WOULD LABEL MOST REAL * GMAIL USERS.** `first.last@gmail.com` is the single most ordinary form a Gmail * address takes. What distinguishes the trick is dot DENSITY: **three or more * dots**, and nothing else. * * ⚠️ **AND THE WEIGHT IS 1, NOT 2, WHICH MATTERS MORE THAN THE BOUNDARY DOES.** * `mary.jane.o.brien@gmail.com` and `maria.de.la.cruz@gmail.com` are three-dot * REAL names — compound surnames and middle initials are ordinary, not rare — * and weight 2 is defined here as what a real inquirer rarely trips. At weight 1 * nothing can be labelled on the shape of its owner's name alone; a genuine * dot-trick address reaches the threshold as soon as it trips anything else, * which spam reliably does. **Do not raise it back.** */ function looksLikeGmailDotTrick(email) { const at = email.lastIndexOf('@'); if (at < 1) return false; const local = email.slice(0, at); const domain = email.slice(at + 1).toLowerCase(); if (domain !== 'gmail.com' && domain !== 'googlemail.com') return false; const dots = local.split('.').length - 1; return dots >= 3; } /** * @param {Record} fields the handler's `clean` map — validated, * plain-texted values, keyed by field name. Absent fields are simply absent. * @returns {{score: number, signals: string[]}} `signals` are written for a * human reading one line of an email, not for a machine. */ export function scoreSubmission(fields) { const signals = []; let score = 0; const add = (weight, label) => { score += weight; signals.push(label); }; const summary = fields.summary ?? ''; const phone = fields.phone ?? ''; const email = fields.email ?? ''; /* Only when a summary exists. An absent one is a validation failure the handler has already turned into the failure page, so scoring an empty string here would be scoring a submission that never got this far. */ if (summary !== '' && summary.length < SHORT_SUMMARY_CHARS) { add(1, `summary under ${SHORT_SUMMARY_CHARS} characters`); } /* `phone` is OPTIONAL. Not giving one is not a signal — most inquirers will not — so this fires only on a number that is present and not North American. Treating absence as suspicious would label the quiet majority. */ if (phone !== '' && !looksNorthAmerican(phone)) { add(1, 'phone is not a Canadian or US number'); } if (URL_IN_TEXT.test(summary)) { add(2, 'link in the dispute summary'); } if (looksLikeGmailDotTrick(email)) { add(1, 'Gmail address using the dot trick'); } return { score, signals }; } /** True when the operator notification should carry the label. */ export const isPossibleSpam = ({ score }) => score >= SPAM_THRESHOLD;