Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6a8302997 | |||
| 3333e19452 | |||
| 1e8feeef71 | |||
| 074d39d9a2 | |||
| b7d4b87d75 | |||
| fb6b89b6dc | |||
| 402628a3a7 | |||
| 5191dd010f | |||
| 7ec4f1ddb8 | |||
| a3d8654198 | |||
| 86ecf03963 | |||
| dc9d5fc301 | |||
| 59522a6e96 | |||
| b7f3a42812 | |||
| 7dbe1469d7 | |||
| 2b19bddf7b | |||
| b905c93884 | |||
| c5c33f7023 | |||
| 2056548702 | |||
| d78fe601ff | |||
| 06b050a5ed | |||
| c0c1777d3f | |||
| 9bbab5d32a | |||
| fcf290a83b |
@@ -0,0 +1,13 @@
|
||||
# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with
|
||||
# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git
|
||||
# stores LF, and the pre-commit/CI format gates flip-flop forever.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.cs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -15,3 +15,6 @@ FRONTEND_ORIGIN=http://localhost:8081
|
||||
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
|
||||
DEV_MODE=false
|
||||
MAX_MESSAGES=0
|
||||
|
||||
# Nightly DB backup rotation (days of dumps to keep in ./backups)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
+44
-2
@@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
- name: Restore
|
||||
run: dotnet restore InboxIntel.sln
|
||||
- name: Build
|
||||
@@ -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: '10.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: '10.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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Renovate
|
||||
|
||||
# RECOMMENDATIONS #2: automated dependency-update PRs (NuGet, npm, Dockerfiles, Actions)
|
||||
# that ride the existing required CI gates. Runs weekly + on demand.
|
||||
#
|
||||
# ONE-TIME SETUP (manual): create a Gitea personal access token with scopes
|
||||
# repo (rw) + user (r) + issue (rw) + organization (r), and add it as the Actions
|
||||
# secret RENOVATE_TOKEN (repo Settings -> Actions -> Secrets). Without the secret this
|
||||
# workflow fails fast with a clear message. See https://docs.renovatebot.com/modules/platform/gitea/
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 4 * * 1' # Mondays 04:30 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require RENOVATE_TOKEN
|
||||
run: |
|
||||
if [ -z "${{ secrets.RENOVATE_TOKEN }}" ]; then
|
||||
echo "RENOVATE_TOKEN secret is not set — see the comment at the top of this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Run Renovate
|
||||
uses: https://github.com/renovatebot/github-action@v40.3.6
|
||||
with:
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.cesnimda.uk/api/v1
|
||||
RENOVATE_REPOSITORIES: cesnimda/Inboxintel
|
||||
RENOVATE_ONBOARDING: "false"
|
||||
RENOVATE_REQUIRE_CONFIG: optional
|
||||
LOG_LEVEL: info
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
- name: Restore
|
||||
run: dotnet restore InboxIntel.sln
|
||||
- name: .NET vulnerable packages (fail on any)
|
||||
@@ -52,3 +52,21 @@ jobs:
|
||||
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
||||
working-directory: frontend
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
|
||||
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
|
||||
# don't look for. Advisory at first (not a required check); promote once tuned.
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The runner image lacks pip, and a semgrep job-container lacks the node that
|
||||
# actions/checkout needs — so install pip via apt on the standard image.
|
||||
- name: Install semgrep
|
||||
run: |
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
|
||||
pipx install semgrep
|
||||
- name: Semgrep scan
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
|
||||
|
||||
@@ -21,6 +21,9 @@ frontend/.vite/
|
||||
appsettings.*.local.json
|
||||
secrets.json
|
||||
|
||||
## DB backups (never commit dumps)
|
||||
backups/
|
||||
|
||||
## Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
+267
@@ -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 | S–M | 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 | S–M | 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 | S–M | 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
@@ -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: M–L** · **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: S–M** · **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: S–M** (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 | M–L | 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 | S–M | Perceived speed; last audit-perf leftover |
|
||||
| 8 | Keyset pagination | M | Scales search; roadmap item |
|
||||
| 9 | Semgrep SAST | S–M | 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).**
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# 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**: nightly `pg_dump` rotation runs via the compose `backup` service
|
||||
into `./backups/` (git-ignored) — keep that directory on an encrypted disk and copy it
|
||||
off-machine. Restore: `docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql`.
|
||||
- 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.
|
||||
+66
-1
@@ -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
|
||||
@@ -20,6 +22,37 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Nightly logical backups (RECOMMENDATIONS #3 — previously there were NONE). Dumps
|
||||
# rotate after BACKUP_KEEP_DAYS. The ./backups host directory should live on an
|
||||
# encrypted disk and be included in your off-machine backup regime (see SECURITY.md).
|
||||
# Restore: docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql
|
||||
backup:
|
||||
image: pgvector/pgvector:pg16
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
while true; do
|
||||
ts=$$(date -u +%Y%m%d-%H%M%S)
|
||||
if pg_dump -h postgres -U inboxintel -d inboxintel > /backups/inboxintel-$$ts.sql.tmp; then
|
||||
mv /backups/inboxintel-$$ts.sql.tmp /backups/inboxintel-$$ts.sql
|
||||
echo "backup OK: inboxintel-$$ts.sql"
|
||||
else
|
||||
rm -f /backups/inboxintel-$$ts.sql.tmp
|
||||
echo "backup FAILED at $$ts" >&2
|
||||
fi
|
||||
find /backups -name 'inboxintel-*.sql' -mtime +$${BACKUP_KEEP_DAYS:-7} -delete
|
||||
sleep 86400
|
||||
done
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
|
||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-7}
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -32,6 +65,10 @@ services:
|
||||
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
|
||||
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
||||
Ai__Mode: ${AI_MODE:-Disabled}
|
||||
# Points at the compose 'ollama' service when the ai profile is up; harmless otherwise.
|
||||
Ai__OllamaBaseUrl: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
# OTLP export activates only when set (e.g. http://lgtm:4317 with the observability profile).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
|
||||
# Dev mode shows the dev banner and caps the initial sync. Set DEV_MODE=true
|
||||
# and MAX_MESSAGES=1000 in deploy/.env to exercise it in this Docker setup.
|
||||
App__DevMode: ${DEV_MODE:-false}
|
||||
@@ -57,6 +94,33 @@ services:
|
||||
ports:
|
||||
- "8081:80"
|
||||
|
||||
# Local AI (semantic search + assistants). Enable with:
|
||||
# docker compose --profile ai up -d && set AI_MODE=LocalOllama in deploy/.env
|
||||
# First run: docker compose exec ollama ollama pull nomic-embed-text
|
||||
# GPU (RTX 3080): uncomment the deploy block to pass the GPU through.
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
profiles: ["ai"]
|
||||
volumes:
|
||||
- ollama:/root/.ollama
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
# Observability (RECOMMENDATIONS #5): all-in-one Grafana+Tempo+Prometheus+Loki.
|
||||
# Enable with: docker compose --profile observability up -d
|
||||
# then set OTEL_ENDPOINT=http://lgtm:4317 in deploy/.env and restart the api.
|
||||
# Grafana UI: http://localhost:3000 (admin/admin on first run).
|
||||
lgtm:
|
||||
image: grafana/otel-lgtm
|
||||
profiles: ["observability"]
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
# Optional reverse proxy. Enable with: docker compose --profile proxy up
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
@@ -72,3 +136,4 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
keys:
|
||||
ollama:
|
||||
|
||||
+2
-10
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+535
-1113
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"timezone": "Europe/Berlin",
|
||||
"schedule": ["before 6am on monday"],
|
||||
"labels": ["dependencies"],
|
||||
"prConcurrentLimit": 5,
|
||||
"commitMessagePrefix": "chore(deps):",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group safe minor+patch updates into one weekly PR per ecosystem",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "{{manager}} minor & patch"
|
||||
},
|
||||
{
|
||||
"description": "Major updates stay individual PRs for careful review",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"dependencyDashboardApproval": true
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"labels": ["security"],
|
||||
"schedule": ["at any time"]
|
||||
},
|
||||
"ignorePaths": ["**/node_modules/**", "**/bin/**", "**/obj/**"]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for the ASP.NET Core API.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy solution + project files first for layer-cached restore.
|
||||
@@ -13,7 +13,7 @@ RUN dotnet restore src/InboxIntel.Api/InboxIntel.Api.csproj
|
||||
COPY src/ src/
|
||||
RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<!-- Required on the startup project for `dotnet ef migrations` to work. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.3" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
|
||||
@@ -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,10 +10,16 @@ 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 Npgsql;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -23,9 +31,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(System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||
.LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
|
||||
}
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
@@ -57,6 +76,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 =>
|
||||
{
|
||||
@@ -89,6 +126,28 @@ builder.Services.AddAuthentication(options =>
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// RECOMMENDATIONS #5: OpenTelemetry traces + metrics (ASP.NET, outbound HTTP, Npgsql).
|
||||
// The OTLP exporter only activates when Otel:Endpoint (or the standard
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT env var) is configured — zero overhead otherwise.
|
||||
// Logs stay on Serilog. Pair with the compose "observability" profile (grafana/otel-lgtm).
|
||||
var otlpEndpoint = builder.Configuration["Otel:Endpoint"]
|
||||
?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
|
||||
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
|
||||
{
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService("inboxintel-api"))
|
||||
.WithTracing(t => t
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddNpgsql()
|
||||
.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)))
|
||||
.WithMetrics(m => m
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddNpgsqlInstrumentation()
|
||||
.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)));
|
||||
}
|
||||
|
||||
builder.Services.AddApiVersioning(o =>
|
||||
{
|
||||
o.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
@@ -98,6 +157,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 +203,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
|
||||
@@ -128,15 +227,14 @@ var forwardedOptions = new ForwardedHeadersOptions
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
|
||||
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
|
||||
};
|
||||
forwardedOptions.KnownNetworks.Clear();
|
||||
forwardedOptions.KnownIPNetworks.Clear();
|
||||
forwardedOptions.KnownProxies.Clear();
|
||||
var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>()
|
||||
?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" };
|
||||
foreach (var cidr in trustedNetworks)
|
||||
{
|
||||
var parts = cidr.Split('/');
|
||||
if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len))
|
||||
forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len));
|
||||
if (System.Net.IPNetwork.TryParse(cidr, out var network))
|
||||
forwardedOptions.KnownIPNetworks.Add(network);
|
||||
}
|
||||
app.UseForwardedHeaders(forwardedOptions);
|
||||
|
||||
@@ -169,9 +267,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -101,3 +120,15 @@ public interface IDigestService
|
||||
{
|
||||
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>System feature flags (fail-closed: unknown key = disabled).</summary>
|
||||
public interface IFeatureFlags
|
||||
{
|
||||
Task<bool> IsEnabledAsync(string key, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Policy gate for AI features: system flag AND the user's opt-in.</summary>
|
||||
public interface IAiGate
|
||||
{
|
||||
Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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 <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.
|
||||
/// </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>
|
||||
|
||||
@@ -23,4 +23,9 @@ public record SearchRequestDto(
|
||||
bool? IsTrashed = null,
|
||||
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
|
||||
string? Category = null, // EmailCategory name, e.g. "Finance"
|
||||
long? MinSizeBytes = null);
|
||||
long? MinSizeBytes = null,
|
||||
// Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last
|
||||
// row's SentAtUtc+Id to fetch the next window without OFFSET (O(pageSize), not O(page)).
|
||||
// When set, TotalCount is not recomputed (-1). Additive; offset paging still works.
|
||||
DateTimeOffset? AfterSentAtUtc = null,
|
||||
Guid? AfterId = null);
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="11.9.2" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
|
||||
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. -->
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using InboxIntel.Domain.Common;
|
||||
|
||||
namespace InboxIntel.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// System-wide feature flag (docs/discovery/multi-provider/04). The admin master switches:
|
||||
/// a disabled flag turns its feature off for EVERYONE regardless of user preferences.
|
||||
/// Reads are fail-closed — an unknown key counts as disabled.
|
||||
/// </summary>
|
||||
public class FeatureFlag : AuditableEntity
|
||||
{
|
||||
/// <summary>Stable key, e.g. "ai.enabled", "provider.google".</summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>True = a user preference may turn the feature OFF for themselves
|
||||
/// (never on beyond the flag); false = system-only switch.</summary>
|
||||
public bool UserOverridable { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-user preferences (docs/discovery/multi-provider/04). One row per user, created
|
||||
/// lazily; absent row = defaults. AiOptIn defaults true so enabling the ai.enabled flag
|
||||
/// behaves exactly like today until a user opts out.
|
||||
/// </summary>
|
||||
public class UserSetting : AuditableEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
/// <summary>"system" | "light" | "dark".</summary>
|
||||
public string Theme { get; set; } = "dark";
|
||||
|
||||
/// <summary>Master per-user AI opt-in (effective only while ai.enabled is on).</summary>
|
||||
public bool AiOptIn { get; set; } = true;
|
||||
|
||||
/// <summary>Free-form UI preferences (layout, density, notifications) as JSON.</summary>
|
||||
public string? PreferencesJson { get; set; }
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
<AssemblyName>InboxIntel.Domain</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- NpgsqlTypes.NpgsqlTsVector is used on the Email entity for FTS mapping. -->
|
||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
||||
<!-- 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="10.0.2" />
|
||||
<PackageReference Include="Pgvector" Version="0.3.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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Fills <c>Email.Embedding</c> (pgvector) for semantic search, in small background batches
|
||||
/// so interactive requests are never starved (per docs/discovery/06: embeddings are the
|
||||
/// small always-on model; the batch pause keeps VRAM/CPU pressure low). Exits immediately
|
||||
/// when the embedding provider is unavailable (AI disabled / Ollama down) — semantic search
|
||||
/// simply stays dormant and lexical search is unaffected.
|
||||
/// </summary>
|
||||
public class EmbeddingBackfillWorker : BackgroundService
|
||||
{
|
||||
private const int BatchSize = 32;
|
||||
private static readonly TimeSpan BatchPause = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan IdleRescan = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<EmbeddingBackfillWorker> _logger;
|
||||
|
||||
public EmbeddingBackfillWorker(IServiceScopeFactory scopeFactory, ILogger<EmbeddingBackfillWorker> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Provider availability is fixed by configuration for the process lifetime.
|
||||
using (var probe = _scopeFactory.CreateScope())
|
||||
{
|
||||
if (!probe.ServiceProvider.GetRequiredService<IEmbeddingProvider>().IsAvailable)
|
||||
{
|
||||
_logger.LogDebug("EmbeddingBackfillWorker idle: no embedding provider (AI disabled).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("EmbeddingBackfillWorker started (batch {Batch}, pause {Pause}s)",
|
||||
BatchSize, BatchPause.TotalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
int processed;
|
||||
try
|
||||
{
|
||||
processed = await ProcessBatchAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ollama hiccups must never crash the host; back off and retry.
|
||||
_logger.LogWarning(ex, "Embedding batch failed; retrying after idle pause.");
|
||||
processed = 0;
|
||||
}
|
||||
|
||||
await Task.Delay(processed > 0 ? BatchPause : IdleRescan, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Embeds one batch. Public-ish (internal) for direct testing.</summary>
|
||||
internal async Task<int> ProcessBatchAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var embeddings = scope.ServiceProvider.GetRequiredService<IEmbeddingProvider>();
|
||||
|
||||
var batch = await db.Emails
|
||||
.Where(e => e.Embedding == null)
|
||||
.OrderByDescending(e => e.SentAtUtc) // newest mail becomes searchable first
|
||||
.Take(BatchSize)
|
||||
.ToListAsync(ct);
|
||||
if (batch.Count == 0) return 0;
|
||||
|
||||
// Subject + snippet is the semantic core; bodies are noisy (signatures, quoting)
|
||||
// and slow to embed. Truncate defensively to keep well inside the model context.
|
||||
var texts = batch
|
||||
.Select(e => Truncate($"{e.Subject}\n{e.Snippet ?? e.BodyText}", 2000))
|
||||
.ToList();
|
||||
var vectors = await embeddings.EmbedBatchAsync(texts, ct);
|
||||
if (vectors.Count != batch.Count)
|
||||
{
|
||||
_logger.LogWarning("Embedding batch returned {Got} vectors for {Want} emails; skipping batch.",
|
||||
vectors.Count, batch.Count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
{
|
||||
if (vectors[i].Length == 0) continue; // provider soft-failure for one item
|
||||
batch[i].Embedding = new Pgvector.Vector(vectors[i]);
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogDebug("Embedded {Count} emails", batch.Count);
|
||||
return batch.Count;
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
|
||||
}
|
||||
@@ -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,15 +73,33 @@ 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>();
|
||||
// Feature flags + AI policy gate (docs/discovery/multi-provider/04). Cached 15s,
|
||||
// fail-closed. Admin toggle surface arrives with the multi-provider admin phase.
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<IFeatureFlags, Features.FeatureFlagService>();
|
||||
services.AddScoped<IAiGate, Features.AiGate>();
|
||||
// Semantic search: fills Email.Embedding in the background; no-ops when the
|
||||
// embedding provider is unavailable (AI disabled), so lexical search is unaffected.
|
||||
services.AddHostedService<EmbeddingBackfillWorker>();
|
||||
|
||||
// Background worker (daily incremental sync + aggregate refresh)
|
||||
services.AddHostedService<GmailSyncWorker>();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Features;
|
||||
|
||||
/// <summary>
|
||||
/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an
|
||||
/// admin toggle takes effect within seconds and per-request reads stay free.
|
||||
/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled.
|
||||
/// </summary>
|
||||
public class FeatureFlagService : IFeatureFlags
|
||||
{
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public FeatureFlagService(AppDbContext db, IMemoryCache cache)
|
||||
{
|
||||
_db = db;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<bool> IsEnabledAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var flags = await _cache.GetOrCreateAsync("feature-flags", async e =>
|
||||
{
|
||||
e.AbsoluteExpirationRelativeToNow = CacheTtl;
|
||||
return await _db.FeatureFlags.AsNoTracking()
|
||||
.ToDictionaryAsync(f => f.Key, f => f.Enabled, ct);
|
||||
});
|
||||
return flags is not null && flags.TryGetValue(key, out var enabled) && enabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // fail closed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user
|
||||
/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true,
|
||||
/// only consulted while the flag is on). Callers still check provider availability
|
||||
/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing.
|
||||
/// </summary>
|
||||
public class AiGate : IAiGate
|
||||
{
|
||||
public const string MasterFlag = "ai.enabled";
|
||||
private readonly IFeatureFlags _flags;
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AiGate(IFeatureFlags flags, AppDbContext db)
|
||||
{
|
||||
_flags = flags;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false;
|
||||
// Absent settings row = default opt-in true.
|
||||
var optIn = await _db.UserSettings.AsNoTracking()
|
||||
.Where(s => s.UserId == userId)
|
||||
.Select(s => (bool?)s.AiOptIn)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return optIn ?? true;
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,18 @@
|
||||
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
|
||||
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
|
||||
<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="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="Polly" Version="8.4.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2024.7.0" />
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
@@ -23,12 +24,15 @@
|
||||
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by
|
||||
EF Core / ASP.NET / DataProtection. Remove once the parent packages ship
|
||||
these versions transitively. -->
|
||||
<PackageReference Include="System.Text.Json" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
|
||||
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="InboxIntel.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NpgsqlTypes;
|
||||
|
||||
#nullable disable
|
||||
|
||||
+743
@@ -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", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+758
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+768
@@ -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", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+831
@@ -0,0 +1,831 @@
|
||||
// <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("20260702152850_FeatureFlagsAndUserSettings")]
|
||||
partial class FeatureFlagsAndUserSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.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.FeatureFlag", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (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.UserSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (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.UserSetting", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeatureFlagsAndUserSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "feature_flags",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||
UserOverridable = table.Column<bool>(type: "boolean", nullable: false),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_feature_flags", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "user_settings",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Theme = table.Column<string>(type: "text", nullable: false),
|
||||
AiOptIn = table.Column<bool>(type: "boolean", nullable: false),
|
||||
PreferencesJson = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_user_settings", x => x.UserId);
|
||||
table.ForeignKey(
|
||||
name: "FK_user_settings_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
// Behaviour-preserving defaults (docs/discovery/multi-provider/04): ai.enabled on
|
||||
// (AI availability still requires Ai:Mode + provider), Google on, others off.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO feature_flags ("Key", "Enabled", "UserOverridable", "Description", "CreatedAtUtc", "UpdatedAtUtc")
|
||||
VALUES
|
||||
('ai.enabled', TRUE, TRUE, 'Master AI switch: off hides AI for everyone', NOW(), NOW()),
|
||||
('provider.google', TRUE, FALSE, 'Google/Gmail provider', NOW(), NOW()),
|
||||
('provider.microsoft', FALSE, FALSE, 'Microsoft/Outlook provider (future)', NOW(), NOW()),
|
||||
('provider.imap', FALSE, FALSE, 'IMAP provider (future)', NOW(), NOW())
|
||||
ON CONFLICT ("Key") DO NOTHING;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "feature_flags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "user_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using NpgsqlTypes;
|
||||
using Pgvector;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@@ -18,9 +19,11 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.4")
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.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");
|
||||
@@ -232,6 +243,32 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.ToTable("email_labels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("UserOverridable")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("feature_flags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -300,6 +337,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 +436,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")
|
||||
@@ -565,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("AiOptIn")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PreferencesJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Theme")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("user_settings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -691,6 +769,17 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
b.Navigation("Sender");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||
.WithOne()
|
||||
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||
{
|
||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext
|
||||
public DbSet<Email> Emails => Set<Email>();
|
||||
public DbSet<MailThread> Threads => Set<MailThread>();
|
||||
public DbSet<Sender> Senders => Set<Sender>();
|
||||
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
|
||||
public DbSet<UserSetting> UserSettings => Set<UserSetting>();
|
||||
public DbSet<MailDomain> Domains => Set<MailDomain>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<Label> Labels => Set<Label>();
|
||||
@@ -47,16 +49,36 @@ public class AppDbContext : DbContext, IAppDbContext
|
||||
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
|
||||
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered
|
||||
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
|
||||
modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
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);
|
||||
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);
|
||||
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Email>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Sender>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailThread>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<MailDomain>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Attachment>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<Label>().HasQueryFilter("Tenant", 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("Tenant", el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<SyncState>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
modelBuilder.Entity<UserSetting>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||
|
||||
// Feature flags are system-wide (no tenant filter). Key is the natural PK.
|
||||
modelBuilder.Entity<FeatureFlag>(b =>
|
||||
{
|
||||
b.ToTable("feature_flags");
|
||||
b.HasKey(f => f.Key);
|
||||
b.Property(f => f.Key).HasMaxLength(128);
|
||||
});
|
||||
modelBuilder.Entity<UserSetting>(b =>
|
||||
{
|
||||
b.ToTable("user_settings");
|
||||
b.HasKey(x => x.UserId);
|
||||
// 1:1 with User sharing the PK — prevents a shadow UserId1 FK column.
|
||||
b.HasOne(x => x.User).WithOne().HasForeignKey<UserSetting>(x => x.UserId);
|
||||
});
|
||||
|
||||
// PostgreSQL full-text search: generated tsvector over subject + body with a
|
||||
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
|
||||
@@ -65,6 +87,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 +97,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,9 +1,11 @@
|
||||
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;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Search;
|
||||
|
||||
@@ -17,7 +19,13 @@ namespace InboxIntel.Infrastructure.Search;
|
||||
public class SearchService : ISearchService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public SearchService(AppDbContext db) => _db = db;
|
||||
private readonly IEmbeddingProvider? _embeddings;
|
||||
|
||||
public SearchService(AppDbContext db, IEmbeddingProvider? embeddings = null)
|
||||
{
|
||||
_db = db;
|
||||
_embeddings = embeddings;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
|
||||
{
|
||||
@@ -64,30 +72,73 @@ 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);
|
||||
|
||||
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.
|
||||
List<EmailSummaryDto> items;
|
||||
if (hasFreeTextQuery)
|
||||
// Hybrid semantic fusion (docs/discovery/05): when embeddings are available, fuse
|
||||
// lexical top-K with vector top-K via Reciprocal Rank Fusion. Runs BEFORE the fuzzy
|
||||
// fallback so a query with ZERO lexical hits (pure semantic recall — "gym receipt"
|
||||
// phrased differently) still surfaces results. Exact lexical hits keep winning (they
|
||||
// rank in both lists). Deeper pages fall through to lexical paging; any failure
|
||||
// (Ollama down, nothing embedded yet) silently degrades to the lexical/fuzzy path.
|
||||
if (hasFreeTextQuery && _embeddings is { IsAvailable: true })
|
||||
{
|
||||
// "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).
|
||||
var hybrid = await TryHybridAsync(structured, matched, term, total, r, ct);
|
||||
if (hybrid is not null) return hybrid;
|
||||
}
|
||||
|
||||
// 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).ThenByDescending(e => e.Id);
|
||||
|
||||
// Keyset (cursor) pagination for the browse path: O(pageSize) regardless of depth,
|
||||
// vs OFFSET's O(page*pageSize). The (SentAtUtc, Id) pair with the Id tie-break above
|
||||
// makes the ordering total, so windows never duplicate or skip rows.
|
||||
IQueryable<Email> paged;
|
||||
if (!hasFreeTextQuery && r is { AfterSentAtUtc: { } afterAt, AfterId: { } afterId })
|
||||
{
|
||||
paged = ranked
|
||||
.Where(e => e.SentAtUtc < afterAt || (e.SentAtUtc == afterAt && e.Id.CompareTo(afterId) < 0))
|
||||
.Take(r.PageSize);
|
||||
total = -1; // not recomputed on cursor windows (that's the point)
|
||||
}
|
||||
else
|
||||
{
|
||||
paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
||||
}
|
||||
|
||||
// "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 && !fuzzy)
|
||||
{
|
||||
// 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
|
||||
@@ -119,4 +170,73 @@ public class SearchService : ISearchService
|
||||
TotalCount = total
|
||||
};
|
||||
}
|
||||
|
||||
private const int HybridK = 50; // candidates taken from each layer
|
||||
private const int RrfConstant = 60; // standard RRF dampening constant
|
||||
|
||||
/// <summary>
|
||||
/// RRF fusion of lexical and vector candidates. Returns null when the requested page
|
||||
/// lies beyond the fused window or anything fails — caller falls back to lexical.
|
||||
/// </summary>
|
||||
private async Task<PagedResult<EmailSummaryDto>?> TryHybridAsync(
|
||||
IQueryable<Email> structured, IQueryable<Email> lexical, string term, int lexicalTotal,
|
||||
SearchRequestDto r, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lexIds = await lexical
|
||||
.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
||||
.ThenByDescending(e => e.SentAtUtc)
|
||||
.Take(HybridK).Select(e => e.Id).ToListAsync(ct);
|
||||
|
||||
var queryVec = await _embeddings!.EmbedAsync(term, ct);
|
||||
List<Guid> vecIds = new();
|
||||
if (queryVec.Length > 0)
|
||||
{
|
||||
var qv = new Pgvector.Vector(queryVec);
|
||||
vecIds = await structured
|
||||
.Where(e => e.Embedding != null)
|
||||
.OrderBy(e => e.Embedding!.CosineDistance(qv))
|
||||
.Take(HybridK).Select(e => e.Id).ToListAsync(ct);
|
||||
}
|
||||
if (vecIds.Count == 0) return null; // nothing embedded yet → lexical path
|
||||
|
||||
var scores = new Dictionary<Guid, double>();
|
||||
for (var i = 0; i < lexIds.Count; i++)
|
||||
scores[lexIds[i]] = scores.GetValueOrDefault(lexIds[i]) + 1.0 / (RrfConstant + i + 1);
|
||||
for (var i = 0; i < vecIds.Count; i++)
|
||||
scores[vecIds[i]] = scores.GetValueOrDefault(vecIds[i]) + 1.0 / (RrfConstant + i + 1);
|
||||
|
||||
var fused = scores.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToList();
|
||||
var pageIds = fused.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize).ToList();
|
||||
if (pageIds.Count == 0 && r.Page > 1) return null; // deep page → lexical paging
|
||||
|
||||
var headlineOpts =
|
||||
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
|
||||
var rows = await _db.Emails.AsNoTracking()
|
||||
.Where(e => pageIds.Contains(e.Id))
|
||||
.Select(e => new EmailSummaryDto(
|
||||
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
|
||||
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
|
||||
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
|
||||
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
|
||||
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
|
||||
.ToListAsync(ct);
|
||||
var byId = rows.ToDictionary(x => x.Id);
|
||||
var items = pageIds.Where(byId.ContainsKey).Select(id => byId[id]).ToList();
|
||||
|
||||
return new PagedResult<EmailSummaryDto>
|
||||
{
|
||||
Items = items,
|
||||
Page = r.Page,
|
||||
PageSize = r.PageSize,
|
||||
// Semantic recall can exceed the lexical match count.
|
||||
TotalCount = Math.Max(lexicalTotal, fused.Count)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // AI must never break search — degrade to lexical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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 new 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",
|
||||
// Npgsql 10 eagerly validates the connection string when the DbContext is
|
||||
// resolved (8.x was lazy); these tests never connect, but the string must parse.
|
||||
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
|
||||
["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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ public class TestAppFactory : WebApplicationFactory<Program>
|
||||
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Database:AutoMigrate"] = "false",
|
||||
// Npgsql 10 eagerly validates the connection string when the DbContext is
|
||||
// resolved (8.x was lazy); these tests never connect, but the string must parse.
|
||||
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
|
||||
// Dummy OAuth creds so the Google challenge produces a real 302 redirect
|
||||
// (an empty ClientId can make the handler throw instead of redirecting).
|
||||
["GoogleOAuth:ClientId"] = "test-client-id",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Ai;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic-search backfill: the worker embeds emails lacking an Embedding and persists the
|
||||
/// vectors. Runs against live Postgres (pgvector) since the Embedding column is ignored under
|
||||
/// the InMemory provider; the CI db-tests job provides the database. Uses a deterministic fake
|
||||
/// embedding provider — real-Ollama integration is verified separately (endpoint contract:
|
||||
/// /api/embeddings, 768 dims).
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveDb")]
|
||||
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
|
||||
public class EmbeddingBackfillTests
|
||||
{
|
||||
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
|
||||
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId => Guid.Empty; // worker scope sees all rows
|
||||
public bool IsAuthenticated => false;
|
||||
}
|
||||
|
||||
private sealed class FakeEmbeddings : IEmbeddingProvider
|
||||
{
|
||||
public bool IsAvailable => true;
|
||||
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
||||
=> Task.FromResult(Vec(text));
|
||||
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<float[]>>(texts.Select(Vec).ToList());
|
||||
private static float[] Vec(string text)
|
||||
{
|
||||
var v = new float[768];
|
||||
v[0] = text.Length; // deterministic, content-dependent
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_embeds_pending_emails_and_persists_vectors()
|
||||
{
|
||||
if (Conn is null) return; // soft-skip outside the live-db CI job
|
||||
var uid = Guid.NewGuid();
|
||||
var opts = new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(Conn!, o => o.UseVector()).Options;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<ICurrentUser, FakeCurrentUser>();
|
||||
services.AddScoped(_ => new AppDbContext(opts, new FakeCurrentUser()));
|
||||
services.AddScoped<IEmbeddingProvider, FakeEmbeddings>();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
await seed.Database.MigrateAsync();
|
||||
seed.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", Domain = dom };
|
||||
var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid };
|
||||
seed.AddRange(dom, snd, thr);
|
||||
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e1" + uid, Subject = "hello world", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
|
||||
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e2" + uid, Subject = "quarterly invoice", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
try
|
||||
{
|
||||
var worker = new EmbeddingBackfillWorker(
|
||||
sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<EmbeddingBackfillWorker>.Instance);
|
||||
var processed = await worker.ProcessBatchAsync(CancellationToken.None);
|
||||
processed.Should().BeGreaterThanOrEqualTo(2);
|
||||
|
||||
using var check = new AppDbContext(opts, new FakeCurrentUser());
|
||||
var mine = await check.Emails.Where(e => e.UserId == uid).ToListAsync();
|
||||
mine.Should().OnlyContain(e => e.Embedding != null);
|
||||
mine.First().Embedding!.ToArray().Length.Should().Be(768);
|
||||
}
|
||||
finally
|
||||
{
|
||||
using var c = new AppDbContext(opts, new FakeCurrentUser());
|
||||
await c.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync();
|
||||
await c.Users.Where(u => u.Id == uid).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Features;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Feature-flag + AI-gate contracts (docs/discovery/multi-provider/04):
|
||||
/// flags are FAIL-CLOSED, and the admin master switch (ai.enabled) beats any user opt-in.
|
||||
/// </summary>
|
||||
public class FeatureFlagTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
private static AppDbContext NewDb(string name) =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
|
||||
|
||||
private static FeatureFlagService Flags(AppDbContext db) =>
|
||||
new(db, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_flag_is_disabled_fail_closed()
|
||||
{
|
||||
using var db = NewDb(nameof(Unknown_flag_is_disabled_fail_closed));
|
||||
(await Flags(db).IsEnabledAsync("does.not.exist")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Enabled_flag_reads_true_disabled_reads_false()
|
||||
{
|
||||
using var db = NewDb(nameof(Enabled_flag_reads_true_disabled_reads_false));
|
||||
db.FeatureFlags.AddRange(
|
||||
new FeatureFlag { Key = "on.flag", Enabled = true },
|
||||
new FeatureFlag { Key = "off.flag", Enabled = false });
|
||||
await db.SaveChangesAsync();
|
||||
var flags = Flags(db);
|
||||
(await flags.IsEnabledAsync("on.flag")).Should().BeTrue();
|
||||
(await flags.IsEnabledAsync("off.flag")).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ai_gate_master_flag_off_beats_user_opt_in()
|
||||
{
|
||||
using var db = NewDb(nameof(Ai_gate_master_flag_off_beats_user_opt_in));
|
||||
var user = Guid.NewGuid();
|
||||
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = false });
|
||||
db.UserSettings.Add(new UserSetting { UserId = user, AiOptIn = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
(await new AiGate(Flags(db), db).IsAiEnabledForUserAsync(user)).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out()
|
||||
{
|
||||
using var db = NewDb(nameof(Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out));
|
||||
var optedOut = Guid.NewGuid();
|
||||
var noRow = Guid.NewGuid();
|
||||
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = true });
|
||||
db.UserSettings.Add(new UserSetting { UserId = optedOut, AiOptIn = false });
|
||||
await db.SaveChangesAsync();
|
||||
var gate = new AiGate(Flags(db), db);
|
||||
|
||||
(await gate.IsAiEnabledForUserAsync(noRow)).Should().BeTrue("no settings row = default opt-in");
|
||||
(await gate.IsAiEnabledForUserAsync(optedOut)).Should().BeFalse("explicit opt-out wins while the flag is on");
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Application.DTOs;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using InboxIntel.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// RECOMMENDATIONS #8: keyset (cursor) pagination for the browse path — the window after a
|
||||
/// (SentAtUtc, Id) cursor returns the next rows with no duplicates/skips and no OFFSET scan.
|
||||
/// </summary>
|
||||
public class KeysetPaginationTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cursor_window_continues_exactly_after_the_previous_page()
|
||||
{
|
||||
var user = Guid.NewGuid();
|
||||
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(nameof(Cursor_window_continues_exactly_after_the_previous_page)).Options;
|
||||
|
||||
var baseline = DateTimeOffset.UtcNow;
|
||||
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
var sender = new Sender { UserId = user, Address = "s@x.x" };
|
||||
seed.Senders.Add(sender);
|
||||
for (var i = 0; i < 5; i++)
|
||||
seed.Emails.Add(new Email { UserId = user, GmailMessageId = $"m{i}", Sender = sender, SentAtUtc = baseline.AddMinutes(-i) });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user });
|
||||
var svc = new SearchService(ctx);
|
||||
|
||||
// First window via offset (page 1, size 2): m0, m1 (newest first).
|
||||
var page1 = await svc.SearchAsync(user, new SearchRequestDto(null, null, null, null, null, null, null, false, 1, 2));
|
||||
page1.Items.Select(i => i.GmailMessageId).Should().Equal("m0", "m1");
|
||||
|
||||
// Next window via cursor from the last row of page 1.
|
||||
var last = page1.Items[^1];
|
||||
var page2 = await svc.SearchAsync(user, new SearchRequestDto(
|
||||
null, null, null, null, null, null, null, false, 1, 2,
|
||||
AfterSentAtUtc: last.SentAtUtc, AfterId: last.Id));
|
||||
|
||||
page2.Items.Select(i => i.GmailMessageId).Should().Equal("m2", "m3"); // no dupes, no skips
|
||||
page2.TotalCount.Should().Be(-1, "cursor windows skip the COUNT — that's the perf win");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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")]
|
||||
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
|
||||
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); }
|
||||
}
|
||||
|
||||
private sealed class DirectionalFakeEmbeddings : IEmbeddingProvider
|
||||
{
|
||||
public bool IsAvailable => true;
|
||||
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
||||
{
|
||||
// Deterministic "semantics": anything fruit-flavoured points one way, else the other.
|
||||
var v = new float[768];
|
||||
if (text.Contains("banana") || text.Contains("tropical")) v[0] = 1; else v[1] = 1;
|
||||
return Task.FromResult(v);
|
||||
}
|
||||
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
||||
{
|
||||
var list = new List<float[]>();
|
||||
foreach (var t in texts) list.Add(await EmbedAsync(t, ct));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hybrid_search_surfaces_semantic_match_with_zero_keyword_overlap()
|
||||
{
|
||||
if (Conn is null) return;
|
||||
var opts = Options();
|
||||
var uid = await SeedAsync(opts);
|
||||
try
|
||||
{
|
||||
var embeddings = new DirectionalFakeEmbeddings();
|
||||
using (var prep = new AppDbContext(opts, new FakeCurrentUser()))
|
||||
{
|
||||
// "Weekly notes" gets a fruit-direction embedding (semantically related to the
|
||||
// query); "Invoice March" points elsewhere. Neither subject contains "banana".
|
||||
var near = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Weekly notes");
|
||||
near.Embedding = new Vector(await embeddings.EmbedAsync("tropical"));
|
||||
var far = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Invoice March");
|
||||
far.Embedding = new Vector(await embeddings.EmbedAsync("finance"));
|
||||
await prep.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
|
||||
var res = await new SearchService(ctx, embeddings)
|
||||
.SearchAsync(uid, GmailQueryParser.Parse("banana", 1, 10));
|
||||
|
||||
// Zero lexical hits for "banana" — hybrid must still surface the semantically
|
||||
// nearest email, ranked first.
|
||||
res.Items.Should().NotBeEmpty("semantic recall should fire with zero keyword overlap");
|
||||
res.Items[0].Subject.Should().Be("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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user