Compare commits

..

12 Commits

Author SHA1 Message Date
cesnimda 733bfc9bae perf(ui): route-level code splitting (RECOMMENDATIONS #7)
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 15s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 51s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 51s
React.lazy per page: the initial bundle drops 678 KB -> 381 KB (gzip 221 -> 125 KB);
Dashboard's 263 KB chunk (Chart.js + react-grid-layout) now loads only when the
dashboard route is visited. Theme-correct Suspense fallback. Landing + Layout stay
eager for first paint. Verified: build green with per-route chunks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:40:52 +02:00
cesnimda 59522a6e96 docs: Phase 3 recommendations (#25)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 46s
CI / db-tests (push) Successful in 50s
Deploy Staging / deploy (push) Successful in 16s
CI / backend (pull_request) Successful in 48s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 45s
CI / db-tests (pull_request) Successful in 53s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 52s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
2026-07-02 16:10:12 +02:00
cesnimda b7f3a42812 docs(audit): Phase 2 resolution status (#24)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 47s
CI / db-tests (push) Successful in 48s
CI / backend (pull_request) Successful in 50s
CI / frontend (pull_request) Successful in 10s
CI / format (pull_request) Successful in 47s
CI / db-tests (pull_request) Successful in 49s
Deploy Staging / deploy (push) Successful in 15s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 52s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 52s
2026-07-02 10:29:04 +02:00
cesnimda 7dbe1469d7 ci: audit batch D — live-db tests + format gate (#23)
CI / backend (push) Successful in 48s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 47s
CI / db-tests (push) Successful in 49s
CI / backend (pull_request) Successful in 48s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 50s
CI / db-tests (pull_request) Successful in 50s
Deploy Staging / deploy (push) Successful in 15s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 50s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
2026-07-02 10:13:12 +02:00
cesnimda 2b19bddf7b feat(security): audit batch C — data posture, retention, DP keys (#22)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 11s
Deploy Staging / deploy (push) Successful in 24s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 52s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 10s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s
2026-07-02 10:13:07 +02:00
cesnimda b905c93884 fix(security): audit batch B — SPA CSP + vite upgrade (#21)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 45s
CI / backend (pull_request) Successful in 56s
CI / frontend (pull_request) Successful in 11s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 54s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
2026-07-02 03:24:57 +02:00
cesnimda c5c33f7023 fix(security): audit batch A — validation, rate limiting, sessions (#20)
CI / backend (push) Successful in 53s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 28s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 56s
2026-07-02 03:22:17 +02:00
cesnimda 2056548702 feat(ai): pgvector embedding infrastructure (#19)
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 46s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 56s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s
2026-07-02 03:00:06 +02:00
cesnimda d78fe601ff feat(ai): IEmbeddingProvider foundation (#18)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 26s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 16s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 53s
2026-07-02 02:05:57 +02:00
cesnimda 06b050a5ed perf(search): trigram indexes for sender/domain filters (#17)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 56s
2026-07-02 01:55:59 +02:00
cesnimda c0c1777d3f feat(search): fuzzy/typo tolerance (pg_trgm) (#16)
CI / backend (push) Successful in 48s
CI / frontend (push) Successful in 13s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 53s
2026-07-01 22:45:10 +02:00
cesnimda 9bbab5d32a feat(search): why this matched highlights (#15)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 47s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
2026-07-01 22:22:38 +02:00
42 changed files with 4302 additions and 1183 deletions
+43 -1
View File
@@ -31,8 +31,50 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- name: Install
run: npm ci
- name: Build
run: npm run build
# AUDIT L-10: the pre-commit hook enforces formatting locally, but --no-verify or web edits
# can bypass it — this makes the same check a server-side gate.
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: dotnet format (verify only)
run: dotnet format InboxIntel.sln --verify-no-changes
# AUDIT M-7: the search paths the InMemory provider can't translate (FTS ranking,
# ts_headline, pg_trgm, pgvector) previously had only manual verification. This job runs
# the Category=LiveDb tests against a real pgvector Postgres service container.
db-tests:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
env:
LIVEDB_CONNECTION: "Host=postgres;Port=5432;Database=test;Username=test;Password=test"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Wait for Postgres
run: |
for i in $(seq 1 30); do
(echo > /dev/tcp/postgres/5432) 2>/dev/null && exit 0
sleep 1
done
echo "Postgres service did not become reachable" >&2
exit 1
- name: Live-DB tests
run: dotnet test InboxIntel.sln --filter "Category=LiveDb" --verbosity normal
+267
View File
@@ -0,0 +1,267 @@
# InboxIntel — Full Application Audit Report
**Date:** 2026-07-02 · **Scope:** entire repository (backend `src/`, `frontend/`, migrations,
CI/CD `.gitea/workflows/`, `docker-compose*`, `deploy/`, git history) · **Phase:** 1 (read-only)
**Stack:** .NET 8 / ASP.NET Core (Clean Architecture) · EF Core + PostgreSQL (pgvector image)
· React 18 + Vite SPA · Google OAuth2 (cookie session) · Gitea Actions CI/CD · self-hosted
Docker Compose (single-operator deployment).
## Executive summary
This codebase is **markedly more hardened than typical** — previous hardening phases (V-01…V-12,
visible in code comments) already addressed the classic killers: IDOR (EF global query filters),
SSRF (a genuinely thorough `SafeHttpGuard` with DNS-rebinding defence), token encryption at rest,
non-root containers, stack-trace suppression, forwarded-header trust, secret hygiene (nothing in
git history), and confirmed-destructive-actions. **No Critical findings.** The most significant
issues are: **registered input validators that never execute (H-1)**, **no rate limiting (H-2)**,
and a **data-at-rest posture** for email bodies that should be a deliberate, documented decision
(H-3). CI has real security gates (gitleaks + dependency audit) already.
Severity counts: **Critical 0 · High 3 · Medium 6 · Low 7**
---
## 1. Security
### H-1 · FluentValidation validators are registered but NEVER executed
- **File:** `src/InboxIntel.Application/DependencyInjection.cs:11` (registration) vs
`src/InboxIntel.Api/Program.cs` (no `AddFluentValidationAutoValidation()` anywhere; no
controller injects `IValidator<>`).
- **Why it's a problem:** `Validators.cs` defines real safety rules — `CleanupRequestValidator`
requires `Confirmed=true` for Trash/HardDelete, `SearchRequestValidator` bounds paging, etc. —
but FluentValidation 11.x requires explicit auto-validation enablement, which is absent. The
rules are dead code; requests reach services unvalidated. *Mitigating factor:* the services
re-check the most dangerous rules (`CleanupService.cs:48` re-enforces `Confirmed`), and the
controller clamps paging — so this is defence-in-depth loss, not an open hole. But rules that
exist **only** in the validators (e.g. `From <= To`, LabelId-required) are silently unenforced.
- **Fix:** add `services.AddFluentValidationAutoValidation()` (package already referenced) in
`Program.cs`; add a regression test posting an invalid DTO and asserting 400.
- **Blast radius:** isolated (1 line + tests). Verify no existing client sends payloads that
would now 400.
### H-2 · No rate limiting on any endpoint
- **File:** `src/InboxIntel.Api/Program.cs` (no `AddRateLimiter`/middleware anywhere).
- **Why:** login (`/auth/login` → OAuth), search (now with FTS + trigram + future AI), export
(PDF generation), unsubscribe (server-side outbound HTTP), and AI endpoints are all
unthrottled. A single authenticated user (or an unauthenticated client hammering
`/auth/login`/`/appinfo`) can exhaust CPU/DB/outbound quota. For the future multi-user platform
(see `docs/discovery/multi-provider/`), this is a prerequisite.
- **Fix:** ASP.NET Core's built-in `RateLimiter` — a global fixed-window per-IP policy + stricter
policies on `auth`, `export`, `unsubscribe`, `ai`. Return 429 with Retry-After.
- **Blast radius:** isolated (Program.cs + policy constants + tests).
### Verified-good (no finding)
- **Authorization / IDOR:** `[Authorize]` on `ApiControllerBase`; only `AppInfo` + `Auth.login`
are `[AllowAnonymous]` (correct). Global query filters on every tenant-scoped entity enforce
per-user isolation even if a query forgets its `Where` — covered by `TenantIsolationTests`.
- **Injection:** all data access via EF parameterisation; no raw SQL string concatenation in app
code; FTS uses `websearch_to_tsquery` parameters. Frontend has **zero**
`dangerouslySetInnerHTML`/`innerHTML`/`eval`; the search-highlight feature deliberately uses
non-HTML sentinels rendered as escaped React elements (XSS-safe by construction).
- **SSRF:** `SafeHttpGuard` validates scheme, resolves DNS and checks **every** address against
loopback/private/link-local/CGNAT/metadata/multicast (v4+v6, v4-mapped), fails closed on
unknown families, and is paired with `AllowAutoRedirect=false` (`DependencyInjection.cs:65`).
This is better than most production code.
- **Secrets:** none hardcoded (empty placeholders in `appsettings.json`); no `.env` ever
committed (git history checked); real secrets live in git-ignored `deploy/.env*` and Gitea
Actions secrets; pre-commit hook + CI gitleaks scan both guard regressions.
- **Session cookies:** HttpOnly, SameSite=Lax, Secure-always outside dev, 401-not-redirect for
XHR. Forwarded headers only trusted from configured proxy CIDRs (V-08).
- **Headers:** nosniff, X-Frame-Options DENY, Referrer-Policy, COOP on API; HSTS in prod.
### M-1 · No absolute session lifetime (sliding-only)
- **File:** `Program.cs:58-59``ExpireTimeSpan = 7d` with `SlidingExpiration = true`.
- **Why:** a session that's touched at least weekly renews forever; a stolen cookie never
expires as long as the attacker uses it. No server-side revocation list exists either
(cookie is self-contained).
- **Fix:** add an absolute cap via an `issued-at` claim checked in `OnValidatePrincipal`
(e.g. re-auth after 30 days), and/or a session-stamp validated against the DB to enable
revocation. (The multi-provider design doc 02/06 already specs DB-backed sessions — this
aligns.)
- **Blast radius:** isolated.
### M-2 · No Content-Security-Policy on the SPA or API
- **File:** `Program.cs:156-167` (comment says CSP "report-only for now" but none is actually
set); `frontend/nginx.conf` serves the SPA without security headers.
- **Why:** CSP is the main mitigation layer against any future XSS slip; currently absent.
- **Fix:** add CSP (default-src 'self'; connect-src API origin; etc.) + nosniff/XFO to the SPA
nginx config; optionally a report-only CSP on the API.
- **Blast radius:** isolated (nginx conf + one middleware line), needs SPA smoke-test (Chart.js
inline styles etc.).
### L-1 · CSRF: no antiforgery tokens (accepted-risk, documented here)
- SameSite=Lax cookies + strict CORS allowlist + JSON-only POST bodies make classic CSRF
impractical in modern browsers. Acceptable for now; revisit if cookie SameSite is ever
relaxed or non-JSON form endpoints are added.
### L-2 · `AllowedHosts: "*"` (`appsettings.json`)
- Host-header filtering disabled; low risk behind the proxy but set it to the real hostnames
at production deployment.
---
## 2. Data protection (top priority)
**Inventory of sensitive data:**
| Data | Where | At-rest protection | Verdict |
|---|---|---|---|
| Google OAuth **refresh/access tokens** | `users.EncryptedRefreshToken` | **Encrypted** (Data Protection API, keys on `/keys` volume) | ✅ good |
| **Passwords** | — | **None stored** (OAuth-only login; no password column exists) | ✅ best-possible |
| **Email bodies / subjects / snippets** | `emails.BodyText` etc. | **Plaintext** in Postgres | ⚠️ **H-3** below |
| Sender names/addresses (PII) | `senders`, `emails` | Plaintext | part of H-3 |
| User email + display name | `users` | Plaintext | part of H-3 |
| DB password | `deploy/.env` (git-ignored) + Actions secret | not in repo | ✅ |
| Data Protection keys | `/keys` Docker volume | filesystem | M-3 below |
| IPs / payment / health data | — | not collected | ✅ n/a |
### H-3 · Email content stored in plaintext at rest, with unbounded retention
- **File:** `src/InboxIntel.Domain/Entities/Email.cs` (`BodyText`, `Snippet`, `Subject`);
Postgres `pgdata` volume; also flows into PDF/CSV/JSON **exports** and (when enabled) to the
local Ollama process.
- **Why:** the entire product is a copy of the user's mailbox. On this self-hosted,
single-operator deployment the DB lives on the operator's own disk — a defensible posture —
but: (a) anyone with disk/volume/backup access reads all mail; (b) there is **no retention or
purge policy** (mail persists even after unsubscribe/cleanup in Gmail; "remove account"
flows don't exist yet); (c) the planned **multi-user platform** (docs/discovery/multi-provider)
makes plaintext-bodies-readable-by-host-admin a real privacy issue (its own security doc
promises "admins never read users' mail" — the DB must back that up).
- **Fix (phased):** 1) *Document* the current posture in README/threat model (deliberate,
local-first). 2) Enable **pgcrypto/field-level encryption or full-disk/volume encryption**
before any multi-user deployment. 3) Add a **data-retention setting** + purge job and an
account-deletion path (GDPR-style erasure). 4) Ensure DB **backups** inherit the same
protection.
- **Blast radius:** documentation = trivial; field-level encryption = **large** (touches search
— FTS can't index encrypted columns; would need architectural decision). Recommend
volume-level encryption + retention/deletion first.
### M-3 · Data Protection keys stored unencrypted on the `/keys` volume
- **File:** `Program.cs:26-28``PersistKeysToFileSystem` without `ProtectKeysWith*`.
- **Why:** whoever reads the volume can decrypt all stored refresh tokens. Same-disk-as-DB
caveat applies, but keys and ciphertext living side-by-side weakens the encryption's value.
- **Fix:** `ProtectKeysWithCertificate(...)` (cert from env/secret), or OS-level DPAPI on
Windows hosts; at minimum document the volume-permissions requirement.
- **Blast radius:** isolated, but requires a key-migration step for existing tokens.
### M-4 · Default DB credentials in `appsettings.json`
- **File:** `appsettings.json:3``Password=inboxintel` as the fallback connection string.
- **Why:** if a deployment forgets the env override, the app happily connects with a guessable
password (compose enforces `POSTGRES_PASSWORD` but a non-compose deployment might not).
- **Fix:** empty the default and fail fast at startup with a clear message when unset.
- **Blast radius:** isolated (plus updating dev docs to use user-secrets).
### Logging & transit — verified
- **Logs:** no token/password logging found; the one PII-ish log is `SmtpEmailSender.cs:28`
(recipient address + subject at Info when SMTP is unconfigured) — acceptable, downgrade to
Debug if desired (L-3). Serilog request logging does not include query strings or bodies.
- **Transit:** TLS terminates at nginx (HSTS enabled in prod); API+DB bound to loopback/compose
network only. **DB connection itself is non-TLS** — fine while Postgres is co-located on the
compose network; **must add `SSL Mode=Require` if the DB ever moves to another host** (L-4).
---
## 3. Dependencies & supply chain
- **`dotnet list package --vulnerable --include-transitive`:** **clean** (all projects) — the
earlier round of CVE pins (System.Text.Json 8.0.6 etc.) is holding, and CI re-checks on every PR.
- **`npm audit` (production deps):** **0 vulnerabilities**.
- **M-5 · `npm audit` (dev deps):** vite 5.x depends on a vulnerable **esbuild** (1 moderate,
1 high, dev-server-only vectors). Not shipped to users and CI already scopes to prod deps —
but the fix is a routine **vite 5→6/7 upgrade**. Effort S.
- **Duplication (L-5):** both `MailKit` (Infrastructure) and the frontend carry sizeable deps;
`react-grid-layout` exists solely for the draggable dashboard the redesign plans to retire —
candidate for removal with the Analytics migration. No abandoned packages spotted.
- CI supply-chain posture: gitleaks (full history) + NuGet/npm audits are **required checks**
good. No SAST/CodeQL (L-6, nice-to-have).
## 4. Error handling & reliability
- **Good:** global `UseExceptionHandler` + RFC7807 ProblemDetails (no stack traces to clients);
Polly retry/backoff on Gmail sync; idempotent sync upserts; AI calls wrapped in try/catch with
non-AI fallbacks ("sync must never fail because the AI provider is down").
- **M-6 · Known EF model warning:** `Email` has a global query filter but is the required end
of the `Email↔EmailLabel` relationship — logged on every boot; can yield surprising results
when filtered parents are excluded. Fix: matching filter on `EmailLabel` (one line) + test.
(Long-noted in logs; this is the nudge to actually do it.)
- **L-7 · Multi-step writes without explicit transactions:** e.g. `GoogleAuthEvents` upsert and
sync batches rely on EF's single-SaveChanges transactionality — mostly fine; the sync
cursor-advance + batch-upsert pairing is the one place an explicit transaction would guard a
mid-batch crash (currently self-heals via idempotent re-sync — acceptable).
## 5. Performance
- **Fixed this cycle (verified live):** relevance-ranked FTS with weighted tsvector + GIN;
trigram GIN indexes for the previously non-sargable sender/domain `.Contains()`; HNSW vector
index ready for semantic search.
- **Remaining (all Low, roadmapped):** offset pagination degrades on deep pages (keyset planned
in `docs/discovery/05`); frontend ships one **678 KB JS bundle** (no code-splitting — vite
`manualChunks`/dynamic import, effort S); no HTTP caching/ETags on read-heavy endpoints
(sidebar counts are fetched per page-load); `docker-compose` frontend nginx lacks gzip/brotli
confirmation for the bundle.
- No N+1 patterns found (queries project with joins; aggregates precomputed in
`AnalyticsAggregate`).
## 6. Code quality & architecture
- Clean Architecture discipline is genuinely observed (dependency rule intact; thin controllers;
DTO mapping at boundaries). Config is env-driven throughout.
- **L-8:** `AiService.GenerateQueryAsync` returns raw LLM output as a search query (advisory-only,
becomes a search string — harmless today; keep it that way when NL search lands: model output
must stay data, never an executable/action).
- Dead-ish code: none significant; `docs/specs/*` and discovery docs are current.
## 7. Testing & CI
- **41 tests** (unit: parser/guard/unsubscribe/embeddings-null; integration: authz challenge,
tenant isolation, pagination clamp, search fallback ordering). Critical security invariants
(IDOR filter, SSRF guard, confirmed-destructive) **are tested** — better than most.
- **Gap (M-7):** no live-Postgres test harness — FTS/ranking/fuzzy/pgvector paths are verified
manually against staging (documented in commit messages) but not repeatably in CI. A
Testcontainers-Postgres (pgvector image) job would convert those throwaway verifications into
permanent regression tests. Effort M.
- Gap (L-9): no coverage for `CleanupService`/`SyncService` beyond compilation; no E2E of the
OAuth flow (hard without creds — acceptable).
- CI: build+test+secrets+deps as **required** PR checks; auto-deploy to staging with a
post-deploy health gate; prod is tag-gated. Solid. Missing: lint/format check in CI (the
pre-commit hook enforces locally; add `dotnet format --verify-no-changes` job, effort S) (L-10).
---
## Prioritized remediation plan
| # | Finding | Sev | Fix effort | Blast radius |
|---|---------|-----|-----------|--------------|
| 1 | **H-1** Enable FluentValidation auto-validation + 400 tests | High | S | Isolated (1 line + tests; verify clients) |
| 2 | **H-2** Rate limiting (global + auth/export/unsub/AI policies) | High | SM | Isolated (Program.cs + tests) |
| 3 | **H-3** Data-at-rest posture: document now; retention setting + purge job + account-deletion; volume-encryption guidance; (defer field-level encryption decision) | High | S (doc) → M (retention) → L (encryption) | Doc: none · Retention: moderate · Encryption: large/architectural |
| 4 | **M-3** Protect Data Protection keys at rest | Med | SM | Isolated + key migration |
| 5 | **M-1** Absolute session lifetime (+ revocation groundwork) | Med | S | Isolated |
| 6 | **M-4** Remove default DB password; fail fast | Med | S | Isolated |
| 7 | **M-2** CSP + security headers on the SPA nginx | Med | S | Isolated (needs SPA smoke test) |
| 8 | **M-6** Fix EF query-filter warning (EmailLabel filter) | Med | S | Isolated + test |
| 9 | **M-5** Vite upgrade (dev-dep CVEs) | Med | S | Frontend build only |
| 10 | **M-7** Testcontainers live-Postgres CI job | Med | M | CI + new test project wiring |
| 11 | L-2/L-3/L-4/L-10 (AllowedHosts, SMTP log level, DB TLS note, CI format check) | Low | S each | Isolated |
| 12 | L-5 bundle split / grid-layout removal (with redesign) | Low | SM | Frontend |
---
# Phase 2 — Remediation status (2026-07-02, all items approved & implemented)
Shipped as four PRs (#20#23), each with tests, green CI, and a verified staging deploy.
| Finding | Status | How it was resolved | PR |
|---------|--------|---------------------|----|
| **H-1** validators never ran | ✅ **Resolved** | `AddFluentValidationAutoValidation()`; invalid DTOs now 400 at the boundary. Proven by 2 integration tests (pageSize=0, From>To → 400) via a new test-auth scheme | #20 |
| **H-2** no rate limiting | ✅ **Resolved** | Global 300/min per-user (per-IP anonymous) + `auth` 10/min + `expensive` 20/min (export/unsubscribe/AI), config-driven, 429/no-queue. Proven by a 429-on-3rd-request test | #20 |
| **H-3** plaintext bodies + unbounded retention | ✅ **Resolved (as scoped)** | `SECURITY.md` documents the deliberate posture (volume-encryption + backup guidance, delete-my-data procedure); **opt-in retention** (`DataRetention:*`, default off) + daily purge worker with 3 tests. Field-level encryption deliberately deferred (FTS can't index encrypted columns) — revisit before any multi-user deployment | #22 |
| **M-1** sliding-only sessions | ✅ **Resolved** | Absolute 30 d cap (`Auth:AbsoluteSessionDays`) via issued-at stamp checked in `OnValidatePrincipal`; 3 unit tests (incl. missing-stamp = expired) | #20 |
| **M-2** no CSP on the SPA | ✅ **Resolved** | Full CSP (`script-src 'self'`, `frame-ancestors 'none'`, …) + nosniff/XFO/Referrer-Policy + gzip on the SPA nginx; inline theme script moved to `/theme-init.js` to keep `script-src 'self'` honest. **Verified serving on staging** | #21 |
| **M-3** unprotected DP key ring | ✅ **Resolved (opt-in)** | `DataProtection:CertificatePath/Password``ProtectKeysWithCertificate`; documented in SECURITY.md as recommended for shared hosts | #22 |
| **M-4** default DB password | ✅ **Resolved** | Guessable default removed from `appsettings.json`; startup fails fast with a clear message when the connection string has no password | #20 |
| **M-5** dev-dep esbuild CVEs | ✅ **Resolved** | vite 5→8 + plugin-react 6; `npm audit` now clean **including dev deps**; build verified | #21 |
| **M-6** EF query-filter warning | ✅ **Resolved** | Matching tenant filter on `EmailLabel` (via Email navigation); boot warning confirmed gone from staging logs; cross-user invisibility test added | #20 |
| **M-7** no live-Postgres tests | ✅ **Resolved** | CI `db-tests` job with a `pgvector/pg16` service container runs 3 permanent `Category=LiveDb` regression tests (FTS weighting, ts_headline sentinels, trigram typo fallback, pgvector cosine). **Confirmed green on the actual runner** | #23 |
| **L-2** AllowedHosts `*` | ✅ Documented | Production checklist item in SECURITY.md (set at deployment) | #22 |
| **L-3** SMTP recipient at Info | ✅ **Resolved** | Downgraded to Debug | #20 |
| **L-4** DB TLS note | ✅ Documented | SECURITY.md: add `SSL Mode=Require` if Postgres ever leaves the host | #22 |
| **L-10** no CI format gate | ✅ **Resolved** | `format` CI job (`dotnet format --verify-no-changes`) — `--no-verify` pushes can no longer bypass formatting | #23 |
| L-1 CSRF (accepted risk) | ✅ Documented | SECURITY.md rationale (SameSite=Lax + CORS + JSON) with revisit conditions | #22 |
| L-5/L-6/L-7/L-8/L-9 | ⏸ Deferred by design | Bundle-split & grid-layout removal ride the UI redesign; SAST/CodeQL, sync transaction hardening, and broader service coverage are Phase 3 recommendation candidates | — |
**Test suite: 40 → 54 tests** (48 always-on + 3 retention + 3 live-DB in CI).
Every deploy through the pipeline stayed green; staging verified after each batch.
+121
View File
@@ -0,0 +1,121 @@
# InboxIntel — Phase 3 Recommendations
**Date:** 2026-07-02 · Follows the completed audit remediation ([AUDIT_REPORT.md](AUDIT_REPORT.md)).
Each item: what · concrete benefit · effort (S/M/L) · risk · sources. **Ranked by value-to-effort.**
> Research notes: grounded in official primary sources (fetched 2026-07-02) plus the
> competitor/feature research already performed in `docs/discovery/02-competitor-analysis.md`.
> (Live web *search* was quota-limited this session; the load-bearing facts below — support
> dates, EF 10 features, Npgsql 10, Renovate/Gitea — were verified against primary docs.)
---
## 1. Migrate .NET 8 → .NET 10 LTS ⚠️ deadline-driven
- **What:** move the solution to .NET 10 / EF Core 10 / Npgsql provider 10; bump
`Pgvector.EntityFrameworkCore` off the 0.2.0 EF8-pin at the same time.
- **Why (hard fact):** **.NET 8 support ends 2026-11-10 — ~4 months away.** After that: no
security patches. .NET 10 is LTS until Nov 2028. This is not optional, only *when*.
- **Bonus value:** EF 10 brings **named query filters** (exactly our multi-filter tenant
scenario — e.g. tenant + soft-delete filters, selectively ignorable), **redacted inlined
constants in SQL logs** (privacy win for an email app), first-class `LeftJoin`, and
better parameterized-collection SQL (plan-cache friendly).
- **Watch-outs:** Npgsql 10 changes `array.Contains(x)` translation to `= ANY(...)` (check
our GIN-indexed paths); the migration touches every csproj + CI images + Dockerfiles.
The live-DB CI job we just added is the safety net for the search paths.
- **Effort: M** (mechanical + verify) · **Risk: M** · Blast radius: whole repo, but staged
behind the pipeline.
- Sources: [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core),
[EF Core 10 what's-new](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew),
[Npgsql EF 10 release notes](https://www.npgsql.org/efcore/release-notes/10.0.html).
## 2. Automated dependency updates via Renovate (self-hosted Gitea)
- **What:** run Renovate against the Gitea instance (PAT with repo/user/issue scopes,
`platform=gitea`); it opens update PRs that ride the existing required CI gates
(build, tests, gitleaks, vuln scan, live-DB).
- **Benefit:** closes the audit's supply-chain gap permanently — the MailKit/System.Text.Json
CVE round we did by hand becomes an automated PR you just merge. NuGet + npm + Dockerfile
+ Actions all covered.
- **Effort: S** (a config + a scheduled runner job) · **Risk: L** (PRs are gated by CI).
- Source: [Renovate Gitea platform docs](https://docs.renovatebot.com/modules/platform/gitea/).
## 3. Database backups (currently none!)
- **What:** nightly `pg_dump` sidecar/cron in compose, rotating N days, written to a
host path covered by your disk-encryption/backup regime (per SECURITY.md).
- **Benefit:** today a bad migration or volume loss = total data loss; the audit fixed
security but the **availability** story is a single Docker volume. Highest
value-per-line-of-config item on this list.
- **Effort: S** · **Risk: L**. Pair with a documented restore drill.
## 4. Activate semantic search (Ollama + embedding backfill + hybrid ranking)
- **What:** the pgvector column, HNSW index, `IEmbeddingProvider`, and live-DB tests are
already shipped. Remaining: an optional `ollama` compose profile, the embedding
backfill worker (batched, VRAM-aware), and RRF hybrid merge in `SearchService`
(design: `docs/discovery/05/06`).
- **Benefit:** the flagship differentiator from the discovery blueprint — *"gym receipt
march"* finds the email; nobody mainstream offers this locally/privately.
- **Effort: ML** · **Risk: M** (quality tuning) · Needs the RTX-3080 box to pull
`nomic-embed-text` (~0.5 GB, always-on per the AI strategy).
## 5. Observability: OpenTelemetry + a dashboard
- **What:** wire .NET's built-in OTel (traces/metrics for ASP.NET, EF, HttpClient) exported
to a compose-profile Prometheus+Grafana (or an OTLP endpoint later). Keep Serilog for logs.
- **Benefit:** today diagnosis = `docker logs`. This gives request latency, sync-job
timings, rate-limit hits, and search-performance baselines — the "what will break first
as usage grows" early-warning system.
- **Effort: M** · **Risk: L** (additive).
## 6. Named query filters for tenancy (after #1)
- **What:** convert the hand-rolled `CurrentUserId == Guid.Empty || …` filters to EF 10
named filters (`"Tenant"`, future `"SoftDelete"`), selectively ignorable per-query.
- **Benefit:** cleaner + safer than the worker-bypass convention; directly feeds the
multi-provider platform's isolation model.
- **Effort: S** (post-migration) · **Risk: L** (isolation tests already exist).
## 7. Frontend bundle code-splitting
- **What:** vite `manualChunks`/dynamic imports to split the 678 KB bundle (charts,
grid-layout, per-route chunks); drop `react-grid-layout` when the Analytics redesign
lands (it's the sole consumer).
- **Benefit:** faster cold loads; audit L-5 closed. Gzip is already on (batch B), so this
is the remaining lever.
- **Effort: SM** · **Risk: L**.
## 8. Keyset (cursor) pagination for search
- **What:** replace offset `Skip/Take` with keyset pagination for browse/date-ordered
paths; ranked paths already effectively top-N (design in `docs/discovery/05`).
- **Benefit:** deep-page latency stops degrading linearly at 100k+ mailboxes.
- **Effort: M** (API shape + frontend infinite-scroll cursor) · **Risk: M** (API change).
## 9. SAST in CI (Semgrep)
- **What:** a `semgrep` job (OSS rules for C#/JS + secrets/OWASP packs) in `ci.yml`
CodeQL is GitHub-centric; Semgrep runs anywhere Docker does.
- **Benefit:** closes audit L-6; catches injection/crypto misuse patterns the current
gates (gitleaks + dep-audit + tests) don't look for.
- **Effort: SM** (tuning noise) · **Risk: L** (advisory job first, required later).
## 10. Settings + feature-flag platform (multi-provider Phase 4)
- **What:** implement `docs/discovery/multi-provider/04``feature_flags`/`user_settings`
tables, `IFeatureFlags`/`IAiGate`, admin toggles later.
- **Benefit:** unblocks shipping AI features dark (`ai.enabled` master switch), the
Settings UI, and everything in the multi-provider plan; prerequisite for #4 to be
properly gated per the approved design.
- **Effort: L** · **Risk: M** — the biggest item, but the one the roadmap already commits to.
---
## Value-to-effort ranking (summary)
| # | Item | Effort | Why this rank |
|---|------|--------|---------------|
| 1 | .NET 10 migration | M | **EOL deadline Nov 2026**; unlocks #6 |
| 2 | Renovate | S | Permanent supply-chain automation for one config file |
| 3 | Backups | S | Only protection against total data loss |
| 4 | Semantic search activation | ML | Flagship product differentiator; infra already live |
| 5 | OpenTelemetry | M | Can't manage what you can't see |
| 6 | Named query filters | S | Cheap once #1 lands |
| 7 | Bundle splitting | SM | Perceived speed; last audit-perf leftover |
| 8 | Keyset pagination | M | Scales search; roadmap item |
| 9 | Semgrep SAST | SM | Last unautomated security layer |
| 10 | Settings/flags platform | L | Roadmap-committed foundation |
**Suggested sequencing:** 2+3 immediately (tiny, standalone) → 1 (deadline) → 6 → 4 (+10 gating if you want flags first) → 5 → 7/8/9 opportunistically.
**STOP — awaiting your selections before implementing anything (Phase 4).**
+63
View File
@@ -0,0 +1,63 @@
# InboxIntel — Security & Data Posture
The deliberate security posture of this application, so operators know exactly what is and
isn't protected. Complements [AUDIT_REPORT.md](AUDIT_REPORT.md) (point-in-time audit) and
`docs/discovery/multi-provider/06-security-model.md` (future multi-user design).
## Deployment model this posture assumes
Self-hosted, **single-operator** instance: the person running the server is the person whose
mailbox is synced. All services (API, Postgres, frontend) run in Docker on the operator's own
machine; Postgres and the API bind to loopback/compose-internal only; TLS terminates at the
reverse proxy.
## What is protected, and how
| Asset | Protection |
|---|---|
| Google OAuth refresh/access tokens | Encrypted at rest (ASP.NET Data Protection, AES); never logged; never sent to the browser |
| Data Protection key ring | Optionally encrypted with an operator-supplied X.509 certificate — set `DataProtection:CertificatePath`/`CertificatePassword`. **Without it, keys sit in plaintext on the `/keys` volume** and anyone with volume access can decrypt stored tokens. Recommended for any shared host. |
| App session | HttpOnly/SameSite=Lax/Secure cookie · sliding 7 d **with an absolute 30 d cap** (`Auth:AbsoluteSessionDays`) |
| Login/abuse | Rate limiting: global 300 req/min per user (or IP when anonymous); `auth` 10/min; export/unsubscribe/AI 20/min (`RateLimiting:*`) |
| Cross-user access | EF global query filters on every tenant-scoped entity (incl. the EmailLabel join) — tested |
| Outbound fetches (unsubscribe etc.) | `SafeHttpGuard` SSRF allowlisting (DNS-rebinding-safe) + redirects disabled |
| Untrusted email content in the UI | Rendered only as escaped React text (no `dangerouslySetInnerHTML`); search highlights use non-HTML sentinels; SPA ships CSP with `script-src 'self'` |
| Secrets | Never committed (`deploy/.env*` git-ignored; pre-commit + CI gitleaks scans); no passwords stored at all (OAuth-only login) |
## What is deliberately NOT protected (accepted risks — read this)
1. **Email bodies are stored in plaintext in Postgres.** Full-text and semantic search index
the body; encrypted columns cannot be indexed this way. On the assumed single-operator
deployment, the database lives on the operator's own disk, so the threat this would
mitigate (a third party reading the DB files) reduces to "someone with access to your
machine" — mitigate it at the layer that actually works:
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
- **Encrypt backups** of the `pgdata` volume the same way.
- Before any **multi-user** deployment, revisit per the multi-provider security design
(host admins must not be able to read members' mail — plaintext bodies break that promise).
2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
loopback. If you ever move Postgres to another host, add `SSL Mode=Require` to the
connection string (audit L-4).
3. **No CSRF tokens** — SameSite=Lax cookies + strict CORS + JSON-only bodies make classic
CSRF impractical; revisit if either changes (audit L-1).
## Data retention (opt-in)
By default the local mailbox copy is kept indefinitely. Two knobs enable automatic purging of
the **local copy only** (your actual Gmail is never touched):
```json
"DataRetention": {
"PurgeTrashedAfterDays": 0, // e.g. 30 — purge local copies of trashed mail after 30 days
"PurgeAllAfterDays": 0 // e.g. 730 — keep at most ~2 years of mail locally
}
```
`0` disables a knob. A daily background worker applies them. For a full "delete my data"
operation: stop the stack and remove the `pgdata` + `keys` volumes
(`docker compose down -v`), and revoke the app's access in your Google account.
## Production checklist (beyond compose defaults)
- Set `AllowedHosts` to your real hostname(s) (audit L-2).
- Terminate TLS at the proxy; HSTS is enabled automatically outside Development.
- Provide `DataProtection:CertificatePath` to encrypt the key ring.
- Keep `deploy/.env` readable only by the service user; rotate the DB password if exposed.
- Dependency + secret scanning run in CI on every PR (required checks).
## Reporting
Single-operator project — if you find a vulnerability, open a private issue or contact the
repository owner directly.
+3 -1
View File
@@ -1,6 +1,8 @@
services:
postgres:
image: postgres:16-alpine
# pgvector-enabled Postgres 16 (semantic search). Drop-in for postgres:16 data;
# the 'vector' extension is created by the AddEmbeddingColumn migration.
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: inboxintel
POSTGRES_USER: inboxintel
+2 -10
View File
@@ -5,16 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InboxIntel — Gmail analytics & cleanup</title>
<script>
// Apply the saved theme before first paint to avoid a flash of the wrong mode.
(function () {
try {
// Dark-first: default new users to dark unless they've chosen light.
var t = localStorage.getItem('ii:theme') || 'dark';
if (t === 'dark') document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
<!-- Theme applied before first paint; external file so CSP can use script-src 'self'. -->
<script src="/theme-init.js"></script>
</head>
<body>
<div id="root"></div>
+13
View File
@@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html;
index index.html;
# AUDIT M-2: security headers on the SPA. script-src 'self' works because the theme
# bootstrap lives in /theme-init.js (no inline scripts); style-src needs 'unsafe-inline'
# for React/Chart.js/grid-layout inline style attributes (low risk with script-src locked).
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
# Compress the SPA bundle (AUDIT perf note: ~680 KB JS).
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# SPA fallback.
location / {
try_files $uri $uri/ /index.html;
+535 -1113
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -35,10 +35,10 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"vite": "^5.3.1"
"vite": "^8.1.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
// Applied before first paint to avoid a flash of the wrong theme. Lives in a file (not
// inline) so the SPA can ship a CSP with script-src 'self' (AUDIT M-2).
(function () {
try {
// Dark-first: default new users to dark unless they've chosen light.
var t = localStorage.getItem('ii:theme') || 'dark';
if (t === 'dark') document.documentElement.classList.add('dark');
} catch (e) {}
})();
+34 -23
View File
@@ -1,42 +1,53 @@
import React from 'react';
import React, { Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Landing from './pages/Landing.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Senders from './pages/Senders.jsx';
import Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
import FolderView from './pages/FolderView.jsx';
import SearchResults from './pages/SearchResults.jsx';
import Layout from './components/Layout.jsx';
import DesignSystem from './pages/DesignSystem.jsx';
import { ToastProvider, TooltipProvider } from './components/ui';
import '@fontsource-variable/inter';
import './index.css';
import './styles.css';
// Route-level code splitting: each page loads its own chunk on first visit, so the
// initial bundle no longer carries Chart.js / grid-layout / every page at once.
// Landing + Layout stay eager (they're the first paint).
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));
const Senders = lazy(() => import('./pages/Senders.jsx'));
const Cleanup = lazy(() => import('./pages/Cleanup.jsx'));
const Unsubscribe = lazy(() => import('./pages/Unsubscribe.jsx'));
const FolderView = lazy(() => import('./pages/FolderView.jsx'));
const SearchResults = lazy(() => import('./pages/SearchResults.jsx'));
const DesignSystem = lazy(() => import('./pages/DesignSystem.jsx'));
// Minimal, theme-correct route fallback (skeleton-style, per the design system).
const RouteFallback = () => (
<div className="p-6 text-sm text-muted-foreground" aria-busy="true">Loading</div>
);
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ToastProvider>
<TooltipProvider delayDuration={200}>
<BrowserRouter>
<Routes>
{/* Public landing page */}
<Route path="/" element={<Landing />} />
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Public landing page */}
<Route path="/" element={<Landing />} />
{/* Authenticated app */}
<Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="senders" element={<Senders />} />
<Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} />
<Route path="folder/:slug" element={<FolderView />} />
<Route path="search" element={<SearchResults />} />
<Route path="design" element={<DesignSystem />} />
</Route>
{/* Authenticated app */}
<Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="senders" element={<Senders />} />
<Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} />
<Route path="folder/:slug" element={<FolderView />} />
<Route path="search" element={<SearchResults />} />
<Route path="design" element={<DesignSystem />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
</TooltipProvider>
</ToastProvider>
@@ -7,6 +7,7 @@ namespace InboxIntel.Api.Controllers;
/// AI endpoints are read-only / advisory. They never trigger destructive
/// actions - suggestions are returned for the user to act on via /cleanup.
/// </summary>
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: LLM calls are the most expensive path
public class AiController : ApiControllerBase
{
private readonly IAiService _ai;
@@ -5,10 +5,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace InboxIntel.Api.Controllers;
[ApiController]
[EnableRateLimiting("auth")] // AUDIT H-2: throttle login/challenge attempts per IP
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class AuthController : ControllerBase
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: PDF/CSV generation is costly
public class ExportController : ApiControllerBase
{
private readonly IExportService _export;
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: triggers server-side outbound HTTP
public class UnsubscribeController : ApiControllerBase
{
private readonly IUnsubscribeService _unsub;
+87 -1
View File
@@ -1,5 +1,7 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using Asp.Versioning;
using FluentValidation.AspNetCore;
using InboxIntel.Api.Auth;
using InboxIntel.Application;
using InboxIntel.Application.Abstractions;
@@ -8,8 +10,10 @@ using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
@@ -23,9 +27,20 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.Console());
// Persist Data Protection keys so encrypted refresh tokens survive restarts.
builder.Services.AddDataProtection()
// AUDIT M-3: optionally encrypt the Data Protection key ring with an X.509 certificate so
// the keys are not readable in plaintext from the /keys volume (which would otherwise let
// anyone with volume access decrypt all stored refresh tokens). Configure
// DataProtection:CertificatePath (+ CertificatePassword) to enable; without it, keys are
// persisted unprotected and a startup warning documents the residual risk.
var dp = builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys"))
.SetApplicationName("InboxIntel");
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
if (!string.IsNullOrWhiteSpace(dpCertPath))
{
dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2(
dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
}
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
@@ -57,6 +72,24 @@ builder.Services.AddAuthentication(options =>
options.Cookie.Name = "inboxintel.session";
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
// AUDIT M-1: sliding expiration alone lets a stolen cookie renew forever. Stamp an
// absolute start at sign-in and reject principals older than the configured cap,
// forcing a full re-login. (Pre-existing sessions without the stamp are rejected
// once — a single re-login, then they carry the stamp.)
var absoluteDays = builder.Configuration.GetValue("Auth:AbsoluteSessionDays", 30);
options.Events.OnSigningIn = ctx =>
{
ctx.Properties.SetString("abs-start", DateTimeOffset.UtcNow.ToString("O"));
return Task.CompletedTask;
};
options.Events.OnValidatePrincipal = async ctx =>
{
if (SessionLifetime.IsExpired(ctx.Properties.GetString("abs-start"), DateTimeOffset.UtcNow, TimeSpan.FromDays(absoluteDays)))
{
ctx.RejectPrincipal();
await ctx.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
};
// API-style behaviour: return status codes rather than redirecting to a login page.
options.Events.OnRedirectToLogin = ctx =>
{
@@ -98,6 +131,35 @@ builder.Services.AddApiVersioning(o =>
}).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; });
builder.Services.AddControllers();
// AUDIT H-1: the validators in InboxIntel.Application/Validation were registered but never
// executed (FluentValidation 11.x needs explicit auto-validation). This wires them into
// model binding so invalid DTOs 400 at the boundary instead of reaching services.
builder.Services.AddFluentValidationAutoValidation();
// AUDIT H-2: rate limiting. Global per-user (or per-IP when anonymous) window, plus stricter
// named policies for auth and expensive endpoints (export/unsubscribe/AI). Limits are
// config-driven so tests and deployments can tune them.
var rl = builder.Configuration.GetSection("RateLimiting");
int Limit(string key, int def) => rl.GetValue(key, def);
var rlWindow = TimeSpan.FromSeconds(Limit("WindowSeconds", 60));
static string Partition(HttpContext ctx) =>
ctx.User.Identity?.IsAuthenticated == true
? ctx.User.FindFirstValue("inboxintel:uid") ?? "auth-unknown"
: ctx.Connection.RemoteIpAddress?.ToString() ?? "anon";
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("GlobalPermitLimit", 300), Window = rlWindow, QueueLimit = 0 }));
o.AddPolicy("auth", ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("AuthPermitLimit", 10), Window = rlWindow, QueueLimit = 0 }));
o.AddPolicy("expensive", ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("ExpensivePermitLimit", 20), Window = rlWindow, QueueLimit = 0 }));
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// V-06: RFC7807 ProblemDetails so the global exception handler returns a safe,
@@ -115,7 +177,18 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true))
{
// AUDIT M-4: the shipped appsettings no longer carries a guessable default DB
// password. Fail fast with a clear message rather than connecting with weak or
// missing credentials (compose/staging/prod inject the full connection string).
var connStr = app.Configuration.GetConnectionString("Postgres") ?? string.Empty;
var csb = new Npgsql.NpgsqlConnectionStringBuilder(connStr);
if (string.IsNullOrWhiteSpace(csb.Password))
throw new InvalidOperationException(
"ConnectionStrings:Postgres has no password. Set the full connection string via " +
"environment/user-secrets (see README) — a default password is deliberately not shipped.");
await db.Database.MigrateAsync();
}
}
// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and
@@ -169,9 +242,22 @@ app.Use(async (ctx, next) =>
app.UseSerilogRequestLogging();
app.UseCors("frontend");
app.UseAuthentication();
// AUDIT H-2: after authentication so authenticated traffic partitions per-user; anonymous
// traffic partitions per-IP. Endpoint policies ("auth", "expensive") apply via attributes.
app.UseRateLimiter();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program { }
/// <summary>
/// AUDIT M-1: absolute session lifetime check, extracted for unit testing. A session with no
/// issued stamp (pre-dating this feature) is treated as expired — one forced re-login.
/// </summary>
public static class SessionLifetime
{
public static bool IsExpired(string? issuedAtIso, DateTimeOffset now, TimeSpan maxAge)
=> !DateTimeOffset.TryParse(issuedAtIso, out var issued) || now - issued > maxAge;
}
+8 -2
View File
@@ -1,12 +1,18 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel"
"Postgres": ""
},
"Database": {
"AutoMigrate": true
},
"DataProtection": {
"KeyPath": "/keys"
"KeyPath": "/keys",
"CertificatePath": "",
"CertificatePassword": ""
},
"DataRetention": {
"PurgeTrashedAfterDays": 0,
"PurgeAllAfterDays": 0
},
"GoogleOAuth": {
"ClientId": "",
@@ -82,6 +82,25 @@ public interface IAiProvider
Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default);
}
/// <summary>
/// Produces vector embeddings for text (the foundation for semantic search, near-duplicate
/// detection, and "find similar"). Kept separate from <see cref="IAiProvider"/> because
/// embeddings are a distinct capability with their own model. The Null implementation returns
/// an empty vector and <see cref="IsAvailable"/> = false, so callers detect unavailability and
/// fall back to lexical search — AI is never required for core functionality.
/// </summary>
public interface IEmbeddingProvider
{
/// <summary>False for the Null provider (AI off / no embedding model configured).</summary>
bool IsAvailable { get; }
/// <summary>Embed a single text. Returns an empty array when unavailable.</summary>
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
/// <summary>Embed many texts, result aligned to input order. Empty list when unavailable.</summary>
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default);
}
public enum ExportFormat { Pdf, Csv, Json }
public interface IExportService
+7 -4
View File
@@ -2,6 +2,13 @@ using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
/// <summary>
/// Row summary for search/list results. <c>MatchHighlight</c> is a "why this matched" body
/// fragment (ts_headline) with matched terms wrapped in U+E000/U+E001 sentinels — NOT HTML;
/// the client renders them as escaped &lt;mark&gt; elements, so untrusted email content can
/// never inject markup. Null unless the search had a free-text query; optional/last so other
/// DTO constructors are unaffected.
/// </summary>
public record EmailSummaryDto(
Guid Id,
string GmailMessageId,
@@ -17,10 +24,6 @@ public record EmailSummaryDto(
EmailCategory Category,
bool HasListUnsubscribe,
bool SupportsOneClick,
// "Why this matched": a ts_headline fragment of the body with matched terms wrapped in
// U+E000/U+E001 sentinels (NOT HTML — the client renders them as escaped <mark> elements,
// so untrusted email content can never inject markup). Null unless the search had a
// free-text query. Optional/last so other DTO constructors are unaffected.
string? MatchHighlight = null);
/// <summary>Full single-email view, including body text, for the detail pane.</summary>
+7
View File
@@ -54,6 +54,13 @@ public class Email : AuditableEntity
/// </summary>
public NpgsqlTypes.NpgsqlTsVector? SearchVector { get; set; }
/// <summary>
/// Semantic-search embedding (pgvector, 768-dim for nomic-embed-text). Populated by the
/// embedding backfill worker when AI is enabled; null otherwise (search falls back to
/// lexical). Nullable so the InMemory test provider and AI-off deployments work unchanged.
/// </summary>
public Pgvector.Vector? Embedding { get; set; }
public ICollection<EmailLabel> EmailLabels { get; set; } = new List<EmailLabel>();
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
}
@@ -4,7 +4,9 @@
<AssemblyName>InboxIntel.Domain</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- NpgsqlTypes.NpgsqlTsVector is used on the Email entity for FTS mapping. -->
<!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as
column types on the Email entity — same pragmatic precedent for both. -->
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="Pgvector" Version="0.2.0" />
</ItemGroup>
</Project>
@@ -2,6 +2,7 @@ using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
using System.Linq;
using System.Net.Http.Json;
using System.Text.Json;
@@ -15,6 +16,53 @@ public class NullAiProvider : IAiProvider
=> Task.FromResult(string.Empty);
}
/// <summary>No-op embeddings used when AI is disabled. Returns empty vectors so callers fall
/// back to lexical search.</summary>
public class NullEmbeddingProvider : IEmbeddingProvider
{
public bool IsAvailable => false;
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<float>());
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<float[]>>(Array.Empty<float[]>());
}
/// <summary>Local embeddings via Ollama's /api/embeddings endpoint (e.g. nomic-embed-text).</summary>
public class OllamaEmbeddingProvider : IEmbeddingProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OllamaEmbeddingProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("ollama");
_http.BaseAddress = new Uri(_options.OllamaBaseUrl);
}
public bool IsAvailable => true;
public async Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
{
var payload = new { model = _options.EmbeddingModel, prompt = text };
var resp = await _http.PostAsJsonAsync("/api/embeddings", payload, ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
return doc.RootElement.GetProperty("embedding").EnumerateArray()
.Select(e => e.GetSingle()).ToArray();
}
// Ollama's /api/embeddings takes one prompt per call, so batch is a sequential loop.
// Kept behind the interface so a future batch endpoint is a drop-in swap.
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
{
var results = new List<float[]>(texts.Count);
foreach (var t in texts)
results.Add(await EmbedAsync(t, ct));
return results;
}
}
/// <summary>Local LLM via Ollama's /api/chat endpoint.</summary>
public class OllamaProvider : IAiProvider
{
@@ -43,6 +43,8 @@ public class AiOptions
// Ollama (local)
public string OllamaBaseUrl { get; set; } = "http://localhost:11434";
public string OllamaModel { get; set; } = "llama3.1";
// Embedding model for semantic search (per docs/discovery/06). Small, always-on when local.
public string EmbeddingModel { get; set; } = "nomic-embed-text";
// OpenAI (cloud, optional)
public string OpenAiApiKey { get; set; } = string.Empty;
@@ -70,3 +72,13 @@ public class DigestOptions
/// <summary>Hour (UTC) the background worker checks for due digests.</summary>
public int SendHourUtc { get; set; } = 8;
}
/// <summary>AUDIT H-3: opt-in local data retention. 0 = disabled (keep forever).</summary>
public class DataRetentionOptions
{
public const string SectionName = "DataRetention";
/// <summary>Purge locally stored emails flagged Trashed older than this many days.</summary>
public int PurgeTrashedAfterDays { get; set; } = 0;
/// <summary>Purge ALL locally stored emails older than this many days (local copy only).</summary>
public int PurgeAllAfterDays { get; set; } = 0;
}
@@ -21,10 +21,11 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
{
// EF Core / PostgreSQL
// EF Core / PostgreSQL. UseVector() enables pgvector mapping for the semantic-search
// embedding column (requires the 'vector' extension — added by the AddEmbeddingColumn migration).
services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("Postgres"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName).UseVector()));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
// Options
@@ -59,6 +60,11 @@ public static class DependencyInjection
services.AddScoped<IDigestService, DigestService>();
services.AddHostedService<DigestWorker>();
// AUDIT H-3: opt-in local data retention (worker no-ops while disabled).
services.Configure<DataRetentionOptions>(config.GetSection(DataRetentionOptions.SectionName));
services.AddScoped<Retention.RetentionService>();
services.AddHostedService<Retention.RetentionWorker>();
// HTTP clients
// V-01: do NOT follow redirects — a validated external URL must not be able to
// 3xx-redirect into an internal target after SafeHttpGuard has checked it.
@@ -67,13 +73,23 @@ public static class DependencyInjection
services.AddHttpClient("ollama");
services.AddHttpClient("openai");
// AI provider selected by configured mode.
// AI provider selected by configured mode. Embeddings come from Ollama when local,
// otherwise the Null provider (empty vectors) so semantic features degrade to lexical.
var aiMode = config.GetSection(AiOptions.SectionName).GetValue<AiProviderMode>("Mode");
switch (aiMode)
{
case AiProviderMode.LocalOllama: services.AddScoped<IAiProvider, OllamaProvider>(); break;
case AiProviderMode.CloudOpenAi: services.AddScoped<IAiProvider, OpenAiProvider>(); break;
default: services.AddScoped<IAiProvider, NullAiProvider>(); break;
case AiProviderMode.LocalOllama:
services.AddScoped<IAiProvider, OllamaProvider>();
services.AddScoped<IEmbeddingProvider, OllamaEmbeddingProvider>();
break;
case AiProviderMode.CloudOpenAi:
services.AddScoped<IAiProvider, OpenAiProvider>();
services.AddScoped<IEmbeddingProvider, NullEmbeddingProvider>(); // OpenAI embeddings: future
break;
default:
services.AddScoped<IAiProvider, NullAiProvider>();
services.AddScoped<IEmbeddingProvider, NullEmbeddingProvider>();
break;
}
services.AddScoped<IAiService, AiService>();
@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.0" />
<PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
@@ -0,0 +1,743 @@
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260701203001_EnablePgTrgm")]
partial class EnablePgTrgm
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Day")
.HasColumnType("date");
b.Property<string>("HourHistogramJson")
.HasColumnType("text");
b.Property<int>("NewsletterCount")
.HasColumnType("integer");
b.Property<int>("TotalReceived")
.HasColumnType("integer");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("TotalUnread")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("WithAttachments")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "Day")
.IsUnique();
b.ToTable("analytics_aggregates", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("GmailAttachmentId")
.HasColumnType("text");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EmailId");
b.HasIndex("UserId", "MimeType");
b.ToTable("attachments", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("HasAttachments")
.HasColumnType("boolean");
b.Property<bool>("HasListUnsubscribe")
.HasColumnType("boolean");
b.Property<bool>("IsImportant")
.HasColumnType("boolean");
b.Property<bool>("IsInInbox")
.HasColumnType("boolean");
b.Property<bool>("IsStarred")
.HasColumnType("boolean");
b.Property<bool>("IsTrashed")
.HasColumnType("boolean");
b.Property<bool>("IsUnread")
.HasColumnType("boolean");
b.Property<string>("ListUnsubscribeRaw")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SizeEstimateBytes")
.HasColumnType("bigint");
b.Property<string>("Snippet")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("SupportsOneClickUnsubscribe")
.HasColumnType("boolean");
b.Property<Guid>("ThreadId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SearchVector");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
b.HasIndex("SenderId");
b.HasIndex("ThreadId");
b.HasIndex("UserId", "Category");
b.HasIndex("UserId", "GmailMessageId")
.IsUnique();
b.HasIndex("UserId", "IsInInbox");
b.HasIndex("UserId", "IsUnread");
b.HasIndex("UserId", "SenderId");
b.HasIndex("UserId", "SentAtUtc");
b.ToTable("emails", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<Guid>("LabelId")
.HasColumnType("uuid");
b.HasKey("EmailId", "LabelId");
b.HasIndex("LabelId");
b.ToTable("email_labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ColorHex")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailLabelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailLabelId")
.IsUnique();
b.ToTable("labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("IsBulkSender")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "Name")
.IsUnique();
b.ToTable("domains", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FirstMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailThreadId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<string>("Snippet")
.HasColumnType("text");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailThreadId")
.IsUnique();
b.ToTable("threads", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("DomainId")
.HasColumnType("uuid");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("HasUnsubscribe")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastReceivedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("UnreadCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("DomainId");
b.HasIndex("UserId", "Address")
.IsUnique();
b.HasIndex("UserId", "EmailCount");
b.ToTable("senders", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("LastHistoryId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("LastSyncType")
.HasColumnType("integer");
b.Property<int>("MessagesProcessed")
.HasColumnType("integer");
b.Property<string>("ResumePageToken")
.HasColumnType("text");
b.Property<DateTimeOffset?>("StartedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalMessagesEstimate")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("sync_states", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("LastAttemptUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Method")
.HasColumnType("integer");
b.Property<string>("ResultMessage")
.HasColumnType("text");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UnsubscribeTarget")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("UserId", "SenderId")
.IsUnique();
b.ToTable("unsubscribe_items", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<byte[]>("EncryptedRefreshToken")
.HasColumnType("bytea");
b.Property<string>("GoogleSubjectId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PictureUrl")
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("GoogleSubjectId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("H")
.HasColumnType("integer");
b.Property<string>("SettingsJson")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.Property<int>("W")
.HasColumnType("integer");
b.Property<string>("WidgetKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("X")
.HasColumnType("integer");
b.Property<int>("Y")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "WidgetKey")
.IsUnique();
b.ToTable("widget_layouts", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany("Emails")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
.WithMany("Emails")
.HasForeignKey("ThreadId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("Emails")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
b.Navigation("Thread");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("EmailLabels")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
.WithMany("EmailLabels")
.HasForeignKey("LabelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
b.Navigation("Label");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
.WithMany("Senders")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Domain");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("WidgetLayouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Navigation("Attachments");
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Navigation("Senders");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Navigation("Emails");
b.Navigation("WidgetLayouts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EnablePgTrgm : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
}
}
}
@@ -0,0 +1,758 @@
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260701235112_TrigramIndexesSenderDomain")]
partial class TrigramIndexesSenderDomain
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Day")
.HasColumnType("date");
b.Property<string>("HourHistogramJson")
.HasColumnType("text");
b.Property<int>("NewsletterCount")
.HasColumnType("integer");
b.Property<int>("TotalReceived")
.HasColumnType("integer");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("TotalUnread")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("WithAttachments")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "Day")
.IsUnique();
b.ToTable("analytics_aggregates", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("GmailAttachmentId")
.HasColumnType("text");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EmailId");
b.HasIndex("UserId", "MimeType");
b.ToTable("attachments", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("HasAttachments")
.HasColumnType("boolean");
b.Property<bool>("HasListUnsubscribe")
.HasColumnType("boolean");
b.Property<bool>("IsImportant")
.HasColumnType("boolean");
b.Property<bool>("IsInInbox")
.HasColumnType("boolean");
b.Property<bool>("IsStarred")
.HasColumnType("boolean");
b.Property<bool>("IsTrashed")
.HasColumnType("boolean");
b.Property<bool>("IsUnread")
.HasColumnType("boolean");
b.Property<string>("ListUnsubscribeRaw")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SizeEstimateBytes")
.HasColumnType("bigint");
b.Property<string>("Snippet")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("SupportsOneClickUnsubscribe")
.HasColumnType("boolean");
b.Property<Guid>("ThreadId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SearchVector");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
b.HasIndex("SenderId");
b.HasIndex("ThreadId");
b.HasIndex("UserId", "Category");
b.HasIndex("UserId", "GmailMessageId")
.IsUnique();
b.HasIndex("UserId", "IsInInbox");
b.HasIndex("UserId", "IsUnread");
b.HasIndex("UserId", "SenderId");
b.HasIndex("UserId", "SentAtUtc");
b.ToTable("emails", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<Guid>("LabelId")
.HasColumnType("uuid");
b.HasKey("EmailId", "LabelId");
b.HasIndex("LabelId");
b.ToTable("email_labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ColorHex")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailLabelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailLabelId")
.IsUnique();
b.ToTable("labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("IsBulkSender")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Name");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("UserId", "Name")
.IsUnique();
b.ToTable("domains", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FirstMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailThreadId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<string>("Snippet")
.HasColumnType("text");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailThreadId")
.IsUnique();
b.ToTable("threads", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("DomainId")
.HasColumnType("uuid");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("HasUnsubscribe")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastReceivedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("UnreadCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Address");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
b.HasIndex("DisplayName");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
b.HasIndex("DomainId");
b.HasIndex("UserId", "Address")
.IsUnique();
b.HasIndex("UserId", "EmailCount");
b.ToTable("senders", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("LastHistoryId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("LastSyncType")
.HasColumnType("integer");
b.Property<int>("MessagesProcessed")
.HasColumnType("integer");
b.Property<string>("ResumePageToken")
.HasColumnType("text");
b.Property<DateTimeOffset?>("StartedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalMessagesEstimate")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("sync_states", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("LastAttemptUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Method")
.HasColumnType("integer");
b.Property<string>("ResultMessage")
.HasColumnType("text");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UnsubscribeTarget")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("UserId", "SenderId")
.IsUnique();
b.ToTable("unsubscribe_items", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<byte[]>("EncryptedRefreshToken")
.HasColumnType("bytea");
b.Property<string>("GoogleSubjectId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PictureUrl")
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("GoogleSubjectId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("H")
.HasColumnType("integer");
b.Property<string>("SettingsJson")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.Property<int>("W")
.HasColumnType("integer");
b.Property<string>("WidgetKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("X")
.HasColumnType("integer");
b.Property<int>("Y")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "WidgetKey")
.IsUnique();
b.ToTable("widget_layouts", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany("Emails")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
.WithMany("Emails")
.HasForeignKey("ThreadId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("Emails")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
b.Navigation("Thread");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("EmailLabels")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
.WithMany("EmailLabels")
.HasForeignKey("LabelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
b.Navigation("Label");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
.WithMany("Senders")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Domain");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("WidgetLayouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Navigation("Attachments");
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Navigation("Senders");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Navigation("Emails");
b.Navigation("WidgetLayouts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TrigramIndexesSenderDomain : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_senders_Address",
table: "senders",
column: "Address")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
migrationBuilder.CreateIndex(
name: "IX_senders_DisplayName",
table: "senders",
column: "DisplayName")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
migrationBuilder.CreateIndex(
name: "IX_domains_Name",
table: "domains",
column: "Name")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_senders_Address",
table: "senders");
migrationBuilder.DropIndex(
name: "IX_senders_DisplayName",
table: "senders");
migrationBuilder.DropIndex(
name: "IX_domains_Name",
table: "domains");
}
}
}
@@ -0,0 +1,768 @@
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
using Pgvector;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260702002304_AddEmbeddingColumn")]
partial class AddEmbeddingColumn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Day")
.HasColumnType("date");
b.Property<string>("HourHistogramJson")
.HasColumnType("text");
b.Property<int>("NewsletterCount")
.HasColumnType("integer");
b.Property<int>("TotalReceived")
.HasColumnType("integer");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("TotalUnread")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("WithAttachments")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "Day")
.IsUnique();
b.ToTable("analytics_aggregates", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("GmailAttachmentId")
.HasColumnType("text");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EmailId");
b.HasIndex("UserId", "MimeType");
b.ToTable("attachments", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.HasColumnType("vector(768)");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("HasAttachments")
.HasColumnType("boolean");
b.Property<bool>("HasListUnsubscribe")
.HasColumnType("boolean");
b.Property<bool>("IsImportant")
.HasColumnType("boolean");
b.Property<bool>("IsInInbox")
.HasColumnType("boolean");
b.Property<bool>("IsStarred")
.HasColumnType("boolean");
b.Property<bool>("IsTrashed")
.HasColumnType("boolean");
b.Property<bool>("IsUnread")
.HasColumnType("boolean");
b.Property<string>("ListUnsubscribeRaw")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SizeEstimateBytes")
.HasColumnType("bigint");
b.Property<string>("Snippet")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("SupportsOneClickUnsubscribe")
.HasColumnType("boolean");
b.Property<Guid>("ThreadId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Embedding");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
b.HasIndex("SearchVector");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
b.HasIndex("SenderId");
b.HasIndex("ThreadId");
b.HasIndex("UserId", "Category");
b.HasIndex("UserId", "GmailMessageId")
.IsUnique();
b.HasIndex("UserId", "IsInInbox");
b.HasIndex("UserId", "IsUnread");
b.HasIndex("UserId", "SenderId");
b.HasIndex("UserId", "SentAtUtc");
b.ToTable("emails", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<Guid>("LabelId")
.HasColumnType("uuid");
b.HasKey("EmailId", "LabelId");
b.HasIndex("LabelId");
b.ToTable("email_labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ColorHex")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailLabelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailLabelId")
.IsUnique();
b.ToTable("labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("IsBulkSender")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Name");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("UserId", "Name")
.IsUnique();
b.ToTable("domains", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FirstMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailThreadId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<string>("Snippet")
.HasColumnType("text");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailThreadId")
.IsUnique();
b.ToTable("threads", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("DomainId")
.HasColumnType("uuid");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("HasUnsubscribe")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastReceivedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("UnreadCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Address");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
b.HasIndex("DisplayName");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
b.HasIndex("DomainId");
b.HasIndex("UserId", "Address")
.IsUnique();
b.HasIndex("UserId", "EmailCount");
b.ToTable("senders", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("LastHistoryId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("LastSyncType")
.HasColumnType("integer");
b.Property<int>("MessagesProcessed")
.HasColumnType("integer");
b.Property<string>("ResumePageToken")
.HasColumnType("text");
b.Property<DateTimeOffset?>("StartedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalMessagesEstimate")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("sync_states", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("LastAttemptUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Method")
.HasColumnType("integer");
b.Property<string>("ResultMessage")
.HasColumnType("text");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UnsubscribeTarget")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("UserId", "SenderId")
.IsUnique();
b.ToTable("unsubscribe_items", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<byte[]>("EncryptedRefreshToken")
.HasColumnType("bytea");
b.Property<string>("GoogleSubjectId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PictureUrl")
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("GoogleSubjectId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("H")
.HasColumnType("integer");
b.Property<string>("SettingsJson")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.Property<int>("W")
.HasColumnType("integer");
b.Property<string>("WidgetKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("X")
.HasColumnType("integer");
b.Property<int>("Y")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "WidgetKey")
.IsUnique();
b.ToTable("widget_layouts", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany("Emails")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
.WithMany("Emails")
.HasForeignKey("ThreadId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("Emails")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
b.Navigation("Thread");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("EmailLabels")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
.WithMany("EmailLabels")
.HasForeignKey("LabelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
b.Navigation("Label");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
.WithMany("Senders")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Domain");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("WidgetLayouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Navigation("Attachments");
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Navigation("Senders");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Navigation("Emails");
b.Navigation("WidgetLayouts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Pgvector;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddEmbeddingColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.Annotation("Npgsql:PostgresExtension:vector", ",,")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
migrationBuilder.AddColumn<Vector>(
name: "Embedding",
table: "emails",
type: "vector(768)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_emails_Embedding",
table: "emails",
column: "Embedding")
.Annotation("Npgsql:IndexMethod", "hnsw")
.Annotation("Npgsql:IndexOperators", new[] { "vector_cosine_ops" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_emails_Embedding",
table: "emails");
migrationBuilder.DropColumn(
name: "Embedding",
table: "emails");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
}
}
}
@@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
using Pgvector;
#nullable disable
@@ -21,6 +22,8 @@ namespace InboxIntel.Infrastructure.Migrations
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
@@ -124,6 +127,9 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.HasColumnType("vector(768)");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
@@ -193,6 +199,11 @@ namespace InboxIntel.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("Embedding");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
b.HasIndex("SearchVector");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
@@ -300,6 +311,11 @@ namespace InboxIntel.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("Name");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("UserId", "Name")
.IsUnique();
@@ -394,6 +410,16 @@ namespace InboxIntel.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("Address");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
b.HasIndex("DisplayName");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
b.HasIndex("DomainId");
b.HasIndex("UserId", "Address")
@@ -25,7 +25,7 @@ public class SmtpEmailSender : IEmailSender
{
if (!IsEnabled)
{
_logger.LogInformation("SMTP not configured; skipping email \"{Subject}\" to {To}", subject, toAddress);
_logger.LogDebug("SMTP not configured; skipping email \"{Subject}\" to {To}", subject, toAddress);
return;
}
@@ -53,6 +53,10 @@ public class AppDbContext : DbContext, IAppDbContext
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
// entity; without a matching filter EF warns on boot and joins could surface rows whose
// parent is filtered out. Filter via the Email navigation so the pair is consistent.
modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
@@ -65,6 +69,9 @@ public class AppDbContext : DbContext, IAppDbContext
// it otherwise (e.g. the InMemory provider used by tests).
if (Database.IsRelational())
{
// pg_trgm powers the fuzzy/typo fallback in SearchService (word_similarity).
modelBuilder.HasPostgresExtension("pg_trgm");
modelBuilder.Entity<Email>().Property(e => e.SearchVector)
.HasColumnType("tsvector")
.HasComputedColumnSql(
@@ -72,10 +79,28 @@ public class AppDbContext : DbContext, IAppDbContext
"setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
stored: true);
modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN");
// Trigram GIN indexes so the sender/domain `.Contains()` filters in SearchService
// (which translate to `LIKE '%x%'`, non-sargable on a btree) become index-accelerated.
// Needs pg_trgm (enabled by the EnablePgTrgm migration).
modelBuilder.Entity<Sender>().HasIndex(s => s.Address)
.HasMethod("gin").HasOperators("gin_trgm_ops");
modelBuilder.Entity<Sender>().HasIndex(s => s.DisplayName)
.HasMethod("gin").HasOperators("gin_trgm_ops");
modelBuilder.Entity<MailDomain>().HasIndex(d => d.Name)
.HasMethod("gin").HasOperators("gin_trgm_ops");
// pgvector: 768-dim embedding for semantic search, with an HNSW cosine index.
// Populated by the embedding backfill worker when AI is enabled; null otherwise.
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<Email>().Property(e => e.Embedding).HasColumnType("vector(768)");
modelBuilder.Entity<Email>().HasIndex(e => e.Embedding)
.HasMethod("hnsw").HasOperators("vector_cosine_ops");
}
else
{
modelBuilder.Entity<Email>().Ignore(e => e.SearchVector);
modelBuilder.Entity<Email>().Ignore(e => e.Embedding);
}
base.OnModelCreating(modelBuilder);
@@ -15,7 +15,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
var conn = Environment.GetEnvironmentVariable("EF_CONNECTION")
?? "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(conn)
.UseNpgsql(conn, o => o.UseVector())
.Options;
return new AppDbContext(options);
}
@@ -0,0 +1,63 @@
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Retention;
/// <summary>
/// AUDIT H-3 (retention): purges locally stored email data past the configured age so the
/// local mailbox copy is not kept forever by default-of-omission. This deletes ONLY the
/// local Postgres rows — the user's actual Gmail is never touched. Both knobs default to 0
/// (disabled) so existing deployments are unchanged until the operator opts in.
/// </summary>
public class RetentionService
{
private readonly AppDbContext _db;
private readonly DataRetentionOptions _options;
private readonly ILogger<RetentionService> _logger;
public RetentionService(AppDbContext db, IOptions<DataRetentionOptions> options, ILogger<RetentionService> logger)
{
_db = db;
_options = options.Value;
_logger = logger;
}
/// <summary>Runs the configured purges. Returns the number of emails removed.</summary>
public async Task<int> PurgeAsync(CancellationToken ct = default)
{
var removed = 0;
var now = DateTimeOffset.UtcNow;
if (_options.PurgeTrashedAfterDays > 0)
{
var cutoff = now.AddDays(-_options.PurgeTrashedAfterDays);
removed += await PurgeWhereAsync(e => e.IsTrashed && e.SentAtUtc < cutoff, ct);
}
if (_options.PurgeAllAfterDays > 0)
{
var cutoff = now.AddDays(-_options.PurgeAllAfterDays);
removed += await PurgeWhereAsync(e => e.SentAtUtc < cutoff, ct);
}
if (removed > 0)
_logger.LogInformation("Retention purge removed {Count} locally stored emails", removed);
return removed;
}
private async Task<int> PurgeWhereAsync(
System.Linq.Expressions.Expression<Func<Domain.Entities.Email, bool>> predicate,
CancellationToken ct)
{
// RemoveRange (not ExecuteDelete) so dependent rows (labels/attachments) cascade via
// the model on every provider, and the InMemory test provider works identically.
var doomed = await _db.Emails.Where(predicate).ToListAsync(ct);
if (doomed.Count == 0) return 0;
_db.Emails.RemoveRange(doomed);
await _db.SaveChangesAsync(ct);
return doomed.Count;
}
}
@@ -0,0 +1,53 @@
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Retention;
/// <summary>
/// Daily driver for <see cref="RetentionService"/> (AUDIT H-3). Exits immediately when both
/// retention knobs are 0 (the default) so existing deployments see no behaviour change.
/// </summary>
public class RetentionWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly DataRetentionOptions _options;
private readonly ILogger<RetentionWorker> _logger;
public RetentionWorker(IServiceScopeFactory scopeFactory, IOptions<DataRetentionOptions> options, ILogger<RetentionWorker> logger)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_options.PurgeTrashedAfterDays <= 0 && _options.PurgeAllAfterDays <= 0)
{
_logger.LogDebug("RetentionWorker idle: retention is disabled (all knobs 0).");
return;
}
_logger.LogInformation("RetentionWorker started; trashed>{Trashed}d, all>{All}d",
_options.PurgeTrashedAfterDays, _options.PurgeAllAfterDays);
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<RetentionService>();
await service.PurgeAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Retention purge failed; will retry next cycle.");
}
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
}
}
}
@@ -1,6 +1,7 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -64,30 +65,47 @@ public class SearchService : ISearchService
// OR, and -exclusions — the syntax users already expect from web search boxes.
var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query);
var term = r.Query?.Trim() ?? string.Empty;
if (hasFreeTextQuery)
q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)));
var total = await q.CountAsync(ct);
var structured = q; // structured filters only — reused by the fuzzy fallback below.
var matched = hasFreeTextQuery
? structured.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)))
: structured;
// Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity,
// recency as a tiebreaker); date-only otherwise — matches the existing browse
// behaviour when the user isn't searching for anything in particular.
var ranked = hasFreeTextQuery
? q.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
.ThenByDescending(e => e.SentAtUtc)
: q.OrderByDescending(e => e.SentAtUtc);
var total = await matched.CountAsync(ct);
// Fuzzy/typo fallback: ONLY when a free-text search found nothing exact. word_similarity
// with an explicit 0.3 threshold — pg_trgm's default 0.6 misses real typos
// ("recieved" -> "received" scores ~0.39). Rare path, so the (non-indexed) scan over the
// user's own structured subset is acceptable. Needs the pg_trgm extension (see migration).
const double fuzzyThreshold = 0.3;
var fuzzy = hasFreeTextQuery && total == 0;
if (fuzzy)
{
matched = structured.Where(e =>
e.Subject != null && EF.Functions.TrigramsWordSimilarity(term, e.Subject) >= fuzzyThreshold);
total = await matched.CountAsync(ct);
}
// Ordering: fuzzy → by word similarity; exact free-text → ts_rank_cd; browse → date.
IQueryable<Email> ranked;
if (fuzzy)
ranked = matched.OrderByDescending(e => EF.Functions.TrigramsWordSimilarity(term, e.Subject!))
.ThenByDescending(e => e.SentAtUtc);
else if (hasFreeTextQuery)
ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
.ThenByDescending(e => e.SentAtUtc);
else
ranked = matched.OrderByDescending(e => e.SentAtUtc);
var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
// Two unconditional projections (no DB function inside a C# ternary → no doubt about
// EF translation). The browse path never touches ts_headline, so it's byte-for-byte
// unchanged AND safe under the InMemory test provider.
// "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no
// highlight — a fuzzy hit has no literal match to headline). Unconditional projections
// (no DB function in a ternary) keep EF translation unambiguous.
List<EmailSummaryDto> items;
if (hasFreeTextQuery)
if (hasFreeTextQuery && !fuzzy)
{
// "Why this matched": ts_headline body fragment with matched terms wrapped in
// U+E000/U+E001 sentinels (safe, non-HTML — the client renders them as escaped
// <mark> spans; see EmailSummaryDto).
// Matched terms wrapped in U+E000/U+E001 sentinels (safe, non-HTML; see EmailSummaryDto).
var headlineOpts =
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
items = await paged
@@ -0,0 +1,172 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
namespace InboxIntel.IntegrationTests;
// ── Tests proving the Phase-2 audit fixes (see AUDIT_REPORT.md) ────────────────────────────
/// <summary>Pass-through auth scheme so integration tests can exercise authenticated
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string Scheme = "Test";
// Stable across requests so per-user rate-limit partitions accumulate correctly.
public static readonly string Uid = Guid.NewGuid().ToString();
public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> o, ILoggerFactory l, UrlEncoder e)
: base(o, l, e) { }
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-sub"),
new Claim("inboxintel:uid", Uid),
}, Scheme);
return Task.FromResult(AuthenticateResult.Success(
new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme)));
}
}
/// <summary>Factory with the test auth scheme + tiny rate-limit windows so limits trip fast.</summary>
public class AuditTestAppFactory : WebApplicationFactory<Program>
{
protected override IHost CreateHost(IHostBuilder builder)
{
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:AutoMigrate"] = "false",
["GoogleOAuth:ClientId"] = "test-client-id",
["GoogleOAuth:ClientSecret"] = "test-client-secret",
// H-2: make the auth policy trip on the 3rd request within the window.
["RateLimiting:AuthPermitLimit"] = "2",
["RateLimiting:WindowSeconds"] = "60",
}));
return base.CreateHost(builder);
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddAuthentication(TestAuthHandler.Scheme)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.Scheme, _ => { });
services.PostConfigure<AuthenticationOptions>(o =>
{
o.DefaultAuthenticateScheme = TestAuthHandler.Scheme;
o.DefaultChallengeScheme = TestAuthHandler.Scheme;
});
});
}
}
public class AuditFixesTests : IClassFixture<AuditTestAppFactory>
{
private readonly AuditTestAppFactory _factory;
public AuditFixesTests(AuditTestAppFactory factory) => _factory = factory;
// H-1: FluentValidation auto-validation now rejects invalid DTOs at the boundary with 400
// (previously the registered validators never executed).
[Fact]
public async Task Invalid_search_request_is_rejected_with_400_by_the_validator()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/api/v1/search", new { page = 1, pageSize = 0 });
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Search_with_from_after_to_is_rejected_with_400()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/api/v1/search",
new { page = 1, pageSize = 10, from = "2026-02-01", to = "2026-01-01" });
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// H-2: the "auth" rate-limit policy returns 429 once the per-window permit is exhausted.
[Fact]
public async Task Auth_endpoint_rate_limits_with_429_after_the_permit_is_exhausted()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var s1 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
var s2 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
var s3 = (await client.GetAsync("/api/v1/auth/login")).StatusCode;
s1.Should().NotBe(HttpStatusCode.TooManyRequests);
s2.Should().NotBe(HttpStatusCode.TooManyRequests);
s3.Should().Be(HttpStatusCode.TooManyRequests);
}
}
// M-1: absolute session lifetime — a session older than the cap (or missing its issued
// stamp) is expired regardless of sliding renewal.
public class SessionLifetimeTests
{
private static readonly DateTimeOffset Now = new(2026, 07, 02, 12, 0, 0, TimeSpan.Zero);
private static readonly TimeSpan Max = TimeSpan.FromDays(30);
[Fact]
public void Fresh_session_is_not_expired()
=> SessionLifetime.IsExpired(Now.AddDays(-1).ToString("O"), Now, Max).Should().BeFalse();
[Fact]
public void Session_older_than_the_cap_is_expired()
=> SessionLifetime.IsExpired(Now.AddDays(-31).ToString("O"), Now, Max).Should().BeTrue();
[Fact]
public void Session_without_a_stamp_is_expired()
=> SessionLifetime.IsExpired(null, Now, Max).Should().BeTrue();
}
// M-6: EmailLabel now carries a matching tenant query filter (via its Email navigation), so
// join rows can never leak across users even without a manual Where.
public class EmailLabelFilterTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
[Fact]
public async Task EmailLabels_are_invisible_across_users()
{
var userA = Guid.NewGuid();
var userB = Guid.NewGuid();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(nameof(EmailLabels_are_invisible_across_users)).Options;
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
{
var emailA = new Email { UserId = userA, GmailMessageId = "a1" };
var emailB = new Email { UserId = userB, GmailMessageId = "b1" };
var labelA = new Label { UserId = userA, GmailLabelId = "LA", Name = "A" };
var labelB = new Label { UserId = userB, GmailLabelId = "LB", Name = "B" };
seed.AddRange(emailA, emailB, labelA, labelB,
new EmailLabel { EmailId = emailA.Id, LabelId = labelA.Id },
new EmailLabel { EmailId = emailB.Id, LabelId = labelB.Id });
await seed.SaveChangesAsync();
}
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = userA });
var visible = await ctx.Set<EmailLabel>().ToListAsync(); // no manual Where — filter must enforce
visible.Should().HaveCount(1);
}
}
@@ -0,0 +1,125 @@
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Search;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// AUDIT M-7: live-PostgreSQL regression tests for the search paths the InMemory provider
/// cannot translate (websearch_to_tsquery, ts_rank_cd, ts_headline, pg_trgm word_similarity,
/// pgvector cosine). These previously existed only as throwaway manual verifications.
///
/// They run when LIVEDB_CONNECTION points at a pgvector-enabled Postgres (the CI `db-tests`
/// job provides one as a service container) and no-op otherwise, so the default local /
/// InMemory test run is unaffected.
/// </summary>
[Trait("Category", "LiveDb")]
public class LiveDbSearchTests
{
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
private static DbContextOptions<AppDbContext> Options() =>
new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(Conn!, o => o.UseVector()).Options;
private static Vector Vec(float x, float y)
{
var arr = new float[768];
arr[0] = x; arr[1] = y;
return new Vector(arr);
}
private static async Task<Guid> SeedAsync(DbContextOptions<AppDbContext> opts)
{
var uid = Guid.NewGuid();
using var db = new AppDbContext(opts, new FakeCurrentUser());
await db.Database.MigrateAsync();
db.Users.Add(new User { Id = uid, GoogleSubjectId = "g" + uid, Email = uid + "@t.t" });
var dom = new MailDomain { UserId = uid, Name = "t.t" };
var snd = new Sender { UserId = uid, Address = "a@t.t", DisplayName = "A", Domain = dom };
var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid };
db.Domains.Add(dom); db.Senders.Add(snd); db.Threads.Add(thr);
db.Emails.AddRange(
new Email { UserId = uid, GmailMessageId = "subj" + uid, Subject = "Invoice March", BodyText = "hello there", Sender = snd, Thread = thr, Embedding = Vec(1, 0), SentAtUtc = DateTimeOffset.UtcNow.AddDays(-2) },
new Email { UserId = uid, GmailMessageId = "body" + uid, Subject = "Weekly notes", BodyText = "we received your invoice today", Sender = snd, Thread = thr, Embedding = Vec(0, 1), SentAtUtc = DateTimeOffset.UtcNow.AddDays(-1) });
await db.SaveChangesAsync();
return uid;
}
private static async Task CleanupAsync(DbContextOptions<AppDbContext> opts, Guid uid)
{
using var db = new AppDbContext(opts, new FakeCurrentUser());
await db.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync();
await db.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync();
await db.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync();
await db.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync();
await db.Users.Where(u => u.Id == uid).ExecuteDeleteAsync();
}
[Fact]
public async Task Ranked_search_weights_subject_hits_first_and_explains_body_hits()
{
if (Conn is null) return; // soft-skip outside the live-db CI job
var opts = Options();
var uid = await SeedAsync(opts);
try
{
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
var res = await new SearchService(ctx).SearchAsync(uid, GmailQueryParser.Parse("invoice", 1, 10));
res.TotalCount.Should().Be(2);
res.Items[0].Subject.Should().Be("Invoice March"); // subject weight A wins
var bodyHit = res.Items.First(i => i.Subject == "Weekly notes");
bodyHit.MatchHighlight.Should().Contain("invoice"); // ts_headline present
bodyHit.MatchHighlight.Should().Contain(((char)0xE000).ToString()); // sentinel wrapping
}
finally { await CleanupAsync(opts, uid); }
}
[Fact]
public async Task Typo_falls_back_to_trigram_word_similarity()
{
if (Conn is null) return;
var opts = Options();
var uid = await SeedAsync(opts);
try
{
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
var res = await new SearchService(ctx).SearchAsync(uid, GmailQueryParser.Parse("invoce", 1, 10));
res.TotalCount.Should().BeGreaterThan(0, "the typo should fuzzy-match via pg_trgm");
}
finally { await CleanupAsync(opts, uid); }
}
[Fact]
public async Task Pgvector_cosine_orders_nearest_embedding_first()
{
if (Conn is null) return;
var opts = Options();
var uid = await SeedAsync(opts);
try
{
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
var query = Vec(1, 0);
var ordered = await ctx.Emails
.Where(e => e.Embedding != null)
.OrderBy(e => e.Embedding!.CosineDistance(query))
.Select(e => e.Subject)
.ToListAsync();
ordered.Should().Equal("Invoice March", "Weekly notes");
}
finally { await CleanupAsync(opts, uid); }
}
}
@@ -0,0 +1,73 @@
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Retention;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// AUDIT H-3 (retention): the purge removes only what the configuration targets and is a
/// no-op while disabled — locking the "no behaviour change until opted in" guarantee.
/// </summary>
public class RetentionServiceTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId => Guid.Empty; // worker scope
public bool IsAuthenticated => false;
}
private static AppDbContext NewDb(string name) =>
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
private static async Task SeedAsync(AppDbContext db)
{
var user = Guid.NewGuid();
db.Emails.AddRange(
new Email { UserId = user, GmailMessageId = "old-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) },
new Email { UserId = user, GmailMessageId = "new-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-5) },
new Email { UserId = user, GmailMessageId = "old-kept", IsTrashed = false, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) });
await db.SaveChangesAsync();
}
private static RetentionService Sut(AppDbContext db, int trashedDays = 0, int allDays = 0) =>
new(db, Options.Create(new DataRetentionOptions
{
PurgeTrashedAfterDays = trashedDays,
PurgeAllAfterDays = allDays
}), NullLogger<RetentionService>.Instance);
[Fact]
public async Task Disabled_retention_purges_nothing()
{
using var db = NewDb(nameof(Disabled_retention_purges_nothing));
await SeedAsync(db);
(await Sut(db).PurgeAsync()).Should().Be(0);
(await db.Emails.CountAsync()).Should().Be(3);
}
[Fact]
public async Task Trashed_purge_removes_only_old_trashed_emails()
{
using var db = NewDb(nameof(Trashed_purge_removes_only_old_trashed_emails));
await SeedAsync(db);
(await Sut(db, trashedDays: 30).PurgeAsync()).Should().Be(1);
var remaining = await db.Emails.Select(e => e.GmailMessageId).ToListAsync();
remaining.Should().BeEquivalentTo(new[] { "new-trashed", "old-kept" });
}
[Fact]
public async Task Age_purge_removes_everything_past_the_cutoff()
{
using var db = NewDb(nameof(Age_purge_removes_everything_past_the_cutoff));
await SeedAsync(db);
(await Sut(db, allDays: 30).PurgeAsync()).Should().Be(2); // both 100-day-old emails
(await db.Emails.SingleAsync()).GmailMessageId.Should().Be("new-trashed");
}
}
@@ -0,0 +1,23 @@
using FluentAssertions;
using InboxIntel.Infrastructure.Ai;
using Xunit;
namespace InboxIntel.UnitTests;
/// <summary>
/// The Null embedding provider is the AI-off / no-model path. Semantic features must be able
/// to detect unavailability (IsAvailable=false) and fall back to lexical search — this locks
/// that contract so "AI is never required" can't silently regress.
/// </summary>
public class EmbeddingProviderTests
{
[Fact]
public async Task Null_provider_reports_unavailable_and_returns_empty_vectors()
{
var sut = new NullEmbeddingProvider();
sut.IsAvailable.Should().BeFalse();
(await sut.EmbedAsync("anything")).Should().BeEmpty();
(await sut.EmbedBatchAsync(new[] { "a", "b" })).Should().BeEmpty();
}
}