diff --git a/tools/v1/individual/spam-risk-checker/README.md b/tools/v1/individual/spam-risk-checker/README.md index 2738fe06..decc9d53 100644 --- a/tools/v1/individual/spam-risk-checker/README.md +++ b/tools/v1/individual/spam-risk-checker/README.md @@ -1,15 +1,48 @@ -# Spam Risk Checker +# Spam Risk Checker (V1) -This folder is the isolated workspace for the Spam Risk Checker tool. +**Release Tier:** V1 +**Audience:** Individual +**Labels:** GrantFox OSS, Maybe Rewarded, Official Campaign, Tooling Ecosystem, V1 Launch Tool, Individual Tool -## Ownership Boundary +## Overview -All work for this tool must stay inside: +The Spam Risk Checker is a small, isolated heuristic tool for reviewing incoming message content and producing a lightweight risk score. It is intentionally self-contained so contributors can validate it without touching the main mail application. -`text -.\tools\v1\individual\spam-risk-checker\ -` +## Setup -Do not wire this tool into the main app, routing, inbox architecture, wallet core, Stellar core, database schema, or existing design system unless a future integration issue explicitly allows it. +1. From the repository root, ensure dependencies are installed. +2. Run the local test suite for this tool: + `npx vitest run tools/v1/individual/spam-risk-checker/tests/spamRiskChecker.test.ts` -See specs.md for the issue categories and contributor expectations. +## Usage + +```ts +import { analyzeSpamRisk } from "./services/spamRiskChecker"; + +const result = analyzeSpamRisk({ + subject: "Urgent update", + body: "Verify your account now and click the link.", +}); + +console.log(result.level, result.score, result.reasons); +``` + +## Fixtures + +The local fixtures live in [tests/fixtures.ts](tests/fixtures.ts) and cover: + +- a benign message that should score as low risk +- a suspicious message with urgency and bait language +- a mixed message that should score as medium risk + +## Known Limitations + +- The current implementation uses simple deterministic heuristics rather than machine learning. +- It evaluates message text only; it does not inspect headers, attachments, or sender reputation. +- The scoring is intentionally conservative so contributors can review and extend it easily. + +## OSS Contributor Review Notes + +- Review the service in [services/spamRiskChecker.ts](services/spamRiskChecker.ts) first. +- Validate the behavior in [tests/spamRiskChecker.test.ts](tests/spamRiskChecker.test.ts). +- Keep future inbox integration work as a separate follow-up issue. diff --git a/tools/v1/individual/spam-risk-checker/REVIEW_NOTES.md b/tools/v1/individual/spam-risk-checker/REVIEW_NOTES.md new file mode 100644 index 00000000..b4aa28e2 --- /dev/null +++ b/tools/v1/individual/spam-risk-checker/REVIEW_NOTES.md @@ -0,0 +1,16 @@ +# Review Notes + +This folder is meant to be reviewed as a self-contained mini-product. + +## What to validate + +- The scoring logic lives in [services/spamRiskChecker.ts](services/spamRiskChecker.ts) and uses local heuristics only. +- The tests in [tests/spamRiskChecker.test.ts](tests/spamRiskChecker.test.ts) cover low, medium, and high-risk cases without depending on the main app. +- The fixture content in [tests/fixtures.ts](tests/fixtures.ts) stays local to the tool and can be extended without touching app-wide fixtures. +- The documentation in [README.md](README.md) explains setup, usage, and current limitations. + +## Isolation checklist + +- No imports from the app shell, dashboard, routing, wallet, or database layers were added. +- The tool can be reviewed and tested from this folder alone. +- Any future integration with the inbox experience should be tracked as a separate follow-up issue. diff --git a/tools/v1/individual/spam-risk-checker/services/spamRiskChecker.ts b/tools/v1/individual/spam-risk-checker/services/spamRiskChecker.ts new file mode 100644 index 00000000..3158727e --- /dev/null +++ b/tools/v1/individual/spam-risk-checker/services/spamRiskChecker.ts @@ -0,0 +1,100 @@ +export type SpamRiskLevel = "low" | "medium" | "high"; + +export interface SpamRiskAnalysis { + score: number; + level: SpamRiskLevel; + reasons: string[]; + summary: string; +} + +export interface SpamRiskInput { + subject?: string; + body?: string; +} + +const URGENCY_PATTERN = /\b(urgent|immediately|act now|click now|limited time|final notice)\b/i; +const SPAM_BAIT_PATTERN = + /\b(free|winner|prize|cash|guaranteed|claim now|verify your account|reset your password)\b/i; +const FINANCE_PATTERN = /\b(crypto|bitcoin|investment|make money|earn money)\b/i; +const SHORTENED_LINK_PATTERN = /\b(bit\.ly|tinyurl|t\.co|ow\.ly)\b/i; +const EXTERNAL_LINK_PATTERN = /https?:\/\/\S+/i; + +function normalizeText(value: string | undefined): string { + return (value ?? "").trim(); +} + +function dedupe(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function classifyScore(score: number): SpamRiskLevel { + if (score >= 5) { + return "high"; + } + + if (score >= 2) { + return "medium"; + } + + return "low"; +} + +export function analyzeSpamRisk(input: string | SpamRiskInput): SpamRiskAnalysis { + const text = + typeof input === "string" ? input : [input.subject, input.body].map(normalizeText).join("\n"); + const trimmedText = normalizeText(text); + + if (!trimmedText) { + return { + score: 0, + level: "low", + reasons: [], + summary: "No content was provided for review.", + }; + } + + const reasons: string[] = []; + let score = 0; + + if (URGENCY_PATTERN.test(trimmedText)) { + score += 2; + reasons.push("Urgency language"); + } + + if (SPAM_BAIT_PATTERN.test(trimmedText)) { + score += 2; + reasons.push("Common spam bait phrases"); + } + + if (FINANCE_PATTERN.test(trimmedText)) { + score += 2; + reasons.push("Financial promises"); + } + + if (SHORTENED_LINK_PATTERN.test(trimmedText) || EXTERNAL_LINK_PATTERN.test(trimmedText)) { + score += 1; + reasons.push("External links"); + } + + if (/!{2,}/.test(trimmedText)) { + score += 1; + reasons.push("Excessive punctuation"); + } + + const uppercaseWords = trimmedText.match(/\b[A-Z]{4,}\b/g) ?? []; + if (uppercaseWords.length > 0) { + score += Math.min(uppercaseWords.length, 2); + reasons.push("All-caps emphasis"); + } + + const level = classifyScore(score); + const verb = + level === "high" ? "looks suspicious" : level === "medium" ? "needs caution" : "looks ordinary"; + + return { + score, + level, + reasons: dedupe(reasons), + summary: `This message ${verb} based on ${reasons.length || 1} signal${reasons.length === 1 ? "" : "s"}.`, + }; +} diff --git a/tools/v1/individual/spam-risk-checker/specs.md b/tools/v1/individual/spam-risk-checker/specs.md index 66693b9c..a1e484d5 100644 --- a/tools/v1/individual/spam-risk-checker/specs.md +++ b/tools/v1/individual/spam-risk-checker/specs.md @@ -1,41 +1,22 @@ -# Spam Risk Checker - -Risk scoring for incoming mail. - -## Scope - -- Release tier: $(System.Collections.Hashtable.Tier.ToUpperInvariant()) -- Audience: $(System.Collections.Hashtable.Audience) -- Folder ownership: $dir/ - -This is a self-contained tooling workspace. Do not wire this tool into the main app, routing, inbox architecture, wallet core, Stellar core, or design system unless a future integration issue explicitly allows it. - -Recommended internal structure: - -- components/ -- services/ -- hooks/ -- ests/ -- docs/ - "@ | Set-Content -Path "tools/v1/individual/spam-risk-checker/README.md" - @" - # Spam Risk Checker Specs ## Purpose -Risk scoring for incoming mail. +Provide a lightweight, folder-local risk scoring helper for incoming message content. ## Contributor boundary -All work for this tool should stay in: +All work for this tool should stay inside this folder so it remains isolated from the main application shell and inbox architecture. + +## Recommended structure -$dir/ +- services/ for the scoring logic +- tests/ for local Vitest coverage +- fixtures for sample inputs +- docs for setup and review notes -## Required issue categories +## Review focus -- Architecture -- Feature -- UI and accessibility -- Security and performance -- Testing and documentation +- Keep the implementation deterministic and easy to reason about +- Prefer local fixtures over app-wide test data +- Avoid integration work unless a dedicated follow-up issue is created diff --git a/tools/v1/individual/spam-risk-checker/tests/fixtures.ts b/tools/v1/individual/spam-risk-checker/tests/fixtures.ts new file mode 100644 index 00000000..2e16815f --- /dev/null +++ b/tools/v1/individual/spam-risk-checker/tests/fixtures.ts @@ -0,0 +1,17 @@ +export const benignFixture = [ + "Hi Maya,", + "I wanted to confirm our lunch plans for Friday.", + "Please let me know if 2 PM works for you.", +].join("\n"); + +export const suspiciousFixture = [ + "URGENT! Verify your account now!!!", + "Click now to claim your free prize before midnight.", + "https://bit.ly/claim-now", +].join("\n"); + +export const mixedFixture = [ + "Quick question about the invoice.", + "Please review the proposal before the meeting now!!!", + "https://example.com/review", +].join("\n"); diff --git a/tools/v1/individual/spam-risk-checker/tests/spamRiskChecker.test.ts b/tools/v1/individual/spam-risk-checker/tests/spamRiskChecker.test.ts new file mode 100644 index 00000000..c409720a --- /dev/null +++ b/tools/v1/individual/spam-risk-checker/tests/spamRiskChecker.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeSpamRisk } from "../services/spamRiskChecker"; +import { benignFixture, mixedFixture, suspiciousFixture } from "./fixtures"; + +describe("Spam Risk Checker", () => { + it("returns a low-risk assessment for ordinary mail", () => { + const result = analyzeSpamRisk(benignFixture); + + expect(result.level).toBe("low"); + expect(result.score).toBe(0); + expect(result.reasons).toEqual([]); + expect(result.summary).toContain("ordinary"); + }); + + it("flags urgent, bait-heavy content as high risk", () => { + const result = analyzeSpamRisk(suspiciousFixture); + + expect(result.level).toBe("high"); + expect(result.score).toBeGreaterThanOrEqual(6); + expect(result.reasons).toEqual( + expect.arrayContaining(["Urgency language", "Common spam bait phrases", "External links"]), + ); + }); + + it("uses the object form for subject and body input", () => { + const result = analyzeSpamRisk({ + subject: "Quick question", + body: mixedFixture, + }); + + expect(result.level).toBe("medium"); + expect(result.score).toBeGreaterThan(0); + expect(result.reasons).toContain("External links"); + }); + + it("returns a neutral result for empty input", () => { + const result = analyzeSpamRisk(""); + + expect(result.level).toBe("low"); + expect(result.score).toBe(0); + expect(result.summary).toContain("No content"); + }); +});