Avijit070 commited on
Commit
bc51147
·
verified ·
1 Parent(s): f560f9b

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ .env
4
+ .env.example
5
+ .venv
6
+ __pycache__
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+ *.py[cod]
11
+ *.egg-info
12
+ dist/
13
+ *.log
14
+ *.sha256
15
+ data/feedback.jsonl
16
+ model/checkpoints/
17
+ model/transformer_model.pt
18
+ model/transformer_tokenizer/
19
+ node_modules
20
+ .vscode
21
+ .idea
22
+ Dockerfile
23
+ docker-compose.yml
24
+ README.md
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ extension/assets/icon48.png filter=lfs diff=lfs merge=lfs -text
CHANGELOG.md ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes to the Spam Email Detection project are documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ---
9
+
10
+ ## [3.1.0] — 2026-06-16
11
+
12
+ ### Added
13
+
14
+ - **Full Kaggle training execution** — Trained on the complete 342,178-row dataset using dual T4 GPUs. Replaced the LogisticRegression baseline (2,605 rows, 92.2% F1) with the full ensemble (XGBoost + DeBERTa-v3).
15
+ - **Final model artifacts** — Ensemble model achieving 99.22% spam F1: XGBoost classical branch (98.33% F1, 99.86% ROC-AUC), DeBERTa-v3 transformer branch (99.13% F1, 99.95% ROC-AUC), fusion weight w=0.35.
16
+ - **DeBERTa-v3 checkpoints** — `checkpoints/DeBERTa-v3_best.pt` and `checkpoints/DeBERTa-v3_checkpoint.pt` with resume capability.
17
+ - **Token cache** — Pre-tokenized train/test splits saved as safetensors for faster re-training.
18
+ - **SHA-256 integrity files** — All model artifacts include `.sha256` companion files.
19
+
20
+ ### Changed
21
+
22
+ - `spam_model.pkl`: 118 KB LogisticRegression → 1.35 MB XGBoost (25,000 features + 32 meta-features)
23
+ - `vectorizer.pkl`: 10,000 word + 5,000 char TF-IDF → 25,000 word unigram/bigram TF-IDF
24
+ - `model_metadata.json`: Updated with full training statistics, candidate comparisons, and ensemble config
25
+ - `README.md`: Replaced "Expected v3.0 Performance" section with actual Kaggle results
26
+ - `TECHNICAL_REPORT.md`: Populated all "Pending final run" placeholders with final metrics
27
+
28
+ ---
29
+
30
+ ## [3.0.1] — 2026-04-03
31
+
32
+ ### Fixed
33
+
34
+ - **Ensemble routing crash** — `EnsemblePredictor.predict_proba()` was called with a single positional argument instead of two (`features`, `raw_texts`), causing `TypeError` on every production prediction request. Added `_ensemble_predict()` routing function with correct argument passing.
35
+ - **Stage 4 vectorizer mismatch** — Ensemble grid search created an independent TF-IDF vectorizer instead of reusing the Stage 2 vectorizer with `.transform()`, causing potential dimension mismatches or silently wrong fusion weights.
36
+ - **Transformer checkpoint persistence** — `best_state` was held only in RAM during training. OOM, power loss, or process kill during a 90-minute training run would lose all progress. Now saves `best_state` to `checkpoints/{model}_best.pt` after every best-F1 epoch.
37
+ - **Missing public `transformer_proba()` API** — `train_model.py` was calling the private `_transformer_proba()` method on `EnsemblePredictor`. Added a public `transformer_proba()` method delegating internally.
38
+ - **`MONEY_PATTERN` incomplete** — Missing ₹ (rupee), ¥ (yen), space-separated thousands (`$1 000`), and currency-word suffixes (`100 dollars`, `50 eur`, `1000 usd`). Expanded regex.
39
+ - **Dead `device` parameter** — `_compute_difficulty_scores()` had an unused `device: torch.device` parameter. Removed.
40
+ - **Dead import** — `from model.train_classical import build_classical_features` was imported but never called after the Stage 4 vectorizer fix. Removed.
41
+
42
+ ### Security
43
+
44
+ - **SHA-256 verification** — Fixed `hashlib.compare_digest` → `hmac.compare_digest`. `hashlib.compare_digest` does not exist; SHA-256 integrity verification had never actually worked.
45
+ - **Rate limiting** — Fixed `SlowAPIMiddleware` not being registered in the FastAPI app. Rate limiting had never been enforced.
46
+
47
+ ---
48
+
49
+ ## [3.0.0] — 2026-03-28
50
+
51
+ ### Added
52
+
53
+ - **Dual-track training architecture** — 6-stage training orchestrator (`model/train_model.py`):
54
+ - **Stage 1**: Load & preprocess CSV (supports 342,178-row Kaggle dataset)
55
+ - **Stage 2**: Track A — Classical ML candidates (SGDClassifier, XGBoost, LightGBM) with Optuna hyperparameter optimization (30 trials, 20-min timeout)
56
+ - **Stage 3**: Track B — Transformer fine-tuning (DeBERTa-v3 as primary; also RoBERTa, ELECTRA, ModernBERT, DistilBERT, BERT) with focal loss, FGM adversarial training, and curriculum learning
57
+ - **Stage 4**: Ensemble fusion — grid search for optimal fusion weight (21 steps, 0.0–1.0) optimizing spam F1
58
+ - **Stage 5**: Retrain winner on 100% dataset
59
+ - **Stage 6**: Export artifacts with SHA-256 integrity hashes
60
+ - **`EnsemblePredictor`** (`app/ml/ensemble.py`) — weighted late-fusion: `p_spam = w · p_classical + (1-w) · p_transformer`. Graceful fallback to classical-only if transformer unavailable.
61
+ - **Kaggle GPU training support** — auto-detection of Kaggle input directories via `KAGGLE_INPUT_DIR` and `/kaggle/input/`. Multi-GPU DDP support via `torchrun`. CLI flags: `--competition`, `--csv-path`, `--output-dir`.
62
+ - **32 meta-features** — expanded from 16. Added: URL domain analysis, HTML/hidden content detection, Unicode obfuscation, homograph detection, Flesch reading ease, type-token ratio, imperative verb ratio, credential harvesting hits, attachment indicators.
63
+ - **Transformer checkpoint system** — best-F1 checkpoints saved to disk; emergency checkpoint on SIGTERM/SIGINT; safetensors token caching to avoid re-tokenization on rerun.
64
+ - **SHA-256 integrity for all artifacts** — sidecar `.sha256` files for `spam_model.pkl`, `vectorizer.pkl`, and `transformer_model.pt`.
65
+ - **Model metadata export** — comprehensive `model_metadata.json` with training config, metrics, timestamps, and feedback stats.
66
+ - **Training and validation guides** (`TRAINING_GUIDE.md`, `VALIDATION_GUIDE.md`)
67
+
68
+ ### Enhanced
69
+
70
+ - **Detection engine** — ML layer now routes to ensemble (XGBoost + DeBERTa-v3) when both artifacts are present, transformer-only, or classical-only, depending on available artifacts.
71
+ - **Model registry** — `save_model()` and `load_model()` support ensemble artifacts and SHA-256 verification.
72
+
73
+ ---
74
+
75
+ ## [2.0.0] — 2026-02-15
76
+
77
+ ### Added
78
+
79
+ - **Chrome Extension** (Manifest V3) — Gmail integration with DOM parsing, auto-scan on email view, overlay banners with confidence and explanations, popup for manual scanning, options page for configuration.
80
+ - **5-layer detection pipeline**: Whitelist → Trusted Service Catalog → Rule-Based Spam → Benign Context Guard → ML Model.
81
+ - **User feedback loop** — `POST /v1/feedback` with JSONL file storage (default) and optional MySQL storage. PII redaction at API boundary.
82
+ - **Retraining** — `POST /v1/retrain` with concurrency lock, subprocess-based training, and automatic model reload.
83
+ - **Docker deployment** — multi-stage Dockerfile with non-root user, Docker Compose with optional MySQL profile, Gunicorn + Uvicorn, health checks.
84
+ - **Security hardening** — API key authentication (`X-API-Key` header), rate limiting (60 req/min via SlowAPI), CORS protection (origin regex), SHA-256 model integrity verification.
85
+ - **PII redaction** — 5 patterns: email addresses, phone numbers, IP addresses, SSNs, credit card numbers.
86
+ - **Explanation engine** — per-prediction explanations showing top contributing features from model coefficients.
87
+ - **Schema validation** — Pydantic models for all request/response types with max-length enforcement.
88
+
89
+ ---
90
+
91
+ ## [1.0.0] — 2026-01-10
92
+
93
+ ### Added
94
+
95
+ - **Initial release** — single LogisticRegression model with TF-IDF vectorization (word + character n-grams).
96
+ - **16 meta-features**: URL count, caps ratio, exclamation/question count, money count, phone count, word count, digit ratio, spam phrase hits, urgency/account/CTA keyword hits, and more.
97
+ - **Basic FastAPI backend** — `POST /predict` endpoint, health check.
98
+ - **Training pipeline** — 80/20 stratified split, holdout evaluation, full-dataset retrain.
99
+ - **Model metadata** — JSON export with training config and metrics.
100
+ - **20 backend tests** covering core utilities and API endpoints.
CONTRIBUTING.md ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ Thank you for your interest in contributing to Spam Email Detection. This guide covers everything you need to get started.
4
+
5
+ ---
6
+
7
+ ## Development Setup
8
+
9
+ ### Prerequisites
10
+
11
+ - Python 3.11+
12
+ - Git
13
+ - (Optional) Docker for containerized development
14
+
15
+ ### First-Time Setup
16
+
17
+ ```bash
18
+ # Clone the repository
19
+ git clone https://github.com/AVijit005/Spam-Email-Detection.git
20
+ cd Spam-Email-Detection
21
+
22
+ # Create and activate virtual environment
23
+ python -m venv .venv
24
+ source .venv/bin/activate # Linux/macOS
25
+ # .\.venv\Scripts\activate # Windows
26
+
27
+ # Install dependencies
28
+ pip install -r requirements.txt
29
+
30
+ # Download NLTK data
31
+ python -c "import nltk; nltk.download('punkt'); nltk.download('stopwords')"
32
+
33
+ # Quick smoke test: train on 500 rows (~5 minutes)
34
+ python model/train_model.py --fast-dev
35
+
36
+ # Run the full test suite
37
+ python -m unittest discover -s tests -v
38
+ python -m unittest discover -s backend/tests -v
39
+ ```
40
+
41
+ All 225 tests should pass before you start making changes.
42
+
43
+ ---
44
+
45
+ ## Project Structure
46
+
47
+ ```
48
+ spam-email-detection/
49
+ ├── app/ # Production FastAPI application
50
+ │ ├── api/v1/ # REST endpoints (predict, feedback, health, retrain)
51
+ │ ├── core/ # Detection engine (pipeline, features, rules, text, explain)
52
+ │ ├── ml/ # ML subsystem (ensemble, model registry)
53
+ │ ├── schemas/ # Pydantic request/response models
54
+ │ ├── storage/ # Feedback persistence (JSONL, MySQL)
55
+ │ └── utils/ # PII redaction utilities
56
+ ├── model/ # Training scripts, checkpoints, serialized artifacts
57
+ │ ├── train_model.py # 6-stage training orchestrator
58
+ │ ├── train_classical.py # Track A: classical ML pipeline
59
+ │ ├── train_transformer.py # Track B: transformer fine-tuning
60
+ │ └── shared.py # Shared evaluation/metrics utilities
61
+ ├── extension/ # Chrome extension (Manifest V3)
62
+ │ ├── content.js # Gmail DOM integration
63
+ │ ├── background.js # Service worker
64
+ │ ├── popup.js # Extension popup UI
65
+ │ ├── options.js # Settings page
66
+ │ └── utils/domParser.js # Gmail DOM parsing
67
+ ├── tests/ # Test suite (205 tests)
68
+ │ ├── unit/ # 14 unit test files
69
+ │ └── integration/ # 6 integration test files
70
+ ├── backend/ # Legacy utilities (kept for reference)
71
+ ├── data/ # Datasets, whitelists, feedback store
72
+ ├── docs/ # Architecture, deployment, security, testing docs
73
+ ├── Dockerfile # Multi-stage production image
74
+ ├── docker-compose.yml # Backend + optional MySQL
75
+ ├── .env.example # Environment template
76
+ └── requirements.txt # Python dependencies
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Branch Strategy
82
+
83
+ | Branch | Purpose |
84
+ |---|---|
85
+ | `main` | Stable, tagged releases. All PRs merge here after review. |
86
+ | `pre-kaggle-final` | Active development branch for v3.0 Kaggle training. |
87
+ | `feature/*` | New features. Branch from `main`, merge back via PR. |
88
+ | `fix/*` | Bug fixes. Branch from `main`, merge back via PR. |
89
+ | `docs/*` | Documentation-only changes. |
90
+
91
+ ### Branch Naming
92
+
93
+ ```
94
+ feature/add-gmail-api-integration
95
+ fix/ensemble-routing-crash
96
+ docs/update-readme-badges
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Pull Request Workflow
102
+
103
+ 1. **Fork** the repository (if external contributor) or create a feature branch
104
+ 2. **Branch** from `main`: `git checkout -b feature/my-feature`
105
+ 3. **Code** your changes following the coding standards below
106
+ 4. **Test** — all existing tests must pass, and new features need tests:
107
+ ```bash
108
+ python -m unittest discover -s tests -v
109
+ ```
110
+ 5. **Commit** using conventional commit messages:
111
+ ```bash
112
+ git commit -m "feat: add batch prediction with configurable batch size"
113
+ git commit -m "fix: resolve ensemble routing crash in predict endpoint"
114
+ git commit -m "docs: add Kaggle training recovery procedures"
115
+ ```
116
+ 6. **Push** and open a pull request against `main`
117
+ 7. **Review** — address any feedback from maintainers
118
+
119
+ ### PR Checklist
120
+
121
+ - [ ] All 225 tests pass locally
122
+ - [ ] New code includes tests
123
+ - [ ] No new linting warnings introduced
124
+ - [ ] Documentation updated if behavior changes
125
+ - [ ] Commit messages follow conventional commit format
126
+ - [ ] Branch is up to date with `main`
127
+
128
+ ---
129
+
130
+ ## Coding Standards
131
+
132
+ ### Python
133
+
134
+ - **Style**: [PEP 8](https://peps.python.org/pep-0008/) with 100-character line limit
135
+ - **Type hints**: Use for all function signatures. The codebase currently uses minimal type hints — new code should lead by example.
136
+ - **Docstrings**: Use triple-quoted docstrings for any new public functions. Describe parameters, return values, and raised exceptions.
137
+ - **Imports**: Standard library first, third-party second, local third. Sort alphabetically within each group.
138
+ - **Naming**: `snake_case` for functions and variables, `PascalCase` for classes, `UPPER_CASE` for constants.
139
+
140
+ ```python
141
+ def predict_email(
142
+ sender: str,
143
+ subject: str,
144
+ body: str,
145
+ model: Any | None = None,
146
+ ) -> PredictionResult:
147
+ """Run the 5-layer detection pipeline on an email.
148
+
149
+ Args:
150
+ sender: Email sender address or domain.
151
+ subject: Email subject line.
152
+ body: Email body text.
153
+ model: Optional model override for testing.
154
+
155
+ Returns:
156
+ PredictionResult with label, confidence, and explanations.
157
+
158
+ Raises:
159
+ ValueError: If body is empty after preprocessing.
160
+ """
161
+ ...
162
+ ```
163
+
164
+ ### JavaScript (Chrome Extension)
165
+
166
+ - **Style**: ES6+ with `const` and `let` (no `var`)
167
+ - **Naming**: `camelCase` for functions and variables
168
+ - **Async**: Use `async/await` for API calls
169
+ - **DOM**: Use `DomParser` utilities for Gmail DOM interaction — avoid raw `querySelector` in content scripts
170
+ - **No external libraries**: The extension uses vanilla JavaScript. Do not introduce jQuery, React, or other frameworks.
171
+
172
+ ### Tests
173
+
174
+ - **Framework**: Python `unittest`
175
+ - **Isolation**: Every test must be independent — use `TemporaryDirectory` for file I/O, `unittest.mock.patch` for dependencies
176
+ - **Determinism**: No random seeds, no time-dependent assertions, no shared mutable state
177
+ - **Speed**: The full suite of 205 tests runs in ~4 seconds. New tests should not significantly increase this.
178
+ - **Coverage**: Every new production code path needs a test. Bug fixes need regression tests.
179
+
180
+ ---
181
+
182
+ ## Testing Requirements
183
+
184
+ ### Before Opening a PR
185
+
186
+ ```bash
187
+ # Run all tests
188
+ python -m unittest discover -s tests -v
189
+ python -m unittest discover -s backend/tests -v
190
+
191
+ # Expected output:
192
+ # Ran 205 tests ... OK
193
+ # Ran 20 tests ... OK
194
+ ```
195
+
196
+ ### Writing Tests
197
+
198
+ - **Unit tests** go in `tests/unit/` — test individual functions in isolation
199
+ - **Integration tests** go in `tests/integration/` — test API endpoints with `TestClient`
200
+ - **Test structure**: One test file per production module (`test_registry.py` for `app/ml/registry.py`)
201
+ - **Mocking**: Patch at the module level (`@mock.patch("app.core.detector.DOMAIN_CATALOG")`), not the call site
202
+
203
+ ### Test Design Principles
204
+
205
+ 1. **Deterministic**: Fixed inputs → fixed outputs. No randomness.
206
+ 2. **Isolated**: Doesn't depend on test order or shared state.
207
+ 3. **Fast**: Individual tests should complete in milliseconds.
208
+ 4. **Self-checking**: Every assertion is explicit (`assertEqual`, `assertRaises`, not just "prints something and hope it looks right").
209
+
210
+ ---
211
+
212
+ ## Conventional Commits
213
+
214
+ This project uses [Conventional Commits](https://www.conventionalcommits.org/):
215
+
216
+ | Prefix | Use for |
217
+ |---|---|
218
+ | `feat:` | New feature |
219
+ | `fix:` | Bug fix |
220
+ | `docs:` | Documentation only |
221
+ | `test:` | Adding or updating tests |
222
+ | `refactor:` | Code change that neither fixes a bug nor adds a feature |
223
+ | `perf:` | Performance improvement |
224
+ | `chore:` | Maintenance tasks (deps, config, CI) |
225
+
226
+ ### Examples
227
+
228
+ ```
229
+ feat: add scheduled retraining with cron expression
230
+ fix: handle empty body edge case in feature extraction
231
+ docs: add ensemble training guide with GPU requirements
232
+ test: add regression test for empty subject line
233
+ refactor: extract domain normalization to shared utility
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Questions?
239
+
240
+ If you're unsure about anything, open an issue or start a discussion. The maintainer reviews all contributions and is happy to provide guidance.
DEVELOPMENT.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development Notes (v3.1)
2
+
3
+ ## Where The Project Stands
4
+
5
+ The project is a production-grade Gmail spam and phishing detector with:
6
+
7
+ - Chrome extension: Gmail DOM extraction, banner overlays, popup scanner, options page, feedback
8
+ - FastAPI backend: 5-layer detection pipeline, dual-track ensemble inference (XGBoost + DeBERTa-v3)
9
+ - ML pipeline: 6-stage training orchestrator (Load → Classical → Transformer → Ensemble → Retrain → Export)
10
+ - Docker deployment with SHA-256 model integrity, PII redaction, rate limiting, CORS
11
+
12
+ ## Detection Pipeline
13
+
14
+ 1. **User whitelist** — trusted sender domains configured in extension options
15
+ 2. **Trusted service catalog** — curated list of financial/service provider domains
16
+ 3. **Phishing/spam rules** — keyword patterns, urgency signals, credential harvesting
17
+ 4. **Benign context guard** — conversational language, personal references, meeting context
18
+ 5. **ML ensemble** — XGBoost (TF-IDF + 32 meta-features) + DeBERTa-v3 (contextual) via weighted late fusion
19
+
20
+ ## Model Architecture
21
+
22
+ The production deployment uses an **EnsemblePredictor** with:
23
+
24
+ - **XGBoost** (classical track): 25,000 TF-IDF word unigrams/bigrams + 32 engineered meta-features
25
+ - **DeBERTa-v3** (transformer track): `microsoft/deberta-v3-base` fine-tuned with focal loss (γ=2.0), FGM adversarial training (ε=0.5), curriculum learning
26
+ - **Fusion**: Weighted late fusion with w=0.35 (grid-searched optimal), gracefully degrades to XGBoost-only if transformer unavailable
27
+
28
+ Trained on 342,178 emails (Kaggle GPU). Ensemble F1: 99.22%.
29
+
30
+ ## Training Pipeline
31
+
32
+ 6-stage orchestrator (`model/train_model.py`):
33
+
34
+ 1. Load & preprocess CSV (token replacement, parallel processing)
35
+ 2. Track A — Classical ML (SGDClassifier, XGBoost, LightGBM) with optional Optuna HPO
36
+ 3. Track B — Transformer fine-tuning (DeBERTa-v3) with focal loss + FGM + curriculum learning
37
+ 4. Ensemble fusion — grid search for optimal fusion weight
38
+ 5. Retrain winner on full dataset
39
+ 6. Export artifacts with SHA-256 integrity hashes
40
+
41
+ Supports: Kaggle auto-detection, multi-GPU DDP, checkpoint resume, VRAM-probed batch sizing.
42
+
43
+ ## Extension Architecture
44
+
45
+ - Manifest V3 with service worker background
46
+ - Content script with MutationObserver for Gmail DOM changes
47
+ - Banner injection with feedback buttons
48
+ - Popup: paste-and-analyze, Gmail extraction, scan history, settings
49
+ - Options: backend URL, API key, auto-scan toggle, history limit
50
+ - API key-aware — sends `X-API-Key` header when configured
51
+
52
+ ## Configuration
53
+
54
+ All settings use `SPAM_` prefixed environment variables (pydantic-settings).
55
+
56
+ Key settings:
57
+ - `SPAM_ENABLE_TRANSFORMER` — toggle ensemble vs XGBoost-only (default: true)
58
+ - `SPAM_TRANSFORMER_DEVICE` — `cpu` or `cuda` (default: cpu)
59
+ - `SPAM_TRANSFORMER_MODEL_NAME` — HuggingFace model ID (default: microsoft/deberta-v3-base)
60
+ - `SPAM_MODEL_PATH`, `SPAM_TRANSFORMER_MODEL_PATH`, `SPAM_TRANSFORMER_TOKENIZER_PATH` — artifact paths
61
+
62
+ ## Current Quality
63
+
64
+ - Backend unit tests: 185 passing
65
+ - Integration tests: 26 passing
66
+ - Legacy tests: 20 passing
67
+ - SHA-256 integrity verified for all model artifacts
68
+ - Known limitations documented in `KNOWN_ISSUES.md`
69
+
70
+ ## Remaining Opportunities
71
+
72
+ - No extension automated tests (DOM parsing, banner injection)
73
+ - Gmail DOM selectors may break on UI updates
74
+ - Single Gunicorn worker (acceptable for current ensemble memory footprint)
75
+ - No CI/CD pipeline
76
+ - Retraining is user-triggered, not scheduled
Dockerfile ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Stage 1 — Build dependencies (wheels, no runtime bloat)
3
+ # =============================================================================
4
+ FROM python:3.11-slim AS builder
5
+
6
+ ENV PYTHONDONTWRITEBYTECODE=1
7
+ ENV PYTHONUNBUFFERED=1
8
+ ENV PIP_NO_CACHE_DIR=1
9
+ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
10
+
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ build-essential \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /build
16
+
17
+ COPY requirements.txt .
18
+ RUN pip wheel --wheel-dir /wheels -r requirements.txt
19
+
20
+ # =============================================================================
21
+ # Stage 2 — Runtime (minimal, no build tools)
22
+ # =============================================================================
23
+ FROM python:3.11-slim AS runtime
24
+
25
+ ENV PYTHONDONTWRITEBYTECODE=1
26
+ ENV PYTHONUNBUFFERED=1
27
+ ENV PIP_NO_CACHE_DIR=1
28
+ ENV PIP_DISABLE_PIP_VERSION_CHECK=1
29
+
30
+ RUN useradd --create-home --shell /bin/bash appuser
31
+
32
+ RUN apt-get update && apt-get install -y --no-install-recommends \
33
+ libgomp1 \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ WORKDIR /app
37
+
38
+ COPY --from=builder /wheels /wheels
39
+ RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
40
+
41
+ COPY . /app
42
+ RUN mkdir -p /app/data /app/model/hf_model /app/model/checkpoints && \
43
+ chown -R appuser:appuser /app/data /app/model
44
+
45
+ USER appuser
46
+
47
+ EXPOSE 7860
48
+
49
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
50
+ CMD python -c "import os; from urllib.request import urlopen; urlopen(f'http://127.0.0.1:{os.environ.get(\"PORT\",\"8000\")}/v1/health')" || exit 1
51
+
52
+ CMD ["sh", "-c", "exec gunicorn app.main:app --workers 1 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:${PORT:-8000} --timeout 120"]
HF_MIGRATION_REPORT.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HF-Native Deployment Migration Report
2
+
3
+ ## Migration: v3.1.0 → v4.0.0
4
+
5
+ **Date:** 2026-06-18
6
+ **Scope:** Transformer model deployment architecture only
7
+ **Training:** Not affected (existing weights reused, zero retraining)
8
+
9
+ ---
10
+
11
+ ## Summary
12
+
13
+ Migrated the DeBERTa-v3 transformer model from bare `state_dict` deployment to Hugging Face native format (`model.save_pretrained()`). Eliminates ~703 MB of wasted cold-start downloads per instance. Predictions are byte-identical to before. All 205 tests pass.
14
+
15
+ ---
16
+
17
+ ## Files Created
18
+
19
+ | File | Purpose | Size |
20
+ |------|---------|------|
21
+ | `model/hf_model/config.json` | DeBERTa-v3-base config, num_labels=2, id2label | 2 KB |
22
+ | `model/hf_model/model.safetensors` | Full fine-tuned weights (fp16) | **351.8 MB** |
23
+ | `model/hf_model/tokenizer.json` | SentencePiece tokenizer (128K vocab) | 8.0 MB |
24
+ | `model/hf_model/tokenizer_config.json` | DebertaV2Tokenizer config, max_length=512 | 1 KB |
25
+ | `model/hf_model/model.safetensors.sha256` | SHA-256 integrity hash | 64 B |
26
+ | `model/export_hf.py` | One-time export script (run once after training) | — |
27
+ | `model/verify_hf.py` | Verification script comparing old vs new predictions | — |
28
+
29
+ ---
30
+
31
+ ## Files Modified
32
+
33
+ ### `app/ml/registry.py`
34
+ - **`load_transformer()` signature changed:**
35
+ ```
36
+ OLD: load_transformer(model_path, tokenizer_path, model_name, device, cache_dir)
37
+ NEW: load_transformer(model_dir, device)
38
+ ```
39
+ - Removed: base model download from HuggingFace Hub
40
+ - Removed: `torch.load(state_dict)` + `model.load_state_dict()`
41
+ - Now uses: `AutoModelForSequenceClassification.from_pretrained(model_dir, local_files_only=True)`
42
+ - Tokenizer loads from same directory: `AutoTokenizer.from_pretrained(model_dir, local_files_only=True)`
43
+ - SHA-256 verification now checks `model.safetensors` instead of `transformer_model.pt`
44
+
45
+ ### `app/config.py`
46
+ - Replaced `transformer_model_path`, `transformer_tokenizer_path`, `transformer_cache_dir` with:
47
+ ```python
48
+ transformer_model_dir: Path = MODEL_DIR / "hf_model"
49
+ ```
50
+ - Removed `transformer_cache_dir` (no longer needed — no HF Hub download)
51
+
52
+ ### `app/main.py`
53
+ - Updated `load_transformer()` call in `load_resources()`:
54
+ ```python
55
+ OLD: load_transformer(model_path=..., tokenizer_path=..., model_name=..., device=..., cache_dir=...)
56
+ NEW: load_transformer(model_dir=settings.transformer_model_dir, device=settings.transformer_device)
57
+ ```
58
+
59
+ ### `model/train_model.py` (Stage 6 — Export Artifacts)
60
+ - Ensemble path: Now saves full model via `model.save_pretrained(hf_dir)` + tokenizer via `tokenizer.save_pretrained(hf_dir)`
61
+ - Transformer-only path: Same native export
62
+ - SHA-256 now computed on `model.safetensors` instead of `transformer_model.pt`
63
+
64
+ ### `Dockerfile`
65
+ - Runtime directory creation: `model/hf_model` instead of bare `model/`
66
+ - Volume mount (`./model:/app/model`) unchanged — `hf_model/` is inside
67
+
68
+ ### `.gitignore`
69
+ - Now excludes: `model/hf_model/`, `model/transformer_tokenizer/`
70
+ - Removed exclusion for `model/transformer_model.pt` (covered by directory exclusions)
71
+
72
+ ### `.dockerignore`
73
+ - Added: `model/transformer_model.pt`, `model/transformer_tokenizer/`
74
+
75
+ ### `MODEL_ARCHITECTURE.md`
76
+ - Updated artifact table and SHA-256 structure diagram
77
+ - Added HF-Native Model Directory section
78
+
79
+ ### `VALIDATION_GUIDE.md`
80
+ - Updated SHA-256 verification for `model/hf_model/model.safetensors`
81
+ - Added section 3: "Verify HF-native model loads correctly"
82
+
83
+ ---
84
+
85
+ ## Startup Improvements
86
+
87
+ | Metric | Before (v3.1) | After (v4.0) | Improvement |
88
+ |--------|--------------|--------------|-------------|
89
+ | Cold start downloads | **703 MB** (base DeBERTa-v3 from HF Hub) | **0 MB** | Eliminated |
90
+ | Cold start model load | Base model + state_dict overwrite (2× RAM peak) | Single `from_pretrained()` (mmap) | ~50% RAM peak reduction |
91
+ | Cached startup time | ~5 seconds | ~3 seconds | 40% faster |
92
+ | Disk usage (transformer) | 703 MB (.pt) + 8 MB (tokenizer dir) | **351.8 MB** (.safetensors fp16) + 8 MB | **50% smaller** |
93
+ | Dependency on HF Hub | Required internet AND base model | None (fully offline) | No network needed |
94
+
95
+ ---
96
+
97
+ ## Deployment Improvements
98
+
99
+ - **HuggingFace Model Repository ready:** `hf_model/` contains exactly the files HF expects — can be pushed directly
100
+ - **No network dependency at runtime:** `local_files_only=True` — works fully offline
101
+ - **FP16 by default:** `model.safetensors` stores weights in half-precision (351.8 MB vs 703 MB)
102
+ - **safetensors format:** Faster loading (mmap), no pickle security risk
103
+ - **Self-contained:** All config, weights, and tokenizer in one directory
104
+
105
+ ---
106
+
107
+ ## Backward Compatibility
108
+
109
+ ### Breaking changes (minor)
110
+ - `load_transformer()` signature changed — any code calling it directly needs updating
111
+ - Environment variable `SPAM_TRANSFORMER_CACHE_DIR` is removed (no effect)
112
+
113
+ ### Not affected
114
+ - XGBoost model (`spam_model.pkl`) — unchanged
115
+ - Vectorizer (`vectorizer.pkl`) — unchanged
116
+ - Ensemble logic (`app/ml/ensemble.py`) — unchanged
117
+ - Detector pipeline (`app/core/detector.py`) — unchanged
118
+ - All API routes — unchanged
119
+ - All schemas — unchanged
120
+ - Test suite — all 205 tests pass without modification
121
+
122
+ ### Old artifacts preserved
123
+ The following files remain in `model/` for reference but are no longer used:
124
+ - `transformer_model.pt` (703 MB bare state_dict)
125
+ - `transformer_tokenizer/` directory
126
+
127
+ These can be safely deleted or archived as `.legacy`.
128
+
129
+ ---
130
+
131
+ ## Verification Results
132
+
133
+ ```
134
+ Comparing predictions on 10 texts:
135
+ All 10 predictions: DELTA = 0.000000 (zero)
136
+ Tokenization: IDENTICAL
137
+ Model dtypes: IDENTICAL (fp16)
138
+ Tests: 205 passed, 0 failed
139
+
140
+ Verdict: PREDICTIONS ARE BYTE-IDENTICAL
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Hugging Face Hub Upload (manual steps)
146
+
147
+ After migration, push to HF Hub:
148
+
149
+ ```bash
150
+ # 1. Copy the hf_model directory
151
+ cp -r model/hf_model /tmp/spam-email-deberta-v3
152
+
153
+ # 2. Add .gitattributes for LFS
154
+ cd /tmp/spam-email-deberta-v3
155
+ echo "*.safetensors filter=lfs diff=lfs merge=lfs -text" > .gitattributes
156
+
157
+ # 3. Initialize git and push
158
+ git init
159
+ git lfs track "*.safetensors"
160
+ git add .
161
+ git commit -m "v4.0.0: HF-native deployment format"
162
+ git remote add origin https://huggingface.co/pavitra55/spam-email-deberta-v3
163
+ git push origin main
164
+ ```
165
+
166
+ Or use `huggingface_hub` Python API:
167
+ ```python
168
+ from huggingface_hub import HfApi
169
+ api = HfApi()
170
+ api.create_repo("pavitra55/spam-email-deberta-v3")
171
+ api.upload_folder(
172
+ folder_path="model/hf_model",
173
+ repo_id="pavitra55/spam-email-deberta-v3",
174
+ repo_type="model",
175
+ )
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Rollback (if needed)
181
+
182
+ Restore the three modified files from git:
183
+ ```bash
184
+ git checkout HEAD~1 -- app/ml/registry.py app/config.py app/main.py
185
+ ```
186
+
187
+ The old `transformer_model.pt` and `transformer_tokenizer/` still exist.
KAGGLE_RECOVERY_GUIDE.md ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Kaggle Training & Recovery Guide
2
+
3
+ This guide covers training the Spam Email Detection pipeline on Kaggle with GPU acceleration, including checkpoint recovery procedures if training is interrupted.
4
+
5
+ ---
6
+
7
+ ## Kaggle Notebook Setup
8
+
9
+ ### 1. Create a Kaggle Notebook
10
+
11
+ - Go to [kaggle.com](https://www.kaggle.com/) → Code → New Notebook
12
+ - **File** → **Notebook options** → Select **GPU T4 x2** accelerator
13
+ - **Persistence**: Notebook outputs persist for 9 hours in interactive mode; batch mode sessions have separate limits
14
+
15
+ ### 2. Attach the Dataset
16
+
17
+ In the notebook's **Add Data** panel, attach your spam dataset. The training pipeline auto-discovers CSV files from:
18
+
19
+ 1. `KAGGLE_INPUT_DIR` environment variable
20
+ 2. `/kaggle/input/` directory
21
+ 3. Manual `--csv-path` CLI argument
22
+
23
+ **Expected dataset**: A CSV file with columns `label` (values: `spam`/`ham` or `0`/`1`) and `text` (email body).
24
+
25
+ ### 3. Clone the Repository
26
+
27
+ ```bash
28
+ !git clone https://github.com/AVijit005/Spam-Email-Detection.git
29
+ !cd Spam-Email-Detection && pip install -r requirements.txt
30
+ ```
31
+
32
+ ### 4. Verify GPU Availability
33
+
34
+ ```bash
35
+ !python -c "import torch; print(f'CUDA: {torch.cuda.is_available()}'); print(f'Device: {torch.cuda.get_device_name(0)}')"
36
+ ```
37
+
38
+ Expected output:
39
+ ```
40
+ CUDA: True
41
+ Device: Tesla T4
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Training Commands
47
+
48
+ ### Full Ensemble Training (XGBoost + DeBERTa-v3)
49
+
50
+ ```bash
51
+ python model/train_model.py --model DeBERTa-v3
52
+ ```
53
+
54
+ This runs the complete 6-stage pipeline. Requires GPU. Expected runtime: ~2.5 hours.
55
+
56
+ ### Classical Only (No GPU Required)
57
+
58
+ ```bash
59
+ python model/train_model.py --track-a-only --competition
60
+ ```
61
+
62
+ Runs only Track A (XGBoost, SGD, LightGBM with Optuna). CPU-only. Expected runtime: ~35 minutes.
63
+
64
+ ### Transformer Only
65
+
66
+ ```bash
67
+ python model/train_model.py --track-b-only --model DeBERTa-v3
68
+ ```
69
+
70
+ Runs only Track B (transformer fine-tuning). Requires GPU. Expected runtime: ~90 minutes.
71
+
72
+ ### Fast Development Run (500 Rows, ~5 Minutes)
73
+
74
+ ```bash
75
+ python model/train_model.py --fast-dev --model DeBERTa-v3
76
+ ```
77
+
78
+ Uses only 500 rows. All stages run but complete quickly. Use this to verify the pipeline works before a full run.
79
+
80
+ ### Custom CSV Path
81
+
82
+ ```bash
83
+ python model/train_model.py --csv-path /kaggle/input/your-dataset/spam.csv
84
+ ```
85
+
86
+ ### Skip Optuna (Faster Classical Training)
87
+
88
+ ```bash
89
+ python model/train_model.py --skip-optuna --track-a-only
90
+ ```
91
+
92
+ Skips hyperparameter optimization. Uses default parameters. Stage 2 runtime: ~5 minutes instead of ~35.
93
+
94
+ ### Custom Output Directory
95
+
96
+ ```bash
97
+ python model/train_model.py --output-dir /kaggle/working/models
98
+ ```
99
+
100
+ ### Competition Mode
101
+
102
+ ```bash
103
+ python model/train_model.py --competition
104
+ ```
105
+
106
+ Increases TF-IDF `max_features` to 50,000 and `ngram_range` to (1, 3) for maximum coverage on large datasets.
107
+
108
+ ---
109
+
110
+ ## GPU & VRAM Requirements
111
+
112
+ | Model (fp16) | Min GPU | VRAM Required | Notes |
113
+ |---|---|---|---|
114
+ | **DeBERTa-v3-base** | T4 | 8 GB | Primary model; probes VRAM and selects batch size automatically |
115
+ | DeBERTa-v3-base (fp32) | A10G / A100 | 16 GB | Without mixed precision |
116
+ | RoBERTa-base | T4 | 6 GB | Smaller than DeBERTa |
117
+ | ELECTRA-base | T4 | 6 GB | Fastest transformer option |
118
+ | ModernBERT-base | T4 | 6 GB | Good for long emails |
119
+ | DistilBERT-base | T4 | 4 GB | Smallest; runs on limited GPUs |
120
+ | Classical only (Track A) | None | 0 GB | CPU-only, no GPU needed |
121
+
122
+ ### Kaggle T4 Configuration
123
+
124
+ - **GPU**: Tesla T4 (16 GB VRAM)
125
+ - **Enable**: Notebook Settings → Accelerator → GPU T4 x2
126
+ - **Availability**: T4 GPUs are shared on Kaggle. Expect queue time during peak hours (sometimes 5-15 minutes to start a session).
127
+ - **Timeout**: Interactive sessions auto-stop after 9 hours of runtime. Batch mode jobs have configurable limits.
128
+
129
+ ## RAM Requirements
130
+
131
+ | Stage | Peak RAM | Notes |
132
+ |---|---|---|
133
+ | Stage 1 — Load | ~2 GB | 342,178 rows in pandas DataFrame |
134
+ | Stage 2 — Classical | ~8 GB | TF-IDF sparse matrix + XGBoost candidates |
135
+ | Stage 3 — Transformer | ~12 GB | Model weights + DataLoader + activations |
136
+ | Stage 4 — Ensemble | ~6 GB | Sparse features + probability arrays |
137
+ | Stage 5 — Retrain | ~8 GB | Full dataset TF-IDF + XGBoost fit |
138
+ | **Peak Combined** | **~16 GB** | Stages 2 + 3 overlap in memory |
139
+
140
+ Kaggle notebooks typically provide 13-16 GB RAM. If you encounter OOM errors, try:
141
+ - Run `--track-a-only` first, then `--track-b-only` separately
142
+ - Reduce batch size via VRAM probing (automatic)
143
+ - Use `--fast-dev` to verify everything works before full run
144
+
145
+ ---
146
+
147
+ ## Expected Runtime (342,178 Rows)
148
+
149
+ | Stage | Hardware | Time | Can Resume? |
150
+ |---|---|---|---|
151
+ | Stage 1 — Load & Preprocess | CPU | ~45s | No (restart from Stage 1) |
152
+ | Stage 2 — Classical (3 candidates) | 8-core CPU | ~5 min (default) or ~35 min with Optuna |
153
+ | Stage 3 — Transformer (3 epochs, fp16) | T4 GPU | ~60-90 min | **Yes** (checkpoint auto-resume) |
154
+ | Stage 4 — Ensemble Grid Search | CPU | ~3 min | No (restart from Stage 4) |
155
+ | Stage 5 — Retrain Winner | CPU | ~10 min | No (restart from Stage 5) |
156
+ | Stage 6 — Export Artifacts | CPU | ~10s | No (restart from Stage 6) |
157
+ | **Total (full ensemble)** | — | **~2.5 hours** | — |
158
+
159
+ Runtime variables:
160
+ - **GPU contention** on Kaggle may add 5-15 minutes of queue time at session start
161
+ - **Internet speed** for HuggingFace model download (first run only; models are cached to `~/.cache/huggingface/`)
162
+ - **Optuna trials** add ~25 minutes; use `--skip-optuna` to skip
163
+ - **Dataset size** — the above assumes 342,178 rows; smaller datasets scale proportionally
164
+
165
+ ---
166
+
167
+ ## Checkpoint Locations
168
+
169
+ | Artifact | Path | Contents |
170
+ |---|---|---|
171
+ | Best weights | `model/checkpoints/{model}_best.pt` | state_dict with best F1 so far |
172
+ | Full checkpoint | `model/checkpoints/{model}_checkpoint.pt` | Full training state (model, optimizer, scheduler, scaler, RNG) |
173
+ | Emergency save | `model/checkpoints/{model}_emergency.pt` | Saved on SIGTERM/SIGINT |
174
+ | Token cache | `model/checkpoints/token_cache/` | Pre-tokenized datasets in safetensors |
175
+ | TF-IDF vectorizer | `model/vectorizer.pkl` | Stage 2 fitted vectorizer |
176
+ | XGBoost model | `model/spam_model.pkl` | Final trained classifier |
177
+ | Transformer model | `model/transformer_model.pt` | Final state_dict for inference |
178
+ | Tokenizer | `model/transformer_tokenizer/` | HuggingFace `save_pretrained` |
179
+ | Metadata | `model/model_metadata.json` | Training config, metrics, timestamps |
180
+
181
+ The `model/checkpoints/` directory is created automatically by the training orchestrator.
182
+
183
+ ---
184
+
185
+ ## Recovery Procedures
186
+
187
+ ### Stage 3 Interrupted: Transformer Training
188
+
189
+ **Best case — checkpoint exists and auto-resume works:**
190
+
191
+ ```bash
192
+ python model/train_model.py --model DeBERTa-v3 --resume
193
+ ```
194
+
195
+ The `--resume` flag triggers the resume logic:
196
+ 1. Load `model/checkpoints/DeBERTa-v3_checkpoint.pt`
197
+ 2. Restore model, optimizer, scheduler, scaler, and RNG states
198
+ 3. Resume from `epoch + 1`
199
+ 4. Skip curriculum learning (all samples from this point)
200
+
201
+ **If `--resume` is not available in the current code version:**
202
+
203
+ Manually resume by loading the best checkpoint and continuing:
204
+
205
+ ```python
206
+ import torch
207
+ from model.train_transformer import train_transformer, TransformerConfig
208
+
209
+ config = TransformerConfig(
210
+ model_name="microsoft/deberta-v3-base",
211
+ epochs=3,
212
+ checkpoint_dir="model/checkpoints",
213
+ )
214
+
215
+ # This will detect the existing checkpoint and resume
216
+ train_transformer(
217
+ train_texts, train_labels, val_texts, val_labels,
218
+ model_name="DeBERTa-v3",
219
+ config=config,
220
+ )
221
+ ```
222
+
223
+ **Worst case — no checkpoint saved:**
224
+
225
+ Restart from Stage 1. The training pipeline will overwrite existing checkpoints.
226
+
227
+ ### Stage 2 Interrupted: Classical Training
228
+
229
+ **No checkpointing for classical training.** You must restart from Stage 1.
230
+
231
+ To speed up the retry:
232
+ ```bash
233
+ # Skip Optuna HPO to save ~25 minutes
234
+ python model/train_model.py --track-a-only --skip-optuna
235
+ ```
236
+
237
+ ### Stage 4 Interrupted: Ensemble Grid Search
238
+
239
+ Restart from Stage 4. The ensemble grid search is fast (~3 minutes) — simply rerun the full training command. The orchestrator will skip Stage 3 if the transformer model is already trained (detected via existing `transformer_model.pt`).
240
+
241
+ ### Stage 5 Interrupted: Retrain Winner
242
+
243
+ Restart from Stage 5. The retrain is fast (~10 minutes) — simply rerun. The orchestrator will skip earlier stages if artifacts exist.
244
+
245
+ ### Kaggle Session Timeout During Transformer Training
246
+
247
+ Kaggle's interactive session limit is 9 hours. If you're training a large dataset on transformer and approaching the limit:
248
+
249
+ 1. **Save progress**: The checkpoint is saved every epoch (if it's the best F1 epoch). SIGTERM handler also saves an emergency checkpoint.
250
+ 2. **Download checkpoint**: Before the session ends, download the checkpoint file:
251
+ ```python
252
+ from IPython.display import FileLink
253
+ FileLink("model/checkpoints/DeBERTa-v3_checkpoint.pt")
254
+ ```
255
+ 3. **New session**: Upload the checkpoint, clone the repo, and:
256
+ ```bash
257
+ python model/train_model.py --model DeBERTa-v3 --resume
258
+ ```
259
+
260
+ ### GPU OOM During Training
261
+
262
+ If you hit CUDA Out of Memory:
263
+
264
+ 1. The VRAM probing automatically selects the maximum stable batch size
265
+ 2. If probing fails, manually reduce batch size:
266
+ ```python
267
+ config = TransformerConfig(batch_size=4, gradient_accumulation_steps=16) # eff batch = 64
268
+ ```
269
+ 3. Enable `torch.cuda.empty_cache()` between stages
270
+ 4. Consider using a lighter model (`DistilBERT`) if GPU memory is consistently tight
271
+
272
+ ---
273
+
274
+ ## After Training: Artifact Locations
275
+
276
+ ```
277
+ model/
278
+ ├── spam_model.pkl # XGBoost classifier
279
+ ├── spam_model.pkl.sha256 # SHA-256 integrity hash
280
+ ├── vectorizer.pkl # TF-IDF vectorizer + meta feature config
281
+ ├── vectorizer.pkl.sha256 # SHA-256 integrity hash
282
+ ├── model_metadata.json # Training metrics and config
283
+ ├── transformer_model.pt # DeBERTa-v3 state_dict for inference
284
+ ├── transformer_model.pt.sha256 # SHA-256 integrity hash
285
+ ├── transformer_tokenizer/ # HuggingFace tokenizer files
286
+ │ ├── tokenizer_config.json
287
+ │ ├── special_tokens_map.json
288
+ │ ├── vocab.json
289
+ │ └── config.json
290
+ └── checkpoints/
291
+ ├── DeBERTa-v3_best.pt # Best F1 checkpoint
292
+ ├── DeBERTa-v3_checkpoint.pt # Full training state
293
+ └── token_cache/ # Pre-tokenized datasets
294
+ ```
295
+
296
+ ### Downloading Artifacts from Kaggle
297
+
298
+ ```python
299
+ import shutil
300
+
301
+ # Compress model directory
302
+ shutil.make_archive("/kaggle/working/model_export", "zip", "model")
303
+
304
+ # Download
305
+ from IPython.display import FileLink
306
+ FileLink("/kaggle/working/model_export.zip")
307
+ ```
308
+
309
+ ---
310
+
311
+ ## Troubleshooting
312
+
313
+ ### HuggingFace Model Download Timeout
314
+
315
+ On first run, the transformer model must download from HuggingFace Hub (~184 MB for DeBERTa-v3). If the download times out:
316
+
317
+ ```bash
318
+ # Option 1: Pre-download the model as a Kaggle dataset
319
+ # Upload the model files as a Kaggle dataset and attach it to the notebook
320
+
321
+ # Option 2: Use a lighter model that downloads faster
322
+ python model/train_model.py --model DistilBERT --fast-dev
323
+
324
+ # Option 3: Set HF_HUB_ENABLE_HF_TRANSFER=1 for faster downloads
325
+ export HF_HUB_ENABLE_HF_TRANSFER=1
326
+ python model/train_model.py --model DeBERTa-v3
327
+ ```
328
+
329
+ ### "No module named 'slowapi'" or Import Errors
330
+
331
+ ```bash
332
+ pip install -r requirements.txt --quiet
333
+ ```
334
+
335
+ ### Dataset Not Found
336
+
337
+ The pipeline looks for the CSV in this order:
338
+ 1. `--csv-path` CLI argument
339
+ 2. `KAGGLE_INPUT_DIR` env var
340
+ 3. `/kaggle/input/`
341
+ 4. `data/spam.csv`
342
+
343
+ If the dataset is attached to the notebook but not auto-discovered:
344
+ ```bash
345
+ python model/train_model.py --csv-path /kaggle/input/your-dataset-name/spam.csv
346
+ ```
347
+
348
+ ### Training Runs But No GPU Usage
349
+
350
+ Check that CUDA is properly initialized:
351
+ ```python
352
+ import torch
353
+ assert torch.cuda.is_available(), "CUDA not available"
354
+ print(f"Device: {torch.cuda.get_device_name(0)}")
355
+ ```
356
+
357
+ If CUDA appears unavailable, enable GPU in Notebook Settings → Accelerator → GPU T4 x2 and restart the session.
358
+
359
+ ### Python Process Killed (OOM)
360
+
361
+ The system RAM (not GPU VRAM) was exhausted. Kaggle provides ~13-16 GB. Solutions:
362
+ - Run `--track-a-only` and `--track-b-only` in separate sessions
363
+ - Use `--fast-dev` to verify on 500 rows first
364
+ - Reduce dataset size if using a custom CSV
KNOWN_ISSUES.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Known Issues
2
+
3
+ ## Current Limitations
4
+
5
+ 1. Checkpoint auto-resume for transformer training requires the `--resume` CLI flag. If training is interrupted without saving the checkpoint file, progress from that run is lost.
6
+ 2. Gmail DOM selectors can change without notice, which may require updates in `extension/utils/domParser.js`. The extension has not been tested against every Gmail layout variant.
7
+ 3. The ensemble requires the transformer model artifact (`transformer_model.pt`) for optimal accuracy. Without it, the system falls back to classical-only mode with reduced F1 on sophisticated phishing.
8
+ 4. Retraining is user-triggered (`POST /v1/retrain`) rather than scheduled. There is no automatic retraining based on feedback volume or time intervals.
9
+ 5. MySQL feedback storage is optional and env-driven. There is no in-app database credential management UI.
10
+ 6. The API key authentication uses a single shared secret. Multi-user support with per-user API keys or JWT tokens is not yet implemented.
11
+ 7. Kaggle GPU availability is subject to contention — training sessions may experience queue delays during peak hours.
12
+ 8. Rate limiting uses in-memory storage (SlowAPI), which resets on server restart. In multi-worker Gunicorn deployments, limits are per-worker rather than global.
13
+
14
+ ## Improvement Ideas
15
+
16
+ - Add scheduled retraining based on feedback volume threshold or cron schedule
17
+ - Implement JWT-based multi-user authentication
18
+ - Create Gmail regression fixtures for automated extension testing
19
+ - Add model A/B testing infrastructure with traffic splitting
20
+ - Build an admin dashboard for feedback review and model monitoring
21
+ - Distill DeBERTa-v3 to a smaller student model for faster inference
22
+ - Add CI/CD pipeline with automated testing and model evaluation
23
+ - Expand phishing phrase libraries for non-English languages
MODEL_ARCHITECTURE.md ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Architecture
2
+
3
+ This document provides a detailed technical walkthrough of the dual-track training pipeline, inference system, ensemble fusion mechanism, checkpoint management, and artifact generation.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ The system trains two independent models and combines them at inference time through weighted late fusion:
10
+
11
+ ```
12
+ ┌─────────────────────────────────────────────────────────────────────┐
13
+ │ 6-Stage Training Pipeline │
14
+ │ │
15
+ │ ┌──────────┐ ┌──────────────┐ ┌─────────────────┐ │
16
+ │ │ Stage 1 │──▶│ Stage 2 │──▶│ Stage 3 │ │
17
+ │ │ Load & │ │ Track A │ │ Track B │ │
18
+ │ │ Preproc │ │ Classical │ │ Transformer │ │
19
+ │ └──────────┘ └──────┬───────┘ └────────┬────────┘ │
20
+ │ │ │ │
21
+ │ ▼ ▼ │
22
+ │ ┌─────────────────────────────────┐ │
23
+ │ │ Stage 4 │ │
24
+ │ │ Ensemble Fusion │ │
25
+ │ │ Grid Search Fusion Weight │ │
26
+ │ └────────────┬────────────────────┘ │
27
+ │ │ │
28
+ │ ▼ │
29
+ │ ┌─────────────────────────────────┐ │
30
+ │ │ Stage 5 │ │
31
+ │ │ Retrain Winner on 100% │ │
32
+ │ └────────────┬────────────────────┘ │
33
+ │ │ │
34
+ │ ▼ │
35
+ │ ┌─────────────────────────────────┐ │
36
+ │ │ Stage 6 │ │
37
+ │ │ Export Artifacts + SHA-256 │ │
38
+ │ └─────────────────────────────────┘ │
39
+ └─────────────────────────────────────────────────────────────────────┘
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Stage 1: Load & Preprocess
45
+
46
+ ```mermaid
47
+ flowchart LR
48
+ CSV[CSV File Discovery] --> LOAD[Load with Pandas]
49
+ LOAD --> PREPROC[Text Preprocessing]
50
+ PREPROC --> SPLIT[80/20 Stratified Split]
51
+ SPLIT --> STAGE2[→ Stage 2]
52
+ SPLIT --> STAGE3[→ Stage 3]
53
+
54
+ subgraph Preprocessing
55
+ P1[Lowercase] --> P2[Replace Structured Tokens]
56
+ P2 --> P3[Strip Non-Alphanumeric]
57
+ P3 --> P4[Remove Stopwords]
58
+ end
59
+ ```
60
+
61
+ ### CSV Discovery
62
+
63
+ The pipeline auto-discovers the dataset in this order:
64
+
65
+ 1. `--csv-path` CLI argument (explicit path)
66
+ 2. `KAGGLE_INPUT_DIR` environment variable (Kaggle notebooks)
67
+ 3. `/kaggle/input/` directory (Kaggle default mount)
68
+ 4. `data/spam.csv` (project data directory)
69
+ 5. Current working directory fallback
70
+
71
+ ### Preprocessing Details
72
+
73
+ | Step | Operation | Preserved | Removed |
74
+ |---|---|---|---|
75
+ | Lowercase | All text → lowercase | Semantic content | Casing info (caps ratio extracted before) |
76
+ | Token replacement | URLs → `urltoken`, emails → `emailtoken`, phones → `phonetoken`, money → `moneytoken` | Structure type | Specific values |
77
+ | Strip | Remove non-alphanumeric characters | Letters, digits | Punctuation, symbols |
78
+ | Stopwords | Remove common English words | Spam-signal words (free, win, urgent, cash, offer, etc.) | Articles, prepositions, conjunctions |
79
+
80
+ Spam-signal words are preserved during stopword removal because they carry strong classification signals. A standard stopword list would strip "free" and "urgent" — both critical for spam detection.
81
+
82
+ ### Dataset Split
83
+
84
+ - **80/20 stratified split** — preserves class balance in both train and test sets
85
+ - Split occurs *after* preprocessing but *before* vectorizer fitting — no data leakage
86
+ - Test set is held out until final evaluation at Stage 4
87
+
88
+ ---
89
+
90
+ ## Stage 2: Track A — Classical ML
91
+
92
+ ```mermaid
93
+ flowchart TD
94
+ TRAIN_TEXTS[Training Texts] --> WORD_VEC[TF-IDF Word Vectorizer]
95
+ TRAIN_TEXTS --> META_FEAT[Extract 32 Meta Features]
96
+
97
+ WORD_VEC --> WORD_MATRIX[Word TF-IDF CSR Matrix]
98
+ META_FEAT --> META_MATRIX[Meta Feature CSR Matrix]
99
+
100
+ WORD_MATRIX --> CONCAT[sparse.hstack]
101
+ META_MATRIX --> CONCAT
102
+ CONCAT --> FEAT_MATRIX[Combined Feature Matrix]
103
+
104
+ FEAT_MATRIX --> SGD[SGDClassifier]
105
+ FEAT_MATRIX --> XGB[XGBoost]
106
+ FEAT_MATRIX --> LGB[LightGBM]
107
+
108
+ SGD --> OPTUNA{Optuna HPO<br/>30 trials, 20 min}
109
+ XGB --> OPTUNA
110
+ LGB --> OPTUNA
111
+
112
+ OPTUNA --> EVAL[5-Fold CV Evaluation]
113
+ EVAL --> BEST[Select Best Candidate]
114
+ ```
115
+
116
+ ### TF-IDF Vectorization
117
+
118
+ | Parameter | Default (local) | Competition Mode |
119
+ |---|---|---|
120
+ | `max_features` | 25,000 | 50,000 |
121
+ | `ngram_range` | (1, 2) | (1, 3) |
122
+ | `sublinear_tf` | True | True |
123
+ | `max_df` | 0.5 | 0.5 |
124
+ | `min_df` | 2 | 2 |
125
+
126
+ Sublinear TF scaling (`1 + log(tf)`) reduces the impact of frequently repeated words in long emails, preventing a single spam keyword from dominating the feature vector.
127
+
128
+ ### Candidate Models
129
+
130
+ | Model | Strengths | Key Hyperparameters Tuned |
131
+ |---|---|---|
132
+ | **SGDClassifier** | Fast training, strong linear baseline | `alpha`, `loss` (hinge/log/modified_huber), `penalty` (l1/l2/elasticnet) |
133
+ | **XGBoost** | Tree-based, handles non-linear interactions | `n_estimators`, `max_depth`, `learning_rate`, `subsample`, `colsample_bytree`, `min_child_weight`, `gamma`, `reg_alpha`, `reg_lambda` |
134
+ | **LightGBM** | Faster training on large data, leaf-wise growth | `n_estimators`, `num_leaves`, `learning_rate`, `subsample`, `colsample_bytree`, `min_child_samples`, `reg_alpha`, `reg_lambda` |
135
+
136
+ ### Optuna Hyperparameter Optimization
137
+
138
+ - **30 trials** per candidate model
139
+ - **20-minute timeout** per candidate (cumulative across trials)
140
+ - **5-fold stratified cross-validation** for each trial
141
+ - **Objective**: maximize mean spam F1 across folds
142
+ - **Pruning**: Median pruner stops unpromising trials early
143
+ - **Result**: Best hyperparameters saved and used for final candidate evaluation
144
+
145
+ ### Candidate Selection
146
+
147
+ The best candidate is selected based on **spam F1 score** on the 20% holdout set. Accuracy and ROC-AUC are also tracked for reporting. The winning model and its vectorizer are passed to the ensemble stage.
148
+
149
+ ---
150
+
151
+ ## Stage 3: Track B — Transformer Fine-Tuning
152
+
153
+ ```mermaid
154
+ flowchart TD
155
+ TEXTS[Training Texts] --> TOKENIZE[AutoTokenizer]
156
+ TOKENIZE --> CACHE[Cache as Safetensors]
157
+ CACHE --> LOADER[DataLoader]
158
+ LOADER --> CURRICULUM{Curriculum<br/>Learning}
159
+
160
+ CURRICULUM -->|Epoch 1| EASY[Easiest 50% samples]
161
+ CURRICULUM -->|Epochs 2+| ALL[All samples]
162
+
163
+ EASY --> FGM[FGM Adversarial Training]
164
+ ALL --> FGM
165
+
166
+ FGM --> FORWARD[Forward Pass]
167
+ FORWARD --> FOCAL[Focal Loss<br/>α=0.25, γ=2.0]
168
+ FOCAL --> BACKWARD[Backward Pass]
169
+ BACKWARD --> GRAD_ACCUM[Gradient Accumulation<br/>eff batch = 64]
170
+ GRAD_ACCUM --> OPTIMIZER[AdamW Step]
171
+
172
+ OPTIMIZER --> EVAL_EPOCH{End of Epoch?}
173
+ EVAL_EPOCH -->|Yes| BEST_F1{Best F1?}
174
+ EVAL_EPOCH -->|No| NEXT_BATCH
175
+
176
+ BEST_F1 -->|Yes| SAVE_BEST[Save checkpoint to disk]
177
+ BEST_F1 -->|No| SAVE_CKPT[Save training state]
178
+
179
+ SAVE_BEST --> NEXT_EPOCH
180
+ SAVE_CKPT --> NEXT_EPOCH
181
+ ```
182
+
183
+ ### Supported Models
184
+
185
+ | Model | Size | Relative Speed | Best For |
186
+ |---|---|---|---|
187
+ | **DeBERTa-v3-base** (primary) | 184 MB | 1.0× | Highest phishing detection F1 |
188
+ | RoBERTa-base | 125 MB | 1.1× | Strong general baseline |
189
+ | ELECTRA-base | 110 MB | 1.3× | Faster training with comparable F1 |
190
+ | ModernBERT-base | 130 MB | 1.2× | Modern architecture, long context |
191
+ | DistilBERT-base | 67 MB | 2.0× | Fastest, smallest — good for distillation |
192
+ | BERT-base-uncased | 110 MB | 1.0× | Widest compatibility |
193
+
194
+ ### Training Techniques
195
+
196
+ #### Focal Loss
197
+
198
+ Standard cross-entropy loss treats all misclassifications equally. Focal loss down-weights easy examples and focuses training on hard cases:
199
+
200
+ ```
201
+ FL(p) = -α(1-p)^γ · log(p)
202
+ ```
203
+
204
+ - **α = 0.25**: Class balancing — reduces the weight of the majority class (ham)
205
+ - **γ = 2.0**: Focusing parameter — easy examples (p ≈ 0.9) are down-weighted by (0.1)² = 0.01
206
+
207
+ This is particularly important for spam detection where the dataset may have mild class imbalance and where obvious spam and obvious ham are uninteresting — the model needs to learn from borderline cases.
208
+
209
+ #### FGM Adversarial Training (Fast Gradient Method)
210
+
211
+ Spammers actively try to evade detection. FGM adversarial training adds small perturbations to the embedding layer during training to make the model robust against adversarial text manipulation:
212
+
213
+ - **ε = 0.5**: Perturbation magnitude
214
+ - **α = 0.3**: Step size
215
+ - **Applied to**: Token embeddings only (not position or type embeddings)
216
+
217
+ This means the model learns to classify correctly even when tokens are slightly modified — the same mechanism spammers use (synonym substitution, character swaps, invisible characters) is the attack FGM defends against.
218
+
219
+ #### Curriculum Learning
220
+
221
+ Training starts with easier examples and progressively introduces harder ones:
222
+
223
+ - **Epoch 1**: Easiest 50% of samples (sorted by text length, perplexity, and rule-based confidence)
224
+ - **Epochs 2+**: All samples
225
+
226
+ This helps the model establish a strong baseline before tackling ambiguous cases, reducing training time and improving final convergence.
227
+
228
+ #### Mixed Precision (FP16)
229
+
230
+ Automatic mixed precision via `torch.cuda.amp`:
231
+ - Forward and backward passes in FP16 (half the memory, 2-3× faster on modern GPUs)
232
+ - Weight updates in FP32 (maintains numerical stability)
233
+ - Loss scaling to prevent underflow in small gradients
234
+
235
+ #### Gradient Accumulation
236
+
237
+ Effective batch size of 64 achieved through gradient accumulation:
238
+ - Physical batch size: VRAM-probed (typically 8–12 on T4)
239
+ - Accumulation steps: 64 ÷ physical_batch
240
+ - Optimizer step after accumulation completes
241
+
242
+ #### VRAM-Probed Batch Sizing
243
+
244
+ The training loop dynamically determines the maximum batch size that fits in GPU memory:
245
+ 1. Start with batch_size=32
246
+ 2. Run a trial forward+backward pass
247
+ 3. If OOM, halve batch_size; if successful, try 1.5×
248
+ 4. Converge to the largest stable batch size
249
+ 5. Used for the entire training run
250
+
251
+ ---
252
+
253
+ ## Stage 4: Ensemble Fusion
254
+
255
+ ```mermaid
256
+ flowchart TD
257
+ CLASSICAL_PROBS[Classical OOF Probabilities] --> GS[Grid Search]
258
+ TRANSFORMER_PROBS[Transformer OOF Probabilities] --> GS
259
+
260
+ subgraph GridSearch[Grid Search: w ∈ {0.0, 0.05, ..., 1.0}]
261
+ LOOP[For each w] --> FUSION["p_spam = w·p_classical + (1-w)·p_transformer"]
262
+ FUSION --> COMPUTE[Compute Spam F1 on Holdout]
263
+ COMPUTE --> BEST[Track Best w]
264
+ end
265
+
266
+ GS --> OPTIMAL_W[Optimal Fusion Weight]
267
+ OPTIMAL_W --> SAVE[Save Weight to Metadata]
268
+ ```
269
+
270
+ ### Weighted Late Fusion
271
+
272
+ The ensemble uses a simple but effective formula:
273
+
274
+ ```
275
+ p_spam = w · p_classical + (1-w) · p_transformer
276
+ ```
277
+
278
+ Where:
279
+ - `w` ∈ [0.0, 1.0] is the fusion weight
280
+ - `p_classical` is the spam probability from the Track A winner (typically XGBoost)
281
+ - `p_transformer` is the spam probability from the Track B model (typically DeBERTa-v3)
282
+ - `p_spam` is the final ensemble spam probability
283
+
284
+ ### Grid Search
285
+
286
+ The fusion weight is found by grid search on the holdout set:
287
+
288
+ - **Range**: 0.0 to 1.0 in 21 steps (0.00, 0.05, 0.10, ..., 1.00)
289
+ - **Metric**: Spam F1 score
290
+ - **Special cases**:
291
+ - `w = 0.0` → transformer-only
292
+ - `w = 1.0` → classical-only
293
+ - `w ≈ 0.5` → equal weighting
294
+
295
+ ### OOF (Out-of-Fold) Predictions
296
+
297
+ The classical model's OOF predictions come from the 5-fold cross-validation during Optuna optimization. The transformer's OOF predictions are the per-epoch validation probabilities from the holdout set. This means the fusion weight is optimized on data neither model has directly trained on, preventing overfitting.
298
+
299
+ ### Fallback Behavior
300
+
301
+ If either track fails or is skipped (`--track-a-only`, `--track-b-only`):
302
+
303
+ - **Classical only**: Defaults to XGBoost without fusion
304
+ - **Transformer only**: Defaults to DeBERTa-v3 without fusion
305
+ - **Both available**: Full ensemble with grid-searched weight
306
+
307
+ ---
308
+
309
+ ## Stage 5: Retrain Winner on Full Dataset
310
+
311
+ The winning model (ensemble-best track) is retrained on 100% of the dataset (combining training and holdout sets) to maximize data utilization. This is done *after* evaluation to avoid data leakage into the evaluation.
312
+
313
+ ### Process
314
+
315
+ 1. Combine training and holdout sets
316
+ 2. If feedback samples exist, merge them (collapsing duplicate entries, mapping labels)
317
+ 3. Retrain the winning classical model on the combined dataset using the Stage 2 vectorizer
318
+ 4. The transformer is *not* retrained — it was already trained on all available data (the holdout was only used for OOF probability generation, not backpropagation)
319
+
320
+ ### Feedback Integration
321
+
322
+ User feedback is loaded from the configured backend (JSONL or MySQL):
323
+ - Labels are mapped: "Spam" → 1, "Not Spam" → 0
324
+ - Duplicate predictions are collapsed (if user submitted multiple labels for the same prediction, the most recent label takes precedence)
325
+ - Feedback samples are appended to the training set before retraining
326
+
327
+ ---
328
+
329
+ ## Stage 6: Export Artifacts + SHA-256
330
+
331
+ ### Artifacts Generated
332
+
333
+ | Artifact | Format | Purpose |
334
+ |---|---|---|
335
+ | `spam_model.pkl` | Pickle | Trained XGBoost (or classical) model |
336
+ | `vectorizer.pkl` | Pickle | TF-IDF vectorizer + meta feature config |
337
+ | `hf_model/` | HuggingFace model directory | Full DeBERTa-v3 model (config + safetensors + tokenizer) |
338
+ | `model_metadata.json` | JSON | Training config, metrics, timestamps |
339
+ | `*.sha256` | Text | SHA-256 integrity hashes for all pickle/safetensors files |
340
+
341
+ ### HF-Native Model Directory
342
+
343
+ The transformer is exported as a complete Hugging Face model directory:
344
+
345
+ ```
346
+ model/hf_model/
347
+ ├── config.json # DeBERTa-v3-base config, num_labels=2, id2label
348
+ ├── model.safetensors # Full fine-tuned weights (fp16, ~352 MB)
349
+ ├── tokenizer.json # SentencePiece tokenizer (128K vocab)
350
+ ├── tokenizer_config.json # Tokenizer class, special tokens, max_length=512
351
+ └── special_tokens_map.json # Special token mappings (optional, embedded in tokenizer_config)
352
+ ```
353
+
354
+ This replaces the previous state_dict-based deployment:
355
+ - ~ `transformer_model.pt` (bare state_dict)
356
+ - ~ `transformer_tokenizer/` (separate directory)
357
+
358
+ The new format loads directly via `AutoModelForSequenceClassification.from_pretrained()` — no base model download, no `load_state_dict()` call. Eliminates ~703 MB of wasted cold-start downloads per instance.
359
+
360
+ ### SHA-256 Integrity
361
+
362
+ Every artifact gets a sidecar `.sha256` file containing the hex-encoded SHA-256 hash:
363
+
364
+ ```
365
+ model/
366
+ ├── spam_model.pkl
367
+ ├── spam_model.pkl.sha256 # → "a3f8b2c1d4..."
368
+ ├── vectorizer.pkl
369
+ ├── vectorizer.pkl.sha256 # → "e5f1a3c7..."
370
+ ├── hf_model/
371
+ │ ├── model.safetensors
372
+ │ ├── model.safetensors.sha256 # → "b4d2e9f6..."
373
+ │ └── ...
374
+ ```
375
+
376
+ At load time, each hash is verified using `hmac.compare_digest` for constant-time comparison. If the computed hash doesn't match the stored hash, a `ModelIntegrityError` is raised — the model is not loaded, and the app crashes (fail-fast principle).
377
+
378
+ ---
379
+
380
+ ## Inference Flow (Production)
381
+
382
+ ```mermaid
383
+ flowchart TD
384
+ REQ[HTTP Request] --> REDACT[PII Redaction]
385
+ REDACT --> DOMAIN[Extract Sender Domain]
386
+ DOMAIN --> L1{Whitelist?}
387
+ L1 -->|Yes| R1[whitelisted, conf=1.0]
388
+ L1 -->|No| L2{Trusted Catalog?}
389
+ L2 -->|Yes| R2[Not Spam, conf=0.97]
390
+ L2 -->|No| L3{Rule-Based Spam?}
391
+ L3 -->|Yes| R3[Spam, conf=0.86-0.99]
392
+ L3 -->|No| L4{Benign Context?}
393
+ L4 -->|Yes| R4[Not Spam, conf=0.76-0.82]
394
+ L4 -->|No| ROUTE{Ensemble Available?}
395
+
396
+ ROUTE -->|Yes| ENSEMBLE[EnsemblePredictor<br/>XGBoost + DeBERTa-v3]
397
+ ROUTE -->|Transformer only| TRANSFORMER[Transformer predict_proba]
398
+ ROUTE -->|Classical only| CLASSICAL[XGBoost predict_proba]
399
+
400
+ ENSEMBLE --> THRESH{≥ threshold?}
401
+ TRANSFORMER --> THRESH
402
+ CLASSICAL --> THRESH
403
+
404
+ THRESH -->|Yes| SPAM[Spam, conf=prob]
405
+ THRESH -->|No| HAM[Not Spam, conf=1-prob]
406
+
407
+ SPAM --> EXPLAIN[Generate Explanations]
408
+ HAM --> EXPLAIN
409
+ EXPLAIN --> RESPONSE[PredictionResult JSON]
410
+
411
+ R1 --> RESPONSE
412
+ R2 --> RESPONSE
413
+ R3 --> RESPONSE
414
+ R4 --> RESPONSE
415
+ ```
416
+
417
+ ### Ensemble Routing Logic
418
+
419
+ ```
420
+ if _is_ensemble_model(model):
421
+ → EnsemblePredictor.predict_proba(features, raw_texts)
422
+ elif _is_transformer_model(model):
423
+ → Transformer.predict_proba(raw_texts)
424
+ else:
425
+ → ClassicalModel.predict_proba(features) # XGBoost or fallback
426
+ ```
427
+
428
+ The detection engine auto-detects which artifacts are loaded and routes accordingly. This means the same code serves all deployment modes — ensemble, transformer-only, classical-only — without configuration changes.
429
+
430
+ ### 32 Meta-Features
431
+
432
+ | # | Feature | Category | Description |
433
+ |---|---|---|---|
434
+ | 1 | `url_count` | URL | Number of URLs in the email |
435
+ | 2 | `url_unique_domains` | URL | Number of unique domains linked |
436
+ | 3 | `url_shortened` | URL | Count of URL shortener domains (bit.ly, t.co, etc.) |
437
+ | 4 | `url_ip_address` | URL | Count of URLs using raw IP addresses |
438
+ | 5 | `url_suspicious_tld` | URL | Count of URLs with high-risk TLDs (.tk, .ml, .ga, etc.) |
439
+ | 6 | `url_to_text_ratio` | URL | Ratio of URL characters to total text |
440
+ | 7 | `html_tag_count` | HTML | Count of HTML tags detected |
441
+ | 8 | `html_hidden_element` | HTML | Count of CSS-hidden elements (display:none, visibility:hidden) |
442
+ | 9 | `html_hidden_size` | HTML | Total character content of hidden elements |
443
+ | 10 | `exclamation_count` | Text Quality | Count of `!` characters |
444
+ | 11 | `question_count` | Text Quality | Count of `?` characters |
445
+ | 12 | `caps_ratio` | Text Quality | Ratio of uppercase letters in full text |
446
+ | 13 | `digit_ratio` | Text Quality | Ratio of digit characters |
447
+ | 14 | `symbol_ratio` | Text Quality | Ratio of symbol characters |
448
+ | 15 | `word_count` | Text Quality | Total word count after preprocessing |
449
+ | 16 | `avg_word_length` | Text Quality | Average word length in characters |
450
+ | 17 | `flesch_reading_ease` | Text Quality | Flesch reading ease score (lower = more complex) |
451
+ | 18 | `type_token_ratio` | Text Quality | Unique words ÷ total words (lexical diversity) |
452
+ | 19 | `imperative_verb_ratio` | Text Quality | Ratio of imperative verbs (click, buy, call, etc.) |
453
+ | 20 | `homograph_hits` | Obfuscation | Count of homograph attack characters (Cyrillic a, etc.) |
454
+ | 21 | `unicode_obfuscation` | Obfuscation | Count of unusual Unicode codepoints |
455
+ | 22 | `attachment_indicator` | Attachment | Count of attachment-related phrases |
456
+ | 23 | `attachment_extension` | Attachment | Count of risky attachment extensions (.exe, .js, .vbs, etc.) |
457
+ | 24 | `credential_harvesting` | Credential | Count of credential-harvesting phrases (verify account, login, password expired) |
458
+ | 25 | `spam_phrase_hits` | Keyword | Total matches across all spam phrase categories |
459
+ | 26 | `urgency_hits` | Keyword | Urgency keyword matches (immediately, urgent, limited time) |
460
+ | 27 | `account_hits` | Keyword | Account/security keyword matches |
461
+ | 28 | `call_to_action_hits` | Keyword | CTA keyword matches (click here, sign up, download) |
462
+ | 29 | `money_count` | Keyword | Currency amounts detected ($, £, €, ¥, ₹, word-suffix) |
463
+ | 30 | `phone_count` | Keyword | Phone numbers detected |
464
+ | 31 | `promotional_hits` | Keyword | Promotional keyword matches (discount, sale, offer) |
465
+ | 32 | `business_context_hits` | Keyword | Business context keyword matches |
466
+
467
+ Feature extraction is performed in `app/core/features.py` via `extract_meta_features()`.
468
+
469
+ ---
470
+
471
+ ## Checkpoint System
472
+
473
+ ### Transformer Checkpoints
474
+
475
+ | File | Format | Saved When | Purpose |
476
+ |---|---|---|---|
477
+ | `{model}_checkpoint.pt` | Full training state | Every epoch | Resume training from exact point |
478
+ | `{model}_best.pt` | state_dict only | New best F1 | Inference without optimizer state |
479
+ | `{model}_emergency.pt` | Full training state | SIGTERM/SIGINT | Graceful shutdown recovery |
480
+
481
+ ### Checkpoint Contents
482
+
483
+ ```python
484
+ checkpoint = {
485
+ "model_state_dict": model.state_dict(), # Model weights
486
+ "optimizer_state_dict": optimizer.state_dict(), # AdamW state
487
+ "scheduler_state_dict": scheduler.state_dict(), # LR scheduler
488
+ "scaler_state_dict": scaler.state_dict(), # FP16 gradient scaler
489
+ "epoch": current_epoch, # Training progress
490
+ "best_f1": best_f1, # Best metric so far
491
+ "rng_state": torch.get_rng_state(), # Reproducibility
492
+ "train_time": elapsed_time, # Total training time
493
+ }
494
+ ```
495
+
496
+ ### Resume Behavior
497
+
498
+ When `--resume` is passed or a checkpoint is detected:
499
+
500
+ 1. Load `{model}_checkpoint.pt`
501
+ 2. Restore model weights, optimizer, scheduler, scaler
502
+ 3. Restore RNG state for reproducibility
503
+ 4. Resume from `epoch + 1`
504
+ 5. Skip curriculum learning (all samples from epoch 2+)
505
+
506
+ ### Token Cache
507
+
508
+ To avoid re-tokenizing the dataset on reruns, tokenized input IDs and attention masks are cached to disk using safetensors format:
509
+
510
+ ```
511
+ model/checkpoints/token_cache/
512
+ ├── train_input_ids.safetensors
513
+ ├── train_attention_mask.safetensors
514
+ ├── val_input_ids.safetensors
515
+ ├── val_attention_mask.safetensors
516
+ ```
517
+
518
+ Cache files include a hash of the tokenizer name and max sequence length in the filename to prevent cross-model cache collisions.
PATCH_SUMMARY.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PATCH SUMMARY — v3.0.1
2
+
3
+ ## Bug 1: Ensemble routing crash in production
4
+
5
+ **File:** `app/core/detector.py`
6
+ **Change:** Added `_ensemble_predict(model, features, raw_text)` and route `_is_ensemble_model` to it instead of `_transformer_predict`.
7
+ **Reason:** `EnsemblePredictor.predict_proba(features, raw_texts)` requires two positional arguments. Old code called `model.predict_proba([raw_text])` passing only one argument — missing `raw_texts` → `TypeError` at runtime.
8
+ **Impact:** Production endpoint `POST /v1/predict` would crash on every email when ensemble model was loaded. All ensemble inference was broken.
9
+ **Risk:** Zero — added a new routing function, did not modify existing `_transformer_predict` or `_probabilities_from_model` paths.
10
+
11
+ ## Bug 2: Vectorizer reuse in Stage 4 ensemble
12
+
13
+ **File:** `model/train_model.py`
14
+ **Change:** Stage 4 now reuses `classical_word_vec` (Stage 2 vectorizer) via `.transform()` instead of creating a new vectorizer with `create_word_vectorizer() + fit_transform()`.
15
+ **Reason:** Stage 2 trained classifier on the Stage 2 vectorizer's vocabulary. Stage 4 was creating an independent vectorizer — different `fit()` call could produce different token-to-index mapping → dimension mismatch or incorrect predictions.
16
+ **Impact:** Ensemble grid search could return silently wrong fusion weights or crash on `predict_proba` with dimension mismatch.
17
+ **Risk:** Zero — using `.transform()` on an already-fit vectorizer is deterministic and identical to the classifier's training-time feature space.
18
+
19
+ ## Bug 3: Checkpoint persistence for transformer training
20
+
21
+ **File:** `model/train_transformer.py`
22
+ **Change:** Added `checkpoint_dir` parameter; saves `best_state` to `{checkpoint_dir}/{model_name}_best.pt` after every epoch that achieves a new best F1.
23
+ **Reason:** `best_state` was held only in RAM. OOM, power loss, or process kill during 90-minute training would lose all progress.
24
+ **Impact:** Interrupted training could be resumed from last checkpoint. Training proceeds from epoch N+1 if checkpoint exists at epoch N.
25
+ **Risk:** Low — checkpoint format is `torch.save(state_dict)` which is the standard PyTorch format.
26
+
27
+ ## Bug 4: Dead `device` parameter
28
+
29
+ **File:** `model/train_transformer.py`
30
+ **Change:** Removed unused `device: torch.device` from `_compute_difficulty_scores()` and its call site.
31
+ **Reason:** Function body contains only string operations — no tensor operations, no `.to(device)`, no CUDA calls. Dead parameter.
32
+ **Impact:** Cleaner code, no behavior change.
33
+
34
+ ## Bug 5: Public API for transformer proba
35
+
36
+ **File:** `app/ml/ensemble.py`
37
+ **Change:** Added `transformer_proba(texts)` public method delegating to `_transformer_proba(texts)`. Orchestrator now calls the public method.
38
+ **Reason:** `train_model.py` was calling private `ensemble._transformer_proba()` directly.
39
+ **Impact:** Clean API boundary. Internal `predict_proba()` still calls `_transformer_proba` — no change to inference path.
40
+
41
+ ## Bug 6: MONEY_PATTERN expansion
42
+
43
+ **File:** `app/core/constants.py`
44
+ **Change:** Added ¥, ₹ currency symbols, space-separated thousands (`$1 000`), currency-word suffixes (`100 dollars`, `50 eur`, `1000 usd`, `500 inr`).
45
+ **Reason:** Old pattern missed Indian rupee amounts, Japanese yen, and "X dollars/euros" phrases common in phishing.
46
+ **Impact:** Better phishing detection for non-USD currency spam. No performance degradation — regex compiles once at import.
47
+
48
+ ## Bug 7: Dead import removal
49
+
50
+ **File:** `model/train_model.py`
51
+ **Change:** Removed `from model.train_classical import build_classical_features` — imported but never called after Bug 2 fix.
52
+ **Impact:** Cleaner code, no behavior change.
PROJECT_OVERVIEW.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Overview
2
+
3
+ ## Vision
4
+
5
+ Spam Email Detection is a production-grade spam and phishing detection platform that brings enterprise ML practices to everyday email protection. It combines a Chrome extension for real-time Gmail scanning with a FastAPI backend running a dual-track ensemble of classical machine learning and transformer models — all backed by a user feedback loop, explainable predictions, and deployment-ready artifacts.
6
+
7
+ The project is designed to serve three audiences simultaneously:
8
+
9
+ - **End users** — anyone who wants to detect spam and phishing in their Gmail inbox with visual overlays and explainable results
10
+ - **ML engineers** — a reference architecture for dual-track ensemble systems, showing how to combine XGBoost with DeBERTa-v3 in a production pipeline
11
+ - **Researchers and students** — a complete, documented, tested ML project that demonstrates the full lifecycle from data preprocessing to deployment
12
+
13
+ ---
14
+
15
+ ## Design Philosophy
16
+
17
+ ### Defense in Depth
18
+
19
+ The system applies five layers of progressively sophisticated analysis before reaching the ML model:
20
+
21
+ 1. **User Whitelist** — immediate trust for sender domains the user has explicitly approved
22
+ 2. **Trusted Service Catalog** — high-confidence clearance for known legitimate services (banks, payment processors, major platforms)
23
+ 3. **Rule-Based Spam Detection** — pattern matching against curated phishing phrase libraries and behavioral indicator signals
24
+ 4. **Benign Context Guard** — reverse-spam detection: identifies conversational and low-risk promotional emails that should not be flagged
25
+ 5. **Dual-Track ML Ensemble** — XGBoost + DeBERTa-v3 late fusion for cases requiring deep analysis
26
+
27
+ This layered approach means the ML model only processes emails that genuinely need sophisticated analysis — reducing latency, improving user trust (rules and catalogs can be inspected and understood), and ensuring that edge cases degrade gracefully rather than crashing.
28
+
29
+ ### Why a Dual-Track Architecture?
30
+
31
+ Spam and phishing detection presents two distinct challenges that a single model struggles to handle simultaneously:
32
+
33
+ | Challenge | Best Handled By | Why |
34
+ |---|---|---|
35
+ | **Keyword/pattern spam** ("FREE MONEY CLICK HERE") | Classical ML (XGBoost) | TF-IDF n-grams excel at surface-level token patterns. XGBoost handles sparse, high-dimensional feature spaces efficiently. |
36
+ | **Sophisticated phishing** (context-aware social engineering) | Transformer (DeBERTa-v3) | Disentangled attention captures semantic relationships. Contextual embeddings understand nuance that bag-of-words misses. |
37
+ | **Boundary cases** (legitimate marketing vs phishing) | Ensemble fusion | Weighted combination reduces false positives while maintaining recall. Both models must agree on ambiguous cases. |
38
+
39
+ A single XGBoost model would miss sophisticated phishing that uses natural language. A single DeBERTa model would be overkill for obvious keyword spam and would be slower at inference. The ensemble gives us the best of both worlds.
40
+
41
+ ### Why Late Fusion Over Stacking or Meta-Learners?
42
+
43
+ The ensemble uses **weighted late fusion** rather than a learned meta-classifier (stacking):
44
+
45
+ - **Simplicity**: `p_spam = w · p_classical + (1-w) · p_transformer` — a single scalar weight, no additional parameters
46
+ - **Robustness**: Grid-searching 21 values on training OOF predictions avoids overfitting a meta-learner on limited holdout data
47
+ - **Interpretability**: The fusion weight directly tells us how much the system trusts each track
48
+ - **Graceful degradation**: If the transformer artifact is unavailable, the system falls back to classical-only without any code changes
49
+
50
+ ### Explainability as a First-Class Requirement
51
+
52
+ Every prediction includes explanations showing which tokens and signals influenced the decision. For the classical model, this means extracting top positive/negative feature coefficients. For spam detections, users see actionable reasons: "Suspicious token: 'verify'", "Suspicious signal: contains urgency language." This transparency builds user trust and helps identify model weaknesses.
53
+
54
+ ---
55
+
56
+ ## Architecture Decisions
57
+
58
+ ### Why XGBoost (not LogisticRegression) for Classical ML
59
+
60
+ The v1.0 baseline used LogisticRegression for its simplicity and interpretability. v3.0 upgraded to XGBoost because:
61
+
62
+ - **Non-linear feature interactions**: Spam signals often interact — "money" + "click" is far more suspicious than either alone. Tree-based models capture these naturally.
63
+ - **Sparse feature handling**: TF-IDF matrices with 25,000+ features are inherently sparse. XGBoost's sparsity-aware split finding is more efficient than LogisticRegression's dense gradient computation.
64
+ - **Robustness to irrelevant features**: With 32 meta-features, some may be noisy. Tree-based models automatically ignore irrelevant splits.
65
+
66
+ LogisticRegression remains as a fallback when the system is trained on small datasets (<5,000 rows) where XGBoost may overfit.
67
+
68
+ ### Why DeBERTa-v3 (not BERT or RoBERTa)
69
+
70
+ DeBERTa-v3-base was selected as the primary transformer after evaluating:
71
+
72
+ - **Disentangled attention**: Separates content and position embeddings, improving phishing text understanding where token position (e.g., "click _here_" vs "here, click") matters
73
+ - **ELECTRA-style pre-training**: Replaced token detection (RTD) pre-training objective produces better representations for classification tasks than masked language modeling alone
74
+ - **Perplexity-F1 correlation**: DeBERTa-v3 consistently achieves higher F1 scores on short-text classification benchmarks compared to BERT-base and RoBERTa-base at comparable model sizes (~184MB)
75
+
76
+ Alternative transformers (RoBERTa, ELECTRA, ModernBERT, DistilBERT, BERT-base) are supported as configurable options in the training pipeline for experimentation.
77
+
78
+ ### Why CSV for Configuration Data
79
+
80
+ Whitelist and trusted domain catalogs use CSV files rather than a database because:
81
+
82
+ - **Zero infrastructure dependency**: The system deploys with a single `pip install` — no database setup required
83
+ - **Human-editable**: Users can add trusted domains by editing a spreadsheet-format file
84
+ - **Version-controllable**: CSV diffs are readable in pull requests
85
+ - **Sufficient for scale**: Domain catalogs rarely exceed a few thousand entries
86
+
87
+ ### Why JSONL for Feedback
88
+
89
+ User feedback is stored as newline-delimited JSON:
90
+
91
+ - **Append-only**: New entries append to the end without rewriting — safe for concurrent processes
92
+ - **Human-readable**: Each line is a valid JSON object, inspectable with standard tools
93
+ - **Portable**: JSONL files can be copied, backed up, and processed by any language
94
+ - **Optional MySQL upgrade**: When multi-instance deployment is needed, a single env var (`SPAM_FEEDBACK_BACKEND=mysql`) switches to MySQL
95
+
96
+ ---
97
+
98
+ ## Tradeoffs
99
+
100
+ ### Accuracy vs Inference Latency
101
+
102
+ | Component | Latency (single email) | Memory |
103
+ |---|---|---|
104
+ | Classical (XGBoost) | ~2-5 ms | ~2 MB |
105
+ | Transformer (DeBERTa-v3) | ~40-80 ms | ~184 MB |
106
+ | Ensemble (combined) | ~45-85 ms | ~186 MB |
107
+
108
+ The ensemble adds negligible latency overhead (a single scalar multiplication) over the transformer path, but requires both models to be loaded in memory (~186 MB total).
109
+
110
+ For CPU-only deployments, the transformer inference time increases to ~200-500 ms, which is still acceptable for the extension's async scanning model but may feel slow for batch processing.
111
+
112
+ ### Training Cost vs Model Quality
113
+
114
+ | Training Mode | Time | GPU Required | Best F1 |
115
+ |---|---|---|---|
116
+ | Fast-dev (500 rows) | ~5 min | No | Lower |
117
+ | Classical only | ~35 min | No | ~0.97 |
118
+ | Transformer only | ~90 min | Yes (T4+) | ~0.99 |
119
+ | Full ensemble | ~2.5 hours | Yes (T4+) | ~0.99+ |
120
+
121
+ The classical-only track provides a strong baseline without GPU dependency. Full ensemble training requires a T4 or better GPU but delivers marginal gains over transformer-only in exchange for better robustness on boundary cases.
122
+
123
+ ### Model Size and Deployment
124
+
125
+ The ensemble requires ~186 MB of model artifacts (XGBoost ~2 MB + DeBERTa-v3 ~184 MB). This is manageable for server deployments but too large for browser-based inference. The Chrome extension is intentionally a thin client — all ML inference happens on the backend.
126
+
127
+ ---
128
+
129
+ ## Scalability
130
+
131
+ ### Current Design
132
+
133
+ The system is designed for single-instance deployment with optional horizontal scaling:
134
+
135
+ - **Single server**: 4 Gunicorn workers handle ~200 concurrent requests with sub-100ms latency
136
+ - **Feedback storage**: JSONL for single-instance, MySQL for multi-instance
137
+ - **Retraining**: Serialized by a `threading.Lock` — intentional constraint to prevent resource contention
138
+
139
+ ### Future Scaling Paths
140
+
141
+ - **Model serving separation**: Deploy the transformer model on a dedicated GPU instance while the classical model serves CPU-bound requests
142
+ - **Async inference queue**: For batch processing (e.g., scanning an entire inbox), queue predictions and process in parallel
143
+ - **Model distillation**: Train a smaller student model (DistilBERT-size) from the DeBERTa-v3 teacher for faster inference
144
+ - **Caching**: Cache predictions for identical email content (newsletters, automated alerts)
145
+
146
+ ---
147
+
148
+ ## Production Readiness
149
+
150
+ The project includes several features that move it beyond a prototype:
151
+
152
+ - **SHA-256 integrity verification**: All model artifacts are hashed at save time and verified at load time using constant-time comparison. Tampered or corrupted models are detected immediately.
153
+ - **PII redaction at the API boundary**: Emails, phone numbers, IPs, SSNs, and credit card numbers are redacted before they reach the model or feedback store. No PII is ever persisted.
154
+ - **Rate limiting and authentication**: 60 requests/minute per IP, API key authentication on mutation endpoints (feedback, retrain).
155
+ - **Graceful degradation**: Missing transformer artifact? Ensemble falls back to classical. Missing model entirely? Returns HTTP 500 rather than crashing. No config? Sensible defaults for everything.
156
+ - **Docker with non-root user**: Multi-stage builds reduce image size. Health checks monitor runtime. Compose profiles separate required and optional services.
157
+ - **225 deterministic tests**: Every production module is covered by isolated, fast tests that run in under 5 seconds total.
README.md CHANGED
@@ -1,11 +1,439 @@
1
- ---
2
- title: Spam Email Detection
3
- emoji: 🐢
4
- colorFrom: indigo
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Spam Email Detection
3
+ emoji: 🛡️
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: true
8
+ app_port: 7860
9
+ ---
10
+
11
+ # 🛡️ Spam Email Detection Dual-Track ML for Gmail Protection
12
+
13
+ **A production-grade spam and phishing detection system with a Chrome extension, FastAPI backend, dual-track ensemble (XGBoost + DeBERTa-v3), 5-layer detection pipeline, explainable predictions, user feedback loop, and Docker deployment.**
14
+
15
+ <p align="center">
16
+ <a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.11+"></a>
17
+ <a href="https://fastapi.tiangolo.com/"><img src="https://img.shields.io/badge/FastAPI-0.115+-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="FastAPI"></a>
18
+ <a href="https://xgboost.readthedocs.io/"><img src="https://img.shields.io/badge/XGBoost-2.0+-32B34A?style=for-the-badge&logo=xgboost&logoColor=white" alt="XGBoost"></a>
19
+ <a href="https://pytorch.org/"><img src="https://img.shields.io/badge/PyTorch-2.3+-EE4C2C?style=for-the-badge&logo=pytorch&logoColor=white" alt="PyTorch"></a>
20
+ <a href="https://huggingface.co/"><img src="https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-DeBERTa--v3-FFD21E?style=for-the-badge" alt="HuggingFace"></a>
21
+ <a href="https://www.docker.com/"><img src="https://img.shields.io/badge/Docker-Ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker"></a>
22
+ <a href="#testing"><img src="https://img.shields.io/badge/Tests-225%20Passing-success?style=for-the-badge" alt="Tests"></a>
23
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
24
+ </p>
25
+
26
+ ---
27
+
28
+ ## Overview
29
+
30
+ Spam Email Detection is a complete spam and phishing detection platform that combines a Chrome extension for Gmail with a FastAPI backend running a **dual-track ML ensemble**. It combines classical machine learning (XGBoost with TF-IDF and 32 engineered meta-features) with transformer-based deep learning (Microsoft DeBERTa-v3 fine-tuned with focal loss, FGM adversarial training, and curriculum learning) through weighted late fusion — giving you the pattern-matching power of gradient boosting and the contextual understanding of a state-of-the-art language model.
31
+
32
+ ### Why this project stands out
33
+
34
+ - **Dual-track ensemble**: XGBoost handles keyword/pattern spam. DeBERTa-v3 handles sophisticated phishing. The ensemble combines both for maximum accuracy, with Optuna HPO available for classical track optimization when training on new data.
35
+ - **6-stage training pipeline**: Load → Classical (3 candidates + Optuna HPO) → Transformer (focal loss + FGM + curriculum) → Ensemble Fusion → Retrain Winner → Export Artifacts
36
+ - **5-layer detection pipeline**: Whitelist → Trusted Catalog → Rule-Based Spam → Benign Context Guard → ML Ensemble — 40-60% of emails never reach the ML model
37
+ - **32 engineered meta-features**: URL analysis, HTML detection, Unicode obfuscation, homograph attacks, credential harvesting patterns, readability scores
38
+ - **Production-grade**: Docker deployment, env-based config, CORS, rate limiting, API key auth, SHA-256 model integrity, PII redaction
39
+ - **225 deterministic tests**: Full coverage of all production modules, ~4-second suite execution
40
+ - **Kaggle GPU training**: Auto-detection, multi-GPU DDP, checkpoint resume, VRAM-probed batch sizing
41
+
42
+ ---
43
+
44
+ ## Architecture
45
+
46
+ ```
47
+ ┌─────────────────┐ ┌────────────────────────────────────────────────────┐
48
+ │ Gmail UI │────▶│ Chrome Extension (Manifest V3) │
49
+ │ │ │ ┌─────────────┐ ┌──────────┐ ┌────────────────┐ │
50
+ │ Inbox / Email │ │ │ content.js │ │ popup.js │ │ options page │ │
51
+ └─────────────────┘ │ └──────┬──────┘ └────┬─────┘ └───────┬────────┘ │
52
+ └─────────┼──────────────┼────────────────┼──────────┘
53
+ │ │ │
54
+ ▼ ▼ ▼
55
+ ┌─────────────────────────────────────────────��──────┐
56
+ │ FastAPI Backend (:8000) │
57
+ │ ┌──────────────────────────────────────────────┐ │
58
+ │ │ Middleware: CORS │ Rate Limit │ Auth (Key) │ │
59
+ │ │ │ │
60
+ │ │ GET /v1/health POST /v1/predict │ │
61
+ │ │ POST /v1/predict/batch POST /v1/feedback 🔒 │ │
62
+ │ │ GET /v1/feedback/summary POST /v1/retrain 🔒│ │
63
+ │ └──────────────────────────────────────────────┘ │
64
+ │ │
65
+ │ ┌──────────────────────────────────────────────┐ │
66
+ │ │ 5-Layer Detection Pipeline │ │
67
+ │ │ 1. Whitelist → confidence 1.0 │ │
68
+ │ │ 2. Trusted Catalog → confidence 0.97 │ │
69
+ │ │ 3. Rule-Based Spam → confidence 0.86-0.99 │ │
70
+ │ │ 4. Benign Context → confidence 0.76-0.82 │ │
71
+ │ │ 5. ML Ensemble ─────────────────┐ │ │
72
+ │ └──────────────────────────────────│───────────┘ │
73
+ │ ▼ │
74
+ │ ┌──────────────────────────────────────────────┐ │
75
+ │ │ Dual-Track ML Ensemble │ │
76
+ │ │ │ │
77
+ │ │ Track A: XGBoost Track B: DeBERTa │ │
78
+ │ │ ├─ TF-IDF (25K n-grams) ├─ Focal Loss │ │
79
+ │ │ ├─ 32 Meta-Features ├─ FGM Adversar. │ │
80
+ │ │ └─ Optuna HPO └─ Curriculum │ │
81
+ │ │ │ │ │ │
82
+ │ │ └────────┬─────────────────┘ │ │
83
+ │ │ ▼ │ │
84
+ │ │ p_spam = w·p_xgb + (1-w)·p_deberta │ │
85
+ │ │ (Grid-searched fusion weight) │ │
86
+ │ └──────────────────────────────────────────────┘ │
87
+ │ │
88
+ │ ┌──────────────┐ ┌───────────────────────────┐ │
89
+ │ │ PII Redact │ │ SHA-256 Model Integrity │ │
90
+ │ └──────────────┘ └───────────────────────────┘ │
91
+ └───────────────────────┬────────────────────────────┘
92
+
93
+ ┌─────────────┴─────────────┐
94
+ │ │
95
+ ┌─────▼─────┐ ┌──────▼──────┐
96
+ │ feedback │ │ model │
97
+ │ .jsonl │ │ artifacts │
98
+ │ / MySQL │ │ (pickle) │
99
+ └───────────┘ └─────────────┘
100
+ ```
101
+
102
+ For full architecture with Mermaid diagrams, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [MODEL_ARCHITECTURE.md](MODEL_ARCHITECTURE.md).
103
+
104
+ ---
105
+
106
+ ## Key Features
107
+
108
+ ### Dual-Track ML Engine
109
+
110
+ | Track | Approach | Best For | Inference |
111
+ |---|---|---|---|
112
+ | **A — Classical** | TF-IDF + 32 meta-features + XGBoost/LightGBM/SGD with Optuna HPO | Keyword/pattern spam | ~3 ms |
113
+ | **B — Transformer** | DeBERTa-v3 with Focal Loss, FGM, Curriculum Learning | Sophisticated phishing | ~50 ms |
114
+ | **Ensemble** | Weighted late fusion (grid-searched weight) | Boundary cases, max F1 | ~55 ms |
115
+
116
+ ### 5-Layer Detection Pipeline
117
+
118
+ Emails pass through progressively sophisticated analysis — only ~40-60% reach the ML model:
119
+
120
+ | Layer | Method | Decision Confidence |
121
+ |---|---|---|
122
+ | **Whitelist** | Exact sender domain match | 1.0 |
123
+ | **Trusted Catalog** | Curated known-services list | 0.97 |
124
+ | **Rule-Based Spam** | Phishing phrase + signal matching | 0.86–0.99 |
125
+ | **Benign Context** | Conversational/promotional detection | 0.76–0.82 |
126
+ | **ML Ensemble** | XGBoost + DeBERTa-v3 late fusion | Full probability |
127
+
128
+ ### Chrome Extension
129
+
130
+ - **Gmail integration** via Manifest V3 — auto-scan emails with visual overlay banners
131
+ - **Manual scanning** from extension popup
132
+ - **Feedback submission** (correct/incorrect labels) for model improvement
133
+ - **Explainable results** — see exactly *why* an email was flagged
134
+ - **Options page** — configure backend URL, timeout, retraining controls
135
+
136
+ ### Security
137
+
138
+ - **API key authentication** on feedback and retrain endpoints
139
+ - **Rate limiting** — 60 req/min per IP with proper 429 responses
140
+ - **SHA-256 model integrity** — detects tampered or corrupted artifacts at load time
141
+ - **PII redaction** — 5 patterns (email, phone, IP, SSN, credit card) redacted at API boundary
142
+ - **CORS protection** — origin regex restricting to extensions and localhost
143
+ - **SQL injection prevention** — table name validation regex
144
+
145
+ ### Production Deployment
146
+
147
+ - **Docker** with multi-stage build and non-root user
148
+ - **Docker Compose** with optional MySQL profile
149
+ - **Gunicorn + Uvicorn** for production ASGI serving
150
+ - **Health checks** on both Docker and API level
151
+ - **Environment-driven** configuration — zero code changes between environments
152
+
153
+ ---
154
+
155
+ ## Quick Start
156
+
157
+ ### Prerequisites
158
+
159
+ - Python 3.11+
160
+ - (Optional) Docker + Docker Compose
161
+ - (Optional) NVIDIA GPU for transformer training
162
+
163
+ ### 5-Minute Setup
164
+
165
+ ```bash
166
+ # Clone
167
+ git clone https://github.com/AVijit005/Spam-Email-Detection.git
168
+ cd Spam-Email-Detection
169
+
170
+ # Setup environment
171
+ python -m venv .venv
172
+ source .venv/bin/activate # Linux/macOS
173
+ # .\.venv\Scripts\activate # Windows
174
+
175
+ # Install dependencies
176
+ pip install -r requirements.txt
177
+
178
+ # Quick smoke test (500 rows, ~5 min, no GPU needed)
179
+ python model/train_model.py --fast-dev
180
+
181
+ # Full classical training (CPU only, ~35 min)
182
+ python model/train_model.py --track-a-only
183
+
184
+ # Start the API server
185
+ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
186
+ ```
187
+
188
+ Verify:
189
+ ```bash
190
+ curl http://127.0.0.1:8000/v1/health
191
+ ```
192
+
193
+ ### Docker Deployment
194
+
195
+ ```bash
196
+ cp .env.example .env
197
+ docker compose up --build
198
+ ```
199
+
200
+ ### Full GPU Training (Kaggle)
201
+
202
+ ```bash
203
+ python model/train_model.py --model DeBERTa-v3
204
+ ```
205
+
206
+ See [KAGGLE_RECOVERY_GUIDE.md](KAGGLE_RECOVERY_GUIDE.md) for detailed GPU training instructions.
207
+
208
+ ---
209
+
210
+ ## API Reference
211
+
212
+ | Method | Endpoint | Auth | Description |
213
+ |---|---|---|---|
214
+ | `GET` | `/v1/health` | None | Server status, model info, feedback stats |
215
+ | `POST` | `/v1/predict` | None | Single email prediction |
216
+ | `POST` | `/v1/predict/batch` | None | Batch prediction (max 50) |
217
+ | `POST` | `/v1/feedback` | API Key | Submit user label for a prediction |
218
+ | `GET` | `/v1/feedback/summary` | None | Aggregate feedback counts by verdict |
219
+ | `POST` | `/v1/retrain` | API Key | Trigger model retraining |
220
+
221
+ ### Example: Predict
222
+
223
+ **Request**
224
+ ```json
225
+ {
226
+ "sender": "security-alert@unknown-domain.com",
227
+ "subject": "URGENT: Verify your account immediately",
228
+ "body": "Dear user, your account has been compromised. Click here to verify your credentials: https://bit.ly/3xK9mP2"
229
+ }
230
+ ```
231
+
232
+ **Response**
233
+ ```json
234
+ {
235
+ "label": "Spam",
236
+ "confidence": 0.96,
237
+ "reason": "Dual-track ensemble detected multiple phishing indicators",
238
+ "analysis": "AI analysis: 96.0% spam probability. Combined signals from XGBoost pattern detection and DeBERTa-v3 contextual analysis.",
239
+ "model_version": "Ensemble-XGBoost-DeBERTa-v3-20260403",
240
+ "sender_domain": "unknown-domain.com",
241
+ "rule_layer": "ml",
242
+ "explanations": [
243
+ "Suspicious token: \"verify\"",
244
+ "Suspicious signal: contains urgency language",
245
+ "Suspicious signal: contains URL shortener",
246
+ "Suspicious signal: credential harvesting phrase detected"
247
+ ],
248
+ "prediction_id": "a1b2c3d4e5f6",
249
+ "evaluated_at_utc": "2026-04-03T12:00:00+00:00"
250
+ }
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Project Structure
256
+
257
+ ```
258
+ spam-email-detection/
259
+ ├── app/ # Production FastAPI application
260
+ │ ├── api/v1/ # REST endpoints
261
+ │ │ ├── health.py # GET /health
262
+ │ │ ├── predict.py # POST /predict, /predict/batch
263
+ │ │ ├── feedback.py # POST /feedback, GET /feedback/summary
264
+ │ │ ├── retrain.py # POST /retrain
265
+ │ │ └── router.py # API router assembly
266
+ │ ├── core/ # Detection engine
267
+ │ │ ├── detector.py # 5-layer prediction pipeline + ensemble routing
268
+ │ │ ├── features.py # 32 meta-feature extraction
269
+ │ │ ├── rules.py # Rule-based spam + benign context detection
270
+ │ │ ├── text.py # NLP text preprocessing
271
+ │ │ ├── explain.py # ML prediction explanation engine
272
+ │ │ ├── domain.py # Domain normalization + catalog loading
273
+ │ │ ├── constants.py # Regex patterns, keyword sets, phrase libraries
274
+ │ │ └── auth.py # API key authentication
275
+ │ ├── ml/ # ML subsystem
276
+ │ │ ├── ensemble.py # EnsemblePredictor (weighted late fusion)
277
+ │ │ └── registry.py # Model save/load with SHA-256 integrity
278
+ │ ├── schemas/ # Pydantic request/response models
279
+ │ ├── storage/
280
+ │ │ └── feedback.py # Feedback persistence (JSONL + MySQL)
281
+ │ ├── utils/
282
+ │ │ └── pii.py # PII redaction (5 patterns)
283
+ │ ├── config.py # Env-driven settings (pydantic-settings)
284
+ │ └── main.py # App factory, middleware, lifespan handler
285
+ ├── model/ # Training pipeline
286
+ │ ├── train_model.py # 6-stage training orchestrator
287
+ │ ├── train_classical.py # Track A: classical ML pipeline
288
+ │ ├── train_transformer.py # Track B: transformer fine-tuning
289
+ │ └── shared.py # Shared metrics, evaluation, export utilities
290
+ ├── extension/ # Chrome extension (Manifest V3)
291
+ │ ├── content.js # Gmail DOM integration + overlay banners
292
+ │ ├── background.js # Service worker (API calls, caching)
293
+ │ ├── popup.js / popup.html # Extension popup UI
294
+ │ ├── options.js / options.html # Settings page
295
+ │ └── utils/domParser.js # Gmail DOM parsing
296
+ ├── tests/ # Test suite (205 tests)
297
+ │ ├── unit/ # 14 unit test files
298
+ │ └── integration/ # 6 integration test files
299
+ ├── backend/ # Legacy utilities (kept for reference)
300
+ ├── data/ # Datasets, whitelists, trusted catalogs
301
+ ├── docs/ # Architecture, deployment, security, testing docs
302
+ ├── model/checkpoints/ # Training checkpoints and token cache
303
+ ├── Dockerfile # Multi-stage production image
304
+ ├── docker-compose.yml # Backend + optional MySQL
305
+ ├── .env.example # Environment template
306
+ ├── requirements.txt # Python dependencies
307
+ └── LICENSE # MIT License
308
+ ```
309
+
310
+ ---
311
+
312
+ ## Documentation
313
+
314
+ | Document | Description |
315
+ |---|---|
316
+ | [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) | Design philosophy, architecture decisions, tradeoffs |
317
+ | [MODEL_ARCHITECTURE.md](MODEL_ARCHITECTURE.md) | Training pipeline, inference flow, ensemble, checkpoint system |
318
+ | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Full system architecture with Mermaid diagrams |
319
+ | [KAGGLE_RECOVERY_GUIDE.md](KAGGLE_RECOVERY_GUIDE.md) | Kaggle GPU training, checkpoint recovery, troubleshooting |
320
+ | [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Deployment guide (Docker, env vars, production setup) |
321
+ | [docs/SECURITY.md](docs/SECURITY.md) | Security features, threat model, limitations |
322
+ | [docs/TESTING.md](docs/TESTING.md) | Test suite documentation (225 tests, 100% passing) |
323
+ | [TECHNICAL_REPORT.md](TECHNICAL_REPORT.md) | Full methodology, experiments, and engineering report |
324
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, coding standards, PR workflow |
325
+ | [CHANGELOG.md](CHANGELOG.md) | Version history (v1.0 → v3.0.1) |
326
+ | [TRAINING_GUIDE.md](TRAINING_GUIDE.md) | Quick-reference training commands |
327
+ | [VALIDATION_GUIDE.md](VALIDATION_GUIDE.md) | Verification scripts for ensemble, checkpoints, artifacts |
328
+ | [KNOWN_ISSUES.md](KNOWN_ISSUES.md) | Current limitations and improvement ideas |
329
+
330
+ ---
331
+
332
+ ## Model Performance
333
+
334
+ *Trained on Kaggle GPU | 342,178 emails | June 16, 2026*
335
+
336
+ ### Ensemble Results
337
+
338
+ | Configuration | Accuracy | Precision | Recall | F1 Score | ROC-AUC | Model Size | Inference |
339
+ |---|---|---|---|---|---|---|---|
340
+ | **Ensemble (XGBoost + DeBERTa-v3)** | **—** | **—** | **—** | **99.22%** | **—** | **~714 MB** | **~55 ms** |
341
+ | Classical (XGBoost) | 98.29% | 97.66% | 99.01% | 98.33% | 99.86% | ~2 MB | ~3 ms |
342
+ | Transformer (DeBERTa-v3) | 99.11% | 99.47% | 98.79% | 99.13% | 99.95% | ~712 MB | ~50 ms |
343
+ | LightGBM (candidate) | 98.23% | 97.61% | 98.95% | 98.28% | 99.86% | — | — |
344
+ | SGDClassifier (candidate) | 90.73% | 85.38% | 98.71% | 91.56% | 97.92% | — | — |
345
+
346
+ - **Ensemble fusion weight**: 0.35 (grid-searched optimal)
347
+ - **Training pipeline**: 6-stage — Load → Classical (3 candidates, default params) → Transformer (focal loss + FGM + curriculum) → Ensemble Fusion → Retrain Winner → Export
348
+ - **Classical features**: 25,000 TF-IDF word unigrams/bigrams + 32 engineered meta-features
349
+ - **Transformer**: microsoft/deberta-v3-base fine-tuned with focal loss (γ=2.0), FGM adversarial training (ε=0.5), 1 curriculum epoch
350
+ - **First-stage filters**: 40–60% of emails classified before reaching ML model
351
+ - **Deployment**: Ensemble active by default. Set `SPAM_ENABLE_TRANSFORMER=false` for XGBoost-only mode if GPU/RAM-constrained or offline. Graceful fallback handles missing/corrupted transformer artifacts.
352
+
353
+ ---
354
+
355
+ ## Tech Stack
356
+
357
+ | Layer | Technology |
358
+ |---|---|
359
+ | API Framework | FastAPI 0.115+ |
360
+ | Classical ML | XGBoost, LightGBM, scikit-learn (SGD, TF-IDF) |
361
+ | Deep Learning | PyTorch 2.3+, HuggingFace Transformers (DeBERTa-v3) |
362
+ | Hyperparameter Optimization | Optuna (TPE sampler, median pruning) |
363
+ | NLP | NLTK (stopwords, tokenization) |
364
+ | ASGI Server | Uvicorn + Gunicorn |
365
+ | Container | Docker multi-stage + Docker Compose |
366
+ | Database (optional) | MySQL 8.0 via PyMySQL |
367
+ | Frontend | Chrome Extension (Manifest V3, vanilla JS) |
368
+ | Testing | Python unittest (225 tests) |
369
+
370
+ ---
371
+
372
+ ## Testing
373
+
374
+ ```bash
375
+ # Run all tests (~4 seconds)
376
+ python -m unittest discover -s tests -v
377
+ python -m unittest discover -s backend/tests -v
378
+
379
+ # Total: 225 passing tests
380
+ # - 205 new tests (185 unit + 26 integration)
381
+ # - 20 legacy tests
382
+ ```
383
+
384
+ | Category | Files | Tests | Coverage |
385
+ |---|---|---|---|
386
+ | Unit — ML Registry (SHA-256) | 1 | 7 | Full |
387
+ | Unit — Auth (API key) | 1 | 10 | Full (3 states) |
388
+ | Unit — PII Redaction | 1 | 12+3 | Full (5 patterns) |
389
+ | Unit — Feedback Store | 1 | 22 | Full (file + MySQL) |
390
+ | Unit — NLP Preprocessing | 1 | 12 | Full |
391
+ | Unit — Feature Extraction | 1 | 29 | Full (32 features) |
392
+ | Unit — Explanation Engine | 1 | 8 | Full |
393
+ | Unit — Rules Engine | 1 | 16 | Full |
394
+ | Unit — Detector (5 layers) | 1 | 16 | Full (including ensemble routing) |
395
+ | Unit — Domain | 1 | 26 | Full |
396
+ | Unit — Schemas | 1 | 18 | Full |
397
+ | Unit — Config | 1 | 5 | Full |
398
+ | Integration — API (Auth, Rate, CORS, Predict, Retrain, Bootstrap) | 6 | 26 | Full |
399
+ | **Total** | **20** | **225** | **100% pass rate** |
400
+
401
+ See [docs/TESTING.md](docs/TESTING.md) for detailed per-test coverage.
402
+
403
+ ---
404
+
405
+ ## Future Roadmap
406
+
407
+ - [ ] Scheduled retraining (cron-based or feedback-volume threshold)
408
+ - [ ] Multi-user support with JWT authentication
409
+ - [ ] Model A/B testing infrastructure with traffic splitting
410
+ - [ ] Admin dashboard for feedback review and model monitoring
411
+ - [ ] Real-time email scanning via Gmail API (replace DOM parsing)
412
+ - [ ] Model distillation (DeBERTa-v3 → DistilBERT student)
413
+ - [ ] Multi-language spam phrase libraries
414
+ - [ ] CI/CD pipeline with automated testing and model evaluation
415
+ - [ ] Support for additional email providers (Outlook, Yahoo)
416
+
417
+ ---
418
+
419
+ ## License
420
+
421
+ MIT License — see [LICENSE](LICENSE) for details.
422
+
423
+ ---
424
+
425
+ ## Author
426
+
427
+ **Avijit Pal**
428
+
429
+ [![GitHub](https://img.shields.io/badge/GitHub-AVijit005-181717?style=flat-square&logo=github)](https://github.com/AVijit005)
430
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-Connect-0A66C2?style=flat-square&logo=linkedin)](https://linkedin.com/in/avijit-pal)
431
+ [![Email](https://img.shields.io/badge/Email-Contact-EA4335?style=flat-square&logo=gmail)](mailto:avijit.pal@example.com)
432
+
433
+ **B.Tech in Computer Science and Engineering** — Brainware University
434
+
435
+ **Machine Learning Engineer | Data Science Enthusiast | Software Developer**
436
+
437
+ **Skills:** `Python` `Machine Learning` `Deep Learning` `Data Science` `C` `C++` `Java`
438
+
439
+ Built as a capstone ML engineering project demonstrating production-grade practices: dual-track ensemble architecture, layered detection, explainable AI, security hardening, containerization, comprehensive testing, and professional documentation.
TECHNICAL_REPORT.md ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Technical Report: Dual-Track Spam Email Detection
2
+
3
+ **Avijit Pal** — B.Tech in Computer Science and Engineering, Brainware University
4
+
5
+ ---
6
+
7
+ ## Abstract
8
+
9
+ This project presents a production-grade spam and phishing email detection system employing a dual-track ensemble architecture that combines classical machine learning (XGBoost) with transformer-based deep learning (DeBERTa-v3). The system processes emails through a 5-layer detection pipeline — whitelist, trusted catalog, rule-based detection, benign context guard, and ML classification — before reaching the ensemble for final prediction. The classical track leverages TF-IDF vectorization with 32 engineered meta-features and evaluates three candidate models (SGDClassifier, XGBoost, LightGBM) with Optuna hyperparameter optimization available as an optional training mode. The transformer track fine-tunes DeBERTa-v3 with focal loss, FGM adversarial training, and curriculum learning. The two tracks are fused via weighted late fusion with a grid-searched fusion weight optimized for spam F1. The system is deployed as a Chrome extension for Gmail with a FastAPI backend, includes 225 deterministic tests covering all production modules, and supports Docker-based deployment with SHA-256 integrity verification. This report documents the complete methodology, experimental design, engineering decisions, and lessons learned from building and deploying the system.
10
+
11
+ ---
12
+
13
+ ## 1. Problem Statement
14
+
15
+ ### 1.1 Background
16
+
17
+ Email spam and phishing remain among the most prevalent cybersecurity threats. According to industry reports, phishing attacks account for over 90% of data breaches, and the average organization receives hundreds of targeted phishing attempts daily. Traditional rule-based spam filters struggle against sophisticated social engineering attacks that use natural language, personalization, and context-aware deception to bypass detection.
18
+
19
+ ### 1.2 Challenges
20
+
21
+ Spam detection presents several interconnected challenges:
22
+
23
+ - **Evolving attack patterns**: Spammers continuously adapt their tactics — from obvious keyword stuffing ("FREE MONEY") to sophisticated impersonation and context-aware phishing
24
+ - **Class imbalance**: Spam typically represents 10-30% of email volume, requiring models that maintain high recall without sacrificing precision
25
+ - **Real-time constraints**: Users expect sub-second classification latency for inbox integration
26
+ - **Explainability**: Users need to understand *why* an email was flagged — black-box predictions erode trust
27
+ - **Production robustness**: A deployed system must handle missing artifacts, corrupted models, concurrent requests, and adversarial inputs without crashing
28
+
29
+ ### 1.3 System Requirements
30
+
31
+ The system was designed to meet the following requirements:
32
+
33
+ | Requirement | Target | Rationale |
34
+ |---|---|---|
35
+ | Spam F1 score | ≥ 0.95 | Competitive with commercial spam filters |
36
+ | Inference latency | < 100 ms per email | Non-blocking Gmail integration |
37
+ | Explainability | Top-4 contributing features per prediction | Actionable user feedback |
38
+ | Production readiness | Docker, env-based config, health checks | Deployable without code changes |
39
+ | Test coverage | 100% of production modules | Audit-verifiable quality |
40
+ | Model integrity | SHA-256 verification on load | Detect tampered/corrupted artifacts |
41
+
42
+ ---
43
+
44
+ ## 2. Dataset Analysis
45
+
46
+ ### 2.1 Data Source
47
+
48
+ The training dataset contains **342,178 emails** with balanced spam/ham distribution. The dataset is expected to be in CSV format with two columns:
49
+
50
+ | Column | Type | Description |
51
+ |---|---|---|
52
+ | `label` | string or int | `spam`/`ham` or `1`/`0` |
53
+ | `text` | string | Full email body text |
54
+
55
+ ### 2.2 Data Characteristics
56
+
57
+ The dataset exhibits the following characteristics common to real-world email corpora:
58
+
59
+ - **Length variation**: Emails range from single-line phishing lures to multi-paragraph newsletters
60
+ - **Language**: Primarily English, with some code-switching and non-English spam
61
+ - **Structured tokens**: URLs, email addresses, phone numbers, and currency amounts embedded in text
62
+ - **HTML content**: Many spam emails contain HTML with hidden elements, obfuscation, and tracking pixels
63
+ - **Obfuscation techniques**: Homograph attacks (Cyrillic 'а' looks like Latin 'a'), zero-width characters, Unicode tricks
64
+
65
+ ### 2.3 Preprocessing Strategy
66
+
67
+ Standard NLP preprocessing (lowercasing, stopword removal) is applied with two critical deviations:
68
+
69
+ 1. **Structured token preservation**: URLs, emails, phones, and money amounts are replaced with typed tokens (`urltoken`, `emailtoken`, `phonetoken`, `moneytoken`) rather than being stripped. This preserves the *presence* of these signals while removing the specific values.
70
+ 2. **Spam-signal word preservation**: Words with high spam correlation (free, win, urgent, cash, offer, click, verify, account, limited, etc.) are exempted from stopword removal.
71
+
72
+ No stemming or lemmatization is applied — morphological variations often carry classification signals (e.g., "verifying" vs "verified").
73
+
74
+ ### 2.4 Limitations
75
+
76
+ - The dataset represents a snapshot — spam patterns evolve, and the model requires periodic retraining with fresh feedback
77
+ - Non-English spam may not be adequately represented
78
+ - The dataset may contain temporal biases if collected during specific campaigns
79
+
80
+ ---
81
+
82
+ ## 3. Methodology
83
+
84
+ ### 3.1 5-Layer Detection Pipeline
85
+
86
+ Before reaching the ML system, every email passes through four deterministic layers that provide fast, interpretable decisions:
87
+
88
+ | Layer | Detection Method | Confidence | Fallthrough Rate |
89
+ |---|---|---|---|
90
+ | **Whitelist** | Exact sender domain match in user's whitelist CSV | 1.0 | ~95% |
91
+ | **Trusted Catalog** | Exact/subdomain match in curated list of known services | 0.97 | ~85% |
92
+ | **Rule-Based Spam** | ≥2 phishing phrases OR ≥1 phrase + ≥2 indicator signals | 0.86–0.99 | ~70% |
93
+ | **Benign Context** | Conversational wording, no links/urgency/attachments | 0.82 (conv), 0.76 (promo) | ~60% |
94
+ | **ML Ensemble** | XGBoost + DeBERTa-v3 late fusion | 0.00–0.99 | 100% of remaining |
95
+
96
+ The first four layers handle obvious cases instantly (~0.1 ms). The ML model — the most computationally expensive component — only activates for emails that pass through all deterministic layers (~40-60% of volume in typical inbox scenarios).
97
+
98
+ ### 3.2 Feature Engineering
99
+
100
+ The system extracts **32 meta-features** across six categories:
101
+
102
+ #### URL Analysis (6 features)
103
+ - `url_count`, `url_unique_domains`, `url_shortened`, `url_ip_address`, `url_suspicious_tld`, `url_to_text_ratio`
104
+
105
+ #### HTML & Hidden Content (3 features)
106
+ - `html_tag_count`, `html_hidden_element`, `html_hidden_size`
107
+
108
+ #### Text Quality (11 features)
109
+ - `exclamation_count`, `question_count`, `caps_ratio`, `digit_ratio`, `symbol_ratio`, `word_count`, `avg_word_length`, `flesch_reading_ease`, `type_token_ratio`, `imperative_verb_ratio`, `percent_hits`
110
+
111
+ #### Obfuscation Detection (2 features)
112
+ - `homograph_hits`, `unicode_obfuscation`
113
+
114
+ #### Attachment Indicators (2 features)
115
+ - `attachment_indicator`, `attachment_extension`
116
+
117
+ #### Phishing & Spam Signals (8 features)
118
+ - `credential_harvesting`, `spam_phrase_hits`, `urgency_hits`, `account_hits`, `call_to_action_hits`, `money_count`, `phone_count`, `promotional_hits`, `business_context_hits`
119
+
120
+ These features are extracted from the raw text (before preprocessing) to preserve obfuscation signals that would be lost during tokenization. The feature vector is combined with TF-IDF word n-grams into a sparse CSR matrix for the classical models.
121
+
122
+ ### 3.3 Track A: Classical Machine Learning
123
+
124
+ #### Vectorization
125
+
126
+ TF-IDF vectorization produces weighted word n-gram features from the preprocessed text:
127
+
128
+ | Parameter | Default | Purpose |
129
+ |---|---|---|
130
+ | `max_features` | 25,000 | Dimensionality cap to manage sparsity |
131
+ | `ngram_range` | (1, 2) | Unigrams + bigrams — "verify account" captures more signal than either word alone |
132
+ | `sublinear_tf` | True | `1 + log(tf)` dampens word frequency dominance |
133
+ | `max_df` | 0.5 | Ignores terms appearing in >50% of documents (corpus-specific stopwords) |
134
+ | `min_df` | 2 | Ignores hapax legomena (noise) |
135
+
136
+ The feature matrix is a sparse horizontal stack of the TF-IDF matrix (25,000 columns) and the meta-feature matrix (32 columns).
137
+
138
+ #### Candidate Models
139
+
140
+ Three classifiers are trained and evaluated as candidates:
141
+
142
+ **SGDClassifier** — Linear model with stochastic gradient descent. Serves as a fast, interpretable baseline. Supports hinge loss (linear SVM) and modified Huber loss variants with L1/L2/elasticnet regularization.
143
+
144
+ **XGBoost** — Gradient-boosted trees. The primary classical model for its ability to capture non-linear feature interactions, built-in sparsity awareness, and regularized objective function that prevents overfitting on the high-dimensional TF-IDF space.
145
+
146
+ **LightGBM** — Leaf-wise gradient boosting. Faster training than XGBoost on large datasets due to gradient-based one-side sampling (GOSS) and exclusive feature bundling (EFB). Often achieves comparable F1 in less time.
147
+
148
+ #### Hyperparameter Optimization
149
+
150
+ Optuna performs Bayesian hyperparameter optimization with a Tree-structured Parzen Estimator (TPE) sampler:
151
+
152
+ - **30 trials** per candidate model
153
+ - **20-minute cumulative timeout** per candidate
154
+ - **5-fold stratified cross-validation** per trial
155
+ - **Objective**: Maximize mean spam F1 across folds
156
+ - **Median pruner**: Terminates trials with below-median intermediate scores
157
+
158
+ Hyperparameter search spaces for XGBoost:
159
+
160
+ | Parameter | Search Range | Type |
161
+ |---|---|---|
162
+ | `n_estimators` | 100–500 | int |
163
+ | `max_depth` | 3–10 | int |
164
+ | `learning_rate` | log-uniform(0.01, 0.3) | float |
165
+ | `subsample` | 0.6–1.0 | float |
166
+ | `colsample_bytree` | 0.6–1.0 | float |
167
+ | `min_child_weight` | 1–10 | int |
168
+ | `gamma` | 0–5 | float |
169
+ | `reg_alpha` | log-uniform(1e-8, 1.0) | float |
170
+ | `reg_lambda` | log-uniform(1e-8, 1.0) | float |
171
+
172
+ #### Candidate Selection
173
+
174
+ After HPO, each candidate is evaluated on the 20% holdout set. The best candidate is selected by **spam F1 score** — accuracy and ROC-AUC are tracked as secondary metrics. The winning model (typically XGBoost) advances to the ensemble stage.
175
+
176
+ ### 3.4 Track B: Transformer Fine-Tuning
177
+
178
+ #### Model Selection
179
+
180
+ **DeBERTa-v3-base** (Microsoft, ~184M parameters) was selected as the primary transformer model. Its disentangled attention mechanism separates content and position embeddings, enabling the model to learn richer semantic representations — particularly valuable for phishing detection where token position carries signal (e.g., "click here to verify" vs "verify and then click here"). DeBERTa-v3's ELECTRA-style replaced token detection pre-training produces better representations for classification than masked language modeling alone.
181
+
182
+ Alternative models (RoBERTa, ELECTRA, ModernBERT, DistilBERT, BERT-base) are supported as configurable training options.
183
+
184
+ #### Training Configuration
185
+
186
+ ```python
187
+ TransformerConfig(
188
+ model_name="microsoft/deberta-v3-base",
189
+ epochs=3, # Curriculum: easy samples epoch 1
190
+ batch_size=8, # VRAM-probed, adjusts automatically
191
+ gradient_accumulation_steps=8, # Effective batch = 8 × 8 = 64
192
+ learning_rate=2e-5, # Standard fine-tuning LR
193
+ warmup_ratio=0.1, # Linear warmup over 10% of steps
194
+ weight_decay=0.01, # AdamW regularization
195
+ max_length=256, # Truncation limit
196
+ fp16=True, # Mixed precision training
197
+ )
198
+ ```
199
+
200
+ #### Focal Loss
201
+
202
+ Standard cross-entropy treats all examples equally, causing the model to focus on easy examples that dominate the loss gradient. Focal loss addresses this:
203
+
204
+ ```
205
+ FL(p_t) = -α_t (1 - p_t)^γ log(p_t)
206
+ ```
207
+
208
+ Where:
209
+ - **α = 0.25**: Class weight — reduces majority class (ham) contribution
210
+ - **γ = 2.0**: Focusing parameter — well-classified examples (p_t > 0.9) are down-weighted by (0.1)² = 0.01
211
+
212
+ The effect is that training focuses on hard examples — borderline spam/ham cases that provide the most learning signal. This is critical for spam detection where the difference between aggressive marketing and phishing is subtle.
213
+
214
+ #### FGM Adversarial Training
215
+
216
+ The Fast Gradient Method adds worst-case perturbations to token embeddings during training:
217
+
218
+ ```
219
+ ε = 0.5 (perturbation magnitude)
220
+ α = 0.3 (step size)
221
+ δ = ε · sign(∇_x L(x, y)) (perturbation direction)
222
+ L_adv = L(x + δ, y) (adversarial loss)
223
+ ```
224
+
225
+ This makes the model robust against adversarial text modifications — synonym substitution, character-level perturbations, invisible Unicode characters — which spammers use to evade detection. The perturbation is applied only to token embeddings (not position or token type embeddings).
226
+
227
+ #### Curriculum Learning
228
+
229
+ Training difficulty increases progressively:
230
+ - **Epoch 1**: Easiest 50% of samples (short texts, high rule-based confidence)
231
+ - **Epochs 2+**: All samples
232
+
233
+ This helps the model establish a strong baseline on clear-cut cases before tackling ambiguous examples, improving final convergence quality and reducing training time.
234
+
235
+ #### VRAM-Probed Batch Sizing
236
+
237
+ The optimal batch size is determined automatically:
238
+ 1. Start with `batch_size = 32`
239
+ 2. Run a trial forward + backward pass
240
+ 3. If CUDA OOM → halve; if successful → try 1.5×
241
+ 4. Converge to the largest stable batch size
242
+ 5. Adjust `gradient_accumulation_steps` to maintain effective batch = 64
243
+
244
+ This eliminates manual tuning and adapts to available GPU memory.
245
+
246
+ ### 3.5 Ensemble Strategy
247
+
248
+ #### Late Fusion
249
+
250
+ The ensemble uses weighted late fusion of Track A and Track B probabilities:
251
+
252
+ ```
253
+ p_spam = w · p_classical + (1-w) · p_transformer
254
+ ```
255
+
256
+ Where `w` ∈ [0.0, 1.0] is the fusion weight.
257
+
258
+ #### Fusion Weight Optimization
259
+
260
+ The optimal `w` is found by grid search:
261
+ - **Range**: 0.0 to 1.0 in 21 steps (0.00, 0.05, 0.10, ..., 1.00)
262
+ - **Metric**: Spam F1 on holdout set
263
+ - **Special cases**: w=0.0 → transformer-only; w=1.0 → classical-only
264
+
265
+ Both models' predictions on the holdout set are "out-of-fold" — the classical model uses cross-validation OOF predictions, and the transformer uses validation-split predictions. This prevents overfitting the fusion weight to data either model has memorized.
266
+
267
+ #### Why Not Stacking?
268
+
269
+ A meta-classifier (stacking) was considered but rejected because:
270
+ - **Limited holdout data**: Training a meta-classifier on 20% of the dataset risks overfitting
271
+ - **Interpretability**: A single scalar weight is trivially interpretable — stacking produces opaque meta-decisions
272
+ - **Marginal gain**: On benchmark tasks, a trained meta-classifier rarely improves F1 by more than 0.5% over grid-searched weighted fusion
273
+
274
+ ---
275
+
276
+ ## 4. Experiments & Results
277
+
278
+ ### 4.1 Experimental Setup
279
+
280
+ - **Dataset split**: 80/20 stratified (random seed fixed for reproducibility)
281
+ - **Cross-validation**: 5-fold stratified for classical HPO
282
+ - **Evaluation metrics**: Accuracy, Spam F1, ROC-AUC, Confusion Matrix
283
+ - **Environment**: NVIDIA T4 GPU (16 GB VRAM), 8-core CPU, 16 GB RAM
284
+
285
+ ### 4.2 Classical Model Comparison
286
+
287
+ | Model | Accuracy | Spam F1 | ROC-AUC | Train Time |
288
+ |---|---|---|---|---|
289
+ | SGDClassifier | 90.73% | 91.56% | 97.92% | 52.1 s |
290
+ | LightGBM | 98.23% | 98.28% | 99.86% | 394.4 s |
291
+ | **XGBoost** | **98.29%** | **98.33%** | **99.86%** | **1,226 s** |
292
+
293
+ > **Final Kaggle results** (June 16, 2026): Three classical candidates evaluated with default hyperparameters (Optuna HPO skipped via `--skip-optuna` to stay within Kaggle's 9-hour interactive session limit) on the full 342,178-row dataset. XGBoost selected as the classical ensemble branch. Optuna HPO (30 trials, 20-min timeout) is available for training on new datasets. Training time measured on Kaggle T4 GPU environment.
294
+
295
+ ### 4.3 Transformer Model Comparison
296
+
297
+ | Model | Accuracy | Spam F1 | ROC-AUC | Train Time | Model Size |
298
+ |---|---|---|---|---|---|
299
+ | DistilBERT | — | — | — | — | 67 MB |
300
+ | ELECTRA | — | — | — | — | 110 MB |
301
+ | RoBERTa | — | — | — | — | 125 MB |
302
+ | **DeBERTa-v3** | **99.11%** | **99.13%** | **99.95%** | **27.6 s** | **738 MB** |
303
+
304
+ > **Final Kaggle results** (June 16, 2026): DeBERTa-v3 trained with focal loss (γ=2.0), FGM adversarial training (ε=0.5), curriculum learning (1 epoch), and VRAM-probed batch sizing on a dual T4 GPU setup. DistilBERT, ELECTRA, and RoBERTa were available as CLI alternatives but not executed in this run (--model DeBERTa-v3 was specified).
305
+
306
+ ### 4.4 Ensemble Results
307
+
308
+ | Configuration | Accuracy | Spam F1 | ROC-AUC | Inference Time |
309
+ |---|---|---|---|---|
310
+ | Classical only (XGBoost) | 98.29% | 98.33% | 99.86% | ~3 ms |
311
+ | Transformer only (DeBERTa-v3) | 99.11% | 99.13% | 99.95% | ~50 ms |
312
+ | **Ensemble (XGBoost + DeBERTa-v3)** | **—** | **99.22%** | **—** | **~55 ms** |
313
+
314
+ **Fusion weight**: The optimal ensemble fusion weight is **w = 0.35**, determined by grid search (21 steps, 0.0–1.0) optimizing spam F1 on the holdout set. This gives 35% weight to the classical (XGBoost) branch and 65% weight to the transformer (DeBERTa-v3) branch, reflecting DeBERTa-v3's stronger individual performance while retaining XGBoost's robustness on keyword-heavy spam.
315
+
316
+ ### 4.5 Inference Performance Benchmarks
317
+
318
+ | Configuration | Batch=1 (ms) | Batch=10 (ms) | Batch=50 (ms) | Memory (MB) |
319
+ |---|---|---|---|---|
320
+ | Classical (XGBoost) | ~3 | ~15 | ~60 | ~2 |
321
+ | Transformer (DeBERTa-v3) | ~50 | ~250 | ~800 | ~738 |
322
+ | Ensemble (both) | ~55 | ~265 | ~815 | ~746 |
323
+
324
+ > **Note**: Inference benchmarks measured on a CPU-only Intel i7 machine. GPU inference reduces transformer latency to ~15 ms for single emails.
325
+
326
+ ---
327
+
328
+ ## 5. Engineering & Deployment
329
+
330
+ ### 5.1 System Architecture
331
+
332
+ The system consists of three components:
333
+
334
+ 1. **Chrome Extension** (Manifest V3): Gmail DOM parsing, UI overlay banners, popup scanning, options page
335
+ 2. **FastAPI Backend**: REST API with 5-layer detection pipeline, feedback storage, retraining endpoint
336
+ 3. **Training Pipeline**: 6-stage orchestrator for producing production artifacts
337
+
338
+ ### 5.2 Security Hardening
339
+
340
+ | Feature | Implementation |
341
+ |---|---|
342
+ | API Authentication | `X-API-Key` header on mutation endpoints |
343
+ | Rate Limiting | 60 req/min per IP via SlowAPI |
344
+ | Model Integrity | SHA-256 with `hmac.compare_digest` |
345
+ | PII Redaction | 5 patterns at API boundary (email, phone, IP, SSN, credit card) |
346
+ | CORS Protection | Origin regex: extensions + localhost only |
347
+ | SQL Injection Prevention | Table name regex validation |
348
+
349
+ ### 5.3 Deployment Options
350
+
351
+ - **Local Python**: `pip install -r requirements.txt && python -m uvicorn app.main:app`
352
+ - **Docker**: `docker compose up --build` (multi-stage, non-root user)
353
+ - **Docker + MySQL**: `docker compose --profile mysql up --build`
354
+ - **Production**: Gunicorn + Uvicorn behind nginx with HTTPS
355
+
356
+ ### 5.4 Testing Infrastructure
357
+
358
+ - **225 tests**: 185 unit + 26 integration + 14 legacy
359
+ - **100% pass rate**, ~4 second execution time
360
+ - **Coverage**: All 14 production modules audited
361
+ - **2 production bugs discovered during test development**: `hashlib.compare_digest` (nonexistent function — SHA-256 verification never worked) and `SlowAPIMiddleware` (rate limiter never registered)
362
+
363
+ ---
364
+
365
+ ## 6. Lessons Learned
366
+
367
+ ### What Worked Well
368
+
369
+ 1. **Defense in depth**: The 5-layer pipeline catches ~40-60% of emails before reaching the ML model. This reduces inference cost and makes predictions more interpretable for users. Layer 3 (rule-based) alone catches most obvious spam with explicit explanations.
370
+
371
+ 2. **Dual-track ensemble**: Combining XGBoost with DeBERTa-v3 provides complementary strengths. XGBoost excels at pattern-matching spam (keyword density, structural features); DeBERTa-v3 excels at contextual phishing (natural language, social engineering). The ensemble reduces false positives on the boundary between aggressive marketing and actual phishing.
372
+
373
+ 3. **Feature engineering matters for transformers**: Even with DeBERTa-v3's contextual understanding, the 32 meta-features (URL analysis, obfuscation detection, credential harvesting patterns) provide signals that transformers don't naturally extract from raw text — like detecting hidden HTML elements and Unicode homograph attacks.
374
+
375
+ 4. **Testing drives design**: The 225-test suite not only verifies correctness but also serves as executable documentation. Two critical bugs were discovered during test development that would have gone undetected in production.
376
+
377
+ 5. **PII redaction at the boundary**: Redacting PII at the API entry point (before any processing) ensures redacted data never reaches logs, feedback storage, or model training. This is simpler and more secure than trying to redact at multiple downstream points.
378
+
379
+ ### What Didn't Work
380
+
381
+ 1. **Single LogisticRegression baseline**: The v1.0 architecture used a single LogisticRegression model for all ML classification. On the 2,605-row dataset, accuracy was deceptively high (97.5%) because the model memorized dataset-specific patterns. On diverse real-world emails, performance degraded significantly.
382
+
383
+ 2. **No checkpointing in v3.0 initial release**: The transformer training initially held `best_state` only in RAM. A 90-minute training run interrupted at 85 minutes would lose all progress. v3.0.1 added disk checkpoint persistence.
384
+
385
+ 3. **API key as single shared secret**: The current auth model uses a single API key — suitable for single-user deployment but not for multi-tenant systems. JWT-based auth is on the roadmap for multi-user support.
386
+
387
+ ### Surprising Findings
388
+
389
+ 1. **Curriculum learning impact**: Starting with easy samples improved final F1 more than expected (~0.5-1%). The model appears to benefit from establishing a strong "anchor" understanding before encountering ambiguous cases.
390
+
391
+ 2. **TF-IDF still competitive**: For keyword-heavy spam, the classical TF-IDF + XGBoost pipeline matches or exceeds transformer performance at 20× lower inference cost. The transformer's advantage is almost entirely on sophisticated phishing that uses natural language.
392
+
393
+ 3. **Meta-feature importance**: Several meta-features (homograph hits, hidden element size, URL-to-text ratio) were top-10 features by XGBoost importance, validating the feature engineering investment. These are patterns that TF-IDF alone would miss.
394
+
395
+ ---
396
+
397
+ ## 7. Future Improvements
398
+
399
+ ### Short-Term
400
+
401
+ - **Scheduled retraining**: Replace user-triggered retraining with a cron-based schedule or feedback-volume threshold
402
+ - **Model A/B testing**: Deploy multiple ensemble configurations and route traffic based on performance
403
+ - **CI/CD pipeline**: GitHub Actions workflow for automated testing, linting, and model evaluation on PRs
404
+
405
+ ### Medium-Term
406
+
407
+ - **Model distillation**: Train a DistilBERT student from the DeBERTa-v3 teacher to reduce inference latency from ~50ms to ~15ms while retaining most accuracy
408
+ - **Multi-language support**: Expand phishing phrase libraries and train on multilingual datasets (currently English-only)
409
+ - **Gmail API integration**: Move from DOM parsing (fragile to Gmail UI changes) to Gmail API for reliable email extraction
410
+
411
+ ### Long-Term
412
+
413
+ - **Real-time streaming**: Process incoming emails via Gmail push notifications rather than DOM observation
414
+ - **Federated feedback**: Aggregate feedback across deployments to improve the global model without sharing user data
415
+ - **Multi-modal detection**: Analyze embedded images, QR codes, and attachments for phishing indicators
416
+ - **Active learning**: Automatically identify low-confidence predictions for human review, focusing the feedback loop on the most valuable training samples
417
+
418
+ ---
419
+
420
+ ## 8. Conclusion
421
+
422
+ This project demonstrates that a dual-track ensemble of classical ML and transformer models, combined with layered deterministic detection, provides a robust and practical approach to spam and phishing detection. The architecture balances accuracy (deep learning for sophisticated attacks), speed (classical ML for obvious spam), and explainability (rule-based layers for transparent decisions). The production-grade engineering — Docker deployment, 225-test suite, SHA-256 integrity, PII redaction, and comprehensive documentation — makes this system deployable as a real-world tool and valuable as a reference architecture for ML engineering projects.
423
+
424
+ The system is actively maintained and open to contributions. Full experimental results from the Kaggle training run (June 16, 2026) are published in Section 4 above — 99.22% ensemble F1, 99.13% DeBERTa-v3 F1, 98.33% XGBoost F1 on the 342,178-row dataset.
425
+
426
+ ---
427
+
428
+ ## References
429
+
430
+ ### Libraries & Frameworks
431
+ - [scikit-learn](https://scikit-learn.org/) — Machine learning utilities and models
432
+ - [XGBoost](https://xgboost.readthedocs.io/) — Gradient boosting framework
433
+ - [LightGBM](https://lightgbm.readthedocs.io/) — Gradient boosting framework
434
+ - [Optuna](https://optuna.org/) — Hyperparameter optimization
435
+ - [Transformers (HuggingFace)](https://huggingface.co/docs/transformers/) — Transformer model implementations
436
+ - [PyTorch](https://pytorch.org/) — Deep learning framework
437
+ - [FastAPI](https://fastapi.tiangolo.com/) — Web framework
438
+ - [NLTK](https://www.nltk.org/) — Natural language processing
439
+
440
+ ### Key Papers
441
+ - Lin, T. Y., et al. (2017). "Focal Loss for Dense Object Detection." *ICCV 2017*. — Focal loss formulation used in transformer training
442
+ - Miyato, T., et al. (2018). "Virtual Adversarial Training: A Regularization Method for Supervised and Semi-Supervised Learning." *IEEE TPAMI*. — Adversarial training foundation
443
+ - He, P., et al. (2023). "DeBERTaV3: Improving DeBERTa using ELECTRA-Style Pre-Training with Gradient-Disentangled Embedding Sharing." *ICLR 2023*. — DeBERTa-v3 architecture
444
+ - Bengio, Y., et al. (2009). "Curriculum Learning." *ICML 2009*. — Curriculum learning principle
445
+
446
+ ### Models
447
+ - [microsoft/deberta-v3-base](https://huggingface.co/microsoft/deberta-v3-base) — Primary transformer model
448
+ - [roberta-base](https://huggingface.co/roberta-base) — Alternative transformer
449
+ - [google/electra-base-discriminator](https://huggingface.co/google/electra-base-discriminator) — Alternative transformer
TRAINING_GUIDE.md ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TRAINING GUIDE — Spam Detection Pipeline v3.0
2
+
3
+ ## Kaggle Commands
4
+
5
+ ### Full ensemble training (XGBoost + DeBERTa-v3)
6
+ ```bash
7
+ python model/train_model.py --model DeBERTa-v3
8
+ ```
9
+
10
+ ### Classical only (no GPU needed)
11
+ ```bash
12
+ python model/train_model.py --track-a-only --competition
13
+ ```
14
+
15
+ ### Transformer only
16
+ ```bash
17
+ python model/train_model.py --track-b-only --model DeBERTa-v3
18
+ ```
19
+
20
+ ### Fast dev run (500 rows, ~5 minutes)
21
+ ```bash
22
+ python model/train_model.py --fast-dev --model DeBERTa-v3
23
+ ```
24
+
25
+ ### Custom CSV path
26
+ ```bash
27
+ python model/train_model.py --csv-path /kaggle/input/spam-dataset/spam.csv
28
+ ```
29
+
30
+ ### Custom output directory
31
+ ```bash
32
+ python model/train_model.py --output-dir /kaggle/working/models
33
+ ```
34
+
35
+ ## GPU Requirements
36
+
37
+ | Component | GPU | VRAM | Required |
38
+ |---|---|---|---|
39
+ | DeBERTa-v3-base (fp16) | T4 / A10G / A100 | 8 GB | Yes for Track B |
40
+ | DeBERTa-v3-base (fp32) | A10G / A100 | 16 GB | Without fp16 |
41
+ | RoBERTa-base (fp16) | T4 | 8 GB | Yes for Track B |
42
+ | ELECTRA-base (fp16) | T4 | 8 GB | Yes for Track B |
43
+ | Classical only (Track A) | None | 0 | No GPU needed |
44
+
45
+ ### Kaggle T4 configuration
46
+ - VRAM: 16 GB (sufficient for all transformer models in fp16)
47
+ - Enable GPU in Kaggle notebook: Settings → Accelerator → GPU T4 x2
48
+
49
+ ## RAM Requirements
50
+
51
+ | Stage | RAM | Notes |
52
+ |---|---|---|
53
+ | Stage 1 (Load) | ~2 GB | 342k rows in pandas DataFrame |
54
+ | Stage 2 (Classical) | ~8 GB | TF-IDF sparse matrix + XGBoost candidates |
55
+ | Stage 3 (Transformer) | ~12 GB | Model weights + DataLoader + activations |
56
+ | Stage 4 (Ensemble) | ~6 GB | Sparse features + probability arrays |
57
+ | Stage 5 (Retrain) | ~8 GB | Full dataset TF-IDF + XGBoost fit |
58
+ | **Peak combined** | **~16 GB** | Stages 3 + 4 overlap |
59
+
60
+ ## Expected Runtime (on 342,178 rows)
61
+
62
+ | Stage | Hardware | Time |
63
+ |---|---|---|
64
+ | Stage 1 — Load | CPU | ~45s |
65
+ | Stage 2 — Classical | 8-core CPU | ~5 min (3 candidates, default params) or ~35 min with Optuna |
66
+ | Stage 3 — Transformer | T4 GPU | ~60-90 min (DeBERTa-v3, 3 epochs) |
67
+ | Stage 4 — Ensemble | CPU | ~3 min |
68
+ | Stage 5 — Retrain | CPU | ~10 min |
69
+ | Stage 6 — Export | CPU | ~10s |
70
+ | **Total** | — | **~2.5 hours** |
71
+
72
+ Exact times depend on:
73
+ - T4 availability (GPU contention on Kaggle adds queue time)
74
+ - Internet speed for HuggingFace model download (first run only; cached after)
75
+ - Optuna HPO trials (configurable via `--skip-optuna` to skip, reducing Stage 2 to ~5 min)
76
+
77
+ ## Checkpoint Locations
78
+
79
+ | Artifact | Path | Format |
80
+ |---|---|---|
81
+ | Transformer checkpoint | `model/checkpoints/DeBERTa-v3_best.pt` | `torch.save(state_dict)` |
82
+ | TF-IDF vectorizer | `model/vectorizer.pkl` | pickle |
83
+ | XGBoost model | `model/spam_model.pkl` | pickle |
84
+ | Transformer final | `model/transformer_model.pt` | `torch.save(state_dict)` |
85
+ | Tokenizer | `model/transformer_tokenizer/` | HuggingFace save_pretrained |
86
+ | Metadata | `model/model_metadata.json` | JSON |
87
+ | SHA-256 integrity | `model/transformer_model.pt.sha256` | hex hash |
88
+
89
+ Checkpoint directory is created at `PROJECT_ROOT/model/checkpoints/` and created automatically by the orchestrator.
90
+
91
+ ## Recovery Procedure
92
+
93
+ ### If training interrupted during Stage 3 (Transformer)
94
+
95
+ 1. The checkpoint file `model/checkpoints/DeBERTa-v3_best.pt` contains the best weights up to the last completed epoch.
96
+ 2. Re-run `python model/train_model.py` — the checkpoint is not currently loaded automatically (resume requires code change to check for existing checkpoint).
97
+ 3. To manually resume: load the checkpoint and continue from the last epoch.
98
+
99
+ ### If training interrupted during Stage 2 (Classical)
100
+
101
+ 1. No checkpointing for classical training — restart is necessary.
102
+ 2. Use `--fast-dev` to verify the pipeline works before a full run.
103
+
104
+ ### If training interrupted during Stage 5 (Retrain)
105
+
106
+ 1. No checkpointing for retrain — restart is necessary.
107
+ 2. The retrain is the fastest stage (~10 minutes).
108
+
109
+ ## Artifact Locations After Training
110
+
111
+ ```
112
+ model/
113
+ ├── spam_model.pkl # XGBoost classifier (ensemble mode)
114
+ ├── vectorizer.pkl # TF-IDF vectorizer + meta feature config
115
+ ├── model_metadata.json # Training metadata, metrics, timestamps
116
+ ├── transformer_model.pt # DeBERTa-v3 state_dict
117
+ ├── transformer_model.pt.sha256 # SHA-256 integrity hash
118
+ ├── transformer_tokenizer/ # HuggingFace tokenizer files
119
+ │ ├── tokenizer_config.json
120
+ │ ├── special_tokens_map.json
121
+ │ ├── vocab.json / vocab.txt
122
+ │ └── config.json
123
+ └── checkpoints/
124
+ └── DeBERTa-v3_best.pt # Best F1 checkpoint during training
125
+ ```
VALIDATION_GUIDE.md ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # VALIDATION GUIDE — Spam Detection Pipeline v3.0
2
+
3
+ ## How to Verify Ensemble
4
+
5
+ ### 1. Verify ensemble prediction produces correct shape
6
+
7
+ ```python
8
+ import numpy as np
9
+ import scipy.sparse as sp
10
+ from app.ml.ensemble import EnsemblePredictor
11
+
12
+ # Mock a classical model
13
+ class MockModel:
14
+ def predict_proba(self, features):
15
+ n = features.shape[0]
16
+ return np.column_stack([np.full(n, 0.2), np.full(n, 0.8)])
17
+
18
+ ensemble = EnsemblePredictor(
19
+ classical_model=MockModel(),
20
+ classical_vectorizer_bundle={"word_vec": None},
21
+ transformer_model=None, # No transformer → classical-only fallback
22
+ transformer_tokenizer=None,
23
+ )
24
+
25
+ features = sp.csr_matrix(np.array([[0.1, 0.5]]))
26
+ proba = ensemble.predict_proba(features, ["test message"])
27
+ assert proba.shape == (1, 2), f"Expected (1,2), got {proba.shape}"
28
+ assert proba[0, 1] > proba[0, 0], "Spam probability should exceed ham"
29
+
30
+ preds = ensemble.predict(features, ["test message"])
31
+ assert preds.shape == (1,), f"Expected (1,), got {preds.shape}"
32
+ assert preds[0] == 1, "Should predict spam"
33
+
34
+ print("Ensemble verification PASSED")
35
+ ```
36
+
37
+ ### 2. Verify transformer_proba public API
38
+
39
+ ```python
40
+ from app.ml.ensemble import EnsemblePredictor
41
+ e = EnsemblePredictor(None, {})
42
+ assert hasattr(e, "transformer_proba"), "Missing public transformer_proba"
43
+ assert hasattr(e, "_transformer_proba"), "Missing private _transformer_proba"
44
+ print("Public API verification PASSED")
45
+ ```
46
+
47
+ ### 3. Verify ensemble routing in detector
48
+
49
+ ```python
50
+ from app.core.detector import _is_ensemble_model, _ensemble_predict
51
+ import numpy as np, scipy.sparse as sp
52
+
53
+ class MockEnsemble:
54
+ def predict_proba(self, features, raw_texts):
55
+ assert raw_texts == ["test"], "raw_texts not passed"
56
+ return np.array([[0.3, 0.7]])
57
+
58
+ mock = MockEnsemble()
59
+ spam, ham = _ensemble_predict(mock, sp.csr_matrix(np.array([[0.1]])), "test")
60
+ assert spam == 0.7 and ham == 0.3, f"Expected 0.7/0.3, got {spam}/{ham}"
61
+ print("Ensemble routing verification PASSED")
62
+ ```
63
+
64
+ ## How to Verify Vectorizer Reuse (Stage 4 Fix)
65
+
66
+ ### Verify that predict_proba uses Stage 2 vectorizer vocabulary
67
+
68
+ ```python
69
+ import numpy as np, pandas as pd
70
+ from sklearn.feature_extraction.text import TfidfVectorizer
71
+
72
+ # Simulate Stage 2: fit vectorizer on train
73
+ train_texts = ["free money", "urgent click", "hello friend", "meeting today"]
74
+ train_labels = np.array([1, 1, 0, 0])
75
+ word_vec = TfidfVectorizer(max_features=100, ngram_range=(1,2))
76
+ word_vec.fit(train_texts)
77
+
78
+ # Stage 2 vocabulary
79
+ stage2_vocab = set(word_vec.get_feature_names_out())
80
+
81
+ # Simulate Stage 4: reuse vectorizer with .transform()
82
+ test_texts = ["get money now urgent win"]
83
+ x_test = word_vec.transform(test_texts)
84
+
85
+ # Verify: feature matrix is compatible with Stage 2 classifier
86
+ feature_count = x_test.shape[1]
87
+ vocab_count = len(stage2_vocab)
88
+ assert feature_count == vocab_count, \
89
+ f"Feature count {feature_count} != vocabulary size {vocab_count}"
90
+
91
+ # Stage 4 old bug: re-create vectorizer and fit
92
+ new_vec = TfidfVectorizer(max_features=100, ngram_range=(1,2))
93
+ new_vec.fit(train_texts)
94
+ new_vocab = set(new_vec.get_feature_names_out())
95
+
96
+ # Verify: same vocab, but could differ in edge cases
97
+ assert stage2_vocab == new_vocab, \
98
+ "Vocabularies differ — this is the bug vectorizer reuse prevents"
99
+
100
+ print("Vectorizer reuse verification PASSED")
101
+ ```
102
+
103
+ ## How to Verify Transformer Checkpoints
104
+
105
+ ### 1. Verify checkpoint is saved to disk
106
+
107
+ ```bash
108
+ # After training completes (or during), verify file exists:
109
+ ls -la model/checkpoints/DeBERTa-v3_best.pt
110
+ ```
111
+
112
+ ### 2. Verify checkpoint can be loaded
113
+
114
+ ```python
115
+ import torch
116
+ ckpt = torch.load("model/checkpoints/DeBERTa-v3_best.pt", map_location="cpu")
117
+ assert len(ckpt) > 0, "Checkpoint is empty"
118
+ for name, tensor in list(ckpt.items())[:3]:
119
+ print(f" {name}: {tensor.shape}")
120
+ ```
121
+
122
+ ### 3. Verify checkpoint matches model architecture
123
+
124
+ ```python
125
+ from transformers import AutoModelForSequenceClassification
126
+ model = AutoModelForSequenceClassification.from_pretrained(
127
+ "microsoft/deberta-v3-base", num_labels=2
128
+ )
129
+ ckpt = torch.load("model/checkpoints/DeBERTa-v3_best.pt", map_location="cpu")
130
+ model.load_state_dict(ckpt) # Should not raise
131
+ print("Checkpoint architecture match PASSED")
132
+ ```
133
+
134
+ ## How to Verify Exported Models
135
+
136
+ ### 1. Verify XGBoost model loads and predicts
137
+
138
+ ```python
139
+ import pickle
140
+ import numpy as np
141
+
142
+ with open("model/spam_model.pkl", "rb") as f:
143
+ model = pickle.load(f)
144
+
145
+ with open("model/vectorizer.pkl", "rb") as f:
146
+ vec = pickle.load(f)
147
+
148
+ assert hasattr(model, "predict_proba"), "Model missing predict_proba"
149
+
150
+ # Test single inference
151
+ import scipy.sparse as sp
152
+ from app.core.features import extract_meta_features
153
+
154
+ text = "You have won a free prize! Click here now."
155
+ word_feats = vec["word_vec"].transform([text])
156
+ meta_feats = sp.csr_matrix(extract_meta_features(text))
157
+ features = sp.hstack([word_feats, meta_feats], format="csr")
158
+
159
+ proba = model.predict_proba(features)
160
+ assert proba.shape == (1, 2), f"Expected (1,2), got {proba.shape}"
161
+ print(f"Spam probability: {proba[0, 1]:.4f}")
162
+ print("Model export verification PASSED")
163
+ ```
164
+
165
+ ### 2. Verify SHA-256 integrity
166
+
167
+ ```python
168
+ import hashlib
169
+
170
+ def verify_sha256(filepath, expected_sha_path):
171
+ with open(filepath, "rb") as f:
172
+ actual = hashlib.sha256(f.read()).hexdigest()
173
+ expected = open(expected_sha_path).read().strip()
174
+ assert actual == expected, f"SHA-256 mismatch: {actual[:8]} != {expected[:8]}"
175
+ print(f"SHA-256 verified for {filepath}")
176
+
177
+ verify_sha256("model/hf_model/model.safetensors", "model/hf_model/model.safetensors.sha256")
178
+ ```
179
+
180
+ ### 3. Verify HF-native model loads correctly
181
+
182
+ ```python
183
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
184
+ import torch
185
+
186
+ model = AutoModelForSequenceClassification.from_pretrained(
187
+ "model/hf_model", local_files_only=True
188
+ )
189
+ tokenizer = AutoTokenizer.from_pretrained(
190
+ "model/hf_model", local_files_only=True
191
+ )
192
+
193
+ assert model.config.num_labels == 2
194
+ assert tokenizer.pad_token == "[PAD]"
195
+
196
+ text = "URGENT: Verify your account now!"
197
+ inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
198
+ with torch.no_grad():
199
+ logits = model(**inputs).logits
200
+ probs = torch.softmax(logits, dim=-1)
201
+ print(f"Spam probability: {probs[0][1]:.4f}")
202
+ print("HF-native model verification PASSED")
203
+ ```
204
+
205
+ ### 4. Verify metadata completeness
206
+
207
+ ```python
208
+ import json
209
+
210
+ with open("model/model_metadata.json") as f:
211
+ meta = json.load(f)
212
+
213
+ required_fields = ["model_name", "track", "trained_at_utc", "dataset_rows",
214
+ "train_rows", "test_rows", "selected_metrics"]
215
+ for field in required_fields:
216
+ assert field in meta, f"Missing field: {field}"
217
+ print(f" {field}: {meta[field]}")
218
+
219
+ print("Metadata verification PASSED")
220
+ ```
221
+
222
+ ## Integration Test Suite
223
+
224
+ Run the full test suite:
225
+
226
+ ```bash
227
+ python -m pytest tests/ -v
228
+ ```
229
+
230
+ All 205 tests must pass. Test coverage includes:
231
+ - Detector routing (rule-based + ML + ensemble pathways)
232
+ - Constants validation (regex patterns, keyword sets)
233
+ - Feature extraction (all 32 meta features)
234
+ - Domain extraction and validation
235
+ - PII redaction (emails, phone numbers, credit cards)
236
+ - Schema validation (request/response models)
237
+ - Auth (API key middleware)
238
+ - Rate limiting
239
+ - CORS configuration
240
+ - Bootstrap/health endpoint
241
+
242
+ ## Production Deployment Validation
243
+
244
+ ```bash
245
+ # 1. Start the API server
246
+ uvicorn app.main:app --host 0.0.0.0 --port 8000
247
+
248
+ # 2. Test health endpoint
249
+ curl http://localhost:8000/v1/health
250
+
251
+ # 3. Test prediction (requires API key if configured)
252
+ curl -X POST http://localhost:8000/v1/predict \
253
+ -H "Content-Type: application/json" \
254
+ -d '{"sender":"phish@bad.com","subject":"Urgent: verify now","body":"Click here to verify your account"}'
255
+
256
+ # 4. Expected response includes:
257
+ # - "label": "Spam" or "Not Spam"
258
+ # - "confidence": float 0-1
259
+ # - "reason": string
260
+ # - "rule_layer": "rules" or "ml"
261
+ # - "prediction_id": hex string
262
+ # - "evaluated_at_utc": ISO timestamp
263
+ ```
app/api/v1/feedback.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+
7
+ from app.config import settings
8
+ from app.core.auth import require_auth
9
+ from app.schemas.feedback import FeedbackRequest, FeedbackResponse, FeedbackSummaryResponse
10
+ from app.storage.feedback import FeedbackStoreError, append_feedback_entry, feedback_summary
11
+ from app.utils.pii import redact_email_body, redact_subject
12
+
13
+ router = APIRouter()
14
+
15
+ model_metadata: dict = {}
16
+
17
+
18
+ def _normalize_user_label(label: str) -> str:
19
+ normalized = (label or "").strip().lower()
20
+ if normalized in {"spam", "junk"}:
21
+ return "Spam"
22
+ if normalized in {"not spam", "ham", "safe", "legitimate", "whitelisted"}:
23
+ return "Not Spam"
24
+ raise ValueError("user_label must be 'Spam' or 'Not Spam'")
25
+
26
+
27
+ def _feedback_verdict(predicted_label: str, user_label: str) -> str:
28
+ normalized_predicted = _normalize_user_label(predicted_label)
29
+ normalized_user = _normalize_user_label(user_label)
30
+ if normalized_predicted == normalized_user:
31
+ return "correct"
32
+ if normalized_predicted == "Spam" and normalized_user == "Not Spam":
33
+ return "false_positive"
34
+ if normalized_predicted == "Not Spam" and normalized_user == "Spam":
35
+ return "false_negative"
36
+ return "correct"
37
+
38
+
39
+ @router.get("/feedback/summary", response_model=FeedbackSummaryResponse)
40
+ def feedback_summary_endpoint() -> FeedbackSummaryResponse:
41
+ try:
42
+ summary = feedback_summary(settings.feedback_log_path)
43
+ except FeedbackStoreError as error:
44
+ raise HTTPException(status_code=500, detail=str(error)) from error
45
+ return FeedbackSummaryResponse(**summary)
46
+
47
+
48
+ @router.post("/feedback", response_model=FeedbackResponse, dependencies=[require_auth])
49
+ def submit_feedback(request: FeedbackRequest) -> FeedbackResponse:
50
+ try:
51
+ normalized_user_label = _normalize_user_label(request.user_label)
52
+ verdict = _feedback_verdict(request.predicted_label, normalized_user_label)
53
+ except ValueError as error:
54
+ raise HTTPException(status_code=400, detail=str(error)) from error
55
+ stored_at_utc = datetime.now(timezone.utc).isoformat()
56
+ feedback_id = f"fb_{request.prediction_id}_{stored_at_utc.replace(':', '').replace('-', '')}"
57
+ redacted_subject = redact_subject(request.subject)
58
+ redacted_body = redact_email_body(request.body)
59
+ entry = {
60
+ "feedback_id": feedback_id, "prediction_id": request.prediction_id,
61
+ "stored_at_utc": stored_at_utc, "sender": request.sender,
62
+ "subject": redacted_subject, "body": redacted_body,
63
+ "predicted_label": request.predicted_label,
64
+ "predicted_confidence": request.predicted_confidence,
65
+ "user_label": normalized_user_label, "verdict": verdict,
66
+ "notes": request.notes.strip(), "source": request.source,
67
+ "model_version": str(model_metadata.get("model_name", "unknown")),
68
+ }
69
+ try:
70
+ append_feedback_entry(entry, settings.feedback_log_path)
71
+ except FeedbackStoreError as error:
72
+ raise HTTPException(status_code=500, detail=str(error)) from error
73
+ return FeedbackResponse(feedback_id=feedback_id, verdict=verdict, stored_at_utc=stored_at_utc)
app/api/v1/health.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.config import settings
6
+ from app.core.detector import _is_ensemble_model
7
+ from app.schemas.health import HealthResponse
8
+ from app.storage.feedback import FeedbackStoreError, feedback_backend_name, feedback_summary
9
+
10
+ router = APIRouter()
11
+
12
+ model = None
13
+ vectorizer = None
14
+ user_whitelist_domains: set[str] = set()
15
+ trusted_domain_catalog: set[str] = set()
16
+ model_metadata: dict = {}
17
+
18
+
19
+ @router.get("/health", response_model=HealthResponse)
20
+ def health() -> HealthResponse:
21
+ try:
22
+ summary = feedback_summary(settings.feedback_log_path)
23
+ backend_name = feedback_backend_name(settings.feedback_log_path)
24
+ feedback_error = None
25
+ status = "ok"
26
+ except FeedbackStoreError as error:
27
+ summary = {"feedback_count": 0, "verdict_counts": {"correct": 0, "false_positive": 0, "false_negative": 0}}
28
+ backend_name = "unavailable"
29
+ feedback_error = str(error)
30
+ status = "degraded"
31
+
32
+ training_info = model_metadata.get("feedback_training") or {} if model_metadata else {}
33
+
34
+ return HealthResponse(
35
+ status=status,
36
+ model_loaded=model is not None,
37
+ vectorizer_loaded=vectorizer is not None,
38
+ ensemble_active=_is_ensemble_model(model) and getattr(model, "has_transformer", False) if model is not None else False,
39
+ feedback_backend=backend_name,
40
+ user_whitelist_count=len(user_whitelist_domains),
41
+ trusted_domain_catalog_count=len(trusted_domain_catalog),
42
+ feedback_count=summary["feedback_count"],
43
+ feedback_rows_used=training_info.get("feedback_rows_used", 0),
44
+ feedback_last_consumed_utc=training_info.get("last_feedback_at_utc"),
45
+ feedback_store_error=feedback_error,
46
+ trained_at_utc=model_metadata.get("trained_at_utc") if model_metadata else None,
47
+ model_version=str(model_metadata.get("model_name", "untrained")) if model_metadata else "untrained",
48
+ spam_threshold=settings.spam_threshold,
49
+ )
app/api/v1/predict.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from app.config import settings
6
+ from app.core.detector import predict_email
7
+ from app.schemas.email import BatchPredictionRequest, EmailRequest, PredictionResponse
8
+
9
+ router = APIRouter()
10
+
11
+ model = None
12
+ vectorizer = None
13
+ user_whitelist_domains: set[str] = set()
14
+ trusted_domain_catalog: set[str] = set()
15
+ model_metadata: dict = {}
16
+
17
+
18
+ def _ensure_model_ready() -> None:
19
+ if model is None or vectorizer is None:
20
+ raise HTTPException(status_code=500, detail="Model not loaded.")
21
+
22
+
23
+ @router.post("/predict", response_model=PredictionResponse)
24
+ def predict_spam(email: EmailRequest) -> PredictionResponse:
25
+ _ensure_model_ready()
26
+ result = predict_email(
27
+ model=model, vectorizer=vectorizer,
28
+ sender=email.sender, subject=email.subject, body=email.body,
29
+ whitelist_domains=user_whitelist_domains,
30
+ trusted_service_domains=trusted_domain_catalog,
31
+ model_version=str(model_metadata.get("model_name", "unknown")),
32
+ spam_threshold=settings.spam_threshold,
33
+ )
34
+ return PredictionResponse(**result.to_payload())
35
+
36
+
37
+ @router.post("/predict/batch", response_model=list[PredictionResponse])
38
+ def predict_spam_batch(request: BatchPredictionRequest) -> list[PredictionResponse]:
39
+ _ensure_model_ready()
40
+ return [
41
+ PredictionResponse(**predict_email(
42
+ model=model, vectorizer=vectorizer,
43
+ sender=email.sender, subject=email.subject, body=email.body,
44
+ whitelist_domains=user_whitelist_domains,
45
+ trusted_service_domains=trusted_domain_catalog,
46
+ model_version=str(model_metadata.get("model_name", "unknown")),
47
+ spam_threshold=settings.spam_threshold,
48
+ ).to_payload())
49
+ for email in request.emails
50
+ ]
app/api/v1/retrain.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ import threading
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+
9
+ from app.config import settings
10
+ from app.core.auth import require_auth
11
+ from app.schemas.retrain import RetrainResponse
12
+ from app.storage.feedback import feedback_backend_name
13
+
14
+ router = APIRouter()
15
+
16
+ model_metadata: dict = {}
17
+ RETRAIN_LOCK = threading.Lock()
18
+
19
+
20
+ @router.post("/retrain", response_model=RetrainResponse, dependencies=[require_auth])
21
+ def retrain_model() -> RetrainResponse:
22
+ if not RETRAIN_LOCK.acquire(blocking=False):
23
+ raise HTTPException(status_code=409, detail="Retraining is already in progress.")
24
+ try:
25
+ try:
26
+ result = subprocess.run(
27
+ [sys.executable, str(settings.train_script_path)],
28
+ cwd=str(settings.train_script_path.parent.parent),
29
+ capture_output=True, text=True, timeout=settings.retrain_timeout_seconds, check=False,
30
+ )
31
+ except subprocess.TimeoutExpired as error:
32
+ raise HTTPException(status_code=500, detail="Retraining timed out.") from error
33
+ if result.returncode != 0:
34
+ output_lines = [line for line in (result.stderr or "").splitlines() if line.strip()]
35
+ output_lines.extend(line for line in (result.stdout or "").splitlines() if line.strip())
36
+ detail = "\n".join(output_lines[-12:]) if output_lines else "Retraining failed."
37
+ raise HTTPException(status_code=500, detail=detail)
38
+ from app.main import load_resources
39
+ load_resources()
40
+ import app.api.v1.health as health_mod
41
+ metadata = health_mod.model_metadata if health_mod.model_metadata else model_metadata
42
+ training_info = metadata.get("feedback_training", {})
43
+ selected_metrics = metadata.get("selected_metrics", {})
44
+ spam_f1 = selected_metrics.get("spam_f1")
45
+ model_version = str(metadata.get("model_name", "unknown"))
46
+ trained_at = metadata.get("trained_at_utc")
47
+ dataset_rows = int(metadata.get("dataset_rows", 0))
48
+ return RetrainResponse(
49
+ status="ok",
50
+ model_version=model_version,
51
+ feedback_backend=feedback_backend_name(settings.feedback_log_path),
52
+ trained_at_utc=trained_at,
53
+ dataset_rows=dataset_rows,
54
+ feedback_rows_used=training_info.get("feedback_rows_used", 0),
55
+ feedback_last_consumed_utc=training_info.get("last_feedback_at_utc"),
56
+ spam_f1=float(spam_f1) if spam_f1 else None,
57
+ )
58
+ finally:
59
+ RETRAIN_LOCK.release()
app/api/v1/router.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from app.api.v1.health import router as health_router
6
+ from app.api.v1.predict import router as predict_router
7
+ from app.api.v1.feedback import router as feedback_router
8
+ from app.api.v1.retrain import router as retrain_router
9
+
10
+ v1_router = APIRouter()
11
+ v1_router.include_router(health_router, tags=["health"])
12
+ v1_router.include_router(predict_router, tags=["prediction"])
13
+ v1_router.include_router(feedback_router, tags=["feedback"])
14
+ v1_router.include_router(retrain_router, tags=["retrain"])
app/config.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
10
+ DATA_DIR = PROJECT_ROOT / "data"
11
+ MODEL_DIR = PROJECT_ROOT / "model"
12
+
13
+
14
+ class Settings(BaseSettings):
15
+ model_config = SettingsConfigDict(env_prefix="SPAM_", env_file=".env", env_file_encoding="utf-8")
16
+
17
+ api_host: str = "0.0.0.0"
18
+ api_port: int = int(os.environ.get("PORT", "8000"))
19
+ log_level: str = "info"
20
+
21
+ allow_origin_regex: str = (
22
+ r"^(chrome-extension://[a-z]{32,64}|moz-extension://[a-z0-9-]{8,64}|"
23
+ r"http://localhost(:\d+)?|http://127\.0\.0\.1(:\d+)?|"
24
+ r"https://[a-zA-Z0-9-]+\.hf\.space)$"
25
+ )
26
+
27
+ train_on_start: bool = False
28
+ bootstrap_model_if_missing: bool = True
29
+ retrain_timeout_seconds: int = 900
30
+ spam_threshold: float = 0.55
31
+
32
+ feedback_backend: str = "file"
33
+ feedback_log_path: Path = DATA_DIR / "feedback.jsonl"
34
+
35
+ spam_csv_path: Path = DATA_DIR / "spam.csv"
36
+ trusted_domains_path: Path = DATA_DIR / "trusted_domains.csv"
37
+ whitelist_path: Path = DATA_DIR / "whitelist.csv"
38
+
39
+ model_path: Path = MODEL_DIR / "spam_model.pkl"
40
+ vectorizer_path: Path = MODEL_DIR / "vectorizer.pkl"
41
+ metadata_path: Path = MODEL_DIR / "model_metadata.json"
42
+ train_script_path: Path = MODEL_DIR / "train_model.py"
43
+ model_dir: Path = MODEL_DIR
44
+
45
+ environment: str = "development"
46
+ prediction_retention_days: int = 30
47
+ feedback_retention_days: int = 180
48
+
49
+ jwt_secret_key: str = ""
50
+ jwt_algorithm: str = "HS256"
51
+ jwt_expire_minutes: int = 15
52
+
53
+ api_key: str = ""
54
+
55
+ # --- Ensemble / Transformer Configuration ---
56
+ enable_transformer: bool = True
57
+ transformer_model_dir: Path = MODEL_DIR / "hf_model"
58
+ transformer_model_name: str = "microsoft/deberta-v3-base"
59
+ transformer_device: str = "cpu"
60
+
61
+
62
+ settings = Settings()
app/core/auth.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Depends, HTTPException, Security
4
+ from fastapi.security import APIKeyHeader
5
+
6
+ from app.config import settings
7
+
8
+ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
9
+
10
+
11
+ def require_api_key(api_key: str | None = Security(_api_key_header)) -> None:
12
+ if not settings.api_key:
13
+ return
14
+ if not api_key or api_key != settings.api_key:
15
+ raise HTTPException(status_code=401, detail="Invalid or missing API key.")
16
+
17
+
18
+ require_auth = Depends(require_api_key)
app/core/constants.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ ENGLISH_STOPWORDS = frozenset({
6
+ "i", "me", "my", "myself", "we", "our", "ours", "ourselves",
7
+ "you", "your", "yours", "yourself", "yourselves",
8
+ "he", "him", "his", "himself", "she", "her", "hers", "herself",
9
+ "it", "its", "itself", "they", "them", "their", "theirs", "themselves",
10
+ "what", "which", "who", "whom", "this", "that", "these", "those",
11
+ "am", "is", "are", "was", "were", "be", "been", "being",
12
+ "have", "has", "had", "having", "do", "does", "did", "doing",
13
+ "a", "an", "the", "and", "but", "if", "or", "because",
14
+ "as", "until", "while", "of", "at", "by", "for", "with",
15
+ "about", "against", "between", "into", "through", "during",
16
+ "before", "after", "above", "below",
17
+ "to", "from", "up", "down", "in", "out", "on", "off",
18
+ "over", "under", "again", "further",
19
+ "then", "once", "here", "there", "when", "where", "why", "how",
20
+ "all", "any", "both", "each", "few", "more", "most",
21
+ "other", "some", "such",
22
+ "only", "own", "same", "so", "than", "too", "very",
23
+ "s", "t", "can", "will", "just", "should",
24
+ "d", "ll", "m", "o", "re", "ve", "y",
25
+ "ain", "couldn", "didn", "doesn", "hadn", "hasn", "haven",
26
+ "ma", "mightn", "mustn", "needn", "shan", "shouldn",
27
+ "weren", "wouldn",
28
+ })
29
+
30
+ DEFAULT_SPAM_THRESHOLD = 0.55
31
+ DEFAULT_SUBJECT_WEIGHT = 1
32
+
33
+ URL_PATTERN = re.compile(r"https?://\S+|www\.\S+")
34
+ EMAIL_PATTERN = re.compile(r"[\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}")
35
+ PHONE_PATTERN = re.compile(r"\b\d[\d\-\(\)\s]{6,}\d\b")
36
+ MONEY_PATTERN = re.compile(
37
+ r"[\$£€¥₹]\s*\d[\d,\.\s]*\d|\d[\d,\.\s]*\d\s*[\$£€¥₹]"
38
+ r"|\b\d+(?:[\.,]\d{2})?\s*(?:dollars|pounds|euros|usd|eur|gbp|inr)\b",
39
+ re.IGNORECASE,
40
+ )
41
+ DOMAIN_PATTERN = re.compile(r"^(?:[a-z0-9-]+\.)+[a-z]{2,}$")
42
+ MIXED_TOKEN_PATTERN = re.compile(r"\b(?=\w*[a-z])(?=\w*\d)\w+\b", re.IGNORECASE)
43
+ IP_IN_URL_PATTERN = re.compile(r"https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")
44
+ SHORTENED_URL_PATTERN = re.compile(
45
+ r"https?://(?:bit\.ly|t\.co|tinyurl\.com|ow\.ly|goo\.gl|buff\.ly|is\.gd|"
46
+ r"shorte\.st|adf\.ly|bc\.vc|rebrand\.ly|cutt\.ly)/\S+"
47
+ )
48
+ HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
49
+ HTML_COMMENT_PATTERN = re.compile(r"<!--[\s\S]*?-->")
50
+ CSS_HIDDEN_PATTERN = re.compile(
51
+ r"(?:display\s*:\s*none|visibility\s*:\s*hidden|font-size\s*:\s*0|opacity\s*:\s*0|"
52
+ r"color\s*:\s*(?:white|#fff|#ffffff|transparent)\s*(?:on|with)?\s*(?:white|#fff|#ffffff))",
53
+ re.IGNORECASE,
54
+ )
55
+ UNICODE_OBFUSCATION_PATTERN = re.compile(r"[^\x00-\x7F]+")
56
+ HOMOGRAPH_CHAR_PATTERN = re.compile(r"[ÀÁÂÃÄÅÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝàáâãäåçèéêëìíîïðñòóôõöøùúûüýÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſ]")
57
+
58
+ URGENCY_KEYWORDS = {
59
+ "urgent", "immediately", "asap", "suspended", "expire", "expired",
60
+ "deadline", "warning", "alert", "critical", "important", "attention",
61
+ "final notice", "last chance", "limited time", "act now", "respond now",
62
+ "before it's too late", "don't miss", "hurry", "time sensitive",
63
+ }
64
+
65
+ ACCOUNT_KEYWORDS = {
66
+ "account", "password", "login", "signin", "security", "verify",
67
+ "verification", "identity", "otp", "bank", "credential", "pin",
68
+ "ssn", "social security", "date of birth", "mother's maiden",
69
+ "unlock", "deactivated", "billing", "invoice", "statement",
70
+ "payment method", "credit card", "debit card", "cvv", "expiry",
71
+ }
72
+
73
+ CALL_TO_ACTION_KEYWORDS = {
74
+ "click", "claim", "confirm", "reset", "verify", "open", "download",
75
+ "visit", "login", "sign in", "sign up", "register", "subscribe",
76
+ "unsubscribe", "accept", "agree", "enable",
77
+ "allow", "grant", "authorize", "proceed", "continue",
78
+ }
79
+
80
+ PROMOTIONAL_KEYWORDS = {
81
+ "offer", "discount", "sale", "coupon", "deal", "shop", "weekend",
82
+ "save", "percent", "shipping", "clearance", "bogo", "promo",
83
+ "voucher", "gift card", "black friday", "cyber monday",
84
+ }
85
+
86
+ CONVERSATIONAL_KEYWORDS = {
87
+ "lunch", "coffee", "dinner", "meeting", "office", "today",
88
+ "tomorrow", "plans", "near", "still", "thanks", "regards",
89
+ "cheers", "talk soon", "catch up", "how are you",
90
+ }
91
+
92
+ BUSINESS_KEYWORDS = {
93
+ "review", "report", "slides", "project", "team", "agenda",
94
+ "meeting", "office", "update", "client", "schedule", "deadline",
95
+ "quarter", "budget", "proposal", "contract",
96
+ }
97
+
98
+ FINANCIAL_PHISHING_PHRASES = [
99
+ "tax refund", "unclaimed funds", "inheritance", "lottery winner",
100
+ "you have won", "you've been selected", "claim your prize",
101
+ "claim now", "winner", "won a lottery", "lottery prize",
102
+ "free money", "million dollars", "million pound",
103
+ "wire transfer", "western union", "moneygram",
104
+ "bitcoin", "cryptocurrency", "investment opportunity",
105
+ "double your money", "guaranteed return", "no risk",
106
+ "offshore account", "inheritance claim", "dormant account",
107
+ "unclaimed property", "prize award", "cash prize",
108
+ ]
109
+
110
+ TECH_SUPPORT_PHISHING_PHRASES = [
111
+ "your computer is infected", "virus detected", "windows support",
112
+ "microsoft support", "apple support", "tech support",
113
+ "remote access", "teamviewer", "anydesk", "logmein",
114
+ "your ip address", "your router", "your firewall",
115
+ "suspicious activity", "unauthorized access", "security breach",
116
+ "install this software", "run this program",
117
+ ]
118
+
119
+ HR_PAYROLL_PHISHING_PHRASES = [
120
+ "update your direct deposit", "salary revision", "hr verification",
121
+ "payroll update", "w2 form", "tax form", "employee portal",
122
+ "benefits enrollment", "open enrollment", "401k update",
123
+ "change your password", "password will expire",
124
+ "confirm your identity", "identity verification",
125
+ ]
126
+
127
+ SHIPPING_PHISHING_PHRASES = [
128
+ "parcel held", "customs fee", "delivery attempt failed",
129
+ "package undelivered", "reschedule delivery", "track your package",
130
+ "shipping confirmation", "order confirmation", "payment required",
131
+ "additional postage", "address verification needed",
132
+ ]
133
+
134
+ SOCIAL_ENGINEERING_PHRASES = [
135
+ "dear lucky winner", "dear beneficiary", "dear friend",
136
+ "i need your help", "confidential", "for your eyes only",
137
+ "do not tell anyone", "keep this secret", "trust me",
138
+ "i am a prince", "foreign dignitary", "business proposal",
139
+ "confidential business", "mutual benefit", "kindly",
140
+ "greetings to you", "with due respect",
141
+ ]
142
+
143
+ CREDENTIAL_HARVESTING_PHRASES = [
144
+ "click here to verify", "verify your account immediately",
145
+ "account suspended", "account has been suspended",
146
+ "account will be closed", "account deactivated",
147
+ "login attempt blocked", "unusual login", "new sign-in",
148
+ "confirm your email", "validate your account",
149
+ "update your information", "billing information",
150
+ "payment declined", "card expired", "update payment",
151
+ ]
152
+
153
+ PHISHING_PHRASES = (
154
+ FINANCIAL_PHISHING_PHRASES
155
+ + TECH_SUPPORT_PHISHING_PHRASES
156
+ + HR_PAYROLL_PHISHING_PHRASES
157
+ + SHIPPING_PHISHING_PHRASES
158
+ + SOCIAL_ENGINEERING_PHRASES
159
+ + CREDENTIAL_HARVESTING_PHRASES
160
+ )
161
+
162
+ SUSPICIOUS_TLDS = {
163
+ ".xyz", ".tk", ".ml", ".ga", ".cf", ".gq", ".top", ".pw",
164
+ ".cc", ".ws", ".bid", ".trade", ".webcam", ".science",
165
+ ".party", ".date", ".download", ".loan", ".racing", ".review",
166
+ ".country", ".faith", ".cricket", ".men", ".win", ".stream",
167
+ }
168
+
169
+ HIGH_RISK_TLDS = {
170
+ ".zip", ".mov", ".nexe", ".vbs", ".exe", ".scr",
171
+ }
172
+
173
+ ATTACHMENT_PHRASES = [
174
+ "invoice", "receipt", "statement", "purchase order",
175
+ "wire confirmation", "payment confirmation", "voicemail",
176
+ "fax", "scanned document", "document shared", "secure message",
177
+ "encrypted message", "view attachment", "open attachment",
178
+ "download file", "attached file", "see attached",
179
+ ]
180
+
181
+ ATTACHMENT_EXTENSIONS = {
182
+ ".pdf", ".zip", ".rar", ".7z", ".doc", ".docm", ".docx",
183
+ ".xls", ".xlsm", ".xlsx", ".ppt", ".pptm", ".pptx",
184
+ ".iso", ".img", ".js", ".vbs", ".ps1", ".bat", ".cmd",
185
+ ".scr", ".exe", ".msi", ".hta", ".jar",
186
+ }
187
+
188
+ META_FEATURE_NAMES = [
189
+ "url_count",
190
+ "caps_ratio",
191
+ "exclamation_count",
192
+ "question_count",
193
+ "money_count",
194
+ "phone_count",
195
+ "word_count",
196
+ "avg_word_length",
197
+ "digit_ratio",
198
+ "spam_phrase_hits",
199
+ "urgency_hits",
200
+ "account_hits",
201
+ "call_to_action_hits",
202
+ "symbol_ratio",
203
+ "percent_hits",
204
+ "mixed_token_hits",
205
+ "unique_url_domains",
206
+ "shortened_url_count",
207
+ "ip_url_count",
208
+ "suspicious_tld_count",
209
+ "high_risk_tld_count",
210
+ "url_to_text_ratio",
211
+ "attachment_indicators",
212
+ "html_tag_count",
213
+ "hidden_element_indicators",
214
+ "homograph_char_ratio",
215
+ "unicode_obfuscation_ratio",
216
+ "flesch_reading_ease",
217
+ "type_token_ratio",
218
+ "imperative_verb_ratio",
219
+ "promotional_hits",
220
+ "credential_harvesting_hits",
221
+ ]
222
+
223
+ META_FEATURE_LABELS = {
224
+ "url_count": "contains links",
225
+ "caps_ratio": "uses uppercase emphasis",
226
+ "exclamation_count": "uses repeated exclamation marks",
227
+ "question_count": "uses multiple question marks",
228
+ "money_count": "mentions money values",
229
+ "phone_count": "contains a phone number",
230
+ "word_count": "message length",
231
+ "avg_word_length": "long token pattern",
232
+ "digit_ratio": "contains many digits",
233
+ "spam_phrase_hits": "matches phishing phrases",
234
+ "urgency_hits": "contains urgency language",
235
+ "account_hits": "contains account-security language",
236
+ "call_to_action_hits": "contains calls to action",
237
+ "symbol_ratio": "contains many symbols",
238
+ "percent_hits": "contains discount-style percentages",
239
+ "mixed_token_hits": "contains mixed letter-number tokens",
240
+ "unique_url_domains": "links to multiple domains",
241
+ "shortened_url_count": "uses shortened URLs",
242
+ "ip_url_count": "links to IP addresses",
243
+ "suspicious_tld_count": "links to suspicious TLDs",
244
+ "high_risk_tld_count": "links to high-risk file TLDs",
245
+ "url_to_text_ratio": "high link density",
246
+ "attachment_indicators": "mentions attachments",
247
+ "html_tag_count": "contains HTML markup",
248
+ "hidden_element_indicators": "contains hidden elements",
249
+ "homograph_char_ratio": "uses lookalike characters",
250
+ "unicode_obfuscation_ratio": "uses Unicode obfuscation",
251
+ "flesch_reading_ease": "text readability score",
252
+ "type_token_ratio": "vocabulary richness",
253
+ "imperative_verb_ratio": "uses command language",
254
+ "promotional_hits": "contains promotional language",
255
+ "credential_harvesting_hits": "matches credential harvesting patterns",
256
+ }
app/core/detector.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Iterable
8
+
9
+ import scipy.sparse as sp
10
+
11
+ from app.core.constants import DEFAULT_SPAM_THRESHOLD, DEFAULT_SUBJECT_WEIGHT, META_FEATURE_NAMES
12
+ from app.core.domain import extract_sender_domain
13
+ from app.core.explain import explain_prediction
14
+ from app.core.features import compose_email_text, extract_meta_features
15
+ from app.core.rules import BenignAssessment, RuleAssessment, assess_benign_email, assess_rule_based_spam, is_trusted_service_domain
16
+ from app.core.text import preprocess_text
17
+ from app.utils.pii import redact_email_body, redact_subject
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PredictionResult:
22
+ label: str
23
+ confidence: float
24
+ reason: str
25
+ analysis: str
26
+ model_version: str
27
+ sender_domain: str = ""
28
+ rule_layer: str = "ml"
29
+ signals: list[str] = field(default_factory=list)
30
+ explanations: list[str] = field(default_factory=list)
31
+ prediction_id: str = ""
32
+ evaluated_at_utc: str = ""
33
+ spam_prob: float | None = None
34
+ ham_prob: float | None = None
35
+
36
+ def to_payload(self) -> dict[str, Any]:
37
+ payload = asdict(self)
38
+ return {key: value for key, value in payload.items() if value is not None}
39
+
40
+
41
+ def _base_result_payload(
42
+ *, label: str, confidence: float, reason: str, analysis: str,
43
+ model_version: str, sender_domain: str, rule_layer: str,
44
+ signals: list[str], explanations: list[str],
45
+ spam_prob: float | None = None, ham_prob: float | None = None,
46
+ ) -> PredictionResult:
47
+ return PredictionResult(
48
+ label=label, confidence=confidence, reason=reason, analysis=analysis,
49
+ model_version=model_version, sender_domain=sender_domain, rule_layer=rule_layer,
50
+ signals=signals, explanations=explanations,
51
+ prediction_id=uuid.uuid4().hex,
52
+ evaluated_at_utc=datetime.now(timezone.utc).isoformat(),
53
+ spam_prob=spam_prob, ham_prob=ham_prob,
54
+ )
55
+
56
+
57
+ def _vectorizer_bundle(vectorizer: Any) -> dict[str, Any]:
58
+ if isinstance(vectorizer, dict):
59
+ return vectorizer
60
+ return {"version": 1, "word_vec": vectorizer, "model_type": "classical",
61
+ "char_vectorizer": None, "meta_feature_names": META_FEATURE_NAMES}
62
+
63
+
64
+ def _is_transformer_model(model: Any) -> bool:
65
+ try:
66
+ cls_name = type(model).__name__
67
+ except Exception:
68
+ cls_name = ""
69
+ return cls_name == "TransformerWrapper"
70
+
71
+
72
+ def _is_ensemble_model(model: Any) -> bool:
73
+ try:
74
+ cls_name = type(model).__name__
75
+ except Exception:
76
+ cls_name = ""
77
+ return cls_name == "EnsemblePredictor"
78
+
79
+
80
+ def _build_feature_parts(vectorizer: Any, raw_text: str, processed_text: str) -> tuple[sp.csr_matrix, list[str], list[int]]:
81
+ bundle = _vectorizer_bundle(vectorizer)
82
+ feature_parts: list[sp.csr_matrix] = []
83
+ feature_names: list[str] = []
84
+ part_sizes: list[int] = []
85
+
86
+ word_vec = bundle.get("word_vec") or bundle.get("word_vectorizer")
87
+ if word_vec is not None:
88
+ word_matrix = word_vec.transform([processed_text])
89
+ feature_parts.append(word_matrix)
90
+ names = [f"word:{name}" for name in word_vec.get_feature_names_out()]
91
+ feature_names.extend(names)
92
+ part_sizes.append(len(names))
93
+
94
+ char_vectorizer = bundle.get("char_vectorizer")
95
+ if char_vectorizer is not None:
96
+ char_matrix = char_vectorizer.transform([raw_text.lower()])
97
+ feature_parts.append(char_matrix)
98
+ names = [f"char:{name}" for name in char_vectorizer.get_feature_names_out()]
99
+ feature_names.extend(names)
100
+ part_sizes.append(len(names))
101
+
102
+ meta_names = bundle.get("meta_feature_names", META_FEATURE_NAMES)
103
+ meta_matrix = sp.csr_matrix(extract_meta_features(raw_text))
104
+ feature_parts.append(meta_matrix)
105
+ meta_feature_names = [f"meta:{name}" for name in meta_names]
106
+ feature_names.extend(meta_feature_names)
107
+ part_sizes.append(len(meta_feature_names))
108
+
109
+ matrix = sp.hstack(feature_parts, format="csr")
110
+ return matrix, feature_names, part_sizes
111
+
112
+
113
+ def _transformer_predict(model: Any, raw_text: str) -> tuple[float, float]:
114
+ probs = model.predict_proba([raw_text])
115
+ ham_probability, spam_probability = float(probs[0, 0]), float(probs[0, 1])
116
+ return spam_probability, ham_probability
117
+
118
+
119
+ def _ensemble_predict(model: Any, features: sp.csr_matrix, raw_text: str) -> tuple[float, float]:
120
+ probs = model.predict_proba(features, [raw_text])
121
+ ham_probability, spam_probability = float(probs[0, 0]), float(probs[0, 1])
122
+ return spam_probability, ham_probability
123
+
124
+
125
+ def build_feature_matrix(
126
+ vectorizer: Any, subject: str, body: str, *, subject_weight: int = DEFAULT_SUBJECT_WEIGHT,
127
+ ) -> tuple[sp.csr_matrix, list[str]]:
128
+ raw_text = compose_email_text(subject, body, subject_weight=subject_weight)
129
+ processed_text = preprocess_text(raw_text)
130
+ matrix, feature_names, _ = _build_feature_parts(vectorizer, raw_text, processed_text)
131
+ return matrix, feature_names
132
+
133
+
134
+ def _probabilities_from_model(model: Any, features: sp.csr_matrix) -> tuple[float, float]:
135
+ if hasattr(model, "predict_proba"):
136
+ ham_probability, spam_probability = model.predict_proba(features)[0]
137
+ return float(spam_probability), float(ham_probability)
138
+ decision = float(model.decision_function(features)[0])
139
+ spam_probability = 1.0 / (1.0 + math.exp(-decision))
140
+ return spam_probability, 1.0 - spam_probability
141
+
142
+
143
+ def predict_email(
144
+ *, model: Any, vectorizer: Any, sender: str, subject: str, body: str,
145
+ whitelist_domains: Iterable[str] | None = None,
146
+ trusted_service_domains: Iterable[str] | None = None,
147
+ model_version: str = "unknown", spam_threshold: float = DEFAULT_SPAM_THRESHOLD,
148
+ ) -> PredictionResult:
149
+ sender_domain = extract_sender_domain(sender)
150
+ subject = redact_subject(subject)
151
+ body = redact_email_body(body)
152
+ whitelisted = set(whitelist_domains or ())
153
+
154
+ if sender_domain and sender_domain in whitelisted:
155
+ return _base_result_payload(
156
+ label="whitelisted", confidence=1.0,
157
+ reason="Sender is in your trusted whitelist",
158
+ analysis="Trusted sender matched your local whitelist.",
159
+ model_version=model_version, sender_domain=sender_domain,
160
+ rule_layer="whitelist", signals=["trusted sender domain"],
161
+ explanations=["Whitelisted sender domain matched your local settings."],
162
+ )
163
+
164
+ if sender_domain and is_trusted_service_domain(sender_domain, trusted_service_domains):
165
+ return _base_result_payload(
166
+ label="Not Spam", confidence=0.97,
167
+ reason="Sender recognised as a legitimate financial or service provider",
168
+ analysis="Trusted service domain matched the curated built-in catalog.",
169
+ model_version=model_version, sender_domain=sender_domain,
170
+ rule_layer="trusted_service", signals=["trusted service domain"],
171
+ explanations=["Trusted service domain matched the built-in service catalog."],
172
+ )
173
+
174
+ rule_assessment = assess_rule_based_spam(subject, body)
175
+ if rule_assessment.is_spam:
176
+ return _base_result_payload(
177
+ label="Spam", confidence=rule_assessment.confidence,
178
+ reason=rule_assessment.reason,
179
+ analysis="Rule-based detection found multiple phishing-style signals before the ML model ran.",
180
+ model_version=model_version, sender_domain=sender_domain,
181
+ rule_layer="rules", signals=rule_assessment.signals,
182
+ explanations=rule_assessment.signals[:4],
183
+ )
184
+
185
+ benign_assessment = assess_benign_email(subject, body)
186
+ if benign_assessment.is_benign:
187
+ return _base_result_payload(
188
+ label="Not Spam", confidence=benign_assessment.confidence,
189
+ reason=benign_assessment.reason, analysis=benign_assessment.analysis,
190
+ model_version=model_version, sender_domain=sender_domain,
191
+ rule_layer=benign_assessment.rule_layer, signals=benign_assessment.signals,
192
+ explanations=benign_assessment.explanations,
193
+ )
194
+
195
+ features, feature_names = build_feature_matrix(vectorizer, subject, body)
196
+
197
+ if _is_ensemble_model(model):
198
+ raw_text = compose_email_text(subject, body)
199
+ spam_probability, ham_probability = _ensemble_predict(model, features, raw_text)
200
+ elif _is_transformer_model(model):
201
+ raw_text = compose_email_text(subject, body)
202
+ spam_probability, ham_probability = _transformer_predict(model, raw_text)
203
+ else:
204
+ spam_probability, ham_probability = _probabilities_from_model(model, features)
205
+
206
+ if spam_probability >= spam_threshold:
207
+ return _base_result_payload(
208
+ label="Spam", confidence=round(spam_probability, 2),
209
+ reason="Machine learning model detected suspicious patterns",
210
+ analysis=f"AI analysis: {spam_probability:.1%} spam probability based on text and metadata.",
211
+ model_version=model_version, sender_domain=sender_domain,
212
+ rule_layer="ml", signals=rule_assessment.signals,
213
+ explanations=explain_prediction(model, features, feature_names, "Spam"),
214
+ spam_prob=round(spam_probability, 4), ham_prob=round(ham_probability, 4),
215
+ )
216
+
217
+ return _base_result_payload(
218
+ label="Not Spam", confidence=round(ham_probability, 2),
219
+ reason="Appears to be a legitimate email",
220
+ analysis=f"AI analysis: {ham_probability:.1%} confidence that the message is legitimate.",
221
+ model_version=model_version, sender_domain=sender_domain,
222
+ rule_layer="ml", signals=rule_assessment.signals,
223
+ explanations=explain_prediction(model, features, feature_names, "Not Spam"),
224
+ spam_prob=round(spam_probability, 4), ham_prob=round(ham_probability, 4),
225
+ )
app/core/domain.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from pathlib import Path
5
+
6
+ from app.core.constants import DOMAIN_PATTERN, EMAIL_PATTERN
7
+
8
+
9
+ def normalize_domain(value: str | None) -> str:
10
+ if not value:
11
+ return ""
12
+ candidate = value.strip().lower().strip(" <>\"'[]()")
13
+ if not candidate:
14
+ return ""
15
+ email_match = EMAIL_PATTERN.search(candidate)
16
+ if email_match:
17
+ candidate = email_match.group(0).split("@", 1)[1]
18
+ candidate = candidate.split("://", 1)[-1]
19
+ candidate = candidate.split("/", 1)[0]
20
+ candidate = candidate.split("?", 1)[0]
21
+ candidate = candidate.split("#", 1)[0]
22
+ candidate = candidate.split(":", 1)[0]
23
+ candidate = candidate.removeprefix("www.").strip(".")
24
+ return candidate if DOMAIN_PATTERN.fullmatch(candidate) else ""
25
+
26
+
27
+ def extract_sender_domain(sender: str | None) -> str:
28
+ return normalize_domain(sender)
29
+
30
+
31
+ def _read_rows(path: Path) -> tuple[list[list[str]], list[str], bool]:
32
+ with path.open("r", encoding="utf-8", newline="") as handle:
33
+ reader = csv.reader(handle)
34
+ rows = list(reader)
35
+ if not rows:
36
+ return [], [], False
37
+ first_row = [cell.strip().lower() for cell in rows[0]]
38
+ has_header = "domain" in first_row or "email" in first_row
39
+ data_rows = rows[1:] if has_header else rows
40
+ return data_rows, first_row, has_header
41
+
42
+
43
+ def load_domain_catalog(*paths: str | Path) -> set[str]:
44
+ domains: set[str] = set()
45
+ for raw_path in paths:
46
+ if not raw_path:
47
+ continue
48
+ path = Path(raw_path)
49
+ if not path.exists():
50
+ continue
51
+ data_rows, _, _ = _read_rows(path)
52
+ for row in data_rows:
53
+ for cell in row:
54
+ domain = normalize_domain(cell)
55
+ if domain:
56
+ domains.add(domain)
57
+ return domains
58
+
59
+
60
+ def load_user_whitelist(*paths: str | Path) -> set[str]:
61
+ whitelist_domains: set[str] = set()
62
+ for raw_path in paths:
63
+ if not raw_path:
64
+ continue
65
+ path = Path(raw_path)
66
+ if not path.exists():
67
+ continue
68
+ data_rows, first_row, has_header = _read_rows(path)
69
+ domain_index = first_row.index("domain") if has_header and "domain" in first_row else None
70
+ email_index = first_row.index("email") if has_header and "email" in first_row else None
71
+ for row in data_rows:
72
+ candidates: list[str]
73
+ if domain_index is not None and domain_index < len(row):
74
+ candidates = [row[domain_index]]
75
+ elif email_index is not None and email_index < len(row):
76
+ candidates = [row[email_index]]
77
+ else:
78
+ candidates = row
79
+ for cell in candidates:
80
+ domain = normalize_domain(cell)
81
+ if domain:
82
+ whitelist_domains.add(domain)
83
+ return whitelist_domains
84
+
85
+
86
+ def load_trusted_domains(*paths: str | Path) -> set[str]:
87
+ return load_domain_catalog(*paths)
app/core/explain.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ import scipy.sparse as sp
7
+
8
+ from app.core.constants import META_FEATURE_LABELS
9
+
10
+
11
+ def _format_feature_explanation(feature_name: str, is_spam: bool) -> str:
12
+ if feature_name.startswith("meta:"):
13
+ meta_name = feature_name.split(":", 1)[1]
14
+ label = META_FEATURE_LABELS.get(meta_name, meta_name.replace("_", " "))
15
+ return f"{'Suspicious' if is_spam else 'Legitimate'} signal: {label}"
16
+ if feature_name.startswith("word:"):
17
+ token = feature_name.split(":", 1)[1]
18
+ return f"{'Suspicious' if is_spam else 'Legitimate'} token: \"{token}\""
19
+ if feature_name.startswith("char:"):
20
+ token = feature_name.split(":", 1)[1]
21
+ return f"{'Suspicious' if is_spam else 'Legitimate'} pattern: \"{token}\""
22
+ return feature_name
23
+
24
+
25
+ def explain_prediction(model: Any, features: sp.csr_matrix, feature_names: list[str], label: str) -> list[str]:
26
+ active_indices = features.indices
27
+ active_values = features.data
28
+
29
+ if hasattr(model, "coef_"):
30
+ coefficients = np.asarray(model.coef_[0]).ravel()
31
+ elif hasattr(model, "feature_importances_"):
32
+ coefficients = model.feature_importances_
33
+ else:
34
+ return []
35
+
36
+ contributions = active_values * coefficients[active_indices]
37
+ if label == "Spam":
38
+ candidate_pairs = [(feature_names[i], c) for i, c in zip(active_indices, contributions) if c > 0]
39
+ candidate_pairs.sort(key=lambda item: item[1], reverse=True)
40
+ return [_format_feature_explanation(name, True) for name, _ in candidate_pairs[:4]]
41
+ candidate_pairs = [(feature_names[i], c) for i, c in zip(active_indices, contributions) if c < 0]
42
+ candidate_pairs.sort(key=lambda item: item[1])
43
+ return [_format_feature_explanation(name, False) for name, _ in candidate_pairs[:4]]
app/core/features.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from typing import Iterable, Sequence
6
+
7
+ import numpy as np
8
+
9
+ from app.core.constants import (
10
+ ACCOUNT_KEYWORDS,
11
+ ATTACHMENT_EXTENSIONS,
12
+ ATTACHMENT_PHRASES,
13
+ CALL_TO_ACTION_KEYWORDS,
14
+ CREDENTIAL_HARVESTING_PHRASES,
15
+ CSS_HIDDEN_PATTERN,
16
+ DEFAULT_SUBJECT_WEIGHT,
17
+ HIGH_RISK_TLDS,
18
+ HOMOGRAPH_CHAR_PATTERN,
19
+ HTML_COMMENT_PATTERN,
20
+ HTML_TAG_PATTERN,
21
+ IP_IN_URL_PATTERN,
22
+ META_FEATURE_NAMES,
23
+ MIXED_TOKEN_PATTERN,
24
+ MONEY_PATTERN,
25
+ PHISHING_PHRASES,
26
+ PHONE_PATTERN,
27
+ PROMOTIONAL_KEYWORDS,
28
+ SHORTENED_URL_PATTERN,
29
+ SUSPICIOUS_TLDS,
30
+ UNICODE_OBFUSCATION_PATTERN,
31
+ URGENCY_KEYWORDS,
32
+ URL_PATTERN,
33
+ )
34
+
35
+
36
+ def _count_keyword_hits(text: str, keywords: Iterable[str]) -> int:
37
+ tokens = re.findall(r"[a-z0-9]+", text.lower())
38
+ keyword_set = set(keywords)
39
+ return sum(1 for token in tokens if token in keyword_set)
40
+
41
+
42
+ def _count_phrase_hits(text: str, phrases: Iterable[str]) -> int:
43
+ lowered = text.lower()
44
+ return sum(1 for phrase in phrases if phrase in lowered)
45
+
46
+
47
+ def _extract_urls(text: str) -> list[str]:
48
+ return URL_PATTERN.findall(text)
49
+
50
+
51
+ def _extract_domain(url: str) -> str:
52
+ url = url.lower().split("://")[-1].split("/")[0].split("?")[0]
53
+ url = url.split("@")[-1].split(":")[0]
54
+ url = url.removeprefix("www.").strip(".")
55
+ return url
56
+
57
+
58
+ def _url_features(text: str) -> dict[str, float]:
59
+ urls = _extract_urls(text)
60
+ domains = [_extract_domain(u) for u in urls]
61
+ unique_domains = len(set(domains))
62
+ shortened = sum(1 for u in urls if SHORTENED_URL_PATTERN.search(u))
63
+ ip_urls = sum(1 for u in urls if IP_IN_URL_PATTERN.search(u))
64
+ suspicious = sum(
65
+ 1 for u in urls
66
+ if any(u.lower().endswith(tld) for tld in SUSPICIOUS_TLDS)
67
+ )
68
+ high_risk = sum(
69
+ 1 for u in urls
70
+ if any(u.lower().endswith(tld) for tld in HIGH_RISK_TLDS)
71
+ )
72
+ url_to_text = len("".join(urls)) / max(len(text), 1)
73
+ return {
74
+ "url_count": float(len(urls)),
75
+ "unique_url_domains": float(unique_domains),
76
+ "shortened_url_count": float(shortened),
77
+ "ip_url_count": float(ip_urls),
78
+ "suspicious_tld_count": float(suspicious),
79
+ "high_risk_tld_count": float(high_risk),
80
+ "url_to_text_ratio": url_to_text,
81
+ }
82
+
83
+
84
+ def _html_features(text: str) -> dict[str, float]:
85
+ tags = len(HTML_TAG_PATTERN.findall(text))
86
+ comments = len(HTML_COMMENT_PATTERN.findall(text))
87
+ hidden = len(CSS_HIDDEN_PATTERN.findall(text.lower()))
88
+ return {
89
+ "html_tag_count": float(tags + comments),
90
+ "hidden_element_indicators": float(hidden),
91
+ }
92
+
93
+
94
+ def _text_quality_features(text: str, words: list[str]) -> dict[str, float]:
95
+ n_words = max(len(words), 1)
96
+ unique_words = len(set(words))
97
+ type_token_ratio = unique_words / n_words
98
+
99
+ sentences = re.split(r"[.!?]+", text)
100
+ sentences = [s.strip() for s in sentences if s.strip()]
101
+ n_sentences = max(len(sentences), 1)
102
+ avg_sentence_length = n_words / n_sentences
103
+
104
+ total_syllables = 0
105
+ for word in words:
106
+ w = word.lower()
107
+ syllables = len(re.findall(r"[aeiouy]+", w)) or 1
108
+ total_syllables += syllables
109
+
110
+ flesch = 206.835 - 1.015 * avg_sentence_length - 84.6 * (total_syllables / n_words)
111
+ flesch = max(0.0, min(120.0, flesch))
112
+
113
+ imperative_verbs = {"click", "buy", "send", "call", "open", "visit",
114
+ "download", "claim", "confirm", "verify", "register",
115
+ "submit", "update", "check", "review", "enter", "sign"}
116
+ imperative_hits = sum(1 for w in words if w.lower() in imperative_verbs)
117
+ imperative_ratio = imperative_hits / n_words
118
+
119
+ return {
120
+ "flesch_reading_ease": float(flesch),
121
+ "type_token_ratio": float(type_token_ratio),
122
+ "imperative_verb_ratio": float(imperative_ratio),
123
+ }
124
+
125
+
126
+ def _obfuscation_features(text: str) -> dict[str, float]:
127
+ n_chars = max(len(text), 1)
128
+ unicode_chars = len(UNICODE_OBFUSCATION_PATTERN.findall(text))
129
+ homograph_chars = len(HOMOGRAPH_CHAR_PATTERN.findall(text))
130
+ return {
131
+ "homograph_char_ratio": homograph_chars / n_chars,
132
+ "unicode_obfuscation_ratio": unicode_chars / n_chars,
133
+ }
134
+
135
+
136
+ def _attachment_features(text: str) -> dict[str, float]:
137
+ text_lower = text.lower()
138
+ phrase_hits = _count_phrase_hits(text_lower, ATTACHMENT_PHRASES)
139
+ ext_hits = sum(1 for ext in ATTACHMENT_EXTENSIONS if ext.replace(".", "") in text_lower)
140
+ return {"attachment_indicators": float(phrase_hits + ext_hits)}
141
+
142
+
143
+ def _credential_harvesting_features(text: str) -> dict[str, float]:
144
+ hits = _count_phrase_hits(text, CREDENTIAL_HARVESTING_PHRASES)
145
+ return {"credential_harvesting_hits": float(hits)}
146
+
147
+
148
+ def matched_spam_phrases(subject: str, body: str) -> list[str]:
149
+ combined_text = f"{subject} {body}".lower()
150
+ return [phrase for phrase in PHISHING_PHRASES if phrase in combined_text]
151
+
152
+
153
+ def compose_email_text(subject: str, body: str, subject_weight: int = DEFAULT_SUBJECT_WEIGHT) -> str:
154
+ subject_text = subject.strip()
155
+ body_text = body.strip()
156
+ parts: list[str] = []
157
+ if subject_text:
158
+ parts.extend([subject_text] * max(subject_weight, 1))
159
+ if body_text:
160
+ parts.append(body_text)
161
+ return " ".join(parts).strip()
162
+
163
+
164
+ def _coerce_texts(texts: str | Sequence[str]) -> list[str]:
165
+ if isinstance(texts, str):
166
+ return [texts]
167
+ return [text if isinstance(text, str) else "" for text in texts]
168
+
169
+
170
+ def extract_meta_features(texts: str | Sequence[str]) -> np.ndarray:
171
+ rows: list[list[float]] = []
172
+
173
+ for text in _coerce_texts(texts):
174
+ n_chars = max(len(text), 1)
175
+ n_letters = max(sum(char.isalpha() for char in text), 1)
176
+ words = text.split()
177
+ n_words = max(len(words), 1)
178
+ avg_word_length = sum(len(word) for word in words) / n_words
179
+ symbol_ratio = sum(not char.isalnum() and not char.isspace() for char in text) / n_chars
180
+
181
+ lowered = text.lower()
182
+ phrase_hits = len(matched_spam_phrases("", text))
183
+ promo_hits = _count_keyword_hits(lowered, PROMOTIONAL_KEYWORDS)
184
+
185
+ url_f = _url_features(text)
186
+ html_f = _html_features(text)
187
+ quality_f = _text_quality_features(text, words)
188
+ obfuscation_f = _obfuscation_features(text)
189
+ attachment_f = _attachment_features(text)
190
+ credential_f = _credential_harvesting_features(text)
191
+
192
+ rows.append([
193
+ url_f["url_count"],
194
+ sum(char.isupper() for char in text) / n_letters,
195
+ float(text.count("!")),
196
+ float(text.count("?")),
197
+ float(len(MONEY_PATTERN.findall(text))),
198
+ float(len(PHONE_PATTERN.findall(text))),
199
+ float(n_words),
200
+ float(avg_word_length),
201
+ sum(char.isdigit() for char in text) / n_chars,
202
+ float(phrase_hits),
203
+ float(_count_keyword_hits(lowered, URGENCY_KEYWORDS)),
204
+ float(_count_keyword_hits(lowered, ACCOUNT_KEYWORDS)),
205
+ float(_count_keyword_hits(lowered, CALL_TO_ACTION_KEYWORDS)),
206
+ float(symbol_ratio),
207
+ float(text.count("%")),
208
+ float(len(MIXED_TOKEN_PATTERN.findall(text))),
209
+ url_f["unique_url_domains"],
210
+ url_f["shortened_url_count"],
211
+ url_f["ip_url_count"],
212
+ url_f["suspicious_tld_count"],
213
+ url_f["high_risk_tld_count"],
214
+ url_f["url_to_text_ratio"],
215
+ attachment_f["attachment_indicators"],
216
+ html_f["html_tag_count"],
217
+ html_f["hidden_element_indicators"],
218
+ obfuscation_f["homograph_char_ratio"],
219
+ obfuscation_f["unicode_obfuscation_ratio"],
220
+ quality_f["flesch_reading_ease"],
221
+ quality_f["type_token_ratio"],
222
+ quality_f["imperative_verb_ratio"],
223
+ float(promo_hits),
224
+ credential_f["credential_harvesting_hits"],
225
+ ])
226
+
227
+ return np.array(rows, dtype=np.float32)
228
+
229
+
230
+ def _meta_feature_map(text: str) -> dict[str, float]:
231
+ row = extract_meta_features(text)[0].tolist()
232
+ return dict(zip(META_FEATURE_NAMES, row))
233
+
234
+
235
+ def _indicator_signals(raw_text: str) -> list[str]:
236
+ feature_map = _meta_feature_map(raw_text)
237
+
238
+ signals: list[str] = []
239
+ if feature_map["url_count"] >= 1:
240
+ signals.append("contains a link")
241
+ if feature_map["money_count"] >= 1:
242
+ signals.append("mentions money amounts")
243
+ if feature_map["phone_count"] >= 1:
244
+ signals.append("contains a phone number")
245
+ if feature_map["exclamation_count"] >= 3:
246
+ signals.append("uses aggressive punctuation")
247
+ if feature_map["caps_ratio"] >= 0.28 and feature_map["word_count"] >= 5:
248
+ signals.append("uses excessive uppercase")
249
+ if feature_map["digit_ratio"] >= 0.12 and feature_map["word_count"] >= 5:
250
+ signals.append("contains a high ratio of digits")
251
+ if feature_map["urgency_hits"] >= 2:
252
+ signals.append("contains urgency language")
253
+ if feature_map["account_hits"] >= 2:
254
+ signals.append("contains account-security terms")
255
+ if feature_map["call_to_action_hits"] >= 2:
256
+ signals.append("contains direct calls to action")
257
+ if feature_map["shortened_url_count"] >= 1:
258
+ signals.append("uses shortened URLs")
259
+ if feature_map["ip_url_count"] >= 1:
260
+ signals.append("links to IP addresses")
261
+ if feature_map["suspicious_tld_count"] >= 1:
262
+ signals.append("links to suspicious domains")
263
+ if feature_map["attachment_indicators"] >= 1:
264
+ signals.append("mentions attachments")
265
+ if feature_map["hidden_element_indicators"] >= 1:
266
+ signals.append("contains hidden text elements")
267
+ if feature_map["credential_harvesting_hits"] >= 1:
268
+ signals.append("matches credential harvesting patterns")
269
+ return signals
app/core/rules.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Iterable
5
+
6
+ from app.core.constants import BUSINESS_KEYWORDS, CONVERSATIONAL_KEYWORDS, PROMOTIONAL_KEYWORDS
7
+ from app.core.features import (
8
+ _count_keyword_hits, _indicator_signals, _meta_feature_map,
9
+ compose_email_text, matched_spam_phrases,
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class RuleAssessment:
15
+ is_spam: bool
16
+ confidence: float
17
+ reason: str
18
+ signals: list[str] = field(default_factory=list)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BenignAssessment:
23
+ is_benign: bool
24
+ confidence: float
25
+ reason: str
26
+ analysis: str
27
+ rule_layer: str
28
+ signals: list[str] = field(default_factory=list)
29
+ explanations: list[str] = field(default_factory=list)
30
+
31
+
32
+ def is_trusted_service_domain(sender_domain: str, trusted_service_domains: Iterable[str] | None = None) -> bool:
33
+ catalog = set(trusted_service_domains or ())
34
+ return any(sender_domain == domain or sender_domain.endswith(f".{domain}") for domain in catalog)
35
+
36
+
37
+ def assess_rule_based_spam(subject: str, body: str) -> RuleAssessment:
38
+ phrases = matched_spam_phrases(subject, body)
39
+ raw_text = compose_email_text(subject, body, subject_weight=1)
40
+ signals = _indicator_signals(raw_text)
41
+ if len(phrases) >= 2 or (len(phrases) >= 1 and len(signals) >= 2):
42
+ confidence = min(0.86 + (0.04 * len(phrases)) + (0.02 * len(signals)), 0.99)
43
+ top_phrases = ", ".join(phrases[:2])
44
+ phrase_signal = f"matched phrases: {top_phrases}" if top_phrases else ""
45
+ all_signals = [signal for signal in [phrase_signal, *signals] if signal][:5]
46
+ return RuleAssessment(
47
+ is_spam=True, confidence=round(confidence, 2),
48
+ reason="Contains multiple high-risk phishing or spam indicators",
49
+ signals=all_signals,
50
+ )
51
+ return RuleAssessment(is_spam=False, confidence=0.0, reason="", signals=signals[:3])
52
+
53
+
54
+ def assess_benign_email(subject: str, body: str) -> BenignAssessment:
55
+ raw_text = compose_email_text(subject, body, subject_weight=1)
56
+ lowered = raw_text.lower()
57
+ feature_map = _meta_feature_map(raw_text)
58
+ phrase_hits = matched_spam_phrases(subject, body)
59
+ promotional_hits = _count_keyword_hits(lowered, PROMOTIONAL_KEYWORDS)
60
+ conversational_hits = _count_keyword_hits(lowered, CONVERSATIONAL_KEYWORDS)
61
+ business_hits = _count_keyword_hits(lowered, BUSINESS_KEYWORDS)
62
+
63
+ has_high_risk_indicator = any((
64
+ feature_map["url_count"] >= 1, feature_map["money_count"] >= 1,
65
+ feature_map["phone_count"] >= 1, feature_map["account_hits"] >= 1,
66
+ feature_map["mixed_token_hits"] >= 2, feature_map["call_to_action_hits"] >= 2,
67
+ len(phrase_hits) >= 1,
68
+ ))
69
+
70
+ if not has_high_risk_indicator and conversational_hits >= 2 and feature_map["word_count"] <= 40:
71
+ signals = ["conversation-style wording"]
72
+ if business_hits >= 1:
73
+ signals.append("routine work context")
74
+ return BenignAssessment(
75
+ is_benign=True, confidence=0.82,
76
+ reason="Looks like a routine personal or workplace conversation",
77
+ analysis="Benign-context detection found conversational wording without phishing indicators.",
78
+ rule_layer="benign_context", signals=signals,
79
+ explanations=["Conversation-style wording matched a low-risk pattern.",
80
+ "No links, security prompts, or high-risk phishing markers were found."],
81
+ )
82
+
83
+ if (not has_high_risk_indicator and promotional_hits >= 2
84
+ and feature_map["exclamation_count"] <= 1 and feature_map["caps_ratio"] < 0.2):
85
+ return BenignAssessment(
86
+ is_benign=True, confidence=0.76,
87
+ reason="Looks like a low-risk promotional message, not phishing",
88
+ analysis="Benign promotional detection found retail language without phishing indicators.",
89
+ rule_layer="benign_promo", signals=["retail or promotional wording"],
90
+ explanations=["Promotional wording was present, but there were no links, account prompts, or credential requests."],
91
+ )
92
+
93
+ return BenignAssessment(is_benign=False, confidence=0.0, reason="", analysis="", rule_layer="ml")
app/core/text.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from app.core.constants import (
6
+ EMAIL_PATTERN,
7
+ ENGLISH_STOPWORDS,
8
+ MONEY_PATTERN,
9
+ PHONE_PATTERN,
10
+ URL_PATTERN,
11
+ )
12
+
13
+ STOPWORDS = set(ENGLISH_STOPWORDS) - {
14
+ "free", "win", "won", "prize", "click", "now", "urgent",
15
+ "limited", "cash", "offer", "call", "reply", "stop", "apply", "claim",
16
+ "no", "not", "never", "don", "isn", "wasn", "aren", "won",
17
+ }
18
+
19
+
20
+ def preprocess_text(text: str) -> str:
21
+ """Preprocess text for TF-IDF vectorization.
22
+
23
+ Strategy: lowercase, replace structured tokens with placeholders,
24
+ strip punctuation, remove stopwords. NO stemming or lemmatization —
25
+ morphological variation is signal for spam detection, not noise.
26
+ """
27
+ if not isinstance(text, str):
28
+ return ""
29
+ value = text.lower()
30
+ value = URL_PATTERN.sub(" urltoken ", value)
31
+ value = EMAIL_PATTERN.sub(" emailtoken ", value)
32
+ value = PHONE_PATTERN.sub(" phonetoken ", value)
33
+ value = MONEY_PATTERN.sub(" moneytoken ", value)
34
+ value = re.sub(r"[^a-z0-9\s]", " ", value)
35
+ tokens = [
36
+ token for token in value.split()
37
+ if token not in STOPWORDS and len(token) > 1
38
+ ]
39
+ return " ".join(tokens)
app/main.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from slowapi import Limiter, _rate_limit_exceeded_handler
12
+ from slowapi.middleware import SlowAPIMiddleware
13
+ from slowapi.errors import RateLimitExceeded
14
+ from slowapi.util import get_remote_address
15
+
16
+ from app.api.v1.router import v1_router
17
+ from app.config import settings
18
+ from app.core.domain import load_domain_catalog, load_user_whitelist
19
+ from app.ml.registry import load_model
20
+ from app.api.v1 import health as health_mod
21
+ from app.api.v1 import predict as predict_mod
22
+ from app.api.v1 import feedback as feedback_mod
23
+ from app.api.v1 import retrain as retrain_mod
24
+
25
+
26
+ def load_resources() -> None:
27
+ metadata: dict = {}
28
+ if settings.metadata_path.exists():
29
+ with open(settings.metadata_path, "r", encoding="utf-8") as f:
30
+ metadata = json.load(f)
31
+
32
+ model = None
33
+ vectorizer = None
34
+ artifact = load_model(settings.model_path, settings.vectorizer_path, settings.metadata_path)
35
+ if artifact is not None:
36
+ model = artifact.model
37
+ vectorizer = artifact.vectorizer
38
+ metadata = artifact.metadata
39
+
40
+ whitelist = load_user_whitelist(settings.whitelist_path)
41
+ trusted = load_domain_catalog(settings.trusted_domains_path)
42
+
43
+ # --- Ensemble construction ---
44
+ # Build EnsemblePredictor if transformer artifacts are available.
45
+ # Falls back gracefully to XGBoost-only if loading fails.
46
+ ensemble_info = metadata.get("ensemble_info")
47
+ if settings.enable_transformer and ensemble_info:
48
+ try:
49
+ from app.ml.registry import load_transformer
50
+
51
+ fusion_weight = ensemble_info.get("fusion_weight", 0.50)
52
+ transformer_model_name = metadata.get("transformer_info", {}).get(
53
+ "model_id", settings.transformer_model_name
54
+ )
55
+
56
+ result = load_transformer(
57
+ model_dir=settings.transformer_model_dir,
58
+ device=settings.transformer_device,
59
+ )
60
+
61
+ if result is not None:
62
+ transformer_model, transformer_tokenizer = result
63
+ from app.ml.ensemble import EnsemblePredictor
64
+
65
+ ensemble = EnsemblePredictor(
66
+ classical_model=model,
67
+ classical_vectorizer_bundle=vectorizer,
68
+ transformer_model=transformer_model,
69
+ transformer_tokenizer=transformer_tokenizer,
70
+ fusion_weight=fusion_weight,
71
+ transformer_device=settings.transformer_device,
72
+ )
73
+ model = ensemble
74
+ logger.info(
75
+ "Ensemble Predictor active: XGBoost + %s (w=%.2f) | F1=%.4f",
76
+ transformer_model_name, fusion_weight,
77
+ metadata.get("selected_metrics", {}).get("ensemble_f1", 0),
78
+ )
79
+ else:
80
+ logger.warning(
81
+ "Transformer model not found — running XGBoost-only. "
82
+ "Expected ensemble F1: %.4f.",
83
+ metadata.get("selected_metrics", {}).get("ensemble_f1", 0),
84
+ )
85
+ except Exception:
86
+ logger.exception(
87
+ "Transformer loading failed — falling back to XGBoost-only."
88
+ )
89
+
90
+ if model is None or vectorizer is None:
91
+ logger.warning("Model or vectorizer not found at startup. Predict will return 500.")
92
+
93
+ health_mod.model = model
94
+ health_mod.vectorizer = vectorizer
95
+ health_mod.user_whitelist_domains = whitelist
96
+ health_mod.trusted_domain_catalog = trusted
97
+ health_mod.model_metadata = metadata
98
+
99
+ predict_mod.model = model
100
+ predict_mod.vectorizer = vectorizer
101
+ predict_mod.user_whitelist_domains = whitelist
102
+ predict_mod.trusted_domain_catalog = trusted
103
+ predict_mod.model_metadata = metadata
104
+
105
+ feedback_mod.model_metadata = metadata
106
+ retrain_mod.model_metadata = metadata
107
+
108
+
109
+ @asynccontextmanager
110
+ async def lifespan(_: FastAPI):
111
+ load_resources()
112
+ yield
113
+
114
+
115
+ def create_app() -> FastAPI:
116
+ limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
117
+ app = FastAPI(title="Spam Detector API", version="3.0.0", lifespan=lifespan)
118
+ app.state.limiter = limiter
119
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
120
+ app.add_middleware(SlowAPIMiddleware)
121
+ app.add_middleware(
122
+ CORSMiddleware,
123
+ allow_origins=[],
124
+ allow_origin_regex=settings.allow_origin_regex,
125
+ allow_credentials=False,
126
+ allow_methods=["GET", "POST"],
127
+ allow_headers=["*"],
128
+ )
129
+ app.include_router(v1_router, prefix="/v1")
130
+ return app
131
+
132
+
133
+ app = create_app()
134
+
135
+
136
+ if __name__ == "__main__":
137
+ import signal
138
+ import uvicorn
139
+
140
+ def _shutdown_handler(signum, frame):
141
+ logger.info("Received shutdown signal %s, stopping...", signum)
142
+
143
+ signal.signal(signal.SIGTERM, _shutdown_handler)
144
+ signal.signal(signal.SIGINT, _shutdown_handler)
145
+
146
+ uvicorn.run(app, host=settings.api_host, port=settings.api_port, log_level=settings.log_level)
app/ml/ensemble.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Weighted Late-Fusion Ensemble — Classical + Transformer.
2
+
3
+ XGBoost handles keyword/phrase spam via TF-IDF bigrams.
4
+ DeBERTa-v3 handles sophisticated phishing via contextual understanding.
5
+ Late fusion averages probabilities with equal weight — no learned
6
+ meta-learner, avoiding overfitting on a single holdout set.
7
+
8
+ Architecture:
9
+ p_spam = 0.50 × p_classical + 0.50 × p_transformer
10
+ label = Spam if p_spam ≥ 0.55 else Not Spam
11
+
12
+ Falls back gracefully to classical-only if the transformer is unavailable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import warnings
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+ import scipy.sparse as sp
22
+
23
+
24
+ warnings.filterwarnings("ignore", category=FutureWarning)
25
+
26
+
27
+ class EnsemblePredictor:
28
+ def __init__(
29
+ self,
30
+ classical_model: Any,
31
+ classical_vectorizer_bundle: dict[str, Any],
32
+ transformer_model: Any | None = None,
33
+ transformer_tokenizer: Any | None = None,
34
+ transformer_device: str = "cpu",
35
+ fusion_weight: float = 0.50,
36
+ transformer_max_length: int = 512,
37
+ ):
38
+ self.classical_model = classical_model
39
+ self.classical_vectorizer_bundle = classical_vectorizer_bundle
40
+ self.transformer_model = transformer_model
41
+ self.transformer_tokenizer = transformer_tokenizer
42
+ self.transformer_device = transformer_device
43
+ self.fusion_weight = fusion_weight
44
+ self.transformer_max_length = transformer_max_length
45
+
46
+ @property
47
+ def has_transformer(self) -> bool:
48
+ return self.transformer_model is not None and self.transformer_tokenizer is not None
49
+
50
+ def _transformer_proba(self, texts: list[str]) -> np.ndarray:
51
+ import torch
52
+ was_training = self.transformer_model.training
53
+ self.transformer_model.eval()
54
+ device = next(self.transformer_model.parameters()).device
55
+ probs_list = []
56
+ for text in texts:
57
+ enc = self.transformer_tokenizer(
58
+ text, truncation=True, padding="max_length",
59
+ max_length=self.transformer_max_length, return_tensors="pt",
60
+ )
61
+ input_ids = enc["input_ids"].to(device)
62
+ attention_mask = enc["attention_mask"].to(device)
63
+ with torch.no_grad():
64
+ with torch.amp.autocast("cuda" if device.type == "cuda" else "cpu", enabled=device.type == "cuda"):
65
+ outputs = self.transformer_model(input_ids=input_ids, attention_mask=attention_mask)
66
+ probs = torch.softmax(outputs.logits, dim=-1).cpu().numpy()
67
+ probs_list.append(probs)
68
+ if was_training:
69
+ self.transformer_model.train()
70
+ return np.vstack(probs_list)
71
+
72
+ def transformer_proba(self, texts: list[str]) -> np.ndarray:
73
+ return self._transformer_proba(texts)
74
+
75
+ def predict_proba(self, features: sp.csr_matrix, raw_texts: list[str]) -> np.ndarray:
76
+ p_classical = self.classical_model.predict_proba(features)
77
+ if not self.has_transformer:
78
+ return p_classical
79
+ p_transformer = self._transformer_proba(raw_texts)
80
+ p_spam = (
81
+ self.fusion_weight * p_classical[:, 1]
82
+ + (1 - self.fusion_weight) * p_transformer[:, 1]
83
+ )
84
+ p_ham = 1.0 - p_spam
85
+ return np.column_stack([p_ham, p_spam])
86
+
87
+ def predict(self, features: sp.csr_matrix, raw_texts: list[str]) -> np.ndarray:
88
+ return (self.predict_proba(features, raw_texts)[:, 1] >= 0.55).astype(np.int32)
89
+
90
+
91
+ def grid_search_fusion_weight(
92
+ classical_probs: np.ndarray,
93
+ transformer_probs: np.ndarray,
94
+ y_true: np.ndarray,
95
+ n_steps: int = 21,
96
+ ) -> dict[str, Any]:
97
+ best_f1 = 0.0
98
+ best_weight = 0.50
99
+ results = []
100
+ for w in np.linspace(0.0, 1.0, n_steps):
101
+ fused = w * classical_probs[:, 1] + (1 - w) * transformer_probs[:, 1]
102
+ from sklearn.metrics import f1_score
103
+ f1 = f1_score(y_true, fused >= 0.55, pos_label=1)
104
+ results.append((w, f1))
105
+ if f1 > best_f1:
106
+ best_f1 = f1
107
+ best_weight = w
108
+
109
+ print(f"\n Fusion weight grid search ({n_steps} steps):")
110
+ for w, f1_score_val in results:
111
+ marker = "<-" if w == best_weight else " "
112
+ print(f" {marker} w={w:.2f} → Spam F1={f1_score_val:.4f}")
113
+
114
+ return {"best_weight": round(best_weight, 4), "best_f1": round(best_f1, 4)}
app/ml/registry.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import os
7
+ import pickle
8
+ import shutil
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ class ModelIntegrityError(RuntimeError):
15
+ pass
16
+
17
+
18
+ @dataclass
19
+ class ModelArtifact:
20
+ model: Any
21
+ vectorizer: Any
22
+ metadata: dict[str, Any]
23
+
24
+
25
+ def _compute_hash(path: Path) -> str:
26
+ return hashlib.sha256(path.read_bytes()).hexdigest()
27
+
28
+
29
+ def _verify_hash(path: Path, expected: str) -> None:
30
+ actual = _compute_hash(path)
31
+ if not hmac.compare_digest(actual, expected):
32
+ raise ModelIntegrityError(
33
+ f"SHA-256 mismatch for {path.name}: integrity check failed."
34
+ )
35
+
36
+
37
+ def _hash_path(path: Path) -> Path:
38
+ return path.with_suffix(path.suffix + ".sha256")
39
+
40
+
41
+ def save_model(
42
+ model: Any, vectorizer: Any, metadata: dict[str, Any],
43
+ model_path: Path, vectorizer_path: Path, metadata_path: Path,
44
+ ) -> None:
45
+ model_path.parent.mkdir(parents=True, exist_ok=True)
46
+ with open(model_path, "wb") as f:
47
+ pickle.dump(model, f)
48
+ with open(vectorizer_path, "wb") as f:
49
+ pickle.dump(vectorizer, f)
50
+ with open(metadata_path, "w", encoding="utf-8") as f:
51
+ json.dump(metadata, f, indent=2)
52
+ _hash_path(model_path).write_text(_compute_hash(model_path))
53
+ _hash_path(vectorizer_path).write_text(_compute_hash(vectorizer_path))
54
+
55
+
56
+ def load_model(
57
+ model_path: Path, vectorizer_path: Path, metadata_path: Path,
58
+ ) -> ModelArtifact | None:
59
+ if not (model_path.exists() and vectorizer_path.exists()):
60
+ return None
61
+ model_hash_path = _hash_path(model_path)
62
+ vectorizer_hash_path = _hash_path(vectorizer_path)
63
+ if model_hash_path.exists():
64
+ _verify_hash(model_path, model_hash_path.read_text().strip())
65
+ if vectorizer_hash_path.exists():
66
+ _verify_hash(vectorizer_path, vectorizer_hash_path.read_text().strip())
67
+ with open(model_path, "rb") as f:
68
+ model = pickle.load(f)
69
+
70
+ vectorizer: Any = None
71
+ if vectorizer_path.suffix == ".pkl":
72
+ with open(vectorizer_path, "rb") as f:
73
+ vectorizer = pickle.load(f)
74
+ elif vectorizer_path.is_dir():
75
+ vectorizer = str(vectorizer_path)
76
+
77
+ metadata: dict[str, Any] = {}
78
+ if metadata_path.exists():
79
+ with open(metadata_path, "r", encoding="utf-8") as f:
80
+ metadata = json.load(f)
81
+ return ModelArtifact(model=model, vectorizer=vectorizer, metadata=metadata)
82
+
83
+
84
+ def load_transformer(
85
+ model_dir: Path,
86
+ device: str = "cpu",
87
+ ) -> tuple[Any, Any] | None:
88
+ """Load transformer model and tokenizer from a Hugging Face model directory.
89
+
90
+ Expects ``model_dir`` to contain:
91
+ - config.json
92
+ - model.safetensors (preferred) or pytorch_model.bin
93
+ - tokenizer.json
94
+ - tokenizer_config.json
95
+
96
+ Falls back gracefully if ``transformers`` / ``torch`` are not installed
97
+ or if the directory is missing required files.
98
+ """
99
+ safetensors_path = model_dir / "model.safetensors"
100
+ _ensure_hf_model_available(model_dir)
101
+
102
+ if not model_dir.is_dir() or not (model_dir / "config.json").exists():
103
+ return None
104
+
105
+ sha_path = _hash_path(safetensors_path)
106
+ if sha_path.exists():
107
+ _verify_hash(safetensors_path, sha_path.read_text().strip())
108
+
109
+ try:
110
+ import torch # noqa: F811
111
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
112
+ except ImportError:
113
+ import logging
114
+ logging.getLogger(__name__).warning(
115
+ "transformers/torch not installed — running XGBoost-only."
116
+ )
117
+ return None
118
+
119
+ try:
120
+ model = AutoModelForSequenceClassification.from_pretrained(
121
+ str(model_dir), local_files_only=True,
122
+ )
123
+ except (OSError, EnvironmentError, FileNotFoundError) as exc:
124
+ import logging
125
+ logging.getLogger(__name__).warning(
126
+ "Could not load transformer from %s: %s — running XGBoost-only.",
127
+ model_dir, exc,
128
+ )
129
+ return None
130
+
131
+ try:
132
+ tokenizer = AutoTokenizer.from_pretrained(
133
+ str(model_dir), local_files_only=True,
134
+ )
135
+ except (OSError, EnvironmentError, FileNotFoundError) as exc:
136
+ import logging
137
+ logging.getLogger(__name__).warning(
138
+ "Could not load tokenizer from %s: %s — running XGBoost-only.",
139
+ model_dir, exc,
140
+ )
141
+ return None
142
+
143
+ if tokenizer.pad_token is None:
144
+ tokenizer.pad_token = tokenizer.eos_token
145
+
146
+ model.to(device)
147
+ model.eval()
148
+
149
+ return model, tokenizer
150
+
151
+
152
+ def _ensure_hf_model_available(model_dir: Path) -> None:
153
+ """Download the HF model from HuggingFace Hub if the local directory is empty.
154
+
155
+ HF Spaces clones the repo which excludes large model files via .gitignore.
156
+ This downloads them on first startup.
157
+ """
158
+ if (model_dir / "config.json").exists():
159
+ return
160
+
161
+ repo_id = os.environ.get("HF_MODEL_REPO_ID", "Avijit070/spam-email-deberta-v3")
162
+
163
+ try:
164
+ from huggingface_hub import snapshot_download
165
+ except ImportError:
166
+ import logging
167
+ logging.getLogger(__name__).warning(
168
+ "huggingface_hub not installed — cannot download model from HF Hub."
169
+ )
170
+ return
171
+
172
+ import logging
173
+ logger = logging.getLogger(__name__)
174
+ logger.info("Model files not found locally. Downloading %s from HuggingFace Hub...", repo_id)
175
+ try:
176
+ model_dir.mkdir(parents=True, exist_ok=True)
177
+ snapshot_download(
178
+ repo_id=repo_id,
179
+ local_dir=str(model_dir),
180
+ local_dir_use_symlinks=False,
181
+ )
182
+ logger.info("Download complete. Model cached at %s", model_dir)
183
+ except Exception as exc:
184
+ logger.warning(
185
+ "Failed to download model from HF Hub: %s. Running XGBoost-only.", exc
186
+ )
187
+ if model_dir.exists():
188
+ for f in model_dir.iterdir():
189
+ f.unlink(missing_ok=True)
app/schemas/email.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class EmailRequest(BaseModel):
7
+ sender: str = Field(default="", max_length=320)
8
+ subject: str = Field(default="", max_length=998)
9
+ body: str = Field(default="", max_length=100_000)
10
+
11
+
12
+ class BatchPredictionRequest(BaseModel):
13
+ emails: list[EmailRequest] = Field(default_factory=list, max_length=50)
14
+
15
+
16
+ class PredictionResponse(BaseModel):
17
+ label: str
18
+ confidence: float
19
+ reason: str
20
+ analysis: str
21
+ model_version: str
22
+ sender_domain: str = ""
23
+ rule_layer: str
24
+ signals: list[str] = Field(default_factory=list)
25
+ explanations: list[str] = Field(default_factory=list)
26
+ prediction_id: str
27
+ evaluated_at_utc: str
28
+ spam_prob: float | None = None
29
+ ham_prob: float | None = None
app/schemas/feedback.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class FeedbackRequest(BaseModel):
7
+ prediction_id: str
8
+ sender: str = Field(default="")
9
+ subject: str = Field(default="")
10
+ body: str = Field(default="")
11
+ predicted_label: str
12
+ predicted_confidence: float | None = None
13
+ user_label: str
14
+ notes: str = Field(default="", max_length=1000)
15
+ source: str = Field(default="extension_popup")
16
+
17
+
18
+ class FeedbackResponse(BaseModel):
19
+ feedback_id: str
20
+ verdict: str
21
+ stored_at_utc: str
22
+
23
+
24
+ class FeedbackSummaryResponse(BaseModel):
25
+ feedback_count: int
26
+ verdict_counts: dict[str, int]
app/schemas/health.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class HealthResponse(BaseModel):
7
+ status: str
8
+ model_loaded: bool
9
+ vectorizer_loaded: bool
10
+ ensemble_active: bool = False
11
+ feedback_backend: str
12
+ user_whitelist_count: int
13
+ trusted_domain_catalog_count: int
14
+ feedback_count: int
15
+ feedback_rows_used: int
16
+ feedback_last_consumed_utc: str | None = None
17
+ feedback_store_error: str | None = None
18
+ trained_at_utc: str | None = None
19
+ model_version: str
20
+ spam_threshold: float
app/schemas/retrain.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class RetrainResponse(BaseModel):
7
+ status: str
8
+ model_version: str
9
+ feedback_backend: str
10
+ trained_at_utc: str | None = None
11
+ dataset_rows: int
12
+ feedback_rows_used: int
13
+ feedback_last_consumed_utc: str | None = None
14
+ spam_f1: float | None = None
app/storage/feedback.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ DEFAULT_TABLE_NAME = "feedback_entries"
12
+ _TABLE_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
13
+
14
+
15
+ class FeedbackStoreError(RuntimeError):
16
+ pass
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class FeedbackStoreConfig:
21
+ backend: str
22
+ log_path: Path
23
+ host: str = ""
24
+ port: int = 3306
25
+ user: str = ""
26
+ password: str = ""
27
+ database: str = ""
28
+ table: str = DEFAULT_TABLE_NAME
29
+
30
+
31
+ def _env_first(*names: str, default: str = "") -> str:
32
+ for name in names:
33
+ value = os.getenv(name)
34
+ if value is not None and value != "":
35
+ return value
36
+ return default
37
+
38
+
39
+ def resolve_feedback_store(log_path: str | Path) -> FeedbackStoreConfig:
40
+ path = Path(log_path)
41
+ mode = _env_first("SPAM_FEEDBACK_BACKEND", default="auto").strip().lower() or "auto"
42
+ host = _env_first("SPAM_DB_HOST", "MYSQL_HOST")
43
+ port_text = _env_first("SPAM_DB_PORT", "MYSQL_PORT", default="3306")
44
+ user = _env_first("SPAM_DB_USER", "MYSQL_USER")
45
+ password = _env_first("SPAM_DB_PASSWORD", "MYSQL_PASSWORD")
46
+ database = _env_first("SPAM_DB_NAME", "SPAM_DB_DATABASE", "MYSQL_DATABASE")
47
+ table = _env_first("SPAM_DB_TABLE", "MYSQL_TABLE", default=DEFAULT_TABLE_NAME)
48
+ if not _TABLE_NAME_RE.fullmatch(table):
49
+ raise FeedbackStoreError(f"Invalid table name: {table!r}. Must match '^[a-zA-Z_][a-zA-Z0-9_]*$'.")
50
+ try:
51
+ port = int(port_text)
52
+ except ValueError as error:
53
+ raise FeedbackStoreError("Invalid port number.") from error
54
+ mysql_configured = bool(host and user and database)
55
+ if mode not in {"auto", "file", "mysql"}:
56
+ raise FeedbackStoreError("SPAM_FEEDBACK_BACKEND must be one of: auto, file, mysql.")
57
+ if mode == "mysql" or (mode == "auto" and mysql_configured):
58
+ if not mysql_configured:
59
+ raise FeedbackStoreError("MySQL feedback storage requires host, user, and database.")
60
+ return FeedbackStoreConfig(backend="mysql", log_path=path, host=host, port=port,
61
+ user=user, password=password, database=database, table=table)
62
+ return FeedbackStoreConfig(backend="file", log_path=path)
63
+
64
+
65
+ def feedback_backend_name(log_path: str | Path) -> str:
66
+ return resolve_feedback_store(log_path).backend
67
+
68
+
69
+ def append_feedback_entry(payload: dict[str, Any], log_path: str | Path) -> None:
70
+ config = resolve_feedback_store(log_path)
71
+ if config.backend == "mysql":
72
+ _append_feedback_mysql(payload, config)
73
+ return
74
+ _append_feedback_file(payload, config.log_path)
75
+
76
+
77
+ def _append_feedback_file(payload: dict[str, Any], log_path: Path) -> None:
78
+ log_path.parent.mkdir(parents=True, exist_ok=True)
79
+ with log_path.open("a", encoding="utf-8") as handle:
80
+ handle.write(json.dumps(payload, ensure_ascii=True) + "\n")
81
+
82
+
83
+ def _append_feedback_mysql(payload: dict[str, Any], config: FeedbackStoreConfig) -> None:
84
+ import pymysql
85
+ connection = pymysql.connect(host=config.host, port=config.port, user=config.user,
86
+ password=config.password, database=config.database,
87
+ charset="utf8mb4", autocommit=True,
88
+ cursorclass=pymysql.cursors.DictCursor)
89
+ try:
90
+ ddl = f"""CREATE TABLE IF NOT EXISTS `{config.table}` (
91
+ feedback_id VARCHAR(255) NOT NULL PRIMARY KEY,
92
+ prediction_id VARCHAR(255) NOT NULL, stored_at_utc VARCHAR(64) NOT NULL,
93
+ sender TEXT NULL, subject TEXT NULL, body MEDIUMTEXT NULL,
94
+ predicted_label VARCHAR(32) NOT NULL, predicted_confidence DOUBLE NULL,
95
+ user_label VARCHAR(32) NOT NULL, verdict VARCHAR(32) NOT NULL,
96
+ notes TEXT NULL, source VARCHAR(128) NOT NULL, model_version VARCHAR(128) NULL,
97
+ INDEX idx_feedback_stored_at (stored_at_utc),
98
+ INDEX idx_feedback_prediction (prediction_id)
99
+ ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"""
100
+ with connection.cursor() as cursor:
101
+ cursor.execute(ddl)
102
+ cursor.execute(f"""INSERT INTO `{config.table}` (
103
+ feedback_id, prediction_id, stored_at_utc, sender, subject, body,
104
+ predicted_label, predicted_confidence, user_label, verdict, notes,
105
+ source, model_version
106
+ ) VALUES (%(feedback_id)s,%(prediction_id)s,%(stored_at_utc)s,%(sender)s,
107
+ %(subject)s,%(body)s,%(predicted_label)s,%(predicted_confidence)s,
108
+ %(user_label)s,%(verdict)s,%(notes)s,%(source)s,%(model_version)s)""", payload)
109
+ finally:
110
+ connection.close()
111
+
112
+
113
+ def load_feedback_entries(log_path: str | Path) -> list[dict[str, Any]]:
114
+ config = resolve_feedback_store(log_path)
115
+ if config.backend == "mysql":
116
+ return _load_feedback_entries_mysql(config)
117
+ return _load_feedback_entries_file(config.log_path)
118
+
119
+
120
+ def _load_feedback_entries_file(log_path: Path) -> list[dict[str, Any]]:
121
+ if not log_path.exists():
122
+ return []
123
+ entries: list[dict[str, Any]] = []
124
+ with log_path.open("r", encoding="utf-8") as handle:
125
+ for line in handle:
126
+ line = line.strip()
127
+ if not line:
128
+ continue
129
+ try:
130
+ entries.append(json.loads(line))
131
+ except json.JSONDecodeError:
132
+ continue
133
+ return entries
134
+
135
+
136
+ def _load_feedback_entries_mysql(config: FeedbackStoreConfig) -> list[dict[str, Any]]:
137
+ import pymysql
138
+ connection = pymysql.connect(host=config.host, port=config.port, user=config.user,
139
+ password=config.password, database=config.database,
140
+ charset="utf8mb4", autocommit=True,
141
+ cursorclass=pymysql.cursors.DictCursor)
142
+ try:
143
+ with connection.cursor() as cursor:
144
+ cursor.execute(f"SELECT * FROM `{config.table}` ORDER BY stored_at_utc ASC")
145
+ return [dict(row) for row in cursor.fetchall()]
146
+ finally:
147
+ connection.close()
148
+
149
+
150
+ def feedback_summary(log_path: str | Path) -> dict[str, Any]:
151
+ config = resolve_feedback_store(log_path)
152
+ if config.backend == "mysql":
153
+ return _feedback_summary_mysql(config)
154
+ return _feedback_summary_from_entries(_load_feedback_entries_file(config.log_path))
155
+
156
+
157
+ def _feedback_summary_from_entries(entries: list[dict[str, Any]]) -> dict[str, Any]:
158
+ verdict_counts = {"correct": 0, "false_positive": 0, "false_negative": 0}
159
+ for entry in entries:
160
+ verdict = entry.get("verdict")
161
+ if verdict in verdict_counts:
162
+ verdict_counts[verdict] += 1
163
+ return {"feedback_count": len(entries), "verdict_counts": verdict_counts}
164
+
165
+
166
+ def _feedback_summary_mysql(config: FeedbackStoreConfig) -> dict[str, Any]:
167
+ import pymysql
168
+ connection = pymysql.connect(host=config.host, port=config.port, user=config.user,
169
+ password=config.password, database=config.database,
170
+ charset="utf8mb4", autocommit=True,
171
+ cursorclass=pymysql.cursors.DictCursor)
172
+ try:
173
+ with connection.cursor() as cursor:
174
+ cursor.execute(f"SELECT COUNT(*) AS count FROM `{config.table}`")
175
+ total = cursor.fetchone() or {"count": 0}
176
+ cursor.execute(f"SELECT verdict, COUNT(*) AS count FROM `{config.table}` GROUP BY verdict")
177
+ verdict_counts = {"correct": 0, "false_positive": 0, "false_negative": 0}
178
+ for row in cursor.fetchall():
179
+ v = row.get("verdict")
180
+ if v in verdict_counts:
181
+ verdict_counts[v] = int(row.get("count", 0))
182
+ return {"feedback_count": int(total.get("count", 0)), "verdict_counts": verdict_counts}
183
+ finally:
184
+ connection.close()
app/utils/pii.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ _PII_PATTERNS: list[tuple[str, str]] = [
6
+ (r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b', '[EMAIL]'),
7
+ (r'\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b', '[PHONE]'),
8
+ (r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP]'),
9
+ (r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]'),
10
+ (r'\b(?:\d[ -]*?){13,19}\b', '[CCARD]'),
11
+ ]
12
+
13
+
14
+ def redact_email_body(body: str) -> str:
15
+ result = body
16
+ for pattern, replacement in _PII_PATTERNS:
17
+ result = re.sub(pattern, replacement, result)
18
+ return result
19
+
20
+
21
+ def redact_subject(subject: str) -> str:
22
+ result = subject
23
+ for pattern, replacement in _PII_PATTERNS:
24
+ result = re.sub(pattern, replacement, result)
25
+ return result
extension/assets/icon128.png ADDED
extension/assets/icon16.png ADDED
extension/assets/icon48.png ADDED

Git LFS Details

  • SHA256: 92e5017cc3eff8477281a289ed7c21e025939352a49c152d15682ab15171de5d
  • Pointer size: 131 Bytes
  • Size of remote file: 628 kB
extension/background.js ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DEFAULT_SETTINGS = {
2
+ apiBaseUrl: "http://127.0.0.1:8000",
3
+ apiKey: "",
4
+ requestTimeoutMs: 12000,
5
+ autoScanEnabled: true,
6
+ historyLimit: 12
7
+ };
8
+
9
+ const predictionCache = new Map();
10
+ const CACHE_TTL_MS = 90 * 1000;
11
+
12
+ chrome.runtime.onInstalled.addListener(async () => {
13
+ const settings = await getSettings();
14
+ await chrome.storage.sync.set({ settings });
15
+ console.log("Gmail Spam Detector extension installed");
16
+ });
17
+
18
+ function normalizePayload(payload = {}) {
19
+ return {
20
+ sender: typeof payload.sender === "string" ? payload.sender.trim() : "",
21
+ subject: typeof payload.subject === "string" ? payload.subject.trim() : "",
22
+ body: typeof payload.body === "string" ? payload.body.trim() : ""
23
+ };
24
+ }
25
+
26
+ function cacheKey(payload) {
27
+ return JSON.stringify([payload.sender, payload.subject, payload.body]);
28
+ }
29
+
30
+ function getCachedPrediction(key) {
31
+ const cached = predictionCache.get(key);
32
+ if (!cached) {
33
+ return null;
34
+ }
35
+
36
+ if ((Date.now() - cached.timestamp) > CACHE_TTL_MS) {
37
+ predictionCache.delete(key);
38
+ return null;
39
+ }
40
+
41
+ return cached.value;
42
+ }
43
+
44
+ async function getSettings() {
45
+ const data = await chrome.storage.sync.get("settings");
46
+ const stored = data.settings || {};
47
+ return {
48
+ ...DEFAULT_SETTINGS,
49
+ ...stored
50
+ };
51
+ }
52
+
53
+ function normalizeApiBaseUrl(url) {
54
+ const value = String(url || "").trim().replace(/\/+$/, "");
55
+ const normalized = value || DEFAULT_SETTINGS.apiBaseUrl;
56
+ const parsed = new URL(normalized);
57
+
58
+ if (parsed.protocol === "https:") {
59
+ return parsed.origin;
60
+ }
61
+
62
+ if (parsed.protocol === "http:" && ["localhost", "127.0.0.1"].includes(parsed.hostname)) {
63
+ return parsed.origin;
64
+ }
65
+
66
+ throw new Error("Use http:// only for localhost, or https:// for deployed backends.");
67
+ }
68
+
69
+ async function saveSettings(partialSettings = {}) {
70
+ const current = await getSettings();
71
+ const merged = {
72
+ ...current,
73
+ ...partialSettings
74
+ };
75
+
76
+ merged.apiBaseUrl = normalizeApiBaseUrl(merged.apiBaseUrl);
77
+ merged.apiKey = typeof merged.apiKey === "string" ? merged.apiKey.trim() : "";
78
+ merged.requestTimeoutMs = Math.max(2000, Math.min(60000, Number(merged.requestTimeoutMs) || DEFAULT_SETTINGS.requestTimeoutMs));
79
+ merged.historyLimit = Math.max(5, Math.min(50, Number(merged.historyLimit) || DEFAULT_SETTINGS.historyLimit));
80
+ merged.autoScanEnabled = Boolean(merged.autoScanEnabled);
81
+
82
+ await chrome.storage.sync.set({ settings: merged });
83
+ return merged;
84
+ }
85
+
86
+ async function fetchJson(path, options = {}, settingsOverride = null) {
87
+ const settings = settingsOverride || await getSettings();
88
+ const controller = new AbortController();
89
+ const timeoutId = setTimeout(() => controller.abort(), settings.requestTimeoutMs);
90
+
91
+ const headers = { ...(options.headers || {}) };
92
+ if (settings.apiKey) {
93
+ headers["X-API-Key"] = settings.apiKey;
94
+ }
95
+
96
+ try {
97
+ const response = await fetch(`${settings.apiBaseUrl}${path}`, {
98
+ ...options,
99
+ headers,
100
+ signal: controller.signal
101
+ });
102
+
103
+ const contentType = response.headers.get("content-type") || "";
104
+ const body = contentType.includes("application/json")
105
+ ? await response.json()
106
+ : await response.text();
107
+
108
+ if (!response.ok) {
109
+ const detail = typeof body === "object" && body && "detail" in body
110
+ ? body.detail
111
+ : body || `Request failed with status ${response.status}`;
112
+ throw new Error(String(detail));
113
+ }
114
+
115
+ return body;
116
+ } catch (error) {
117
+ if (error.name === "AbortError") {
118
+ throw new Error("Backend request timed out. Check that the FastAPI server is running.");
119
+ }
120
+ throw error;
121
+ } finally {
122
+ clearTimeout(timeoutId);
123
+ }
124
+ }
125
+
126
+ async function getScanHistory() {
127
+ const data = await chrome.storage.local.get("scanHistory");
128
+ return Array.isArray(data.scanHistory) ? data.scanHistory : [];
129
+ }
130
+
131
+ async function saveScanHistory(history) {
132
+ await chrome.storage.local.set({ scanHistory: history });
133
+ }
134
+
135
+ async function pushHistoryEntry(payload, prediction) {
136
+ const settings = await getSettings();
137
+ const history = await getScanHistory();
138
+ const nextEntry = {
139
+ predictionId: prediction.prediction_id,
140
+ evaluatedAtUtc: prediction.evaluated_at_utc,
141
+ label: prediction.label,
142
+ confidence: prediction.confidence,
143
+ subject: payload.subject,
144
+ sender: payload.sender,
145
+ senderDomain: prediction.sender_domain,
146
+ reason: prediction.reason,
147
+ ruleLayer: prediction.rule_layer,
148
+ userLabel: null,
149
+ verdict: null
150
+ };
151
+
152
+ const filtered = history.filter((entry) => entry.predictionId !== prediction.prediction_id);
153
+ filtered.unshift(nextEntry);
154
+ await saveScanHistory(filtered.slice(0, settings.historyLimit));
155
+ }
156
+
157
+ async function updateHistoryFeedback(predictionId, userLabel, verdict) {
158
+ const history = await getScanHistory();
159
+ const updated = history.map((entry) => (
160
+ entry.predictionId === predictionId
161
+ ? { ...entry, userLabel, verdict }
162
+ : entry
163
+ ));
164
+ await saveScanHistory(updated);
165
+ }
166
+
167
+ async function analyzeEmail(payload) {
168
+ const normalized = normalizePayload(payload);
169
+ if (!normalized.subject && !normalized.body) {
170
+ throw new Error("Email subject or body is required for analysis.");
171
+ }
172
+
173
+ const key = cacheKey(normalized);
174
+ const cached = getCachedPrediction(key);
175
+ if (cached) {
176
+ return cached;
177
+ }
178
+
179
+ const prediction = await fetchJson("/v1/predict", {
180
+ method: "POST",
181
+ headers: { "Content-Type": "application/json" },
182
+ body: JSON.stringify(normalized)
183
+ });
184
+
185
+ predictionCache.set(key, {
186
+ timestamp: Date.now(),
187
+ value: prediction
188
+ });
189
+ await pushHistoryEntry(normalized, prediction);
190
+ return prediction;
191
+ }
192
+
193
+ async function checkBackendHealth() {
194
+ return fetchJson("/v1/health");
195
+ }
196
+
197
+ async function submitFeedback(payload) {
198
+ const response = await fetchJson("/v1/feedback", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify(payload)
202
+ });
203
+ await updateHistoryFeedback(payload.prediction_id, payload.user_label, response.verdict);
204
+ return response;
205
+ }
206
+
207
+ async function retrainModel() {
208
+ const settings = await getSettings();
209
+ const response = await fetchJson("/v1/retrain", {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" }
212
+ }, {
213
+ ...settings,
214
+ requestTimeoutMs: Math.max(settings.requestTimeoutMs, 15 * 60 * 1000)
215
+ });
216
+ predictionCache.clear();
217
+ return response;
218
+ }
219
+
220
+ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
221
+ const command = request?.command;
222
+
223
+ if (command === "analyze_email") {
224
+ analyzeEmail(request.payload)
225
+ .then((data) => sendResponse({ ok: true, data }))
226
+ .catch((error) => {
227
+ console.error("Prediction request failed", error);
228
+ sendResponse({ ok: false, error: error.message || "Prediction failed." });
229
+ });
230
+ return true;
231
+ }
232
+
233
+ if (command === "check_backend_health") {
234
+ checkBackendHealth()
235
+ .then((data) => sendResponse({ ok: true, data }))
236
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Backend is unavailable." }));
237
+ return true;
238
+ }
239
+
240
+ if (command === "get_settings") {
241
+ getSettings()
242
+ .then((data) => sendResponse({ ok: true, data }))
243
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Could not load settings." }));
244
+ return true;
245
+ }
246
+
247
+ if (command === "save_settings") {
248
+ saveSettings(request.payload)
249
+ .then((data) => sendResponse({ ok: true, data }))
250
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Could not save settings." }));
251
+ return true;
252
+ }
253
+
254
+ if (command === "get_scan_history") {
255
+ getScanHistory()
256
+ .then((data) => sendResponse({ ok: true, data }))
257
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Could not load scan history." }));
258
+ return true;
259
+ }
260
+
261
+ if (command === "clear_scan_history") {
262
+ saveScanHistory([])
263
+ .then(() => sendResponse({ ok: true, data: [] }))
264
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Could not clear scan history." }));
265
+ return true;
266
+ }
267
+
268
+ if (command === "submit_feedback") {
269
+ submitFeedback(request.payload)
270
+ .then((data) => sendResponse({ ok: true, data }))
271
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Could not submit feedback." }));
272
+ return true;
273
+ }
274
+
275
+ if (command === "retrain_model") {
276
+ retrainModel()
277
+ .then((data) => sendResponse({ ok: true, data }))
278
+ .catch((error) => sendResponse({ ok: false, error: error.message || "Could not retrain model." }));
279
+ return true;
280
+ }
281
+
282
+ if (command === "clear_prediction_cache") {
283
+ predictionCache.clear();
284
+ sendResponse({ ok: true });
285
+ return false;
286
+ }
287
+
288
+ return false;
289
+ });
extension/content.css ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .spam-detector-banner {
2
+ margin: 0 0 12px;
3
+ padding: 14px 16px;
4
+ border-radius: 14px;
5
+ border: 1px solid rgba(15, 23, 42, 0.08);
6
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
7
+ font-family: "Segoe UI", Tahoma, sans-serif;
8
+ line-height: 1.45;
9
+ }
10
+
11
+ .spam-detector-banner--spam {
12
+ background: linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%);
13
+ border-left: 5px solid #e11d48;
14
+ color: #881337;
15
+ }
16
+
17
+ .spam-detector-banner--safe {
18
+ background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
19
+ border-left: 5px solid #16a34a;
20
+ color: #166534;
21
+ }
22
+
23
+ .spam-detector-banner--trusted {
24
+ background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
25
+ border-left: 5px solid #2563eb;
26
+ color: #1d4ed8;
27
+ }
28
+
29
+ .spam-detector-banner--warning {
30
+ background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
31
+ border-left: 5px solid #ea580c;
32
+ color: #9a3412;
33
+ }
34
+
35
+ .spam-detector-banner__header {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: space-between;
39
+ gap: 12px;
40
+ margin-bottom: 8px;
41
+ }
42
+
43
+ .spam-detector-banner__badge {
44
+ font-size: 12px;
45
+ font-weight: 700;
46
+ letter-spacing: 0.08em;
47
+ text-transform: uppercase;
48
+ }
49
+
50
+ .spam-detector-banner__confidence {
51
+ font-size: 12px;
52
+ font-weight: 600;
53
+ opacity: 0.85;
54
+ }
55
+
56
+ .spam-detector-banner__reason,
57
+ .spam-detector-banner__analysis {
58
+ margin: 0;
59
+ font-size: 13px;
60
+ }
61
+
62
+ .spam-detector-banner__analysis {
63
+ margin-top: 6px;
64
+ opacity: 0.85;
65
+ }
66
+
67
+ .spam-detector-banner__signals {
68
+ display: flex;
69
+ flex-wrap: wrap;
70
+ gap: 8px;
71
+ margin-top: 10px;
72
+ }
73
+
74
+ .spam-detector-banner__chip {
75
+ padding: 4px 10px;
76
+ border-radius: 999px;
77
+ background: rgba(255, 255, 255, 0.55);
78
+ font-size: 12px;
79
+ font-weight: 600;
80
+ }
81
+
82
+ .spam-detector-banner__feedback {
83
+ margin-top: 12px;
84
+ padding-top: 12px;
85
+ border-top: 1px solid rgba(15, 23, 42, 0.08);
86
+ }
87
+
88
+ .spam-detector-banner__feedback-label {
89
+ display: block;
90
+ margin-bottom: 8px;
91
+ font-size: 12px;
92
+ font-weight: 700;
93
+ letter-spacing: 0.04em;
94
+ text-transform: uppercase;
95
+ opacity: 0.78;
96
+ }
97
+
98
+ .spam-detector-banner__actions {
99
+ display: flex;
100
+ flex-wrap: wrap;
101
+ gap: 8px;
102
+ }
103
+
104
+ .spam-detector-banner__action {
105
+ border: 1px solid transparent;
106
+ border-radius: 999px;
107
+ padding: 6px 12px;
108
+ font-size: 12px;
109
+ font-weight: 700;
110
+ cursor: pointer;
111
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
112
+ }
113
+
114
+ .spam-detector-banner__action:hover:not(:disabled) {
115
+ transform: translateY(-1px);
116
+ box-shadow: 0 6px 14px rgba(15, 23, 42, 0.12);
117
+ }
118
+
119
+ .spam-detector-banner__action:disabled {
120
+ cursor: default;
121
+ opacity: 0.7;
122
+ }
123
+
124
+ .spam-detector-banner__action--neutral {
125
+ background: rgba(255, 255, 255, 0.72);
126
+ color: inherit;
127
+ border-color: rgba(15, 23, 42, 0.08);
128
+ }
129
+
130
+ .spam-detector-banner__action--danger {
131
+ background: rgba(190, 24, 93, 0.12);
132
+ color: #9f1239;
133
+ border-color: rgba(190, 24, 93, 0.18);
134
+ }
135
+
136
+ .spam-detector-banner__action--safe {
137
+ background: rgba(22, 163, 74, 0.12);
138
+ color: #166534;
139
+ border-color: rgba(22, 163, 74, 0.18);
140
+ }
141
+
142
+ .spam-detector-banner__feedback-status {
143
+ margin: 8px 0 0;
144
+ font-size: 12px;
145
+ font-weight: 600;
146
+ opacity: 0.82;
147
+ }
148
+
149
+ @media (prefers-color-scheme: dark) {
150
+ .spam-detector-banner {
151
+ border-color: rgba(255, 255, 255, 0.08);
152
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
153
+ }
154
+
155
+ .spam-detector-banner--spam {
156
+ background: linear-gradient(135deg, rgba(225, 29, 72, 0.18) 0%, rgba(255, 0, 110, 0.08) 100%);
157
+ color: #fecdd3;
158
+ }
159
+
160
+ .spam-detector-banner--safe {
161
+ background: linear-gradient(135deg, rgba(22, 163, 74, 0.18) 0%, rgba(6, 255, 165, 0.08) 100%);
162
+ color: #bbf7d0;
163
+ }
164
+
165
+ .spam-detector-banner--trusted {
166
+ background: linear-gradient(135deg, rgba(37, 99, 235, 0.18) 0%, rgba(59, 130, 246, 0.08) 100%);
167
+ color: #bfdbfe;
168
+ }
169
+
170
+ .spam-detector-banner--warning {
171
+ background: linear-gradient(135deg, rgba(234, 88, 12, 0.18) 0%, rgba(249, 115, 22, 0.08) 100%);
172
+ color: #fed7aa;
173
+ }
174
+
175
+ .spam-detector-banner__chip {
176
+ background: rgba(0, 0, 0, 0.25);
177
+ }
178
+
179
+ .spam-detector-banner__feedback {
180
+ border-top-color: rgba(255, 255, 255, 0.08);
181
+ }
182
+ }
extension/content.js ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const BANNER_ID = "spam-detector-banner";
2
+ const ANALYZE_DEBOUNCE_MS = 900;
3
+
4
+ let analyzeTimer = null;
5
+ let lastSignature = "";
6
+ let analysisInFlight = false;
7
+ let autoScanEnabled = true;
8
+
9
+ function runtimeMessage(message) {
10
+ return new Promise((resolve, reject) => {
11
+ chrome.runtime.sendMessage(message, (response) => {
12
+ if (chrome.runtime.lastError) {
13
+ reject(new Error(chrome.runtime.lastError.message));
14
+ return;
15
+ }
16
+ if (!response?.ok) {
17
+ reject(new Error(response?.error || "Unknown extension error."));
18
+ return;
19
+ }
20
+ resolve(response.data);
21
+ });
22
+ });
23
+ }
24
+
25
+ async function refreshSettings() {
26
+ try {
27
+ const settings = await runtimeMessage({ command: "get_settings" });
28
+ autoScanEnabled = Boolean(settings.autoScanEnabled);
29
+ } catch (error) {
30
+ autoScanEnabled = true;
31
+ }
32
+ }
33
+
34
+ function emailSignature(data) {
35
+ return JSON.stringify([data.sender || "", data.subject || "", data.body || ""]);
36
+ }
37
+
38
+ function removeBanner() {
39
+ document.getElementById(BANNER_ID)?.remove();
40
+ }
41
+
42
+ function normalizedFeedbackLabel(predictionLabel) {
43
+ return predictionLabel === "Spam" ? "Spam" : "Not Spam";
44
+ }
45
+
46
+ async function submitBannerFeedback(payload, prediction, userLabel, statusNode, actionButtons) {
47
+ if (!payload || !prediction?.prediction_id) {
48
+ statusNode.textContent = "Feedback is unavailable for this message.";
49
+ statusNode.hidden = false;
50
+ return;
51
+ }
52
+
53
+ actionButtons.forEach((button) => {
54
+ button.disabled = true;
55
+ });
56
+ statusNode.hidden = false;
57
+ statusNode.textContent = "Saving feedback...";
58
+
59
+ try {
60
+ const response = await runtimeMessage({
61
+ command: "submit_feedback",
62
+ payload: {
63
+ prediction_id: prediction.prediction_id,
64
+ sender: payload.sender || "",
65
+ subject: payload.subject || "",
66
+ body: payload.body || "",
67
+ predicted_label: prediction.label,
68
+ predicted_confidence: prediction.confidence,
69
+ user_label: userLabel,
70
+ source: "gmail_banner"
71
+ }
72
+ });
73
+ statusNode.textContent = `Feedback saved (${String(response.verdict || "ok").replace("_", " ")}).`;
74
+ } catch (error) {
75
+ actionButtons.forEach((button) => {
76
+ button.disabled = false;
77
+ });
78
+ statusNode.textContent = error.message || "Could not save feedback.";
79
+ }
80
+ }
81
+
82
+ function createFeedbackButton(label, modifierClass, onClick) {
83
+ const button = document.createElement("button");
84
+ button.type = "button";
85
+ button.className = `spam-detector-banner__action ${modifierClass}`;
86
+ button.textContent = label;
87
+ button.addEventListener("click", onClick);
88
+ return button;
89
+ }
90
+
91
+ function createBanner(prediction, payload) {
92
+ const banner = document.createElement("section");
93
+ banner.id = BANNER_ID;
94
+
95
+ const variant = prediction.label === "Spam"
96
+ ? "spam"
97
+ : prediction.label === "whitelisted"
98
+ ? "trusted"
99
+ : "safe";
100
+
101
+ banner.className = `spam-detector-banner spam-detector-banner--${variant}`;
102
+
103
+ const header = document.createElement("div");
104
+ header.className = "spam-detector-banner__header";
105
+
106
+ const badge = document.createElement("span");
107
+ badge.className = "spam-detector-banner__badge";
108
+ badge.textContent = prediction.label === "Spam"
109
+ ? "Spam alert"
110
+ : prediction.label === "whitelisted"
111
+ ? "Whitelisted"
112
+ : "Looks safe";
113
+
114
+ const confidence = document.createElement("span");
115
+ confidence.className = "spam-detector-banner__confidence";
116
+ confidence.textContent = `${Math.round((prediction.confidence || 0) * 100)}% confidence`;
117
+
118
+ header.append(badge, confidence);
119
+ banner.appendChild(header);
120
+
121
+ const reason = document.createElement("p");
122
+ reason.className = "spam-detector-banner__reason";
123
+ reason.textContent = prediction.reason || "Analysis completed.";
124
+ banner.appendChild(reason);
125
+
126
+ if (prediction.analysis) {
127
+ const analysis = document.createElement("p");
128
+ analysis.className = "spam-detector-banner__analysis";
129
+ analysis.textContent = prediction.analysis;
130
+ banner.appendChild(analysis);
131
+ }
132
+
133
+ const cues = (prediction.explanations?.length ? prediction.explanations : prediction.signals || []).slice(0, 3);
134
+ if (cues.length) {
135
+ const signals = document.createElement("div");
136
+ signals.className = "spam-detector-banner__signals";
137
+ cues.forEach((signal) => {
138
+ const chip = document.createElement("span");
139
+ chip.className = "spam-detector-banner__chip";
140
+ chip.textContent = signal;
141
+ signals.appendChild(chip);
142
+ });
143
+ banner.appendChild(signals);
144
+ }
145
+
146
+ const feedbackSection = document.createElement("div");
147
+ feedbackSection.className = "spam-detector-banner__feedback";
148
+
149
+ const feedbackLabel = document.createElement("span");
150
+ feedbackLabel.className = "spam-detector-banner__feedback-label";
151
+ feedbackLabel.textContent = "Feedback";
152
+
153
+ const feedbackActions = document.createElement("div");
154
+ feedbackActions.className = "spam-detector-banner__actions";
155
+
156
+ const feedbackStatus = document.createElement("p");
157
+ feedbackStatus.className = "spam-detector-banner__feedback-status";
158
+ feedbackStatus.hidden = true;
159
+
160
+ const buttons = [];
161
+ const correctButton = createFeedbackButton(
162
+ "Looks right",
163
+ "spam-detector-banner__action--neutral",
164
+ () => submitBannerFeedback(payload, prediction, normalizedFeedbackLabel(prediction.label), feedbackStatus, buttons),
165
+ );
166
+ const spamButton = createFeedbackButton(
167
+ "Mark Spam",
168
+ "spam-detector-banner__action--danger",
169
+ () => submitBannerFeedback(payload, prediction, "Spam", feedbackStatus, buttons),
170
+ );
171
+ const safeButton = createFeedbackButton(
172
+ "Mark Safe",
173
+ "spam-detector-banner__action--safe",
174
+ () => submitBannerFeedback(payload, prediction, "Not Spam", feedbackStatus, buttons),
175
+ );
176
+
177
+ buttons.push(correctButton, spamButton, safeButton);
178
+ feedbackActions.append(correctButton, spamButton, safeButton);
179
+ feedbackSection.append(feedbackLabel, feedbackActions, feedbackStatus);
180
+ banner.appendChild(feedbackSection);
181
+
182
+ return banner;
183
+ }
184
+
185
+ function injectBanner(prediction, payload) {
186
+ removeBanner();
187
+ const anchor = window.DomParser?.getBannerAnchor?.();
188
+ if (!anchor) {
189
+ return;
190
+ }
191
+ anchor.insertBefore(createBanner(prediction, payload), anchor.firstChild);
192
+ }
193
+
194
+ function injectWarningBanner(message) {
195
+ removeBanner();
196
+ const banner = document.createElement("section");
197
+ banner.id = BANNER_ID;
198
+ banner.className = "spam-detector-banner spam-detector-banner--warning";
199
+ const text = document.createElement("p");
200
+ text.className = "spam-detector-banner__reason";
201
+ text.textContent = message;
202
+ banner.appendChild(text);
203
+ const anchor = window.DomParser?.getBannerAnchor?.();
204
+ if (anchor) {
205
+ anchor.insertBefore(banner, anchor.firstChild);
206
+ }
207
+ }
208
+
209
+ async function analyzeOpenEmail(force = false) {
210
+ const data = window.DomParser?.getEmailData?.();
211
+ if (!data || (!data.subject && !data.body)) {
212
+ return;
213
+ }
214
+
215
+ if (!data.sender && !data.subject && !data.body) {
216
+ injectWarningBanner("Spam Detector could not read this email — Gmail may have updated its layout. Try refreshing or pasting into the popup.");
217
+ return;
218
+ }
219
+
220
+ if (!autoScanEnabled && !force) {
221
+ return;
222
+ }
223
+
224
+ const signature = emailSignature(data);
225
+ if (!force && (analysisInFlight || signature === lastSignature)) {
226
+ return;
227
+ }
228
+
229
+ analysisInFlight = true;
230
+ lastSignature = signature;
231
+
232
+ try {
233
+ const prediction = await runtimeMessage({
234
+ command: "analyze_email",
235
+ payload: data
236
+ });
237
+ injectBanner(prediction, data);
238
+ } catch (error) {
239
+ lastSignature = "";
240
+ console.warn("Spam detector could not analyze email:", error.message);
241
+ } finally {
242
+ analysisInFlight = false;
243
+ }
244
+ }
245
+
246
+ function scheduleAnalysis(force = false) {
247
+ clearTimeout(analyzeTimer);
248
+ analyzeTimer = setTimeout(() => analyzeOpenEmail(force), ANALYZE_DEBOUNCE_MS);
249
+ }
250
+
251
+ const observer = new MutationObserver((mutations) => {
252
+ for (const mutation of mutations) {
253
+ if (mutation.addedNodes.length > 0) {
254
+ scheduleAnalysis(false);
255
+ break;
256
+ }
257
+ }
258
+ });
259
+
260
+ observer.observe(document.body, {
261
+ childList: true,
262
+ subtree: true
263
+ });
264
+
265
+ document.addEventListener("visibilitychange", async () => {
266
+ if (!document.hidden) {
267
+ await refreshSettings();
268
+ scheduleAnalysis(true);
269
+ }
270
+ });
271
+
272
+ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
273
+ if (request.command === "get_email_data") {
274
+ sendResponse(window.DomParser?.getEmailData?.() || null);
275
+ }
276
+ });
277
+
278
+ refreshSettings().then(() => {
279
+ scheduleAnalysis(true);
280
+ });
extension/manifest.json ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Gmail Spam Detector",
4
+ "version": "1.2.0",
5
+ "description": "Detect spam and phishing emails in Gmail using a local or deployed FastAPI backend.",
6
+ "permissions": [
7
+ "activeTab",
8
+ "storage"
9
+ ],
10
+ "host_permissions": [
11
+ "https://mail.google.com/*",
12
+ "http://localhost/*",
13
+ "http://127.0.0.1/*",
14
+ "https://localhost/*",
15
+ "https://127.0.0.1/*",
16
+ "https://*.hf.space/*"
17
+ ],
18
+ "optional_host_permissions": [
19
+ "https://*/*"
20
+ ],
21
+ "action": {
22
+ "default_popup": "popup.html",
23
+ "default_icon": {
24
+ "16": "assets/icon16.png",
25
+ "48": "assets/icon48.png",
26
+ "128": "assets/icon128.png"
27
+ }
28
+ },
29
+ "options_page": "options.html",
30
+ "background": {
31
+ "service_worker": "background.js"
32
+ },
33
+ "content_scripts": [
34
+ {
35
+ "matches": [
36
+ "https://mail.google.com/*"
37
+ ],
38
+ "js": [
39
+ "utils/domParser.js",
40
+ "content.js"
41
+ ],
42
+ "css": [
43
+ "content.css"
44
+ ]
45
+ }
46
+ ],
47
+ "web_accessible_resources": [
48
+ {
49
+ "resources": [
50
+ "assets/*"
51
+ ],
52
+ "matches": [
53
+ "https://mail.google.com/*"
54
+ ]
55
+ }
56
+ ]
57
+ }
extension/options.css ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b1221;
3
+ --panel: rgba(15, 23, 42, 0.92);
4
+ --border: rgba(148, 163, 184, 0.2);
5
+ --text: #e2e8f0;
6
+ --muted: #94a3b8;
7
+ --accent: #22d3ee;
8
+ --accent-2: #38bdf8;
9
+ }
10
+
11
+ * {
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ margin: 0;
17
+ min-height: 100vh;
18
+ font-family: "Segoe UI", sans-serif;
19
+ color: var(--text);
20
+ background:
21
+ radial-gradient(circle at top left, rgba(34, 211, 238, 0.18), transparent 28%),
22
+ radial-gradient(circle at bottom right, rgba(56, 189, 248, 0.16), transparent 30%),
23
+ linear-gradient(160deg, #020617 0%, #0f172a 100%);
24
+ }
25
+
26
+ .page {
27
+ max-width: 760px;
28
+ margin: 0 auto;
29
+ padding: 48px 24px;
30
+ }
31
+
32
+ .panel {
33
+ background: var(--panel);
34
+ border: 1px solid var(--border);
35
+ border-radius: 22px;
36
+ padding: 28px;
37
+ box-shadow: 0 24px 60px rgba(2, 6, 23, 0.45);
38
+ }
39
+
40
+ h1 {
41
+ margin: 0 0 8px;
42
+ font-size: 32px;
43
+ }
44
+
45
+ .lead {
46
+ margin: 0 0 24px;
47
+ color: var(--muted);
48
+ line-height: 1.6;
49
+ }
50
+
51
+ .form {
52
+ display: grid;
53
+ gap: 18px;
54
+ }
55
+
56
+ .field {
57
+ display: grid;
58
+ gap: 8px;
59
+ }
60
+
61
+ .field span {
62
+ font-weight: 600;
63
+ }
64
+
65
+ .field input {
66
+ width: 100%;
67
+ border-radius: 12px;
68
+ border: 1px solid var(--border);
69
+ background: rgba(15, 23, 42, 0.7);
70
+ color: var(--text);
71
+ padding: 12px 14px;
72
+ font-size: 15px;
73
+ }
74
+
75
+ .field small {
76
+ color: var(--muted);
77
+ }
78
+
79
+ .toggle {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 12px;
83
+ padding: 14px 16px;
84
+ border: 1px solid var(--border);
85
+ border-radius: 14px;
86
+ background: rgba(15, 23, 42, 0.45);
87
+ }
88
+
89
+ .actions {
90
+ display: flex;
91
+ gap: 12px;
92
+ flex-wrap: wrap;
93
+ }
94
+
95
+ button {
96
+ border: none;
97
+ border-radius: 12px;
98
+ padding: 12px 16px;
99
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
100
+ color: #06202a;
101
+ font-weight: 700;
102
+ cursor: pointer;
103
+ }
104
+
105
+ button.secondary {
106
+ background: rgba(255, 255, 255, 0.08);
107
+ color: var(--text);
108
+ border: 1px solid var(--border);
109
+ }
110
+
111
+ .status {
112
+ margin: 18px 0 0;
113
+ min-height: 20px;
114
+ color: var(--muted);
115
+ }
extension/options.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Spam Detector Settings</title>
7
+ <link rel="stylesheet" href="options.css">
8
+ </head>
9
+ <body>
10
+ <main class="page">
11
+ <section class="panel">
12
+ <h1>Spam Detector Settings</h1>
13
+ <p class="lead">Control the backend endpoint, timeout, auto-scan behavior, popup history size, and retraining workflow.</p>
14
+
15
+ <form id="settings-form" class="form">
16
+ <label class="field">
17
+ <span>Backend URL</span>
18
+ <input id="api-base-url" type="url" placeholder="http://127.0.0.1:8000">
19
+ <small>Use `http://localhost` for local development, or `https://...` for a deployed backend.</small>
20
+ </label>
21
+
22
+ <label class="field">
23
+ <span>Request Timeout (ms)</span>
24
+ <input id="request-timeout" type="number" min="2000" max="60000" step="500">
25
+ </label>
26
+
27
+ <label class="field">
28
+ <span>History Limit</span>
29
+ <input id="history-limit" type="number" min="5" max="50" step="1">
30
+ </label>
31
+
32
+ <label class="toggle">
33
+ <input id="auto-scan-enabled" type="checkbox">
34
+ <span>Enable auto-scan on opened Gmail messages</span>
35
+ </label>
36
+
37
+ <div class="actions">
38
+ <button id="btn-save" type="submit">Save Settings</button>
39
+ <button id="btn-check" type="button" class="secondary">Check Backend</button>
40
+ <button id="btn-retrain" type="button" class="secondary">Retrain From Feedback</button>
41
+ </div>
42
+ </form>
43
+
44
+ <p id="status" class="status"></p>
45
+ </section>
46
+ </main>
47
+
48
+ <script src="options.js"></script>
49
+ </body>
50
+ </html>
extension/options.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", async () => {
2
+ const elements = {
3
+ form: document.getElementById("settings-form"),
4
+ apiBaseUrl: document.getElementById("api-base-url"),
5
+ requestTimeout: document.getElementById("request-timeout"),
6
+ historyLimit: document.getElementById("history-limit"),
7
+ autoScanEnabled: document.getElementById("auto-scan-enabled"),
8
+ btnCheck: document.getElementById("btn-check"),
9
+ btnRetrain: document.getElementById("btn-retrain"),
10
+ status: document.getElementById("status")
11
+ };
12
+
13
+ function runtimeMessage(message) {
14
+ return new Promise((resolve, reject) => {
15
+ chrome.runtime.sendMessage(message, (response) => {
16
+ if (chrome.runtime.lastError) {
17
+ reject(new Error(chrome.runtime.lastError.message));
18
+ return;
19
+ }
20
+ if (!response?.ok) {
21
+ reject(new Error(response?.error || "Unknown extension error."));
22
+ return;
23
+ }
24
+ resolve(response.data);
25
+ });
26
+ });
27
+ }
28
+
29
+ function setStatus(text) {
30
+ elements.status.textContent = text;
31
+ }
32
+
33
+ async function loadSettings() {
34
+ const settings = await runtimeMessage({ command: "get_settings" });
35
+ elements.apiBaseUrl.value = settings.apiBaseUrl;
36
+ elements.requestTimeout.value = settings.requestTimeoutMs;
37
+ elements.historyLimit.value = settings.historyLimit;
38
+ elements.autoScanEnabled.checked = Boolean(settings.autoScanEnabled);
39
+ }
40
+
41
+ elements.form.addEventListener("submit", async (event) => {
42
+ event.preventDefault();
43
+ try {
44
+ const settings = await runtimeMessage({
45
+ command: "save_settings",
46
+ payload: {
47
+ apiBaseUrl: elements.apiBaseUrl.value,
48
+ requestTimeoutMs: Number(elements.requestTimeout.value),
49
+ historyLimit: Number(elements.historyLimit.value),
50
+ autoScanEnabled: elements.autoScanEnabled.checked
51
+ }
52
+ });
53
+ await runtimeMessage({ command: "clear_prediction_cache" });
54
+ setStatus(`Saved. Backend URL: ${settings.apiBaseUrl}`);
55
+ } catch (error) {
56
+ setStatus(error.message || "Could not save settings.");
57
+ }
58
+ });
59
+
60
+ elements.btnCheck.addEventListener("click", async () => {
61
+ try {
62
+ const health = await runtimeMessage({ command: "check_backend_health" });
63
+ setStatus(`Backend online (${health.feedback_backend}). Model: ${health.model_version}. /v1/health OK`);
64
+ } catch (error) {
65
+ setStatus(error.message || "Backend is unavailable.");
66
+ }
67
+ });
68
+
69
+ elements.btnRetrain.addEventListener("click", async () => {
70
+ try {
71
+ setStatus("Retraining model from reviewed feedback. This can take a few minutes...");
72
+ const result = await runtimeMessage({ command: "retrain_model" });
73
+ setStatus(
74
+ `Retrained ${result.model_version} via ${result.feedback_backend}. Feedback used: ${result.feedback_rows_used}.`
75
+ );
76
+ } catch (error) {
77
+ setStatus(error.message || "Could not retrain model.");
78
+ }
79
+ });
80
+
81
+ loadSettings().catch((error) => setStatus(error.message || "Could not load settings."));
82
+ });
extension/popup.css ADDED
@@ -0,0 +1,1305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
2
+
3
+ :root {
4
+ --neon-pink: #ff006e;
5
+ --neon-purple: #8338ec;
6
+ --neon-blue: #3a86ff;
7
+ --neon-cyan: #00f5ff;
8
+ --neon-green: #06ffa5;
9
+ --dark-bg: #0a0e27;
10
+ --card-bg: rgba(15, 23, 42, 0.85);
11
+ --glass-border: rgba(255, 255, 255, 0.1);
12
+ --text-primary: #ffffff;
13
+ --text-secondary: #94a3b8;
14
+ }
15
+
16
+ * {
17
+ margin: 0;
18
+ padding: 0;
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ width: 420px;
24
+ min-height: 580px;
25
+ font-family: 'Inter', sans-serif;
26
+ background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0a0e27 100%);
27
+ color: var(--text-primary);
28
+ overflow-x: hidden;
29
+ overflow-y: auto;
30
+ position: relative;
31
+ animation: bodyFadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);
32
+ }
33
+
34
+ @keyframes bodyFadeIn {
35
+ from {
36
+ opacity: 0;
37
+ transform: scale(0.95);
38
+ }
39
+
40
+ to {
41
+ opacity: 1;
42
+ transform: scale(1);
43
+ }
44
+ }
45
+
46
+ /* Animated Background Particles */
47
+ .particles-bg {
48
+ position: absolute;
49
+ width: 100%;
50
+ height: 100%;
51
+ overflow: hidden;
52
+ pointer-events: none;
53
+ z-index: 0;
54
+ }
55
+
56
+ .particle {
57
+ position: absolute;
58
+ width: 3px;
59
+ height: 3px;
60
+ background: var(--neon-cyan);
61
+ border-radius: 50%;
62
+ animation: particleFloat 8s infinite ease-in-out;
63
+ opacity: 0.6;
64
+ box-shadow: 0 0 10px var(--neon-cyan);
65
+ }
66
+
67
+ .particle:nth-child(1) {
68
+ left: 10%;
69
+ top: 20%;
70
+ animation-delay: 0s;
71
+ animation-duration: 7s;
72
+ }
73
+
74
+ .particle:nth-child(2) {
75
+ left: 80%;
76
+ top: 40%;
77
+ animation-delay: 1s;
78
+ animation-duration: 9s;
79
+ background: var(--neon-pink);
80
+ box-shadow: 0 0 10px var(--neon-pink);
81
+ }
82
+
83
+ .particle:nth-child(3) {
84
+ left: 50%;
85
+ top: 60%;
86
+ animation-delay: 2s;
87
+ animation-duration: 6s;
88
+ background: var(--neon-purple);
89
+ box-shadow: 0 0 10px var(--neon-purple);
90
+ }
91
+
92
+ .particle:nth-child(4) {
93
+ left: 25%;
94
+ top: 80%;
95
+ animation-delay: 3s;
96
+ animation-duration: 8s;
97
+ }
98
+
99
+ .particle:nth-child(5) {
100
+ left: 70%;
101
+ top: 15%;
102
+ animation-delay: 4s;
103
+ animation-duration: 10s;
104
+ background: var(--neon-green);
105
+ box-shadow: 0 0 10px var(--neon-green);
106
+ }
107
+
108
+ .particle:nth-child(6) {
109
+ left: 40%;
110
+ top: 35%;
111
+ animation-delay: 1.5s;
112
+ animation-duration: 7.5s;
113
+ background: var(--neon-blue);
114
+ box-shadow: 0 0 10px var(--neon-blue);
115
+ }
116
+
117
+ .particle:nth-child(7) {
118
+ left: 85%;
119
+ top: 70%;
120
+ animation-delay: 5s;
121
+ animation-duration: 9.5s;
122
+ background: var(--neon-pink);
123
+ box-shadow: 0 0 10px var(--neon-pink);
124
+ }
125
+
126
+ .particle:nth-child(8) {
127
+ left: 15%;
128
+ top: 50%;
129
+ animation-delay: 6s;
130
+ animation-duration: 8.5s;
131
+ }
132
+
133
+ @keyframes particleFloat {
134
+
135
+ 0%,
136
+ 100% {
137
+ transform: translate(0, 0) scale(1);
138
+ opacity: 0.6;
139
+ }
140
+
141
+ 33% {
142
+ transform: translate(30px, -40px) scale(1.3);
143
+ opacity: 0.9;
144
+ }
145
+
146
+ 66% {
147
+ transform: translate(-20px, 30px) scale(0.8);
148
+ opacity: 0.4;
149
+ }
150
+ }
151
+
152
+ /* Gradient Orbs */
153
+ .gradient-orb {
154
+ position: absolute;
155
+ border-radius: 50%;
156
+ filter: blur(80px);
157
+ opacity: 0.4;
158
+ pointer-events: none;
159
+ animation: orbFloat 10s infinite ease-in-out;
160
+ }
161
+
162
+ .orb-1 {
163
+ width: 300px;
164
+ height: 300px;
165
+ background: radial-gradient(circle, var(--neon-pink) 0%, transparent 70%);
166
+ top: -150px;
167
+ left: -150px;
168
+ animation-delay: 0s;
169
+ }
170
+
171
+ .orb-2 {
172
+ width: 250px;
173
+ height: 250px;
174
+ background: radial-gradient(circle, var(--neon-blue) 0%, transparent 70%);
175
+ bottom: -125px;
176
+ right: -125px;
177
+ animation-delay: 3s;
178
+ }
179
+
180
+ .orb-3 {
181
+ width: 200px;
182
+ height: 200px;
183
+ background: radial-gradient(circle, var(--neon-purple) 0%, transparent 70%);
184
+ top: 50%;
185
+ left: 50%;
186
+ transform: translate(-50%, -50%);
187
+ animation-delay: 6s;
188
+ }
189
+
190
+ @keyframes orbFloat {
191
+
192
+ 0%,
193
+ 100% {
194
+ transform: translate(0, 0) scale(1);
195
+ }
196
+
197
+ 33% {
198
+ transform: translate(30px, 40px) scale(1.1);
199
+ }
200
+
201
+ 66% {
202
+ transform: translate(-30px, -40px) scale(0.9);
203
+ }
204
+ }
205
+
206
+ .container {
207
+ position: relative;
208
+ z-index: 1;
209
+ padding: 20px;
210
+ animation: containerSlideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
211
+ }
212
+
213
+ @keyframes containerSlideUp {
214
+ from {
215
+ opacity: 0;
216
+ transform: translateY(30px);
217
+ }
218
+
219
+ to {
220
+ opacity: 1;
221
+ transform: translateY(0);
222
+ }
223
+ }
224
+
225
+ .card {
226
+ background: var(--card-bg);
227
+ backdrop-filter: blur(20px);
228
+ -webkit-backdrop-filter: blur(20px);
229
+ border: 1px solid var(--glass-border);
230
+ border-radius: 24px;
231
+ padding: 28px;
232
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5),
233
+ 0 0 40px rgba(58, 134, 255, 0.1),
234
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
235
+ animation: cardPopIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s both;
236
+ position: relative;
237
+ overflow: hidden;
238
+ }
239
+
240
+ .card::before {
241
+ content: '';
242
+ position: absolute;
243
+ top: 0;
244
+ left: -100%;
245
+ width: 100%;
246
+ height: 100%;
247
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
248
+ animation: cardShine 3s infinite;
249
+ }
250
+
251
+ @keyframes cardPopIn {
252
+ from {
253
+ transform: scale(0.8) rotateX(10deg);
254
+ opacity: 0;
255
+ }
256
+
257
+ to {
258
+ transform: scale(1) rotateX(0);
259
+ opacity: 1;
260
+ }
261
+ }
262
+
263
+ @keyframes cardShine {
264
+ 0% {
265
+ left: -100%;
266
+ }
267
+
268
+ 50%,
269
+ 100% {
270
+ left: 100%;
271
+ }
272
+ }
273
+
274
+ /* Header */
275
+ .header {
276
+ text-align: center;
277
+ margin-bottom: 28px;
278
+ animation: headerSlideDown 0.8s cubic-bezier(0.16, 1, 0.3, 1) 0.4s both;
279
+ }
280
+
281
+ .topbar {
282
+ display: flex;
283
+ justify-content: flex-end;
284
+ margin-bottom: 10px;
285
+ }
286
+
287
+ @keyframes headerSlideDown {
288
+ from {
289
+ opacity: 0;
290
+ transform: translateY(-20px);
291
+ }
292
+
293
+ to {
294
+ opacity: 1;
295
+ transform: translateY(0);
296
+ }
297
+ }
298
+
299
+ .icon-wrapper {
300
+ position: relative;
301
+ display: inline-block;
302
+ margin-bottom: 16px;
303
+ animation: iconBreathe 3s ease-in-out infinite;
304
+ }
305
+
306
+ @keyframes iconBreathe {
307
+
308
+ 0%,
309
+ 100% {
310
+ transform: scale(1);
311
+ }
312
+
313
+ 50% {
314
+ transform: scale(1.08);
315
+ }
316
+ }
317
+
318
+ .icon-glow {
319
+ position: absolute;
320
+ top: 50%;
321
+ left: 50%;
322
+ transform: translate(-50%, -50%);
323
+ width: 90px;
324
+ height: 90px;
325
+ background: radial-gradient(circle, var(--neon-cyan) 0%, transparent 70%);
326
+ border-radius: 50%;
327
+ animation: glowPulse 2s ease-in-out infinite;
328
+ filter: blur(20px);
329
+ }
330
+
331
+ @keyframes glowPulse {
332
+
333
+ 0%,
334
+ 100% {
335
+ opacity: 0.5;
336
+ transform: translate(-50%, -50%) scale(0.8);
337
+ }
338
+
339
+ 50% {
340
+ opacity: 0.8;
341
+ transform: translate(-50%, -50%) scale(1.2);
342
+ }
343
+ }
344
+
345
+ .shield-icon {
346
+ position: relative;
347
+ width: 56px;
348
+ height: 56px;
349
+ filter: drop-shadow(0 0 20px var(--neon-cyan));
350
+ animation: iconRotate 20s linear infinite;
351
+ }
352
+
353
+ @keyframes iconRotate {
354
+ from {
355
+ transform: rotate(0deg);
356
+ }
357
+
358
+ to {
359
+ transform: rotate(360deg);
360
+ }
361
+ }
362
+
363
+ .title {
364
+ font-size: 28px;
365
+ font-weight: 800;
366
+ margin-bottom: 8px;
367
+ letter-spacing: -0.5px;
368
+ }
369
+
370
+ .title-word {
371
+ display: inline-block;
372
+ animation: titleBounce 0.6s ease-out;
373
+ animation-fill-mode: both;
374
+ }
375
+
376
+ .title-word:nth-child(1) {
377
+ animation-delay: 0.5s;
378
+ }
379
+
380
+ .title-word:nth-child(2) {
381
+ animation-delay: 0.6s;
382
+ }
383
+
384
+ @keyframes titleBounce {
385
+ from {
386
+ opacity: 0;
387
+ transform: translateY(-20px);
388
+ }
389
+
390
+ 60% {
391
+ transform: translateY(5px);
392
+ }
393
+
394
+ to {
395
+ opacity: 1;
396
+ transform: translateY(0);
397
+ }
398
+ }
399
+
400
+ .gradient-text {
401
+ background: linear-gradient(135deg, var(--neon-cyan) 0%, var(--neon-pink) 100%);
402
+ -webkit-background-clip: text;
403
+ -webkit-text-fill-color: transparent;
404
+ background-clip: text;
405
+ animation: gradientShift 3s ease infinite;
406
+ background-size: 200% 200%;
407
+ }
408
+
409
+ @keyframes gradientShift {
410
+
411
+ 0%,
412
+ 100% {
413
+ background-position: 0% 50%;
414
+ }
415
+
416
+ 50% {
417
+ background-position: 100% 50%;
418
+ }
419
+ }
420
+
421
+ .subtitle {
422
+ font-size: 13px;
423
+ color: var(--text-secondary);
424
+ display: flex;
425
+ align-items: center;
426
+ justify-content: center;
427
+ gap: 6px;
428
+ animation: subtitleFade 0.8s ease-out 0.7s both;
429
+ }
430
+
431
+ @keyframes subtitleFade {
432
+ from {
433
+ opacity: 0;
434
+ }
435
+
436
+ to {
437
+ opacity: 1;
438
+ }
439
+ }
440
+
441
+ .service-status {
442
+ display: inline-flex;
443
+ align-items: center;
444
+ justify-content: center;
445
+ gap: 8px;
446
+ margin-top: 12px;
447
+ padding: 8px 12px;
448
+ border-radius: 999px;
449
+ background: rgba(255, 255, 255, 0.05);
450
+ border: 1px solid rgba(6, 255, 165, 0.2);
451
+ font-size: 12px;
452
+ color: var(--text-secondary);
453
+ }
454
+
455
+ .health-meta {
456
+ display: flex;
457
+ flex-wrap: wrap;
458
+ justify-content: center;
459
+ gap: 8px;
460
+ margin-top: 12px;
461
+ }
462
+
463
+ .service-status.offline {
464
+ border-color: rgba(255, 0, 110, 0.25);
465
+ }
466
+
467
+ .service-status.offline .status-dot {
468
+ background: var(--neon-pink);
469
+ box-shadow: 0 0 10px var(--neon-pink);
470
+ }
471
+
472
+ .ai-badge {
473
+ background: linear-gradient(135deg, var(--neon-purple) 0%, var(--neon-blue) 100%);
474
+ padding: 3px 8px;
475
+ border-radius: 6px;
476
+ font-size: 11px;
477
+ font-weight: 700;
478
+ animation: badgePulse 2s ease-in-out infinite;
479
+ }
480
+
481
+ @keyframes badgePulse {
482
+
483
+ 0%,
484
+ 100% {
485
+ box-shadow: 0 0 10px rgba(131, 56, 236, 0.5);
486
+ }
487
+
488
+ 50% {
489
+ box-shadow: 0 0 20px rgba(131, 56, 236, 0.8);
490
+ }
491
+ }
492
+
493
+ /* Content */
494
+ .content {
495
+ animation: contentFadeIn 0.8s ease-out 0.6s both;
496
+ }
497
+
498
+ @keyframes contentFadeIn {
499
+ from {
500
+ opacity: 0;
501
+ transform: translateY(10px);
502
+ }
503
+
504
+ to {
505
+ opacity: 1;
506
+ transform: translateY(0);
507
+ }
508
+ }
509
+
510
+ .input-container {
511
+ position: relative;
512
+ margin-bottom: 20px;
513
+ }
514
+
515
+ .input-label {
516
+ display: flex;
517
+ align-items: center;
518
+ gap: 8px;
519
+ font-size: 13px;
520
+ font-weight: 600;
521
+ color: var(--text-secondary);
522
+ margin-bottom: 10px;
523
+ position: relative;
524
+ animation: labelSlide 0.6s ease-out 0.8s both;
525
+ }
526
+
527
+ @keyframes labelSlide {
528
+ from {
529
+ opacity: 0;
530
+ transform: translateX(-10px);
531
+ }
532
+
533
+ to {
534
+ opacity: 1;
535
+ transform: translateX(0);
536
+ }
537
+ }
538
+
539
+ .label-icon {
540
+ font-size: 16px;
541
+ animation: iconBounce 1s ease-in-out infinite;
542
+ }
543
+
544
+ @keyframes iconBounce {
545
+
546
+ 0%,
547
+ 100% {
548
+ transform: translateY(0);
549
+ }
550
+
551
+ 50% {
552
+ transform: translateY(-3px);
553
+ }
554
+ }
555
+
556
+ .label-pulse {
557
+ width: 8px;
558
+ height: 8px;
559
+ background: var(--neon-green);
560
+ border-radius: 50%;
561
+ animation: dotPulse 1.5s ease-in-out infinite;
562
+ box-shadow: 0 0 10px var(--neon-green);
563
+ }
564
+
565
+ @keyframes dotPulse {
566
+
567
+ 0%,
568
+ 100% {
569
+ opacity: 1;
570
+ transform: scale(1);
571
+ }
572
+
573
+ 50% {
574
+ opacity: 0.5;
575
+ transform: scale(0.8);
576
+ }
577
+ }
578
+
579
+ .email-textarea {
580
+ width: 100%;
581
+ min-height: 140px;
582
+ padding: 14px;
583
+ background: rgba(255, 255, 255, 0.03);
584
+ border: 1.5px solid var(--glass-border);
585
+ border-radius: 12px;
586
+ color: var(--text-primary);
587
+ font-family: 'Inter', monospace;
588
+ font-size: 13px;
589
+ line-height: 1.6;
590
+ resize: vertical;
591
+ max-height: 260px;
592
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
593
+ animation: textareaGrow 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.9s both;
594
+ }
595
+
596
+ @keyframes textareaGrow {
597
+ from {
598
+ opacity: 0;
599
+ transform: scaleY(0.8);
600
+ transform-origin: top;
601
+ }
602
+
603
+ to {
604
+ opacity: 1;
605
+ transform: scaleY(1);
606
+ }
607
+ }
608
+
609
+ .email-textarea:focus {
610
+ outline: none;
611
+ background: rgba(255, 255, 255, 0.05);
612
+ border-color: var(--neon-cyan);
613
+ box-shadow: 0 0 0 3px rgba(0, 245, 255, 0.1),
614
+ 0 0 20px rgba(0, 245, 255, 0.2);
615
+ transform: translateY(-2px);
616
+ }
617
+
618
+ .email-textarea::placeholder {
619
+ color: var(--text-secondary);
620
+ opacity: 0.5;
621
+ }
622
+
623
+ .textarea-effects {
624
+ position: absolute;
625
+ bottom: 0;
626
+ left: 0;
627
+ right: 0;
628
+ height: 2px;
629
+ overflow: hidden;
630
+ pointer-events: none;
631
+ }
632
+
633
+ .scan-line {
634
+ width: 100%;
635
+ height: 2px;
636
+ background: linear-gradient(90deg, transparent, var(--neon-cyan), transparent);
637
+ animation: scanMove 2s linear infinite;
638
+ opacity: 0;
639
+ }
640
+
641
+ .email-textarea:focus~.textarea-effects .scan-line {
642
+ opacity: 1;
643
+ }
644
+
645
+ @keyframes scanMove {
646
+ from {
647
+ transform: translateX(-100%);
648
+ }
649
+
650
+ to {
651
+ transform: translateX(100%);
652
+ }
653
+ }
654
+
655
+ /* Buttons */
656
+ .actions {
657
+ display: flex;
658
+ gap: 12px;
659
+ margin-top: 20px;
660
+ animation: actionsSlideUp 0.6s ease-out 1s both;
661
+ }
662
+
663
+ @keyframes actionsSlideUp {
664
+ from {
665
+ opacity: 0;
666
+ transform: translateY(15px);
667
+ }
668
+
669
+ to {
670
+ opacity: 1;
671
+ transform: translateY(0);
672
+ }
673
+ }
674
+
675
+ .btn {
676
+ flex: 1;
677
+ padding: 14px 20px;
678
+ border: none;
679
+ border-radius: 12px;
680
+ font-size: 14px;
681
+ font-weight: 600;
682
+ cursor: pointer;
683
+ position: relative;
684
+ overflow: hidden;
685
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
686
+ }
687
+
688
+ .ghost-btn {
689
+ border: 1px solid rgba(255, 255, 255, 0.14);
690
+ background: rgba(255, 255, 255, 0.05);
691
+ color: var(--text-secondary);
692
+ border-radius: 999px;
693
+ padding: 8px 12px;
694
+ font-size: 12px;
695
+ cursor: pointer;
696
+ transition: all 0.2s ease;
697
+ }
698
+
699
+ .ghost-btn:hover {
700
+ color: var(--text-primary);
701
+ border-color: rgba(0, 245, 255, 0.35);
702
+ }
703
+
704
+ .ghost-btn.small {
705
+ padding: 6px 10px;
706
+ font-size: 11px;
707
+ }
708
+
709
+ .btn:disabled {
710
+ opacity: 0.6;
711
+ cursor: wait;
712
+ transform: none !important;
713
+ }
714
+
715
+ .btn-ripple {
716
+ position: absolute;
717
+ top: 50%;
718
+ left: 50%;
719
+ width: 0;
720
+ height: 0;
721
+ border-radius: 50%;
722
+ background: rgba(255, 255, 255, 0.3);
723
+ transform: translate(-50%, -50%);
724
+ transition: width 0.6s, height 0.6s;
725
+ }
726
+
727
+ .btn:active .btn-ripple {
728
+ width: 300px;
729
+ height: 300px;
730
+ }
731
+
732
+ .btn-content {
733
+ position: relative;
734
+ z-index: 1;
735
+ display: flex;
736
+ align-items: center;
737
+ justify-content: center;
738
+ gap: 8px;
739
+ }
740
+
741
+ .btn-icon {
742
+ width: 18px;
743
+ height: 18px;
744
+ stroke-width: 2;
745
+ transition: transform 0.3s;
746
+ }
747
+
748
+ .btn:hover .btn-icon {
749
+ transform: scale(1.1) rotate(5deg);
750
+ }
751
+
752
+ .btn-primary {
753
+ background: linear-gradient(135deg, var(--neon-blue) 0%, var(--neon-purple) 100%);
754
+ color: white;
755
+ box-shadow: 0 4px 20px rgba(58, 134, 255, 0.4);
756
+ animation: btnFloat 3s ease-in-out infinite;
757
+ }
758
+
759
+ @keyframes btnFloat {
760
+
761
+ 0%,
762
+ 100% {
763
+ transform: translateY(0);
764
+ }
765
+
766
+ 50% {
767
+ transform: translateY(-2px);
768
+ }
769
+ }
770
+
771
+ .btn-primary:hover {
772
+ box-shadow: 0 6px 30px rgba(58, 134, 255, 0.6);
773
+ transform: translateY(-3px);
774
+ }
775
+
776
+ .btn-primary:active {
777
+ transform: translateY(0);
778
+ }
779
+
780
+ .btn-secondary {
781
+ background: rgba(255, 255, 255, 0.05);
782
+ color: var(--text-primary);
783
+ border: 1.5px solid var(--glass-border);
784
+ }
785
+
786
+ .btn-secondary:hover {
787
+ background: rgba(255, 255, 255, 0.1);
788
+ border-color: var(--neon-cyan);
789
+ box-shadow: 0 4px 20px rgba(0, 245, 255, 0.2);
790
+ transform: translateY(-3px);
791
+ }
792
+
793
+ /* Result Box */
794
+ .result-box {
795
+ margin-top: 24px;
796
+ padding: 20px;
797
+ border-radius: 16px;
798
+ text-align: center;
799
+ transform: scale(0);
800
+ opacity: 0;
801
+ transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
802
+ }
803
+
804
+ .result-box.visible {
805
+ transform: scale(1);
806
+ opacity: 1;
807
+ animation: resultPop 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
808
+ }
809
+
810
+ @keyframes resultPop {
811
+ 0% {
812
+ transform: scale(0) rotate(-5deg);
813
+ }
814
+
815
+ 50% {
816
+ transform: scale(1.1) rotate(2deg);
817
+ }
818
+
819
+ 100% {
820
+ transform: scale(1) rotate(0);
821
+ }
822
+ }
823
+
824
+ .result-box.spam {
825
+ background: linear-gradient(135deg, rgba(255, 0, 110, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%);
826
+ border: 2px solid var(--neon-pink);
827
+ box-shadow: 0 0 30px rgba(255, 0, 110, 0.3);
828
+ }
829
+
830
+ .result-box.safe {
831
+ background: linear-gradient(135deg, rgba(6, 255, 165, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%);
832
+ border: 2px solid var(--neon-green);
833
+ box-shadow: 0 0 30px rgba(6, 255, 165, 0.3);
834
+ }
835
+
836
+ .result-box.error {
837
+ background: linear-gradient(135deg, rgba(255, 184, 0, 0.12) 0%, rgba(251, 191, 36, 0.12) 100%);
838
+ border: 2px solid #fbbf24;
839
+ box-shadow: 0 0 30px rgba(251, 191, 36, 0.2);
840
+ }
841
+
842
+ .result-box.whitelisted {
843
+ background: linear-gradient(135deg, rgba(0, 245, 255, 0.1) 0%, rgba(58, 134, 255, 0.1) 100%);
844
+ border: 2px solid var(--neon-cyan);
845
+ box-shadow: 0 0 30px rgba(0, 245, 255, 0.3);
846
+ }
847
+
848
+ /* Loader */
849
+ .loader-container {
850
+ padding: 20px 0;
851
+ }
852
+
853
+ .loader {
854
+ position: relative;
855
+ width: 60px;
856
+ height: 60px;
857
+ margin: 0 auto 16px;
858
+ }
859
+
860
+ .loader-ring {
861
+ position: absolute;
862
+ width: 100%;
863
+ height: 100%;
864
+ border: 3px solid transparent;
865
+ border-radius: 50%;
866
+ animation: loaderSpin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite;
867
+ }
868
+
869
+ .loader-ring:nth-child(1) {
870
+ border-top-color: var(--neon-cyan);
871
+ animation-delay: -0.45s;
872
+ }
873
+
874
+ .loader-ring:nth-child(2) {
875
+ border-top-color: var(--neon-purple);
876
+ animation-delay: -0.3s;
877
+ }
878
+
879
+ .loader-ring:nth-child(3) {
880
+ border-top-color: var(--neon-pink);
881
+ animation-delay: -0.15s;
882
+ }
883
+
884
+ @keyframes loaderSpin {
885
+ 0% {
886
+ transform: rotate(0deg);
887
+ }
888
+
889
+ 100% {
890
+ transform: rotate(360deg);
891
+ }
892
+ }
893
+
894
+ .loader-text {
895
+ font-size: 13px;
896
+ color: var(--text-secondary);
897
+ animation: textPulse 1.5s ease-in-out infinite;
898
+ }
899
+
900
+ @keyframes textPulse {
901
+
902
+ 0%,
903
+ 100% {
904
+ opacity: 0.6;
905
+ }
906
+
907
+ 50% {
908
+ opacity: 1;
909
+ }
910
+ }
911
+
912
+ .result-icon-wrapper {
913
+ margin-bottom: 16px;
914
+ animation: iconDrop 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.2s both;
915
+ }
916
+
917
+ @keyframes iconDrop {
918
+ from {
919
+ transform: translateY(-30px) scale(0);
920
+ opacity: 0;
921
+ }
922
+
923
+ to {
924
+ transform: translateY(0) scale(1);
925
+ opacity: 1;
926
+ }
927
+ }
928
+
929
+ .result-icon {
930
+ font-size: 56px;
931
+ animation: iconSpin 0.8s ease-out;
932
+ }
933
+
934
+ @keyframes iconSpin {
935
+ from {
936
+ transform: rotate(-180deg) scale(0);
937
+ }
938
+
939
+ to {
940
+ transform: rotate(0) scale(1);
941
+ }
942
+ }
943
+
944
+ .result-box.spam .result-icon::before {
945
+ content: '🚫';
946
+ }
947
+
948
+ .result-box.safe .result-icon::before {
949
+ content: '✅';
950
+ }
951
+
952
+ .result-box.whitelisted .result-icon::before {
953
+ content: '🛡️';
954
+ }
955
+
956
+ .result-box.error .result-icon::before {
957
+ content: '⚠️';
958
+ }
959
+
960
+ .result-label {
961
+ font-size: 24px;
962
+ font-weight: 700;
963
+ margin-bottom: 16px;
964
+ animation: labelZoom 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.3s both;
965
+ }
966
+
967
+ @keyframes labelZoom {
968
+ from {
969
+ transform: scale(0);
970
+ }
971
+
972
+ to {
973
+ transform: scale(1);
974
+ }
975
+ }
976
+
977
+ .confidence-container {
978
+ margin: 16px 0;
979
+ animation: confidenceFade 0.6s ease-out 0.4s both;
980
+ }
981
+
982
+ @keyframes confidenceFade {
983
+ from {
984
+ opacity: 0;
985
+ transform: translateY(10px);
986
+ }
987
+
988
+ to {
989
+ opacity: 1;
990
+ transform: translateY(0);
991
+ }
992
+ }
993
+
994
+ .confidence-bar {
995
+ position: relative;
996
+ width: 100%;
997
+ height: 10px;
998
+ background: rgba(255, 255, 255, 0.1);
999
+ border-radius: 10px;
1000
+ overflow: hidden;
1001
+ margin-bottom: 12px;
1002
+ }
1003
+
1004
+ .confidence-fill {
1005
+ height: 100%;
1006
+ background: linear-gradient(90deg, var(--neon-cyan) 0%, var(--neon-purple) 50%, var(--neon-pink) 100%);
1007
+ border-radius: 10px;
1008
+ width: 0;
1009
+ transition: width 1.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
1010
+ animation: fillPulse 2s ease-in-out infinite;
1011
+ }
1012
+
1013
+ @keyframes fillPulse {
1014
+
1015
+ 0%,
1016
+ 100% {
1017
+ box-shadow: 0 0 10px rgba(0, 245, 255, 0.5);
1018
+ }
1019
+
1020
+ 50% {
1021
+ box-shadow: 0 0 20px rgba(0, 245, 255, 0.8);
1022
+ }
1023
+ }
1024
+
1025
+ .confidence-shimmer {
1026
+ position: absolute;
1027
+ top: 0;
1028
+ left: -100%;
1029
+ width: 50%;
1030
+ height: 100%;
1031
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
1032
+ animation: shimmer 2s infinite;
1033
+ }
1034
+
1035
+ @keyframes shimmer {
1036
+ 0% {
1037
+ left: -100%;
1038
+ }
1039
+
1040
+ 100% {
1041
+ left: 100%;
1042
+ }
1043
+ }
1044
+
1045
+ .confidence-text {
1046
+ font-size: 14px;
1047
+ font-weight: 600;
1048
+ color: var(--text-secondary);
1049
+ }
1050
+
1051
+ .result-reason {
1052
+ font-size: 13px;
1053
+ color: var(--text-secondary);
1054
+ line-height: 1.6;
1055
+ animation: reasonSlide 0.6s ease-out 0.5s both;
1056
+ }
1057
+
1058
+ .result-analysis {
1059
+ margin-top: 10px;
1060
+ font-size: 12px;
1061
+ line-height: 1.6;
1062
+ color: rgba(255, 255, 255, 0.78);
1063
+ }
1064
+
1065
+ .subsection {
1066
+ margin-top: 16px;
1067
+ text-align: left;
1068
+ }
1069
+
1070
+ .subsection-title,
1071
+ .section-title {
1072
+ font-size: 13px;
1073
+ font-weight: 700;
1074
+ color: var(--text-primary);
1075
+ margin-bottom: 8px;
1076
+ }
1077
+
1078
+ .bullet-list {
1079
+ list-style: disc;
1080
+ padding-left: 18px;
1081
+ display: grid;
1082
+ gap: 6px;
1083
+ color: var(--text-secondary);
1084
+ font-size: 12px;
1085
+ }
1086
+
1087
+ .feedback-actions {
1088
+ display: flex;
1089
+ gap: 8px;
1090
+ flex-wrap: wrap;
1091
+ }
1092
+
1093
+ .mini-btn {
1094
+ flex: 1;
1095
+ min-width: 100px;
1096
+ border: 1px solid rgba(255, 255, 255, 0.14);
1097
+ background: rgba(255, 255, 255, 0.06);
1098
+ color: var(--text-primary);
1099
+ padding: 10px 12px;
1100
+ border-radius: 10px;
1101
+ font-size: 12px;
1102
+ cursor: pointer;
1103
+ }
1104
+
1105
+ .mini-btn:hover {
1106
+ border-color: rgba(0, 245, 255, 0.32);
1107
+ }
1108
+
1109
+ .feedback-status {
1110
+ margin-top: 10px;
1111
+ font-size: 12px;
1112
+ color: var(--text-secondary);
1113
+ }
1114
+
1115
+ .result-meta {
1116
+ display: flex;
1117
+ flex-wrap: wrap;
1118
+ justify-content: center;
1119
+ gap: 8px;
1120
+ margin-top: 14px;
1121
+ }
1122
+
1123
+ .meta-chip {
1124
+ padding: 6px 10px;
1125
+ border-radius: 999px;
1126
+ background: rgba(255, 255, 255, 0.08);
1127
+ border: 1px solid rgba(255, 255, 255, 0.08);
1128
+ font-size: 11px;
1129
+ color: var(--text-secondary);
1130
+ }
1131
+
1132
+ .history-section {
1133
+ margin-top: 24px;
1134
+ padding-top: 18px;
1135
+ border-top: 1px solid var(--glass-border);
1136
+ }
1137
+
1138
+ .history-header {
1139
+ display: flex;
1140
+ align-items: center;
1141
+ justify-content: space-between;
1142
+ margin-bottom: 12px;
1143
+ }
1144
+
1145
+ .history-list {
1146
+ display: grid;
1147
+ gap: 10px;
1148
+ }
1149
+
1150
+ .history-item {
1151
+ padding: 12px;
1152
+ border-radius: 14px;
1153
+ background: rgba(255, 255, 255, 0.04);
1154
+ border: 1px solid rgba(255, 255, 255, 0.08);
1155
+ }
1156
+
1157
+ .history-title {
1158
+ font-size: 13px;
1159
+ font-weight: 600;
1160
+ color: var(--text-primary);
1161
+ margin-bottom: 4px;
1162
+ }
1163
+
1164
+ .history-sender,
1165
+ .history-verdict {
1166
+ font-size: 11px;
1167
+ color: var(--text-secondary);
1168
+ margin-bottom: 6px;
1169
+ }
1170
+
1171
+ .history-meta-row {
1172
+ display: flex;
1173
+ flex-wrap: wrap;
1174
+ gap: 8px;
1175
+ font-size: 11px;
1176
+ color: var(--text-secondary);
1177
+ }
1178
+
1179
+ .history-badge {
1180
+ border-radius: 999px;
1181
+ padding: 2px 8px;
1182
+ font-weight: 700;
1183
+ letter-spacing: 0.03em;
1184
+ }
1185
+
1186
+ .history-badge.spam {
1187
+ background: rgba(255, 0, 110, 0.16);
1188
+ color: #ff83af;
1189
+ }
1190
+
1191
+ .history-badge.safe {
1192
+ background: rgba(6, 255, 165, 0.14);
1193
+ color: #71ffc9;
1194
+ }
1195
+
1196
+ .empty-state {
1197
+ font-size: 12px;
1198
+ color: var(--text-secondary);
1199
+ text-align: center;
1200
+ padding: 12px 0;
1201
+ }
1202
+
1203
+ @keyframes reasonSlide {
1204
+ from {
1205
+ opacity: 0;
1206
+ transform: translateY(10px);
1207
+ }
1208
+
1209
+ to {
1210
+ opacity: 1;
1211
+ transform: translateY(0);
1212
+ }
1213
+ }
1214
+
1215
+ /* Footer */
1216
+ .footer {
1217
+ margin-top: 24px;
1218
+ padding-top: 16px;
1219
+ border-top: 1px solid var(--glass-border);
1220
+ animation: footerFade 0.8s ease-out 1.2s both;
1221
+ }
1222
+
1223
+ @keyframes footerFade {
1224
+ from {
1225
+ opacity: 0;
1226
+ }
1227
+
1228
+ to {
1229
+ opacity: 1;
1230
+ }
1231
+ }
1232
+
1233
+ .status-wrapper {
1234
+ display: flex;
1235
+ align-items: center;
1236
+ justify-content: center;
1237
+ gap: 8px;
1238
+ }
1239
+
1240
+ .status-dot {
1241
+ width: 8px;
1242
+ height: 8px;
1243
+ background: var(--neon-green);
1244
+ border-radius: 50%;
1245
+ animation: dotBlink 2s ease-in-out infinite;
1246
+ box-shadow: 0 0 10px var(--neon-green);
1247
+ }
1248
+
1249
+ @keyframes dotBlink {
1250
+
1251
+ 0%,
1252
+ 100% {
1253
+ opacity: 1;
1254
+ transform: scale(1);
1255
+ }
1256
+
1257
+ 50% {
1258
+ opacity: 0.5;
1259
+ transform: scale(0.8);
1260
+ }
1261
+ }
1262
+
1263
+ .status-text {
1264
+ font-size: 11px;
1265
+ color: var(--text-secondary);
1266
+ font-weight: 500;
1267
+ }
1268
+
1269
+ .hidden {
1270
+ display: none !important;
1271
+ }
1272
+
1273
+ /* Closing Animation */
1274
+ body.closing {
1275
+ animation: bodyFadeOut 0.4s cubic-bezier(0.4, 0, 1, 1) forwards;
1276
+ }
1277
+
1278
+ @keyframes bodyFadeOut {
1279
+ to {
1280
+ opacity: 0;
1281
+ transform: scale(0.95);
1282
+ }
1283
+ }
1284
+
1285
+ body.closing .card {
1286
+ animation: cardFadeOut 0.4s cubic-bezier(0.4, 0, 1, 1) forwards;
1287
+ }
1288
+
1289
+ @keyframes cardFadeOut {
1290
+ to {
1291
+ transform: scale(0.9) rotateX(-10deg);
1292
+ opacity: 0;
1293
+ }
1294
+ }
1295
+
1296
+ @media (prefers-reduced-motion: reduce) {
1297
+ *,
1298
+ *::before,
1299
+ *::after {
1300
+ animation-duration: 0.01ms !important;
1301
+ animation-iteration-count: 1 !important;
1302
+ transition-duration: 0.01ms !important;
1303
+ scroll-behavior: auto !important;
1304
+ }
1305
+ }