Compare commits

...

14 Commits

Author SHA1 Message Date
cesnimda 18acd9f6e8 feat(search): 'why this matched' highlights (ts_headline)
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 13s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 51s
Slice 3 of search-core. Search results now show WHY they matched: a ts_headline
body fragment with matched terms highlighted. The API returns U+E000/U+E001
sentinels around matches (NOT HTML); the client tokenises them into escaped
<mark> React elements, so untrusted email content can never inject markup (no
dangerouslySetInnerHTML). Subtle accent tint. Only when there's a free-text query
(browse unchanged); projection branched (no DB function in a ternary) for
unambiguous EF translation.

Verified: build + all 40 tests; ts_headline output shape in raw Postgres; and the
ranked+headline EF query proven to translate + execute against a live Postgres via
a throwaway test (removed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:14:15 +02:00
cesnimda 9545f94461 feat(search): weight subject above body in the FTS vector
CI / backend (pull_request) Successful in 1m16s
CI / frontend (pull_request) Successful in 27s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m14s
Slice 2 of search-core: the generated SearchVector now applies setweight 'A' to
the subject and 'B' to the body, so ts_rank_cd (added last slice) ranks a subject
match above a body-only mention -- what users expect. Adds an EF migration that
drops/recreates the stored generated column + GIN index (Npgsql emits this
correctly and transactionally).

Verified: the migration DDL was dry-run against the live staging Postgres
(schema + real data) inside a rolled-back transaction -- applies cleanly; and the
weighted ranking (subject 1.0 vs body 0.4 for the same term) was confirmed
directly in Postgres. Build + all 40 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:24:03 +02:00
cesnimda a9be48daee feat(search): relevance ranking + websearch_to_tsquery (#13)
CI / backend (push) Successful in 1m13s
CI / frontend (push) Successful in 27s
Deploy Staging / deploy (push) Successful in 44s
CI / backend (pull_request) Successful in 1m12s
CI / frontend (pull_request) Successful in 21s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m3s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m3s
2026-07-01 21:08:53 +02:00
cesnimda dfd8579899 feat(ui): discoverable-first search placeholder (#12)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Successful in 32s
CI / backend (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m16s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m12s
2026-07-01 20:35:13 +02:00
cesnimda 4d6f10c707 feat(ui): reduced-motion + focus rings (#11)
CI / backend (push) Successful in 1m7s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Successful in 30s
CI / backend (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 22s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m4s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m11s
2026-07-01 20:23:42 +02:00
cesnimda dde23c980c feat(ui): Inter + design-system preview page (#10)
CI / backend (push) Successful in 1m11s
CI / frontend (push) Successful in 28s
Deploy Staging / deploy (push) Successful in 1m9s
CI / backend (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 23s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m6s
2026-07-01 20:14:51 +02:00
cesnimda e482d60b69 feat(ui): design-system v2 tokens (#9)
CI / backend (push) Successful in 53s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 24s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m22s
CI / backend (pull_request) Successful in 1m1s
CI / frontend (pull_request) Successful in 18s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m6s
2026-07-01 20:01:43 +02:00
cesnimda 4ce2df0a2b docs: discovery blueprint + multi-provider design (#8)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
2026-07-01 19:59:53 +02:00
cesnimda 9ee5d757f5 docs(changelog): note health gate + verified end-to-end pipeline (#7)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 16s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 14s
Deploy Staging / deploy (push) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 53s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 53s
Co-authored-by: cesnimda <connor.babbington@cesnimda.co.uk>
Reviewed-on: #7
2026-07-01 17:20:34 +02:00
cesnimda 045be2bb81 Add post-deploy health gate to staging (#5)
CI / backend (push) Successful in 1m0s
CI / frontend (push) Successful in 19s
Deploy Staging / deploy (push) Successful in 19s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m1s
CI / backend (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 19s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m1s
2026-07-01 16:08:27 +02:00
cesnimda 11e6768d17 Fix staging deploy: ASCII-only echo (#4)
Deploy Staging / deploy (push) Successful in 21s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m3s
CI / backend (push) Successful in 1m0s
CI / frontend (push) Successful in 18s
2026-07-01 13:16:31 +02:00
cesnimda d9a07a01b2 Fix staging auto-deploy: env write + port isolation (#3)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Failing after 24s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m4s
2026-07-01 12:50:12 +02:00
cesnimda 8727be9e94 Automated staging deploy: build env from secrets (#2)
CI / backend (push) Successful in 1m18s
CI / frontend (push) Successful in 27s
Deploy Staging / deploy (push) Failing after 1m5s
Security / secrets (push) Successful in 8s
Security / dependencies (push) Successful in 1m8s
2026-07-01 12:35:58 +02:00
cesnimda ae8e6b672e Git workflow, environments & CI/CD pipeline (#1)
CI / backend (push) Successful in 1m14s
CI / frontend (push) Successful in 28s
Security / secrets (push) Successful in 6s
Security / dependencies (push) Successful in 1m14s
Deploy Staging / deploy (push) Failing after 43s
2026-07-01 11:44:34 +02:00
64 changed files with 3944 additions and 152 deletions
+17
View File
@@ -0,0 +1,17 @@
# Copy to deploy/.env.staging and fill in. Used by:
# docker compose -p inboxintel-staging --env-file deploy/.env.staging \
# -f docker-compose.yml -f docker-compose.staging.yml up
# (or ./deploy/up.ps1 -Staging). Kept separate from deploy/.env (production) so
# staging can never touch prod credentials, DB, or Google project.
POSTGRES_PASSWORD=change-me-staging
# Use a SEPARATE Google OAuth client for staging with redirect URI:
# http://localhost:18081/signin-google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AI_MODE=Disabled
FRONTEND_ORIGIN=http://localhost:18081
# Staging caps the initial mailbox sync so rehearsals are fast.
MAX_MESSAGES=2000
+45
View File
@@ -0,0 +1,45 @@
name: Deploy Production
# Production promotion. The APPROVAL GATE is the git tag: production only ever
# deploys a tagged release cut on main (see docs/WORKFLOW.md §5). Cutting the tag
# is the deliberate, auditable "approve to go live" action — and the tag doubles
# as the rollback target. workflow_dispatch adds a manual "Run workflow" button
# for re-deploys/rollbacks.
#
# STATUS: inactive until (a) the Linux production server exists and (b) a
# self-hosted Gitea runner is registered on it with labels [self-hosted, production].
# Tag pushes before then will queue harmlessly. This file is the wiring, ready to
# switch on — review the deploy step for your server before first use.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
ref:
description: 'Tag or commit to deploy (e.g. v1.2.0)'
required: true
jobs:
deploy:
runs-on: [self-hosted, production]
steps:
- uses: actions/checkout@v4
with:
# Deploy the exact tag that triggered the run (immutable), or the
# ref given to a manual dispatch.
ref: ${{ github.event.inputs.ref || github.ref_name }}
fetch-depth: 0
- name: Deploy release to production
run: |
echo "Deploying ${{ github.event.inputs.ref || github.ref_name }} to production"
# deploy/.env lives on the server (never in git). up.sh validates it,
# builds the Linux images, applies EF migrations on boot, and starts
# the stack behind the nginx reverse proxy.
./deploy/up.sh --proxy
- name: Smoke check
run: |
sleep 5
curl -fsS http://localhost/ >/dev/null && echo "Prod responding on :80" || \
{ echo "Smoke check failed"; exit 1; }
+70
View File
@@ -0,0 +1,70 @@
name: Deploy Staging
# Continuous deployment to the LOCAL staging stack. Fires when develop advances
# (i.e. after a PR is merged into develop), rebuilding and restarting the isolated
# staging stack on this machine.
#
# Runs on the self-hosted host-mode runner (labels: self-hosted, windows) so it can
# reach the host's Docker and publish to localhost:18081. Because the runner does a
# fresh checkout that (correctly) does NOT contain the git-ignored deploy/.env.staging,
# the staging secrets are supplied as Gitea Actions secrets and the env file is
# regenerated here at deploy time.
on:
push:
branches: [develop]
workflow_dispatch: {} # also allow a manual "Run workflow" from the Gitea UI
jobs:
deploy:
runs-on: [self-hosted, windows]
steps:
- uses: actions/checkout@v4
- name: Write staging env from secrets
shell: powershell
# Per-line writes (not a here-string) so the step can't be broken by how the
# runner indents/wraps the script. Gitea substitutes ${{ secrets.* }} first;
# ascii = no BOM, which docker compose's env parser needs.
run: |
Set-Content deploy/.env.staging "POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}" -Encoding ascii
Add-Content deploy/.env.staging "GOOGLE_CLIENT_ID=${{ secrets.STAGING_GOOGLE_CLIENT_ID }}" -Encoding ascii
Add-Content deploy/.env.staging "GOOGLE_CLIENT_SECRET=${{ secrets.STAGING_GOOGLE_CLIENT_SECRET }}" -Encoding ascii
Add-Content deploy/.env.staging "AI_MODE=Disabled" -Encoding ascii
Add-Content deploy/.env.staging "FRONTEND_ORIGIN=http://localhost:18081" -Encoding ascii
Add-Content deploy/.env.staging "MAX_MESSAGES=2000" -Encoding ascii
Write-Host "wrote $((Get-Content deploy/.env.staging).Count) env lines"
- name: Redeploy staging stack
shell: powershell
run: |
docker compose -p inboxintel-staging `
--env-file deploy/.env.staging `
-f docker-compose.yml -f docker-compose.staging.yml `
up -d --build
docker compose -p inboxintel-staging ps
- name: Verify staging health
shell: powershell
# 'up -d' returns as soon as containers START, so a container that crashes
# on boot (e.g. bad DB password) would still report success. Poll the actual
# endpoints and fail the job if either isn't serving, dumping api logs so the
# cause is visible in the run. ASCII only (Windows PowerShell reads .ps1 as ANSI).
run: |
$ok = $false
foreach ($i in 1..20) {
Start-Sleep -Seconds 3
try { Invoke-WebRequest "http://localhost:18081/" -UseBasicParsing -TimeoutSec 5 | Out-Null; $fe = 200 }
catch { $fe = 0 }
# A 401 from the api means it is serving (auth enforced); Invoke-WebRequest
# throws on non-2xx, so read the status code off the exception.
try { Invoke-WebRequest "http://localhost:18080/api/v1/auth/me" -UseBasicParsing -TimeoutSec 5 | Out-Null; $api = 200 }
catch { $api = $_.Exception.Response.StatusCode.value__; if (-not $api) { $api = 0 } }
Write-Host "attempt $i - frontend=$fe api=$api"
if ($fe -eq 200 -and $api -gt 0) { $ok = $true; break }
}
if (-not $ok) {
Write-Host "Staging health check FAILED. Last 40 api log lines:"
docker logs inboxintel-staging-api-1 --tail 40
exit 1
}
Write-Host "Staging healthy - Frontend http://localhost:18081 API http://localhost:18080"
+54
View File
@@ -0,0 +1,54 @@
name: Security
# Scans run alongside CI on every PR and on pushes to the long-lived branches.
# This is the DETECTIVE layer (backstop). The PREVENTIVE layer is the local
# pre-commit hook — this catches anything that slipped past it (e.g. --no-verify)
# and scans the full history, not just the staged diff.
on:
push:
branches: [main, develop]
pull_request:
jobs:
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so gitleaks scans every commit
- name: Secret scan (gitleaks)
# Run the binary directly — the container-mode runner has no Docker socket,
# so `docker run` isn't available inside a job.
run: |
GITLEAKS_VERSION=8.18.4
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" -o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
/tmp/gitleaks detect --source=. --redact --verbose --exit-code=1
dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore InboxIntel.sln
- name: .NET vulnerable packages (fail on any)
# Match dotnet's own "has the following vulnerable packages" line rather
# than raw severity words, so package/project names can't false-positive.
run: |
dotnet list InboxIntel.sln package --vulnerable --include-transitive 2>&1 | tee vuln.txt
if grep -q "has the following vulnerable" vuln.txt; then
echo "::error::Vulnerable NuGet packages detected — see the table above."
exit 1
fi
echo "No vulnerable NuGet packages."
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: npm audit (production deps, fail on high/critical)
# Only production dependencies ship to users; dev-only toolchain advisories
# (Vite/PostCSS/etc.) shouldn't block a merge.
working-directory: frontend
run: npm audit --omit=dev --audit-level=high
+1
View File
@@ -54,6 +54,7 @@ lpt[1-9].*
*.code-workspace *.code-workspace
.env.* .env.*
!.env.example !.env.example
!.env.staging.example
.next/ .next/
dist/ dist/
build/ build/
+36
View File
@@ -0,0 +1,36 @@
# Changelog
All notable changes to InboxIntel are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[Semantic Versioning](https://semver.org/). See [docs/WORKFLOW.md](docs/WORKFLOW.md).
## [Unreleased]
### Security
- Patched all High/Moderate NuGet advisories the new dependency gate surfaced:
`System.Text.Json` 8.0.0→8.0.6, `Microsoft.Extensions.Caching.Memory` 8.0.0→8.0.1
(+ `DependencyInjection.Abstractions`→8.0.2), `System.Security.Cryptography.Xml`
8.0.1→8.0.3 (transitive pins), and `MailKit`/`MimeKit` 4.13.0→4.17.0.
### Added
- CI/CD pipeline (`.gitea/workflows/`): `security` (gitleaks secret scan + NuGet/npm
vulnerability gate), `deploy-staging` (auto-redeploy local staging on `develop`,
with a post-deploy health gate), `deploy-prod` (tag-gated production promotion,
inactive until the server exists). Verified end-to-end on the self-hosted runner.
- Formal Git workflow & environment strategy (`docs/WORKFLOW.md`).
- Staging environment overlay (`docker-compose.staging.yml`) — production-shaped
Linux containers on Windows, isolated ports/volumes.
- Version-controlled Git hooks (`scripts/git-hooks/`) + installer
(`scripts/install-hooks.ps1`): pre-commit secret/format checks, pre-push
build+test gate.
- `VERSION` file as the single source of truth for the release number.
## [0.1.0] — scaffold
### Added
- .NET 8 Clean Architecture backend (Domain/Application/Infrastructure/Api) + React/Vite SPA.
- Docker Compose stack (Postgres 16, API, frontend, optional nginx proxy).
- Gitea Actions CI (backend build+test, frontend build) on `main`/`develop` + PRs.
- Security hardening: encrypted OAuth tokens, EF global query filters (IDOR),
loopback binds, non-root containers, SSRF egress guard.
- One-command deploy scripts (`deploy/up.ps1`, `deploy/up.sh`).
[Unreleased]: https://your-gitea-host/InboxIntel/compare/v0.1.0...HEAD
+1
View File
@@ -0,0 +1 @@
0.1.0
+12 -3
View File
@@ -5,13 +5,22 @@
./deploy/down.ps1 ./deploy/down.ps1
./deploy/down.ps1 -Volumes ./deploy/down.ps1 -Volumes
#> #>
param([switch]$Volumes) param([switch]$Volumes, [switch]$Staging)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $PSScriptRoot '.env'
$composeArgs = @('compose', '--env-file', $envFile, 'down') if ($Staging) {
$envFile = Join-Path $PSScriptRoot '.env.staging'
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
$project = @('-p', 'inboxintel-staging')
} else {
$envFile = Join-Path $PSScriptRoot '.env'
$composeFiles = @()
$project = @()
}
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles + @('down')
if ($Volumes) { $composeArgs += '--volumes' } if ($Volumes) { $composeArgs += '--volumes' }
Push-Location $root Push-Location $root
+23 -6
View File
@@ -8,16 +8,29 @@
#> #>
param( param(
[switch]$Proxy, [switch]$Proxy,
[switch]$Foreground [switch]$Foreground,
[switch]$Staging # production-shaped staging stack: separate env, ports, volumes
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down) $root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
$envFile = Join-Path $PSScriptRoot '.env'
# Staging vs production: pick the env file + compose overlay + isolated project name.
if ($Staging) {
$envFile = Join-Path $PSScriptRoot '.env.staging'
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
$project = @('-p', 'inboxintel-staging')
if (-not (Test-Path $envFile)) {
throw "Missing $envFile. Create it from .env.staging.example."
}
} else {
$envFile = Join-Path $PSScriptRoot '.env'
$composeFiles = @()
$project = @()
if (-not (Test-Path $envFile)) { if (-not (Test-Path $envFile)) {
throw "Missing $envFile. Create it from .env.example with your real secrets." throw "Missing $envFile. Create it from .env.example with your real secrets."
} }
}
# Fail fast if a required key is absent or blank. # Fail fast if a required key is absent or blank.
$required = 'POSTGRES_PASSWORD','GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET' $required = 'POSTGRES_PASSWORD','GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET'
@@ -28,19 +41,23 @@ Get-Content $envFile | ForEach-Object {
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) } $missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" } if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
$composeArgs = @('compose', '--env-file', $envFile) $composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles
if ($Proxy) { $composeArgs += @('--profile', 'proxy') } if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
$composeArgs += @('up', '--build') $composeArgs += @('up', '--build')
if (-not $Foreground) { $composeArgs += '-d' } if (-not $Foreground) { $composeArgs += '-d' }
Push-Location $root Push-Location $root
try { try {
Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan Write-Host "Starting InboxIntel via docker compose (env: $envFile)..." -ForegroundColor Cyan
& docker @composeArgs & docker @composeArgs
if (-not $Foreground) { if (-not $Foreground) {
& docker compose --env-file $envFile ps & docker compose @project --env-file $envFile @composeFiles ps
if ($Staging) {
Write-Host "`n[STAGING] Frontend: http://localhost:18081 API/Swagger: http://localhost:18080/swagger" -ForegroundColor Green
} else {
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray }
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1$(if($Staging){' -Staging'})" -ForegroundColor DarkGray
} }
} }
finally { Pop-Location } finally { Pop-Location }
+46
View File
@@ -0,0 +1,46 @@
# Staging overlay for InboxIntel.
#
# The base docker-compose.yml IS the production definition (Linux containers,
# ASPNETCORE_ENVIRONMENT=Production). This overlay layers a *staging* variant on
# top of it so you can run a production-shaped stack locally on Windows WITHOUT
# clobbering a real production deployment's data, ports, or volumes.
#
# It differs from prod only in the ways staging is meant to differ:
# - the dev/test banner is on (App__DevMode=true)
# - the initial Gmail sync is capped so a big mailbox doesn't take forever
# - ports are shifted into the 18xxx range so staging can run alongside prod
# - a distinct project name gives it its own isolated pgdata + keys volumes
#
# Run it with the -p (project name) flag so volumes/networks are namespaced:
#
# docker compose -p inboxintel-staging \
# --env-file deploy/.env.staging \
# -f docker-compose.yml -f docker-compose.staging.yml up --build -d
#
# (deploy/up.ps1 -Staging / up.sh --staging wrap this for you.)
services:
postgres:
# !override replaces the base port list instead of merging with it, so staging
# binds ONLY its shifted 18xxx/15432 ports and never squats on prod's 8080/8081.
ports: !override
- "127.0.0.1:15432:5432"
api:
environment:
# Same Production runtime as prod (real config binding, real build), but
# flagged as a non-production instance so the UI shows the staging banner
# and the first sync is bounded.
App__DevMode: "true"
GmailSync__MaxMessages: ${MAX_MESSAGES:-2000}
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:18081}
ports: !override
- "127.0.0.1:18080:8080"
frontend:
ports: !override
- "18081:80"
nginx:
ports: !override
- "18000:80"
+188
View File
@@ -0,0 +1,188 @@
# InboxIntel — Git Workflow & Environment Strategy
The single source of truth for how we branch, commit, version, and promote code
across environments. Optimised for a **solo developer on Windows 11 with a future
Linux production server**. Kept deliberately lightweight — every rule here earns
its place.
---
## 1. Branch strategy
A trimmed **GitHub Flow + a long-lived `develop`** model. Two permanent branches,
short-lived branches off `develop`.
| Branch | Lives for | Purpose | Deploys to |
|-----------------|-----------|------------------------------------------------------|------------|
| `main` | forever | Always releasable. Every commit is tagged & shippable | production |
| `develop` | forever | Integration branch. What staging runs | staging |
| `feature/*` | hoursdays| One feature or refactor | dev (local)|
| `fix/*` | hours | Non-urgent bug fix | dev (local)|
| `hotfix/*` | minuteshrs| Urgent prod fix, branched from `main` | prod (fast)|
| `release/x.y.0` | optional | Only if a release needs stabilisation before tagging | staging |
**Why this shape (not full GitFlow):** a solo dev doesn't need GitFlow's ceremony
(separate release managers, parallel release trains). But keeping `develop`
separate from `main` gives one thing that pure trunk-based can't: a **staging
environment that always mirrors `develop`** while `main` stays clean and
tag-perfect for production. `release/*` exists only when you want to freeze
features and stabilise — skip it for routine work.
### Normal flow
```
main ──────●────────────────────●────────── (tagged: v1.2.0, v1.3.0)
\ /
develop ──●──●──●──●──●──●──●──●──────────── (staging)
\ / \ /
feature/x ●─● \ /
fix/y ●───●
```
1. `git switch develop && git pull`
2. `git switch -c feature/sender-policy`
3. Commit in small conventional commits.
4. Push, open a PR **into `develop`** (Gitea). CI must be green.
5. Squash-merge. Delete the branch.
6. When `develop` is ready to ship → PR `develop → main`, tag, deploy.
---
## 2. Commit conventions — Conventional Commits
Format: `type(scope): short imperative summary`
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`,
`build`, `style`, `security`.
Rules:
- Summary ≤ 72 chars, imperative mood ("add", not "added").
- One logical change per commit.
- Body explains **why**, not what (the diff shows what).
- Breaking change → add `!` (`feat!:`) and a `BREAKING CHANGE:` footer.
Examples (matching this repo's history):
```
feat(ui): F1+F2 — component primitives + rebuilt app shell
fix(security): systemic IDOR safeguard via EF global query filters
ci: add Gitea Actions build + test pipeline
```
Why: conventional commits drive **automatic semver bumps** and a generated
CHANGELOG, and make `git log` scannable. `feat` → minor, `fix` → patch,
`BREAKING CHANGE` → major.
---
## 3. Pull request / merge flow (Gitea)
Even solo, PRs are worth it: they run CI, give a diff review checkpoint, and build
a paper trail.
- **Target:** `feature/*` and `fix/*``develop`. `develop`/`hotfix/*``main`.
- **Gate:** the `CI` workflow (backend build+test, frontend build) must pass.
- **Merge style:** **squash-merge** feature branches (one clean commit on
`develop`). **Merge commit** for `develop → main` (preserves the integration
history and makes the release boundary visible).
- **Branch protection (Gitea → Settings → Branches):** protect `main` and
`develop`; require status checks to pass; disallow force-push.
---
## 4. Versioning — Semantic Versioning (`MAJOR.MINOR.PATCH`)
- **MAJOR** — breaking API/behaviour change.
- **MINOR** — backward-compatible feature.
- **PATCH** — backward-compatible fix.
The current version lives in [`VERSION`](../VERSION) and is the one place bumped
per release. Pre-1.0 while scaffolding: stay on `0.x` (minor = features, patch =
fixes; anything may change).
---
## 5. Tagging & releases
Tags are cut **only on `main`**, annotated, prefixed `v`:
```bash
git switch main && git pull
# bump VERSION + CHANGELOG in a release commit, then:
git tag -a v1.3.0 -m "v1.3.0 — sender policy + staging overlay"
git push origin main --tags
```
- Tag == the exact commit deployed to production == the rollback target.
- The tag message summarises the release; details live in `CHANGELOG.md`.
---
## 6. Hotfix process
For a bug already in production:
```bash
git switch main && git pull
git switch -c hotfix/oauth-callback-500
# fix + test
git commit -m "fix(security): guard null OAuth state on callback"
# PR hotfix/* -> main, CI green, merge
git switch main && git pull
git tag -a v1.3.1 -m "v1.3.1 hotfix — OAuth callback" && git push origin main --tags
# deploy the tag, THEN back-merge so develop doesn't lose the fix:
git switch develop && git merge main && git push
```
The **back-merge to `develop`** is the step people forget — without it the next
release silently reintroduces the bug.
---
## 7. Environments
| Env | Host | Runtime | Config source | Purpose |
|-------------|-------------------------|--------------------------------------|--------------------------|--------------------|
| Development | Windows 11 (native) | `dotnet run` + `vite` (hot reload) | `appsettings.Development.json` + user-secrets | fast iteration, debugging |
| Staging | Windows 11 (Docker) | Linux containers, `ASPNETCORE_ENVIRONMENT=Production` | `deploy/.env.staging` + `docker-compose.staging.yml` | production rehearsal |
| Production | Linux server (Docker) | identical Linux containers | `deploy/.env` on the server | live |
**Parity principle:** dev is fast (native, Windows) and *not* production-shaped —
that's fine, it's for inner-loop speed. **Staging is the parity gate**: it runs the
*same Linux images* Docker builds for production, so "works in staging" genuinely
predicts "works in prod". The only staging↔prod differences are the dev banner,
capped sync, ports, and volume namespace — see `docker-compose.staging.yml`.
### Commands
```powershell
# DEV (native, hot reload) — two terminals
dotnet run --project src/InboxIntel.Api # http://localhost:5080
cd frontend; npm run dev # http://localhost:5173
# STAGING (production-shaped, on Windows via Docker)
./deploy/up.ps1 -Staging # http://localhost:18081
./deploy/down.ps1 -Staging
# PRODUCTION-shaped run locally (smoke test the real config)
./deploy/up.ps1 # http://localhost:8081
```
Dev and staging use **different ports and different volumes**, so they can run at
the same time and never share a database.
---
## 8. Git hooks
Installed via `core.hooksPath` (run once per clone):
```powershell
./scripts/install-hooks.ps1
```
- **pre-commit** (fast): blocks committed `.env`/secrets, verifies `dotnet format`.
- **pre-push** (thorough): `dotnet build -c Release` + `dotnet test` + frontend
build — the same gate CI runs, caught before the push.
Emergency bypass: `--no-verify`. CI still enforces the gate server-side, so a
bypassed push can still be rejected by branch protection.
+45
View File
@@ -0,0 +1,45 @@
# Design Brief (locked via interview)
The confirmed design direction from the Phase 4A interview. This governs the UX/UI
redesign, design system, themes, and search experience.
## Direction in one line
**An approachable, professional, "anyone can pick it up" email tool in the spirit of
Notion & Arc — a polished, responsive web app that is fast and premium without being
intimidating.**
## Confirmed preferences
| Dimension | Decision | Design implication |
|-----------|----------|--------------------|
| Reference feel | **Notion / Arc, but professional** | Friendly, spatial, low learning curve — not a power-user speed tool (not Superhuman). |
| Density | **Balanced** | Comfortable, readable rows; efficient but not cramped. |
| Input model | **Pointer-first / discoverable** | Clickable UI is the backbone. Command palette + shortcuts are **accelerators for power users**, layered on top — never required. |
| App metaphor | **Polished web app** | Exceptional in-browser experience; shareable URLs; no native-desktop assumptions. |
| Platforms | **Desktop (Win + Mac/Linux), Tablet, Mobile** | **Fully responsive** is mandatory — layouts must gracefully collapse desktop → mobile. |
| Default theme | **Dark-first** | Design primarily for a layered dark UI; light theme fully first-class (genuine white, not grey). |
| Motion | **Subtle & smooth** | Gentle, quick transitions and tasteful micro-interactions. Respect `prefers-reduced-motion`. |
| Colour personality | **Warm ∩ cool blend, green accent** | Accent built around **`#3ba31f`** (refined into an accessible ramp); neutrals lean subtly warm. |
| Icons | **Clean line, rounded** (Lucide-style) | Consistent stroke weight, soft corners. |
| Speed vs polish | **Balanced** | Polish is welcome only when it never costs perceptible speed. |
| Audience | **Mainstream, power-capable** | Simple by default; power features revealed progressively. |
| Accessibility | **Deferred (nice-to-have)** | AA basics baked in cheaply; deeper a11y is a pre-launch backlog item. See below. |
## The pivotal insight
The original brief leaned "keyboard-first"; the interview corrected this to
**discoverable-first**. That reframes the flagship **search** away from a syntax you
must learn (`from:x after:y`) toward an **inviting, visual, assisted** experience —
filter chips, live suggestions, and natural language — with the power syntax still
available underneath. This single decision shapes the entire redesign.
## Accent colour note
`#3ba31f` = rgb(59,163,31). It's a strong differentiator (most email apps default to
blue) and bridges warm/cool. It will be developed into a full ramp (50→900); the
**interactive** shade will be nudged for WCAG AA contrast on dark surfaces and on
buttons, and green will **never be the sole signal** for selection/status (always
paired with icon/shape) so the design is colour-blind-safe by construction.
## Deferred: accessibility
Per the user, accessibility is a **nice-to-have to revisit before public launch**, not
a hard requirement now. Cheap AA basics (contrast, focus rings, reduced-motion,
non-colour-only state) are still baked in; high-contrast mode, full screen-reader
semantics, font-scaling controls, and formal colour-blind audits are **backlog**.
+88
View File
@@ -0,0 +1,88 @@
# 01 — Architecture Review (current state)
Phase 1 deliverable: a grounded assessment of InboxIntel as it exists today, from the
source. This is the baseline the redesign builds on.
## Architecture
**Clean Architecture, .NET 8** — strict dependency rule `Api → Infrastructure → Application → Domain`.
| Layer | Responsibility | Key contents |
|-------|----------------|--------------|
| **Domain** | Entities + enums, no external deps | `Email`, `Sender`, `Domain`, `MailThread`, `Label`/`EmailLabel`, `Attachment`; `EmailCategory` |
| **Application** | Interfaces, DTOs, validation, parsing | `ISearchService` et al., `GmailQueryParser`, FluentValidation |
| **Infrastructure** | EF Core/Npgsql, integrations, workers | `SearchService`, `CleanupService`, `AnalyticsService`, `HeuristicClassifier`, **AI providers**, exports, `GmailSyncWorker`, `DigestWorker` |
| **Api** | ASP.NET Core Web API | Thin controllers, DI, Serilog, OAuth |
| **Frontend** | React 18 + Vite SPA | Chart.js, react-grid-layout (draggable dashboard), Tailwind |
- **Data:** PostgreSQL (EF Core + Npgsql). `Email` designed for 100k+ rows/user; a
**generated `tsvector`** column backs full-text search.
- **Background:** hosted workers — `GmailSyncWorker` (scheduled sync), `DigestWorker` (digest).
- **AI seam (already present):** `IAiProvider` with `NullAiProvider` (disabled),
`OllamaProvider` (local `/api/chat`), `OpenAiProvider` (cloud). Contract today is a
single `CompleteAsync(systemPrompt, userPrompt)`.
- **Security:** Google OAuth2 (read-only Gmail), cookie session + JWT, refresh tokens
encrypted via Data Protection API, **IDOR-safe global query filters**, SSRF egress
guard, non-root containers, destructive actions require `Confirmed` + server preview.
- **Delivery:** Docker Compose (Postgres/API/frontend/optional nginx) + Gitea CI/CD.
## Feature set
Gmail connect + sync → Postgres · analytics dashboard (draggable widgets) · advanced
search (Gmail operators + FTS) · safe bulk cleanup · unsubscribe management
(List-Unsubscribe / one-click) · heuristic categorisation (smart folders) · exports
(PDF/CSV/JSON) · optional **advisory** AI · sender/domain aggregation.
## Existing search implementation (flagship)
1. **`GmailQueryParser`** (Application) parses `from: to: domain: after: before:
is:unread|read has:attachment`; remaining text → free-text.
2. **`SearchService`** (Infrastructure) composes structured filters as EF `WHERE`
clauses, and free text via Postgres FTS:
`SearchVector.Matches(PlainToTsQuery('english', term))`, where
`SearchVector = to_tsvector('english', coalesce(Subject,'') || ' ' || coalesce(BodyText,''))`.
3. Results are per-user filtered (IDOR-safe), **ordered by `SentAtUtc DESC`**,
offset-paginated, projected to `EmailSummaryDto`.
## Strengths
- Clean, testable layering; DI throughout; 39 tests + CI/CD gate.
- **Real Postgres FTS** (generated tsvector), not naïve `LIKE`.
- Security-forward (IDOR filters, encrypted tokens, SSRF guard, confirmed destructive ops).
- **AI already decoupled behind `IAiProvider`** — the "no-AI / Ollama / future" requirement is architecturally seeded.
- Read-only scope + advisory AI = privacy-respecting.
## Weaknesses
- **Search is single-mode**: ordered by **date, not relevance** (no `ts_rank`); no
fuzzy/typo tolerance, no semantic/vector search, no grouping, no "why matched," no
saved/recent/suggested searches.
- FTS is **English-only** and covers **only Subject + Body** (not sender, attachment
names, labels).
- Sender/domain filters use `.Contains()` → **non-sargable ILIKE scans** (no `pg_trgm`).
- Category is a **single heuristic enum** — no multi-label, confidence, or learning.
- **`IAiProvider` is chat-only** — no embeddings/classification/extraction contract, so
semantic search & structured extraction can't yet be expressed.
- Threading (`ThreadId`) exists but isn't surfaced as conversation intelligence.
## Technical debt
- EF global-query-filter vs required `Email↔EmailLabel` relationship warning (in logs).
- Hardcoded English FTS config.
- **678 KB single JS chunk** (no code-splitting) — cold-load cost.
- Relevance-blind ordering.
- AI interface too narrow for the roadmap.
## Performance bottlenecks
- `.Contains()` sender/domain → sequential scans (need `pg_trgm` GIN indexes).
- **Offset pagination** (`Skip/Take` + `Count`) degrades on deep pages → keyset/cursor.
- No score-based top-N (no ranking) → sorts full match set by date.
- Single JS chunk (~219 KB gzip) slows cold loads.
- Full-mailbox sync latency at 100k+ (batching/Polly present, but per-message fetch).
## Documentation quality
Strong for a scaffold: `README`, `docs/ARCHITECTURE.md`, OAuth setup, `docs/specs/*`,
`AGENTS.md`, plus `WORKFLOW.md`/`CHANGELOG`; meaningful XML-doc comments.
**Gaps:** no API reference, no ERD/data-model doc, no ADRs, no search/AI design docs —
which this discovery produces.
## Verdict
A clean, secure, well-tested foundation with a **genuine FTS base** and an **AI seam
already in place**. The biggest opportunities are exactly where the product wants to
win: **relevance-ranked, multi-mode, assisted search**; a **richer AI contract**
(embeddings/classification/extraction); **conversation intelligence**; and a **modern,
approachable UX**. None of these require a rewrite — they extend the existing seams.
+73
View File
@@ -0,0 +1,73 @@
# 02 — Competitor Analysis (Phase 2)
Goal: understand the modern email landscape, isolate what users consistently praise
and complain about, and find **where InboxIntel can be genuinely better** — without
copying anyone. Analysis is framed against the [Design Brief](00-design-brief.md):
approachable-professional, fast, **local/private AI**, search-centric.
## Landscape snapshot
| Product | Positioning | Standout strength | Recurring weakness |
|---------|-------------|-------------------|--------------------|
| **Gmail** | Default mass-market webmail | Powerful search operators; scale; free; Gemini bolt-ons | Cluttered; privacy optics; AI feels tacked-on; dated triage |
| **Outlook** | Enterprise mail + calendar | Calendar/email fusion; Copilot; Rules | Heavy; **search is famously flaky/slow**; inconsistent "new Outlook" |
| **Superhuman** | Speed tool for pros | **Blazing speed, keyboard triage, polish**; Superhuman AI | $30/mo; Gmail/Outlook-only; **intimidating for casual users** |
| **Proton Mail** | Privacy / E2EE | Zero-access encryption; Swiss; open source; local-ish Scribe AI | **Search limited** (encrypted); fewer productivity features; slower |
| **Spark** | Smart inbox + team email | Collaboration (shared drafts, comments), smart inbox, cross-platform | Privacy history concerns; can feel cluttered; sync hiccups |
| **Shortwave** | AI-native Gmail client | **AI search + assistant that genuinely work**; bundles; fast | Gmail-only; **cloud AI (privacy)**; pricing crept up |
| **Thunderbird** | Open-source local client | Free, private, extensible, local storage, multi-account | UX still catching up; **no built-in AI**; search not modern |
| **HEY** | Opinionated workflow | Novel triage (Screener/Imbox/Feed/Paper Trail) | Locked-in; no folders/search-first; pricey |
| **Fastmail** | Fast private power-mail | Excellent fast search; reliable; standards-based | No AI; power-user aesthetic; niche |
| **Missive** | Team shared inbox | Best-in-class collaboration/assignment/rules | Team-first; overkill for individuals |
## Deep dive on the two axes that decide this market
### Search (InboxIntel's flagship battleground)
- **Gmail / Fastmail**: fast, operator-rich, but **syntax-first** and browse-anchored — great for people who already know `from:` / `has:attachment`, opaque to everyone else.
- **Outlook**: powerful on paper, but **unreliable/slow search is its most complained-about feature** for years.
- **Shortwave**: the modern bar — **AI/semantic search + "ask your inbox"** questions ("what did Sarah say about the invoice?"). Genuinely loved, but **cloud-processed**.
- **Proton/Thunderbird**: privacy-strong but **search is a known weak point** (encryption / dated indexing).
- **Gap:** nobody offers **fast, relevance-ranked, semantic, *explainable* search that is also local/private and approachable to non-power-users.** That is precisely InboxIntel's opening.
### AI
- The market has **split into two camps**:
1. **AI, but cloud** — Gmail/Gemini, Outlook/Copilot, Shortwave, Spark. Useful, but your email is processed off-device.
2. **Private, but little/no AI** — Proton, Thunderbird, Fastmail.
- Proton's **Scribe** (privacy-first, can run locally) hints at the future but is narrow (writing only).
- Common complaints: AI feels **generic/bolted-on**, **unexplained**, and **can't be turned off cleanly**.
- **Gap:** **genuinely useful AI that runs 100% locally (Ollama), is modular, explainable, and fully optional** — nobody mainstream owns this.
## What users consistently PRAISE (cross-product)
1. **Speed** — instant search/open/triage (Superhuman, Fastmail).
2. **AI that saves real time** — summaries of long threads, "ask your inbox," draft replies (Shortwave, Superhuman AI).
3. **Privacy you can trust** — Proton's whole brand.
4. **Effortless triage** — Split Inbox / Bundles / Screener (Superhuman, Shortwave, HEY).
5. **Collaboration** — assign, comment, share drafts (Missive, Spark).
6. **Clean, calm, modern UI** that reduces overwhelm.
## What users consistently COMPLAIN about
1. **Search that's slow, flaky, or syntax-only** (Outlook; Gmail/Proton on mobile).
2. **Privacy cost of AI** — "I want the AI but not to send my mail to a server."
3. **Clutter & overwhelm** — noisy inboxes, ads, too many features.
4. **AI that's generic and unexplained** — "why did it say that / categorise that?"
5. **Power tools that alienate casual users** (Superhuman's learning curve; HEY's rigidity).
6. **Price** — best experiences gated behind $1030/mo.
7. **Opaque automation/categorisation** you can't understand or correct.
## Opportunity gaps → where InboxIntel wins (ranked)
1. **Local, private, *useful* AI.** Resolve the market's central tension (AI **vs** privacy) by delivering both via Ollama. This is the headline differentiator and aligns with the existing `IAIProvider` seam and read-only scope.
2. **Approachable power-search as the home screen.** Discoverable, visual, assisted (filter chips + NL + suggestions) so a *mainstream* user searches confidently — with operators/semantic power underneath for those who grow into it.
3. **Explainability everywhere.** "Why this result matched," "why this was categorised/prioritised." No competitor does this well; it builds trust *and* teaches the product — perfect for the "anyone can pick it up" audience.
4. **Own-your-data intelligence.** Analytics, cleanup, unsubscribe, and local AI combine into a "your inbox, your machine, your intelligence" story that Gmail/Outlook structurally can't tell.
5. **Fast *and* friendly.** Superhuman is fast-but-intimidating; casual apps are friendly-but-slow. The brief's sweet spot (balanced, pointer-first, polished) is largely unoccupied.
6. **Honest, correctable automation.** Categories/priority a user can see, understand, and fix — the antidote to Gmail's opaque tabs.
## Explicitly NOT copying
- Not Superhuman's keyboard-only speed-sport (brief = discoverable-first).
- Not HEY's rigid, search-hostile opinionation.
- Not cloud-AI-at-any-cost (brief = local/private).
- Not enterprise-collaboration-first (individual mainstream user is primary; collaboration is a later opportunity, see roadmap).
## Positioning statement (draft)
> **InboxIntel** — the email tool that makes **search the fastest way to think about your
> inbox**, with **AI that runs on your own machine** and always explains itself.
> Powerful enough for pros, simple enough for anyone.
+107
View File
@@ -0,0 +1,107 @@
# 03 — User Research: Personas & Journey Maps (Phase 3)
Personas are prioritised against the [Design Brief](00-design-brief.md) audience:
**mainstream, power-capable.** So the **primary** personas are everyday individuals and
small-business owners; **secondary** personas are high-volume role users whose needs we
support *progressively* (power features revealed as people grow into them).
Legend: 🟢 primary · 🔵 secondary
---
## 🟢 Priya — Personal user (mainstream)
Busy professional with a personal Gmail full of subscriptions, receipts, travel, and the
occasional important thread buried in noise.
- **Goals:** find things fast; not miss the important stuff; keep the inbox from feeling overwhelming.
- **Pain points:** can't remember where things are; newsletters bury real mail; unsubscribing is tedious; search needs the "right words."
- **Daily workflow:** skim inbox on phone/desktop → star/leave a few → hunt for a receipt or booking → ignore the rest.
- **Search needs:** *natural, forgiving* ("flight to Lisbon", "gym receipt March") — no operators. Attachment/receipt finding. Typo tolerance.
- **AI opportunities:** thread summaries, "what needs a reply," smart unsubscribe suggestions, receipt/booking extraction, gentle priority.
## 🟢 Marcus — Business owner / solopreneur (mainstream, high-value)
Runs a small business from one inbox: clients, vendors, invoices, admin — **no assistant**.
- **Goals:** never drop a client ball; find any past agreement/invoice instantly; spend less time in email.
- **Pain points:** follow-ups slip; important buried under admin; digging for "what did we agree?"; context-switching.
- **Daily workflow:** triage first thing → reply to clients → chase unpaid invoices → search for prior context mid-reply.
- **Search needs:** people-centric ("everything with this client"), attachment/invoice search, timeline ("our thread about the contract"), "did they reply?"
- **AI opportunities:** follow-up detection, "you haven't heard back" nudges, thread/relationship summaries, task/commitment extraction, draft replies with context.
## 🔵 Dev — Developer / power user
Technical inbox: GitHub, CI alerts, monitoring, mailing lists, plus real mail.
- **Goals:** cut notification noise; automate; keep signal. **Strongly values local/private AI** and self-hostable.
- **Pain points:** alert floods; wants rules/automation and keyboard speed; distrusts cloud AI on their mail.
- **Daily workflow:** bulk-archive alerts → scan PR/issue threads → deep-search history → automate the repetitive.
- **Search needs:** operators + regex-ish precision, entity/sender/domain, saved searches, fast keyboard-driven search.
- **AI opportunities:** local summarisation of long threads, auto-labelling/rules suggestions, semantic search across archives, duplicate/near-duplicate detection.
## 🔵 Rina — Recruiter (role power user, high volume)
Hundreds of candidate threads, CV attachments, scheduling, multi-stage follow-ups.
- **Goals:** find any candidate/CV instantly; track stage; never lose a promising lead.
- **Pain points:** attachments scattered; who's at what stage; follow-up timing; duplicate applicants.
- **Search needs:** **attachment/document search** (search *inside* CVs), people search, stage/timeline, entity extraction (skills, roles).
- **AI opportunities:** CV/document understanding, candidate summaries, follow-up detection, duplicate detection, relationship mapping.
## 🔵 Sam — Sales · 🔵 Tom — Support (role power users)
- **Sam:** pipeline follow-ups, response tracking, templates. Needs "who hasn't replied," saved searches, sentiment, reply suggestions.
- **Tom:** triage by category/SLA, canned responses, categorisation accuracy. Needs reliable auto-categorisation, priority prediction, phishing/spam confidence, thread summaries.
- (Both hint at **collaboration/shared-inbox** — deliberately a *later* opportunity; individual mainstream user is primary.)
---
## Cross-persona signal
| Need | Who feels it most | Priority |
|------|-------------------|----------|
| **Forgiving, natural search** | Priya, Marcus (everyone) | 🔴 Highest |
| **Find people / attachments / past context** | Marcus, Rina, Dev | 🔴 High |
| **Follow-up / "did they reply?" detection** | Marcus, Sam, Rina | 🔴 High |
| **Thread & relationship summaries** | Marcus, Dev, Rina | 🟠 Medium-high |
| **Noise reduction (unsubscribe, bulk, categorise)** | Priya, Dev, Tom | 🟠 Medium-high |
| **Local/private AI** | Dev (loud), all (latent) | 🔴 Strategic |
| **Explainability of results/priority** | everyone (trust) | 🟠 Medium-high |
| Collaboration / shared inbox | Sam, Tom | 🟢 Later |
---
## Journey maps
Format: **stage → what they do → current pain → InboxIntel opportunity.**
### J1 — Morning triage ("what needs *me* today?") — Priya, Marcus
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Arrive | Open inbox | Wall of mixed noise + important | AI **priority lane** + summary of "needs you" (local, explainable) |
| Scan | Skim subjects | No signal of what's urgent/awaiting reply | **Follow-up/awaiting-reply** badges; thread one-liners |
| Act | Reply/defer/clean | Repetitive, context-switch heavy | One-click defer/snooze; context-aware **draft reply**; bulk clean noise |
| Exit | Close, hope nothing missed | Anxiety of missed items | "You're caught up on what matters" confidence state |
### J2 — Find one specific thing (the flagship moment) — everyone
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Recall | Remember fragments ("invoice, that vendor, spring") | Must guess exact keywords/operators | **Natural-language + semantic** search; typo-tolerant |
| Query | Type in search | Syntax anxiety; blank box | **Suggestions, recent, filter chips**; search-as-home |
| Scan results | Look through hits | Date-sorted, not relevant; no context | **Relevance ranking** + **"why this matched"** + instant preview |
| Confirm | Open the right one | Re-open several to be sure | Grouped results, inline preview, people/attachment facets |
### J3 — Reduce the noise — Priya, Dev, Tom
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Notice | "Too many newsletters" | Unsub links hidden/dark-patterned | **One-click unsubscribe** (already seeded) + AI suggestions of what to cut |
| Decide | Which to keep? | Manual, per-email | AI **bulk suggestions** by sender/category with confidence + preview |
| Act | Unsubscribe / bulk clean | Risky, irreversible-feeling | **Confirmed + previewed** bulk actions (already a strength) |
| Trust | Did it do the right thing? | Opaque | Explainable "why suggested," undo, audit |
### J4 — Track a commitment / follow-up — Marcus, Sam, Rina
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Send | Email a client/candidate | Then forget | AI **follow-up detection**: "expecting a reply?" |
| Wait | Time passes | No system nudges you | "**No reply in N days**" surfaced automatically |
| Recall | "What did we agree?" | Dig through thread | Thread **summary + extracted commitments/tasks** |
| Resolve | Chase / close | Re-draft from scratch | Context-aware **draft nudge** referencing the thread |
---
## Research-driven product principles
1. **Search is the front door**, and it must work for someone who types *"that gym receipt"* — not just `from:gym has:attachment`.
2. **Explain everything** the system decides (match, category, priority) — it builds trust and teaches the app.
3. **AI removes toil, never control** — advisory, previewed, undoable, and fully optional/local.
4. **Progressive power** — mainstream-simple by default; operators, saved searches, automation revealed as users grow.
5. **Reduce anxiety** — "you're caught up," undo, and confidence states matter as much as features.
@@ -0,0 +1,182 @@
# 04 — UX/UI Redesign, Information Architecture & Design System (Phase 4A)
A **complete redesign**, not an iterative tweak. Nothing about the current layout is
assumed to survive. Governed by the [Design Brief](00-design-brief.md): Notion/Arc-
professional, pointer-first & discoverable, balanced density, dark-first, green
`#3ba31f`, subtle motion, fully responsive, mainstream-friendly.
---
## Part A — UX Research (per-screen evaluation)
Evaluating the *jobs*, not the current pixels. For each screen: **goal · is it intuitive
· what's unnecessary · how to simplify · click reduction · what must be obvious · sources
of cognitive overload.**
| Screen | User goal | Redesign verdict |
|--------|-----------|------------------|
| **Dashboard (current draggable widgets)** | Understand my inbox | Becomes the **Analytics** view, not the home. A draggable widget grid is *configuration overhead* most users never want. Default = a curated overview; customisation is progressive. |
| **Inbox/list** | Triage what matters | Reframe from "all mail newest-first" to **lanes** (Needs you · Awaiting reply · Everything). Obvious: who/subject/one-line intent/time. Overload source: undifferentiated noise → fix with grouping + priority. |
| **Reading a message** | Understand + act | Add a **thread summary** header, **extracted actions/dates**, and inline reply. Reduce clicks: reply/snooze/label as one-key or one-click from the pane. |
| **Search** | Find a specific thing | **Promote to the front door.** Today it's an operator box; redesign to inviting, visual, assisted (see [05](05-search-redesign.md)). |
| **Cleanup / unsubscribe** | Reduce noise safely | Strong bones (confirm+preview). Make it **suggestion-led** ("cut these 12 newsletters?") with confidence + undo. |
| **Settings** | Configure incl. AI | Add a clear **AI panel**: off / local (Ollama) / provider, model status, VRAM. AI-off must feel first-class, not degraded. |
**Cross-cutting UX principles:** search-as-home · lanes over one big list · explain every
decision · progressive disclosure of power · confidence/undo everywhere · one primary
action per screen.
---
## Part B — Information Architecture
### Navigation shell (responsive 3-pane → 1-pane)
```
┌───────────────────────────────────────────────────────────────┐
│ TopBar: [ 🔍 Search your inbox… ⌘K ] ☾ ⚙ 👤 │
├──────────┬──────────────────────────┬─────────────────────────┤
│ Sidebar │ List / Results │ Reading / Preview │
│ (collaps)│ (virtualised) │ (thread + AI summary) │
│ │ │ │
│ Search │ ▸ Needs you (lane) │ Subject │
│ Priority │ ▸ Awaiting reply │ ⟶ AI summary (local) │
│ Unread │ ▸ Everything │ ⟶ extracted actions │
│ Saved ★ │ │ body … │
│ Categories │ [Reply] [Snooze] […] │
│ Cleanup │ │ │
│ Analytics│ │ │
│ ─────────│ │ │
│ 👤 acct │ │ │
└──────────┴──────────────────────────┴─────────────────────────┘
```
- **Search sits at the top of everything** (top bar) *and* as the first sidebar item — reinforcing search-as-home.
- **Responsive collapse:** 3-pane (wide desktop) → 2-pane (list+reading, laptop) → 1-pane with push navigation (tablet/mobile). Reading opens as an overlay sheet on mobile.
- **Progressive disclosure:** advanced filters, operators, saved-search management, and automation are revealed on demand — never in a beginner's face.
- **Command palette (⌘K / Ctrl+K):** an *accelerator* — navigate, act, and search — layered on top of the fully clickable UI (pointer-first per brief).
- **Right-click context menus** on rows/senders/threads (archive, label, unsubscribe, "find similar," "everything from this sender").
- **Multi-window / dockable panels:** deferred (web app); pop-out reading view is a v-later opportunity.
### Screen hierarchy
1. **Search-home** (front door) · 2. **List/Results** (lanes, ranked) · 3. **Reading/thread**
· 4. **Cleanup** · 5. **Analytics** · 6. **Settings (incl. AI)**.
---
## Part C — Design System
### Colour — accent ramp (from `#3ba31f`)
```
green-50 #f890? → use tint set:
--green-50: #f1f9ec --green-300: #93d07d --green-600: #2f8419
--green-100: #dcf0d0 --green-400: #63b84a --green-700: #266a15
--green-200: #bde3ab --green-500: #3ba31f --green-800: #1e5312
(brand base) --green-900: #163a0e
```
**Usage rules (dark-first):**
- Brand/base = `green-500`. On dark surfaces, interactive fills use `green-500`/`green-400`; **foreground on accent is contrast-checked** (near-black `#0f1a0b` on light greens, white on `green-600`+).
- Accent is used **sparingly** — primary actions, selection, active nav, positive status.
- **Never colour-only:** selection also shows a left-bar/checkbox; status pairs green with an icon/label (colour-blind-safe by construction, even though formal a11y is deferred).
### Colour — neutrals (warm-leaning)
| Token | Dark (default) | Light |
|-------|----------------|-------|
| `--bg` | `#1a1917` (warm charcoal, **not** pure black) | `#ffffff` (genuine white) |
| `--surface-1` | `#211f1d` | `#faf9f7` |
| `--surface-2` | `#2a2724` | `#f4f2ee` |
| `--surface-3` | `#34302c` | `#ebe8e2` |
| `--border` | `#3a3632` | `#e4e0d9` |
| `--text` | `#f2efe9` (warm off-white) | `#1c1a17` |
| `--text-muted` | `#a8a29a` | `#6b6459` |
| `--text-subtle` | `#7a746c` | `#928b7e` |
| semantic | `info #4a90d9 · warn #d9a441 · danger #d95a4a · success = green-500` | same, contrast-tuned |
### Typography
- **UI font:** Inter (or system fallback) — clean, neutral, highly legible.
- **Optional mono:** JetBrains Mono / ui-monospace for addresses, IDs, data.
- **Scale (px / line-height), UI base = 14 for balanced density:**
`xs 12/16 · sm 13/18 · base 14/20 · md 16/24 · lg 18/26 · xl 20/28 · 2xl 24/32 · 3xl 30/38`
- Weights: 400 body · 500 UI/labels · 600 headings/emphasis. Avoid 700 except brand.
### Spacing (4px base) & layout
`space: 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64`.
Grid: 12-col fluid content area; sidebar fixed (240px, collapsible to 56px icon rail);
reading pane min 420px. Density "balanced" → row height ~44px, 812px internal padding.
### Radius / elevation / motion
- **Radius:** `sm 4 · md 6 (buttons/inputs) · lg 8 (cards/panels, default) · xl 12 (modals) · pill 999`.
- **Elevation:** dark = surface-layering + faint shadow + 1px border; light = soft shadows
`e1 0 1 2 /6% · e2 0 4 12 /10% · e3 0 12 32 /16%`. Levels: e0 flat · e1 menus · e2 popovers · e3 modals.
- **Motion:** durations `120 / 180 / 240ms`; easing `cubic-bezier(0.2,0,0,1)` (ease-out) for enters, `cubic-bezier(0.4,0,1,1)` for exits; a spring only for selection/drag. **Respect `prefers-reduced-motion`.**
### Icons & illustration
- **Lucide** (line, rounded), stroke 1.5px, 20px default (16px dense, 24px feature).
- Illustration: minimal, single-accent line spot-art for empty states — friendly, not corporate stock.
### States (must all be designed)
- **Loading:** skeleton rows (list), shimmer summary card (reading) — never spinners for content.
- **Empty:** friendly line-art + one clear CTA ("Nothing here yet — connect Gmail" / "No results — try broader terms" with a *Did you mean* / *Broaden* action).
- **Error:** calm, specific, recoverable ("Couldn't reach Gmail — Retry"), never a raw stack.
- **Success:** toast + inline confirmation; destructive actions show **preview → confirm → undo**.
### Components (catalogue)
Buttons (`primary` green / `secondary` surface / `ghost` / `danger`) · icon-button · input
· **search field** (hero variant) · **filter chip** (removable, typed) · segmented control ·
toggle · dropdown menu · **context menu** · **command palette** · **email row** (avatar,
sender, subject, one-line intent, badges, time, hover-actions) · **sender chip/avatar** ·
**thread summary card** · **category badge** · **priority indicator** · tabs · tooltip ·
toast · modal · **side sheet** (mobile reading) · skeletons · empty-state · pagination /
**virtualised infinite scroll** · avatar/initials · progress/VRAM meter (AI panel).
---
## Part D — Themes (both fully polished)
### Dark (default)
Layered **warm charcoal** surfaces (`#1a1917``#34302c`), warm off-white text, green
accent nudged for on-dark contrast, faint borders to separate layers. Avoids pure black;
depth via surface elevation + hairline borders, not heavy shadow.
### Light
**Genuine white** base (`#ffffff`) with warm off-white surfaces — *not grey-pretending-to-
be-white*. Generous whitespace, soft shadows for elevation, restrained green accent.
Premium, low-noise, highly readable.
Both share tokens; only the neutral map + shadow strategy differ. Theme follows a
`data-theme` attribute; **dark is the design source of truth**, light is derived and
independently QA'd.
---
## Part E — Interaction design
- **Hover:** rows raise to `surface-2`, reveal quick-actions (archive/snooze/label/unsub).
- **Selection:** checkbox on hover + click-row-to-open; shift/⌘-click multi-select; a
sticky **bulk action bar** slides up when >1 selected.
- **Search:** instant results, **live filtering** as chips are added/removed, suggestions +
recent on focus (see [05](05-search-redesign.md)).
- **Previews:** hover peek + inline reading; attachments preview in a lightbox.
- **Drag & drop:** rows → labels/categories/cleanup; respects reduced-motion.
- **Notifications:** toasts (non-blocking), with undo for reversible actions.
- **Transitions:** pane content crossfades; mobile reading slides up as a sheet.
- **Scrolling:** **virtualised list** (mandatory for 100k+ rows) with sticky lane headers.
---
## Part F — Accessibility review (baseline; deeper a11y deferred)
Baked in cheaply now: AA-tuned contrast via the ramps, visible focus rings, `prefers-
reduced-motion`, **non-colour-only** state, semantic HTML + ARIA on lists/dialogs/menus,
full keyboard reachability of core actions. **Deferred to pre-launch backlog** (per brief):
high-contrast mode, formal screen-reader passes, font-scaling controls, colour-blind audit.
---
## Part G — Migration strategy (strangler, low-risk)
1. **Introduce tokens + component library** (Tailwind config from this doc) — no behaviour change.
2. **Rebuild the shell** (sidebar / top-bar / search-home) around the existing API.
3. **Migrate screen-by-screen behind a feature flag** (`ui.v2`): Search → Reading → List →
Cleanup → Analytics (the old draggable dashboard *becomes* Analytics).
4. **Keep the API stable**; frontend-only migration. Delete old screens once parity is verified.
5. Ship per-screen via the `develop → staging` pipeline; each screen is its own epic (see [Git plan](10-git-plan.md)).
## Part H — Future design opportunities
Pop-out / multi-window reading · dockable panels · custom accent picker · additional themes
· a Tauri/Electron shell if a true desktop build is ever wanted · plugin-contributed widgets
on the Analytics canvas · command-palette extensibility.
+129
View File
@@ -0,0 +1,129 @@
# 05 — Search Redesign (Phase 4B)
Search is the **flagship**. The goal: make people *want to search rather than browse*
an experience that's **inviting, visual, forgiving, ranked, and explainable**, works for
*"that gym receipt from March"* (not just `from:gym has:attachment`), and runs **locally
and privately**. Grounded in the existing Postgres FTS + `IAiProvider` seam (see
[01](01-architecture-review.md)); AI modes are **optional** — with AI off, lexical +
structured search is still excellent.
---
## The layered search engine
Four cooperating layers, each usable alone; higher layers *degrade gracefully* to lower
ones when AI is disabled.
```
┌ Layer 4 · AI-ASSISTED (optional, local) ────────────────────────────┐
│ NL→query · conversational "ask your inbox" · explanations · related │
├ Layer 3 · SEMANTIC (optional, local embeddings + pgvector) ──────────┤
│ meaning-based recall · "similar to this" · concept search │
├ Layer 2 · LEXICAL + RANKING (Postgres FTS, always on) ───────────────┤
│ websearch_to_tsquery · ts_rank · pg_trgm fuzzy · multi-field │
├ Layer 1 · STRUCTURED (SQL WHERE, always on) ─────────────────────────┤
│ sender/date/labels/flags/size/category · filter chips │
└───────────────────────────────────────────────────────────────────────┘
▲ hybrid fusion (RRF) ranks Layer 2+3 together ▲
```
**Hybrid ranking (RRF):** lexical and semantic candidate sets are merged via *Reciprocal
Rank Fusion*, then boosted by **recency**, **sender importance**, and **unread**. This
replaces today's date-only ordering — the single biggest search win.
---
## The experience: search as the front door
- **Hero search field** in the top bar + a **search-home** when the box is focused/empty:
**recent searches**, **suggested searches** ("Unread from people you reply to",
"Large attachments", "Receipts this month"), and **saved searches ★**.
- Type freely → **live results** with **filter chips** the user can add/remove by click
(pointer-first). Behind an unobtrusive " Filters" for the full builder.
- Every result shows **"why it matched"** (highlighted terms, or the semantic concept, or
the parsed NL interpretation as *editable chips*).
- **Zero-result recovery:** *Did you mean* · *Broaden* · *Search all mail/trash*.
- Fully keyboard-drivable as an **accelerator** (⌘K → type → arrow → enter), but never required.
---
## Search modes & features
Each with **Problem solved · Implementation · Complexity · Effort · User value.**
Effort = rough dev-days for one engineer; Complexity = S/M/L/XL.
| Feature | Problem solved | Implementation | Cx | Effort | Value |
|---------|----------------|----------------|----|--------|-------|
| **Relevance ranking** | Results sorted by date, not usefulness | `ts_rank_cd` + hybrid RRF + recency/sender/unread boosts; top-N by score | M | 35d | 🔴🔴🔴 |
| **Multi-field FTS** | Only Subject+Body indexed | Extend generated `tsvector` (sender name/addr, attachment filenames, labels) with weights (A/B/C/D) | S | 23d | 🔴🔴 |
| **Fuzzy / typo tolerance** | "recieved" finds nothing | `pg_trgm` GIN + similarity fallback when FTS is empty | S | 12d | 🔴🔴 |
| **Phrase / prefix / boolean** | `plainto_tsquery` too blunt | Swap to `websearch_to_tsquery` (quotes, OR, -exclude) | S | 1d | 🔴 |
| **Interactive filter chips** | Operators are a syntax wall | UI chips ↔ structured DTO; add/remove live | M | 34d | 🔴🔴🔴 |
| **Advanced query builder** | Power users want precision | Visual builder → same DTO; operators still work | M | 34d | 🔵🔵 |
| **Natural-language search** | "gym receipt March" | Local LLM (or rules-first) parses intent → chips + terms, shown editable | L | 58d | 🔴🔴🔴 |
| **Semantic search** | Recall by *meaning*, not keywords | `IEmbeddingProvider` (Ollama) → `pgvector` (HNSW); backfill job; hybrid with FTS | L | 812d | 🔴🔴🔴 |
| **Conversational "ask your inbox"** | "What did Sarah say about the invoice?" | RAG: semantic retrieve → local LLM answers with **citations** to source emails | XL | 1015d | 🔴🔴🔴 |
| **"Why this matched"** | Trust + learnability | `ts_headline` highlights (lexical); nearest-concept + snippet (semantic); parsed-intent chips (NL) | M | 34d | 🔴🔴 |
| **Saved searches ★** | Repeated queries | Persist DTO; pin to sidebar; optional live count | S | 2d | 🔴🔴 |
| **Recent searches / history** | Re-find what you searched | Per-user history table; privacy-clearable | S | 12d | 🔴 |
| **Suggested searches** | Blank-box paralysis | Rules over user's own stats (top senders, unreplied, big attachments) | S | 23d | 🔴🔴 |
| **People search** | "Everything with this person" | Sender/recipient facet + aggregation; person profile panel | M | 35d | 🔴🔴 |
| **Attachment search** | Find files, not emails | Index filenames now; **content/OCR** later (see below) | S→L | 2d → +8d | 🔴🔴 |
| **Entity extraction** | Search by amounts/dates/orgs | Local NER (LLM or rules) → entity index; facets (money, dates, companies) | L | 610d | 🔴🔴 |
| **Timeline search** | "our thread over time" | Date-bucketed results + a timeline scrubber UI | M | 34d | 🔵🔵 |
| **Related conversations** | Surface adjacent context | Vector nearest-neighbours + same-participants/thread heuristics | M | 35d | 🔴🔴 |
| **Duplicate / near-dup detection** | Clutter, repeated sends | Hash (exact) + embedding similarity (near) → group/cleanup | M | 46d | 🔵🔵 |
| **Conversation intelligence** | Long threads are walls | Thread summary + extracted actions/decisions (local LLM) surfaced in results & reading | L | 812d | 🔴🔴🔴 |
### Additional ideas (beyond the brief)
- **Search-driven bulk actions** — run cleanup/label/unsub on a *result set* ("archive all newsletters older than 6 months").
- **Scoped search** — constrain to this folder / label / person / thread inline.
- **"Find similar to this email"** — one-click semantic neighbours from any message.
- **Zero-result recovery** — *did-you-mean* (trigram), *broaden*, *search trash/all*.
- **Search from selection** — highlight text → "search inbox for this".
- **Sender reputation / safety facet** — filter by phishing/spam confidence (ties to AI).
- **Search templates** — parameterised saved searches ("invoices from {client}").
- **Search analytics (for the user)** — "you search 'invoice' most" → suggests a saved search / smart folder.
---
## Ranking design (detail)
```
score = RRF(lexical_rank, semantic_rank)
+ w_recency · decay(sentAt)
+ w_sender · senderImportance(userReplyRate, frequency)
+ w_unread · isUnread
```
- Weights are configurable; defaults tuned so **exact/lexical hits never lose to fuzzy noise**.
- `senderImportance` derives from the user's *own* behaviour (reply rate, frequency) —
computable from existing data, no AI required.
## Performance
- **Indexes:** GIN on `SearchVector`; GIN `pg_trgm` on sender/subject; **HNSW** (or IVFFlat)
on the `pgvector` embedding column.
- **Keyset/cursor pagination** for lexical/structured (replaces offset `Skip/Take`); score-
ordered top-N for ranked/semantic (fetch N, no deep offset).
- **Async embedding backfill** worker (reuse the hosted-worker pattern) so semantic search
builds in the background; new mail embedded on sync. Batch to respect the 3080's VRAM.
- Cache suggestions/recent; debounce live search (~120ms).
- Target: **<150ms** lexical, **<400ms** hybrid on 100k emails (local).
## Privacy
- Embeddings and NER computed **locally via Ollama**, stored **locally** in Postgres.
- Nothing leaves the machine unless the user explicitly selects a cloud provider.
- **AI-off fallback:** Layers 12 (structured + ranked lexical + fuzzy) — still a top-tier
search. Semantic/NL/conversational simply hide when AI is disabled.
## Mapping to the existing code (extend, don't rewrite)
- **`GmailQueryParser``SearchIntentParser`:** keep operator parsing; add a rules-first
NL layer, optional local-LLM disambiguation, emit the same `SearchRequestDto` (+ new fields).
- **`SearchService`:** add ranking (`ts_rank_cd`), `websearch_to_tsquery`, `pg_trgm`
fallback, multi-field vector, hybrid RRF, keyset pagination, `ts_headline` explanations.
- **`Email.SearchVector`:** widen the generated column (weighted A/B/C/D) + add a
`pgvector` `Embedding` column + `EmailEntities`/`ThreadSummary` tables.
- **AI seam:** extend to `IEmbeddingProvider.EmbedAsync` and a structured
`IAiProvider.CompleteStructuredAsync` (see [06 AI Strategy](06-ai-strategy.md)); Null
provider returns "unavailable" so callers fall back to lexical.
## Phasing (feeds the roadmap)
- **MVP search:** relevance ranking · multi-field FTS · fuzzy · chips · saved/recent/suggested · "why matched" (lexical) · keyset pagination.
- **v1.1:** natural-language parse · people search · attachment (filename) · search-driven bulk.
- **v1.2:** semantic (pgvector) · related · find-similar · thread summaries in results.
- **v2.0:** conversational "ask your inbox" (RAG + citations) · entity search · duplicate detection · attachment OCR/content.
+113
View File
@@ -0,0 +1,113 @@
# 06 — AI Strategy & Model Recommendations (Phase 5)
AI is **optional, modular, local-first, and explainable**. The app must be fully usable
with AI disabled. Primary hardware: **NVIDIA RTX 3080 (10 GB VRAM)**. All AI sits behind
an abstraction so models/providers swap without touching application logic.
---
## Part A — The AI abstraction (extends the existing seam)
Today there is one interface, `IAiProvider.CompleteAsync(system, user)`, with
`NullAiProvider` / `OllamaProvider` / `OpenAiProvider`. We extend it into a small set of
capability interfaces plus a task-oriented facade.
### Provider-level interfaces (Infrastructure)
```csharp
public interface IAiProvider { // chat / generation (exists)
AiProviderMode Mode { get; }
Task<string> CompleteAsync(string system, string user, CancellationToken ct = default);
// NEW: schema-constrained JSON (Ollama `format`, OpenAI `response_format`)
Task<T?> CompleteStructuredAsync<T>(string system, string user, CancellationToken ct = default);
}
public interface IEmbeddingProvider { // NEW: vectors for semantic search/dedup
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default);
}
public interface IAiCapabilities { // NEW: lets the UI hide what isn't available
bool Chat { get; } bool Embeddings { get; } bool Vision { get; } bool StructuredJson { get; }
}
```
`NullAiProvider`/`NullEmbeddingProvider` return empty/"unavailable" so **every caller has a
graceful non-AI path** (fall back to lexical search, heuristics, or hide the feature).
### Task facade (Application) — the only thing feature code calls
```csharp
public interface IInboxAi {
Task<AiResult<string>> SummarizeThreadAsync(Guid threadId, CancellationToken ct);
Task<AiResult<Extraction>> ExtractAsync(string body, ExtractKinds kinds, CancellationToken ct);
Task<AiResult<Classification>>ClassifyAsync(string subject, string body, CancellationToken ct);
Task<AiResult<Answer>> AskAsync(string question, SearchScope scope, CancellationToken ct); // RAG
Task<float[]?> EmbedAsync(string text, CancellationToken ct);
Task<AiResult<RiskVerdict>> AssessPhishingAsync(EmailContext ctx, CancellationToken ct);
}
```
- `AiResult<T>` carries `{ value, available, model, latencyMs, explanation }` → powers the
**"explainability"** and graceful-degradation requirements everywhere.
- A **model router** maps *logical task → model name* from config, so swapping a model is a
config change: `Ai:Models:{Chat,Summarize,Embed,Classify,Extract,Vision}`.
- Prompt templates are **versioned files**, separate from code.
- Cross-cutting: async, `CancellationToken`, per-call timeout + **Polly** fallback to the
Null path, and a token/VRAM-aware concurrency limiter.
### Config shape
```
Ai:Mode = Disabled | LocalOllama | Cloud...
Ai:Ollama:BaseUrl, KeepAliveSeconds, VramBudgetMb
Ai:Models:Chat = qwen2.5:7b-instruct
Ai:Models:Embed = nomic-embed-text
Ai:Models:Vision = qwen2.5vl:7b (load-on-demand)
```
---
## Part B — Model recommendations for the RTX 3080 (10 GB)
**Budget reality:** a 78B instruct model at Q4/Q5 (~5 GB + ~1 GB KV cache at 8k context)
runs **alongside** a small embedding model (~0.5 GB) inside 10 GB with headroom. 14B is
possible (~9 GB) but leaves no room for concurrency and is slower — **78B is the sweet spot.**
| Task | Recommended (primary) | Alt | ~VRAM (Q4) | Latency | License | Notes |
|------|----------------------|-----|-----------|---------|---------|-------|
| **Chat / reasoning / RAG answers** | **Qwen2.5-7B-Instruct** | Llama-3.1-8B, Gemma-2-9B | ~5.5 GB | ~3060 tok/s | Apache-2.0 | Strong reasoning, multilingual, great JSON |
| **Summarisation** | reuse **Qwen2.5-7B** | Llama-3.1-8B | (shared) | fast | Apache-2.0 | No separate model needed |
| **Extraction (tasks/dates/entities)** | reuse **Qwen2.5-7B** + `format:json` | Phi-4 | (shared) | fast | Apache-2.0 | Schema-constrained JSON output |
| **Classification / tagging** | **Qwen2.5-3B** (fast) *or* embeddings-zero-shot | rules-first (exists) | ~2.2 GB | very fast | Apache-2.0/Qwen | High-volume → small model or embeddings, not 7B |
| **Embeddings (semantic search, dedup, zero-shot class.)** | **nomic-embed-text** (768-d, 8k ctx) | mxbai-embed-large, **bge-m3** (multilingual) | ~0.5 GB | very fast | Apache-2.0 | **Keep permanently loaded** |
| **OCR / attachment vision** | **Qwen2.5-VL-7B** *or* **MiniCPM-V** | llama3.2-vision-11B, moondream (tiny) | ~7 GB | slow | Apache/varied | **Load-on-demand**; evicts the text LLM |
| **Language detection** | **library, not an LLM** (fastText-lid / CLD3) | — | ~0 | instant | MIT/Apache | Don't waste VRAM on this |
| **Translation** | reuse **Qwen2.5-7B** (multilingual) | NLLB-200 (dedicated MT) | (shared) | med | Apache-2.0 | Good for common pairs; on-demand |
| **Spam detection** | **rules + small classifier/embeddings** | 3B LLM for edge cases | ~02 GB | fast | — | Traditional-first; LLM only for ambiguity |
| **Phishing detection** | **rules/URL analysis + embeddings**, LLM **reasoning** for suspicious | Qwen2.5-7B for explanation | shared | on-demand | — | LLM explains *why*; runs async on flagged mail |
| **Duplicate detection** | **hashing (exact) + embedding cosine (near)** | — | (uses embed) | fast | — | No chat model required |
**Selection rationale:** accuracy + **Apache-2.0 licensing** (commercial-safe) + fits 10 GB
+ strong **structured-JSON** and multilingual (mailboxes aren't English-only). Qwen2.5-7B is
a single versatile workhorse for chat/summarise/extract/translate; nomic-embed-text is a
tiny always-on retrieval engine; a vision model is a heavy, rare, on-demand guest.
### Load policy (VRAM management)
| State | Models | Rationale |
|-------|--------|-----------|
| **Hot (permanent)** | `nomic-embed-text` (~0.5 GB) | Used constantly (search, dedup, classify, backfill) — must be instant |
| **Warm (keep-alive)** | `qwen2.5:7b-instruct` (~5.5 GB) | Loaded on first chat/summarise, kept warm ~510 min idle via Ollama `keep_alive`, then unloaded |
| **Cold (on-demand)** | `qwen2.5vl:7b` vision, `nllb` translation | Loaded only for the specific job; unloaded after (would otherwise exceed budget alongside the 7B) |
- Ollama handles load/unload; we set `keep_alive` per task and a **VRAM budget guard** that
serialises a vision job behind unloading the text LLM.
- Embedding **backfill** runs as a hosted background worker, batched, low priority, so it
never starves interactive requests.
---
## Part C — Principles
1. **Traditional-first.** If rules/heuristics/library solve it well (language detection,
exact dedup, basic spam, most categorisation), use them — AI only where it *clearly* wins.
2. **Always optional.** Null providers + capability flags → the app is whole without AI.
3. **Local & private by default.** Ollama on-device; data never leaves unless a cloud
provider is explicitly chosen.
4. **Explainable.** Every AI output carries a short rationale + the model used.
5. **Swappable.** Logical-task→model routing via config; prompts versioned; no feature code
references a model name.
6. **Bounded.** Timeouts, cancellation, Polly fallback, VRAM-aware concurrency — AI can never
hang or crash the core experience.
+70
View File
@@ -0,0 +1,70 @@
# 07 — AI Feature Catalogue (Phase 6)
Every realistic AI feature, judged honestly against the guiding rule: **AI only where it
clearly beats traditional approaches.** Each entry: *problem · AI role & why · could
traditional solve it? · complexity · performance · privacy.* All AI is local (Ollama),
optional, advisory, and explainable (see [06](06-ai-strategy.md)). Complexity = S/M/L/XL.
Legend for the verdict column:
🟢 **AI clearly wins** · 🟡 **traditional-first, AI for the hard tail** · ⚪ **not AI at all**
---
## Group 1 — 🟢 AI clearly wins
LLM/embeddings are genuinely the right tool; traditional approaches are weak here.
| Feature | Problem | Why AI (and why traditional falls short) | Cx | Perf | Privacy |
|---------|---------|------------------------------------------|----|------|---------|
| **Thread / conversation summary** | Long threads are walls of text | LLMs summarise free text; regex/extractive summaries miss nuance & context | L | Warm 7B; cache per-thread, invalidate on new msg | Body → local LLM only |
| **Conversational "ask your inbox"** (RAG) | "What did Sarah say about the invoice?" | Retrieval + generation over many emails; impossible with filters alone | XL | Semantic retrieve → 7B answer w/ **citations**; ~13s | Retrieval + gen fully local |
| **Reply suggestions / writing assistant** | Blank-page drafting, tone | LLM drafts context-aware replies; templates can't adapt to content | L | Warm 7B, streamed | Thread context → local |
| **Task / meeting / calendar / reminder extraction** | Commitments hide in prose | LLM structured-JSON extraction of {task, date, attendee}; regex catches only rigid formats | L | 7B `format:json`, async on read/sync | Body → local |
| **Entity extraction** (amounts, orgs, dates, order #s) | Can't search/facet by meaning | LLM/NER generalises across phrasings; regex is brittle per-vendor | L | 7B or small NER, batched at sync | Local |
| **Document / attachment understanding** | Can't search *inside* files | OCR/vision + summarise; no traditional equivalent for images/PDF meaning | XL | Vision model **on-demand** (heavy); OCR async | File content → local |
| **Cross-thread linking / related conversations** | Related context is scattered | Embedding nearest-neighbours find semantic links; keyword join misses paraphrase | M | pgvector HNSW; precomputed | Vectors local |
| **Relationship mapping / knowledge graph** | No view of who/what connects | Extraction + embeddings build a people/topic graph; not expressible in SQL alone | XL | Batch build; incremental | Local graph store |
| **Conversation insights** (decisions, sentiment shift) | "What was decided / how's this going?" | LLM reads intent/sentiment over a thread; rules can't | L | 7B; cache | Local |
| **Sentiment analysis** | Gauge tone (angry client?) | Small model/LLM classifies tone; lexicon methods are crude/misleading | M | small model or embeddings | Local |
| **Email comparison** ("what changed vs last quote?") | Manual diffing of prose | LLM semantic diff; text-diff shows characters, not meaning | M | 7B on two bodies | Local |
| **Explain search results / decisions** | Trust & learnability | For semantic/NL, only the model can say *why*; lexical uses `ts_headline` (non-AI) | M | cheap (reuse retrieval) | Local |
## Group 2 — 🟡 Traditional-first, AI for the hard tail
Heuristics/rules do 7090% cheaply and instantly; AI handles ambiguity and adds explanations.
The existing `HeuristicClassifier` and unsubscribe signals are the traditional backbone.
| Feature | Problem | Traditional core | Where AI adds value | Cx | Perf / Privacy |
|---------|---------|------------------|---------------------|----|----------------|
| **Automatic categorisation** | Sort inbox into buckets | Rules on sender/domain/headers (exists) | Embedding zero-shot / small-LLM for the ambiguous long tail + confidence | M | Rules instant; LLM only on "unknown"; local |
| **Smart filing / smart labels** | Where should this go? | Rules + user's past filing patterns | LLM/embedding *suggestions* with confidence, user-correctable | M | Suggest async; local |
| **Priority prediction** | What needs me now? | Behavioural signals: your reply-rate to sender, frequency, VIPs, keywords, direct-to-me | ML/LLM refines ranking for edge cases | M | Mostly SQL/heuristic; local |
| **Follow-up / awaiting-reply detection** | Dropped balls | Heuristic: *you* sent, contains a question, no reply in N days | LLM confirms "expects a reply" & drafts nudge | M | Heuristic instant; LLM optional; local |
| **Smart notifications** | Notification fatigue | Rules over priority + quiet hours | LLM tunes "is this actually urgent" for borderline | S | Rules-first; local |
| **Spam detection** | Junk | Rules/Bayesian + provider signals | Small model for novel spam; LLM explains | M | Fast; local |
| **Phishing detection** | Safety | URL/domain analysis, SPF/DKIM hints, lookalike detection (+ existing SSRF guard) | **LLM reasons about social-engineering cues**; runs async on flagged mail, explains risk | L | Rules sync; LLM async on suspicious; local |
| **Duplicate email detection** | Clutter / repeats | **Exact hash** for identical | **Embedding cosine** for near-duplicates | M | hash instant; vector cheap; local |
| **Inbox assistant** (daily brief) | "Catch me up" | Compose from priority/follow-up/counts (rules) | LLM writes the natural-language brief over that structured data | L | 7B once/session; local |
## Group 3 — ⚪ Not AI (don't waste VRAM)
| Feature | Do it with | Why not AI |
|---------|-----------|------------|
| **Language detection** | fastText-lid / CLD3 library | Instant, accurate, ~0 VRAM; an LLM is pure overhead |
| **Exact duplicate detection** | content hash | Deterministic and free |
| **Unsubscribe detection** | List-Unsubscribe header parsing (exists) | Structured signal already present |
---
## Selection guidance (feeds the roadmap)
- **First AI wins (highest value / lowest risk):** thread summary · follow-up detection
(heuristic + AI confirm) · reply suggestions · NL search parse. All reuse the one warm 7B.
- **Semantic tier (needs pgvector + embeddings):** related/find-similar · near-dup ·
conversation insights · categorisation long-tail.
- **Ambitious tier:** ask-your-inbox (RAG) · knowledge graph · attachment/vision understanding.
- **Never gate the core on any of these** — each has a non-AI fallback or simply hides when
AI is off.
## Privacy posture (applies to all)
Email bodies and attachments are processed **on-device via Ollama**; embeddings, summaries,
extractions, and graphs are **stored locally in Postgres**. No content leaves the machine
unless the user deliberately configures a cloud provider — and even then, per-feature
consent should gate it. This is the product's defining trust advantage (see
[02](02-competitor-analysis.md)).
+115
View File
@@ -0,0 +1,115 @@
# 08 — Technical Architecture (Phase 7)
How the redesign is built on the **existing Clean Architecture** — extending seams, not
rewriting. Everything here preserves the dependency rule `Api → Infrastructure →
Application → Domain` and the security posture from [01](01-architecture-review.md).
> **Update — multi-provider platform.** This architecture now assumes the
> [multi-provider design](multi-provider/README.md): the Gmail-centric `Email`/`Sender`
> model generalises to **account-scoped, provider-normalised** entities behind
> `IEmailProvider`; the app becomes **small-team multi-user** (Admin/Member) with
> **OAuth-as-login**, settings, and feature flags; and **AI gating is feature-flag-driven**
> (`ai.enabled` system flag → user pref). See that folder for the provider abstraction,
> DB schema, security model, and admin/settings design; this doc remains the search/AI/
> caching/indexing reference they build on.
## Overall
```
React SPA (v2 shell)
│ REST (+ SSE for streaming AI/search)
Api ──────────────────────────────────────────────
Application: IInboxAi · ISearchService · SearchIntentParser · DTOs
Infrastructure:
├ Search: SearchService (structured + lexical rank + hybrid)
├ AI: IAiProvider / IEmbeddingProvider / model router / VRAM guard
├ Jobs: EmbeddingBackfill · AiEnrichment · IndexMaintenance (IHostedService + Channel queue)
├ Gmail/Sync/Cleanup/Analytics (existing)
└ Persistence: EF Core + Npgsql (+ pgvector)
Domain: entities/enums (+ Embedding, Entities, ThreadSummary, SavedSearch)
External (optional): Ollama (local, own container) · cloud AI (opt-in only)
```
## Search architecture
The 4-layer engine from [05](05-search-redesign.md):
- **Structured** (SQL WHERE) + **Lexical** (`websearch_to_tsquery` + `ts_rank_cd`, weighted
multi-field `tsvector`, `pg_trgm` fuzzy fallback) — **always on**.
- **Semantic** (pgvector, HNSW) + **AI-assisted** (intent parse, RAG) — **optional**.
- **Hybrid fusion (RRF)** merges lexical+semantic; boosts recency/sender/unread.
- **Keyset pagination** replaces offset; score-ordered top-N for ranked queries.
- `SearchIntentParser` (evolves `GmailQueryParser`): operators → rules-NL → optional LLM.
## AI architecture
- Provider interfaces + `IInboxAi` facade + **model router** (task→model via config) +
**prompt templates** (versioned files) + **VRAM guard** (serialises heavy vision jobs
behind unloading the 7B; embeddings stay hot). See [06](06-ai-strategy.md).
- **Streaming** via SSE for summaries/replies/RAG answers (perceived speed).
- **Bounded**: per-call timeout, `CancellationToken`, Polly fallback to Null path.
## Plugin / modularity architecture
- **Analyzer pipeline:** email enrichment is a set of `IEmailAnalyzer` plugins
(classifier, entity-extractor, summariser, phishing, dedup). Each declares required
capabilities (`Chat`/`Embeddings`/none) and is **skipped gracefully** if unavailable.
New AI features = new analyzers; no core changes.
- **Search providers** implement a common `ISearchLayer` so semantic/AI layers plug in.
- **AI providers** already pluggable (`IAiProvider`); adding a provider = one class + config.
- This is the "modular AI" requirement realised structurally.
## Caching
- **Embeddings & summaries**: persisted in Postgres (compute once, invalidate on new msg).
- **Search suggestions / recent**: cached per-user (memory + DB); debounced live search.
- **Query results**: short-TTL cache for identical repeated queries; ETag on read endpoints.
- **Model warmth**: Ollama `keep_alive` keeps the 7B hot between calls.
## Indexing
| Index | Column | Purpose |
|-------|--------|---------|
| GIN | `SearchVector` (weighted A/B/C/D) | Full-text |
| GIN `pg_trgm` | sender addr/name, subject | Fuzzy / substring (fixes non-sargable `.Contains`) |
| HNSW | `Email.Embedding` (pgvector) | Semantic k-NN |
| btree | `(UserId, SentAtUtc)`, `(UserId, ThreadId)` | Keyset pagination, threading |
- Built incrementally on sync; **EmbeddingBackfillWorker** batch-fills history (VRAM-aware, low priority).
## Background jobs
- Keep the existing `IHostedService` worker pattern; add a **`Channel<T>` in-process queue**
with a bounded concurrency worker for AI enrichment (summaries/entities/embeddings on new
mail). Idempotent, resumable, backpressured. (Upgrade path: durable queue if multi-node.)
- Jobs: `GmailSyncWorker` (exists), `DigestWorker` (exists), `EmbeddingBackfillWorker`,
`AiEnrichmentWorker`, `IndexMaintenanceWorker`.
## Database schema improvements
- **Widen** `Email.SearchVector` (subject/sender/filename/labels, weighted).
- **Add**: `Email.Embedding vector(768)`; tables `EmailEntities`, `ThreadSummary`,
`SavedSearch`, `SearchHistory`, `AiJob` (status/retry), `SenderImportance` (materialised).
- **Fix debt**: resolve the `Email↔EmailLabel` global-query-filter warning (optional nav or
matching filters); make FTS **language-aware** (detect → per-language config) instead of hardcoded English.
- **Scale**: consider per-`UserId` list partitioning of `Emails` if single users exceed ~1M rows.
## Scalability
- Single-user/self-host is the primary shape → vertical scaling + good indexes is enough.
- Connection pooling (Npgsql), keyset pagination, top-N-by-score, batched embeddings.
- **Ollama is the throughput bottleneck** → serialise/queue AI, cache aggressively, prefer
the small/embedding models for high-volume paths.
- Multi-tenant future: read replicas, per-user partitioning, durable job queue.
## Deployment
- Extend the Compose stack (from the CI/CD we built): add an **optional `ollama` service**
(profile `ai`) and a **pgvector-enabled Postgres image**. AI-off deployments omit the
profile entirely. Dev→staging auto-deploy already proven; prod is tag-gated.
## Offline support
- Data is **already local** (Postgres on the user's machine) — the product is local-first
by nature. SPA: service-worker cache for shell + last-viewed mail (read offline); queue
mutations (label/cleanup) to replay on reconnect. AI is inherently offline (Ollama local).
## Security model
- Retain: OAuth **read-only** scope, encrypted refresh tokens (Data Protection), **IDOR
global query filters**, SSRF egress guard, non-root containers, confirmed+previewed
destructive actions.
- **New AI concerns:**
- **Prompt injection** — email content is untrusted input to the LLM. Treat model output
as **advisory only**; never let it trigger actions directly; sanitise/format; the LLM
can *suggest* but a human/rule confirms (aligns with "AI never acts").
- **Attachment/vision** — sandbox parsing; size/type limits; never execute.
- **Local-only by default** — cloud provider is explicit opt-in with per-feature consent
and egress logging; the SSRF guard already constrains outbound calls.
- Secrets stay in `deploy/.env` / Actions secrets (never in git) — as established.
+93
View File
@@ -0,0 +1,93 @@
# 09 — Product Roadmap (Phase 8)
Everything from discovery, split into releases and **prioritised by user value vs
engineering effort**. The guiding sequence: **ship the redesign + dramatically better
(deterministic) search first**, then layer **optional local AI** in value order, each tier
reusing infrastructure the previous one built.
> **Update — multi-provider platform epic.** The
> [multi-provider + admin/settings design](multi-provider/README.md) is a **v1.x platform
> epic** (its own 6-phase plan) that the search/AI features build on. Sequencing note: the
> **provider abstraction, unified email model, settings, and feature-flag engine land early
> in v1.x** (they underpin multi-user + AI gating); the AI features from this roadmap then
> plug into that flag system. Both plans share the same flag-gated, ship-dark discipline.
## Prioritisation framework
Score each item **Value (15) × (6 Effort 15)**, then sequence so that (a) high-value/
low-effort ships first, (b) risky/expensive AI comes only after its infrastructure exists,
and (c) **nothing in an early release depends on AI being enabled.**
```
Value ▲ ★ MVP first ● do next ○ later
5 │ ★ranking ★redesign ●NL search ○ask-inbox
4 │ ★fuzzy ★chips ★summary ●semantic ●brief ○knowledge graph
3 │ ★perf/indexes ●people ●extract ○collaboration
2 │ ●categorise-tail ○plugins ○mobile
1 │ ○multi-provider
└───────────────────────────────────────────────────►
low effort ──────────────────────────► high effort
```
---
## MVP → **v1.0.0** — "The redesign + world-class deterministic search"
Rationale: the single biggest perceived-quality jump, built mostly on **deterministic**
tech (low risk). AI appears only as a few optional, reversible wins behind a toggle.
- **UX v2 shell + design system + both themes** (dark-first, green ramp) — the "world-class" feel.
- **Search core:** relevance ranking (RRF/`ts_rank`), multi-field weighted FTS, `pg_trgm`
fuzzy, **filter chips**, saved/recent/suggested, **"why matched"** (lexical), keyset pagination.
- **Perf/debt:** `pg_trgm` + FTS indexes, virtualised list, code-splitting, fix EF
query-filter warning, language-aware FTS.
- **AI foundation:** extended abstraction (`IAiProvider`+`IEmbeddingProvider`+facade+router),
Null + Ollama wired, **VRAM guard**, prompt templates.
- **First AI wins (optional):** thread summary · follow-up detection (heuristic + AI confirm)
· reply suggestions.
- **Why now:** delivers the flagship promise (fast, approachable, ranked, explainable search
+ a premium UI) even with AI off.
## v1.1 — "Assisted search & productivity"
Rationale: build on MVP's embedding groundwork; assist without heavy compute.
- **Natural-language search** parse (rules-first + optional LLM), shown as editable chips.
- **People search** · **attachment (filename) search** · **search-driven bulk actions**.
- **Inbox assistant / daily brief** · **task/calendar/reminder extraction**.
- **Categorisation long-tail** (embedding zero-shot + confidence) · **priority prediction v1**.
- **Why now:** the "assisted, not syntactic" search vision, plus the highest-value AI
productivity features — all still light on VRAM.
## v1.2 — "Semantic tier"
Rationale: introduces `pgvector` + embedding backfill; unlocks meaning-based features.
- **Semantic search** (pgvector HNSW, hybrid RRF) · **related / find-similar**.
- **Near-duplicate detection** · **thread summaries surfaced in results** · **conversation insights**.
- **Relationship mapping (basics)**.
- **Why now:** semantic recall is a headline differentiator but needs the embedding
infrastructure and backfill worker to be mature and VRAM-safe.
## v2.0 — "Ambitious AI"
Rationale: flagship, compute-heavy features that need a mature semantic + vision stack.
- **Conversational "ask your inbox"** (RAG + **citations**).
- **Entity search & facets** (amounts, orgs, dates) · **attachment OCR/content understanding** (vision, on-demand).
- **Phishing reasoning** (LLM over flagged mail, async, explained) · **knowledge graph**.
- **Why now:** highest wow-factor and the strongest "local private AI" story, but only
worthwhile once retrieval, extraction, and vision infra are proven.
## v3.0 — "Platform"
Rationale: expand beyond the individual power-user once the core is best-in-class.
- **Collaboration / shared inbox** (assign, comment) · **automation / rules engine**
(a `docs/specs/feature-rules-engine.md` already exists — fold it in).
- **Multi-account & additional providers** (IMAP/Outlook) · **plugin ecosystem** (analyzer
API) · **mobile app** · **multi-window / desktop shell** (Tauri) · **multi-provider AI**.
- **Why now:** platform bets that only pay off on top of a beloved core product.
---
## Sequencing principles (explained)
1. **Deterministic value before AI.** MVP's biggest wins (ranking, chips, redesign) need no
AI — they de-risk the release and prove the product before compute-heavy features.
2. **Infrastructure amortised.** Embeddings introduced once (v1.1 foundations → v1.2 usage)
power search, dedup, related, categorisation, and RAG — spread the cost.
3. **Value-first within a release.** Inside each version, highest value/effort ships first so
partial delivery is still shippable.
4. **AI is always additive.** Every release is complete and excellent with AI disabled —
protecting the "works without AI" mandate and the mainstream audience.
5. **Platform last.** Collaboration/plugins/mobile are large and only worthwhile once the
individual experience is genuinely best-in-class.
+60
View File
@@ -0,0 +1,60 @@
# 10 — Git Implementation Plan (Phase 10)
Turns the [roadmap](09-roadmap.md) into executable Git work, on the workflow already in
place ([../WORKFLOW.md](../WORKFLOW.md)): trunked `develop`/`main`, Conventional Commits,
PR-gated CI/Security, auto-deploy to staging, tag-gated production.
## Structure: Epics → Features → Tasks
- **Epic** = a roadmap theme → a milestone + a long-lived integration effort.
- **Feature** = one shippable slice → one `feature/*` branch → one PR into `develop`.
- **Task** = one atomic commit (conventional) within a feature branch.
## Epics (mapped to releases)
| Milestone | Epic | Example feature branches |
|-----------|------|--------------------------|
| **v1.0.0** | `epic/design-system` | `feature/design-tokens` · `feature/app-shell` · `feature/themes-dark-light` |
| **v1.0.0** | `epic/search-core` | `feature/search-ranking` · `feature/fts-multifield` · `feature/search-fuzzy-trgm` · `feature/search-chips` · `feature/saved-recent-searches` · `feature/search-why-matched` · `feature/keyset-pagination` |
| **v1.0.0** | `epic/perf-and-debt` | `feature/trgm-indexes` · `feature/virtualised-list` · `feature/code-splitting` · `fix/ef-queryfilter-warning` · `feature/language-aware-fts` |
| **v1.0.0** | `epic/ai-foundation` | `feature/ai-abstraction` · `feature/embedding-provider` · `feature/ai-vram-guard` · `feature/ai-thread-summary` · `feature/ai-followup-detect` · `feature/ai-reply-suggest` |
| **v1.1.0** | `epic/assisted-search` | `feature/nl-search-parse` · `feature/people-search` · `feature/attachment-filename-search` · `feature/search-bulk-actions` |
| **v1.1.0** | `epic/ai-productivity` | `feature/inbox-brief` · `feature/task-calendar-extract` · `feature/categorise-tail` · `feature/priority-v1` |
| **v1.2.0** | `epic/semantic` | `feature/pgvector-schema` · `feature/embedding-backfill` · `feature/semantic-search` · `feature/find-similar` · `feature/near-dup` · `feature/thread-insights` |
| **v2.0.0** | `epic/ambitious-ai` | `feature/ask-your-inbox-rag` · `feature/entity-facets` · `feature/attachment-ocr` · `feature/phishing-reasoning` · `feature/knowledge-graph` |
| **v3.0.0** | `epic/platform` | `feature/rules-engine` · `feature/collaboration` · `feature/multi-account` · `feature/plugin-api` · `feature/mobile` |
## Branch naming
- `feature/<kebab-scope>` · `fix/<kebab>` · `hotfix/<kebab>` (off `main`) · optional
`release/x.y.0` for stabilisation. Epics tracked via milestone/label, not a long branch
(avoid merge hell); features integrate continuously into `develop`.
## Commit strategy
- **Conventional Commits** (already used): `type(scope): summary`. Types drive SemVer:
`feat`→minor, `fix`→patch, `feat!`/`BREAKING CHANGE`→major.
- One logical change per commit; **docs updated in the same commit/PR** as the behaviour
they describe (enforced by review checklist — see below).
## PR strategy
- `feature/* → develop`, **squash-merge**; `develop → main`, **merge commit** (release boundary).
- **Required checks** (already enforced): `CI/backend`, `CI/frontend`, `Security/secrets`,
`Security/dependencies`. Merge to `develop` auto-deploys **staging**.
- **PR checklist:** tests added (unit + integration for API changes) · docs updated ·
AI features have a **Null/AI-off path** · no secret committed · perf-sensitive paths have
an index/plan note.
## Release milestones (tags on `main`)
| Tag | Contents | Gate |
|-----|----------|------|
| `v1.0.0` | Redesign + deterministic search + AI foundation & first wins | Redesign parity + search benchmarks green |
| `v1.1.0` | Assisted search + AI productivity | NL parse accuracy + extraction quality bar |
| `v1.2.0` | Semantic tier | Backfill complete + hybrid-rank quality bar |
| `v2.0.0` | Ambitious AI (RAG, vision, graph) | RAG citation accuracy + VRAM stability |
| `v3.0.0` | Platform (collab, rules, plugins, mobile) | — |
- Cutting a tag = the production-promotion action (see [../WORKFLOW.md](../WORKFLOW.md) §56);
`deploy-prod.yml` fires on `v*` once the Linux server + its runner exist.
- Interim work ships as `0.x`/pre-release increments on `develop`; `v1.0.0` is the first
"world-class" cut.
## Docs-alongside-code (hard rule)
Every feature PR updates the relevant doc: `docs/discovery/*` decisions graduate into
`docs/` living docs (architecture, search, AI, API reference) as they're implemented, and
`CHANGELOG.md` gains an entry. Discovery docs are the *source*; implementation keeps them true.
+37
View File
@@ -0,0 +1,37 @@
# 11 — Risk Assessment & Future Opportunities
## Risk assessment
Likelihood (L) / Impact (I): H/M/L.
| # | Risk | L | I | Mitigation |
|---|------|---|---|------------|
| R1 | **Full UI redesign destabilises a working app** | M | H | Strangler migration behind `ui.v2` flag, screen-by-screen; API untouched; ship via the proven `develop→staging` pipeline; keep old screens until parity verified |
| R2 | **VRAM (10 GB) can't hold desired models concurrently** | M | M | 78B sweet-spot (not 14B); embeddings hot + LLM warm + vision on-demand; VRAM guard serialises heavy jobs; small models for high-volume paths |
| R3 | **Local AI quality/latency disappoints** | M | M | Traditional-first (AI only where it clearly wins); AI advisory + optional; stream responses; cache; model routing lets us swap models without code change |
| R4 | **Prompt injection via email content** | M | H | Treat all model output as advisory; **AI never triggers actions**; human/rule confirms; sanitise; SSRF/egress guards; local-only by default |
| R5 | **Gmail API quota / sync scale at 100k+ mailboxes** | M | M | Batching + Polly backoff (exists); incremental sync; background enrichment queue with backpressure; keyset pagination |
| R6 | **Semantic infra (pgvector/embeddings) ops complexity** | M | M | Introduce once (v1.2), backfill worker VRAM-aware + resumable; HNSW tuning; feature hides if unavailable |
| R7 | **Search relevance regressions vs today** | L | M | Lexical hits never lose to fuzzy noise (weighting); benchmark suite as a release gate; keep date-sort as a user option |
| R8 | **Scope creep — trying to beat everyone at once** | H | M | Roadmap value/effort discipline; MVP is deterministic + small AI; platform features deferred to v3 |
| R9 | **Solo-dev bandwidth / single-machine staging** | H | M | Small shippable features; CI/CD automation already reduces toil; staging owned by automation (don't hand-run it — see memory note) |
| R10 | **Mainstream-vs-power tension dilutes the UX** | M | M | Progressive disclosure: simple default, power revealed on demand; pointer-first with keyboard as accelerator |
| R11 | **Privacy promise broken by a cloud provider option** | L | H | Local default; cloud is explicit per-feature opt-in with egress logging + consent; never silent |
| R12 | **pgvector image / Ollama container adds deploy friction** | L | L | Optional Compose profiles (`ai`); AI-off deployments omit them entirely |
## Future opportunities (beyond v3.0)
- **Additional mail backends** — IMAP/JMAP, Outlook/Graph — become a true multi-provider client.
- **On-device personalisation** — light fine-tuning / user-preference adapters for priority & tone.
- **Calendar & tasks integration** — close the loop from extraction to action.
- **Voice** — dictate replies, "ask your inbox" by voice (local Whisper).
- **Plugin marketplace** — third-party analyzers/widgets on the analyzer + command-palette APIs.
- **Team knowledge base** — shared, permissioned knowledge graph across a team inbox.
- **Native desktop shell** (Tauri) for OS integration, global hotkey, tray, true multi-window.
- **Smart compose surfaces** — templates that learn, snippet library, per-recipient tone memory.
- **Local model upgrades** — swap in newer/quantised models as they ship (router makes it a config change).
## Recommendation
The foundation is strong and the wedge is real. **Proceed with v1.0.0 (redesign +
deterministic search + AI foundation)** — it's high-value, low-risk, and independent of AI
being enabled — then layer local AI in value order. The biggest watch-items are **R1
(migration discipline)** and **R8 (scope)**; both are controlled by the strangler approach
and the value/effort-sequenced roadmap.
+70
View File
@@ -0,0 +1,70 @@
# Executive Summary — InboxIntel Discovery Blueprint
**Ambition:** become one of the best **email search and management** experiences available
— fast, scalable, secure, intuitive — with **AI used only where it clearly beats
traditional approaches**, running **locally and privately**, and **never as a hard
dependency**.
## The opportunity (the wedge)
The market has split in two, and both halves leave a gap:
- **AI, but cloud** (Gmail/Gemini, Outlook/Copilot, Shortwave, Spark) — useful, but your
mail is processed off-device.
- **Private, but little/no AI** (Proton, Thunderbird, Fastmail).
**No mainstream product owns "genuinely useful AI that runs on your own machine."**
InboxIntel can — and it already has the seams for it. Combined with three more openings —
**approachable power-search as the home screen**, **explainability** ("why this matched /
was categorised"), and **fast *and* friendly** (Superhuman is fast-but-intimidating; casual
apps are friendly-but-slow) — this is a defensible, differentiated position.
> **Positioning:** *Make search the fastest way to think about your inbox, with AI that runs
> on your own machine and always explains itself. Powerful for pros, simple for anyone.*
## Current state — verdict: extend, don't rewrite
A clean, secure, well-tested .NET 8 / React foundation with **real Postgres full-text
search** and **an AI provider abstraction already in place** (`NullAiProvider` /
`OllamaProvider` / `OpenAiProvider`). The gaps are exactly where the product wants to win:
relevance-ranked multi-mode search, a richer AI contract (embeddings/extraction),
conversation intelligence, and a modern approachable UX. **None require a rewrite.**
## Design direction (from the interview)
Notion/Arc-**professional**, "anyone can pick it up": **pointer-first & discoverable**
(palette/shortcuts as accelerators), **balanced density**, **dark-first** (genuine light
too), **subtle motion**, **green `#3ba31f`** accent, clean rounded line icons, **fully
responsive** desktop→mobile. The pivotal decision — *discoverable-first over keyboard-first*
— reframes search from "a syntax you learn" into "an inviting, assisted experience."
## Search & AI in a nutshell
- **Search:** a 4-layer engine — Structured → **Lexical+Ranking** (always on) → Semantic
(pgvector) → AI-assisted (NL / ask-your-inbox / explanations) — with **hybrid RRF ranking**
replacing today's date-only sort. Degrades gracefully with AI off.
- **AI:** extend the abstraction to embeddings + structured output behind a task facade with
**config-driven model routing**; on the **RTX 3080 (10 GB)**, **Qwen2.5-7B** (warm) +
**nomic-embed-text** (hot) do most jobs, vision on-demand. Traditional-first everywhere;
every AI feature has a non-AI fallback.
## Roadmap shape
**v1.0** redesign + world-class *deterministic* search + AI foundation → **v1.1** assisted
search & productivity → **v1.2** semantic tier → **v2.0** ambitious AI (RAG, vision, graph)
**v3.0** platform (collaboration, rules, plugins, mobile). Sequenced so **deterministic
value ships before AI**, infrastructure is amortised, and **every release is complete with
AI disabled.**
## Key risks
Migration of a full redesign (**mitigated by strangler + `ui.v2` flag**), VRAM limits
(**78B sweet spot + load policy**), prompt injection (**AI advisory-only, never acts**), and
scope creep (**value/effort-sequenced roadmap**). See [11](11-risks-and-future.md).
## Recommendation
**Proceed to v1.0.0** — the redesign, ranked/assisted search, and AI foundation. It's the
biggest quality jump, it's low-risk and deterministic, and it stands entirely on its own
without AI. Then layer local, private, explainable AI in value order.
---
### Read the full blueprint
[01 Architecture](01-architecture-review.md) · [02 Competitors](02-competitor-analysis.md) ·
[03 Users](03-user-research.md) · [04 UX + Design System](04-ux-redesign-and-design-system.md) ·
[05 Search](05-search-redesign.md) · [06 AI Strategy](06-ai-strategy.md) ·
[07 AI Features](07-ai-feature-catalogue.md) · [08 Architecture](08-technical-architecture.md) ·
[09 Roadmap](09-roadmap.md) · [10 Git Plan](10-git-plan.md) ·
[11 Risks & Future](11-risks-and-future.md) · [Design Brief](00-design-brief.md)
+48
View File
@@ -0,0 +1,48 @@
# InboxIntel — Discovery & Blueprint
This folder is the **product discovery output**: a world-class specification that will
become the blueprint for the next generation of InboxIntel. It is a **planning
artifact** — no application code is changed during discovery.
> Vision: become one of the best **email search and management** experiences available —
> extremely fast, scalable, secure, and intuitive. AI is introduced **only where it
> clearly beats traditional approaches**, and never as a hard dependency.
## Non-negotiable constraints (carried into every phase)
- **AI is optional & modular.** The app must work fully with AI disabled. All AI sits
behind an `IAIProvider` abstraction (already seeded: `NullAiProvider` / `OllamaProvider`
/ `OpenAiProvider`) so providers swap without touching application logic.
- **Local-first AI.** Primary target: Ollama on an NVIDIA RTX 3080 (10 GB).
- **Privacy.** Read-only Gmail scope; AI advisory-only; no destructive AI actions.
- **Speed & security are features**, not afterthoughts.
## Document index
> **Start here:** [Executive Summary](EXECUTIVE-SUMMARY.md)
| # | Document | Phase | Status |
|---|----------|-------|--------|
| — | [Executive Summary](EXECUTIVE-SUMMARY.md) | all | ✅ draft |
| 00 | [Design Brief](00-design-brief.md) | 4A (interview) | ✅ locked |
| 01 | [Architecture Review](01-architecture-review.md) | 1 | ✅ draft |
| 02 | [Competitor Analysis](02-competitor-analysis.md) | 2 | ✅ draft |
| 03 | [User Research & Journey Maps](03-user-research.md) | 3 | ✅ draft |
| 04 | [UX/UI Redesign + Design System](04-ux-redesign-and-design-system.md) | 4A | ✅ draft |
| 05 | [Search Redesign](05-search-redesign.md) | 4B | ✅ draft |
| 06 | [AI Strategy & Model Recommendations](06-ai-strategy.md) | 5 | ✅ draft |
| 07 | [AI Feature Catalogue](07-ai-feature-catalogue.md) | 6 | ✅ draft |
| 08 | [Technical Architecture](08-technical-architecture.md) | 7 | ✅ draft |
| 09 | [Product Roadmap (MVP→v3)](09-roadmap.md) | 8 | ✅ draft |
| 10 | [Git Implementation Plan](10-git-plan.md) | 10 | ✅ draft |
| 11 | [Risk Assessment & Future Opportunities](11-risks-and-future.md) | cross-cutting | ✅ draft |
## Extension designs
- [**Multi-Provider Email Platform + Admin/Settings**](multi-provider/README.md) — evolves
InboxIntel into a small-team, multi-provider (Gmail/Outlook/IMAP) platform with OAuth-as-
login, settings, feature flags, and an admin panel. Reshapes the core assumptions above
(see the "Update" callouts in [08](08-technical-architecture.md) and [09](09-roadmap.md)).
## Conventions
- Every recommendation is a durable markdown doc suitable for long-term maintenance.
- Each feature is specified with: **problem solved · why AI (or why not) · complexity ·
estimated effort · user value · perf & privacy considerations**.
- Nothing here is implemented until explicitly approved and scheduled via the Git plan.
@@ -0,0 +1,80 @@
# 01 — Provider Abstraction (Part 1)
A unified layer so Gmail, Outlook/Graph, and future IMAP look identical to the rest of the
app. **No provider-specific logic in Domain**; specifics live in Infrastructure adapters.
## Layering
```
Domain : Account, EmailMessage, EmailThread, Label (provider-agnostic)
Application : IEmailProvider (contract) · ISyncOrchestrator · DTOs
Infrastructure : GmailProvider · OutlookProvider · ImapProvider (adapters)
ProviderFactory (ProviderType → adapter) · ITokenStore
```
## The contract
```csharp
public enum ProviderType { Google, Microsoft, Imap }
public interface IEmailProvider {
ProviderType Type { get; }
ProviderCapabilities Capabilities { get; } // read, modifyFlags, folders, delta, send?
// Auth (details in 02-auth-and-signin.md)
Task<OAuthResult> ExchangeCodeAsync(string code, CancellationToken ct);
Task<TokenSet> RefreshAsync(TokenSet current, CancellationToken ct);
Task<ProviderIdentity> GetIdentityAsync(TokenSet tokens, CancellationToken ct); // sub + email
// Sync (pull-based, incremental)
Task<SyncPage> SyncAsync(SyncCursor cursor, TokenSet tokens, CancellationToken ct);
Task<RawMessage> FetchMessageAsync(string providerMessageId, TokenSet tokens, CancellationToken ct);
// Mutations (only if Capabilities allow; mirrors current gmail.modify scope)
Task ApplyFlagAsync(string providerMessageId, MailFlagChange change, TokenSet tokens, CancellationToken ct);
}
```
- `SyncPage` = `{ IReadOnlyList<RawMessage> upserts, IReadOnlyList<string> deletes, SyncCursor next, bool hasMore }`.
- `RawMessage` is the **provider-shaped** payload; a **normaliser** maps it to the domain
`EmailMessage`. The rest of the app never sees `RawMessage`.
- Capabilities let the UI/engine **degrade gracefully** (e.g., an IMAP server without
CONDSTORE falls back to full-scan sync; no `send` today for any provider).
## Provider implementations
| Provider | API | Incremental cursor | Threading | Folders/Labels | Notes |
|----------|-----|--------------------|-----------|----------------|-------|
| **Gmail** | Gmail REST | `historyId` (History API) | `threadId` | labels | Reuses existing client; read + modify (no send), as today |
| **Outlook/365** | Microsoft Graph | **delta query** `@odata.deltaLink` | `conversationId` | mailFolders | OAuth via Microsoft identity platform |
| **IMAP** | IMAP4rev1 | `UIDVALIDITY`+`UIDNEXT`, `HIGHESTMODSEQ` (CONDSTORE/QRESYNC) | heuristic (References/In-Reply-To) | folders | Fallback = periodic UID scan if no CONDSTORE; MailKit already a dependency |
## Normalisation (the unified model)
Each adapter maps provider fields → domain via a `IMessageNormaliser`:
| Domain field | Gmail | Graph | IMAP |
|--------------|-------|-------|------|
| `ProviderMessageId` | message id | message id | `UIDVALIDITY:UID` |
| `ProviderThreadId` | threadId | conversationId | derived (References) |
| flags (unread/star/important/trashed/inbox) | labelIds | isRead/flag/folder | `\Seen \Flagged`, folder |
| labels/folders | labels | mailFolders | folders |
| sent/received, from, subject, snippet, body, attachments, size | headers/parts | message resource | RFC822 parse (MailKit) |
- **Threads are per-account** (each provider defines its own). *Cross-provider* thread
linking is a later semantic/AI feature (see blueprint [07](../07-ai-feature-catalogue.md)),
not part of core normalisation.
## Sync engine
- **`ISyncOrchestrator`** replaces the Gmail-specific worker: for each active `Account`, it
loads the `SyncCursor`, calls `provider.SyncAsync`, **upserts** normalised messages
(idempotent on `(AccountId, ProviderMessageId)`), applies deletes, and **persists the next
cursor** atomically.
- Runs as the existing hosted-worker pattern (`AccountSyncWorker`), one logical job per
account, bounded concurrency, Polly backoff, resumable.
- **Token refresh:** `SyncAsync`/mutations get a valid `TokenSet` from `ITokenStore`, which
refreshes on expiry/401 and **re-encrypts** at rest; a failed refresh flips the account to
`reauth_needed` (surfaced in UI, see [07](07-ux-flows.md)) — never crashes sync.
- **New mail** triggers AI enrichment + embedding jobs (blueprint [08](../08-technical-architecture.md)).
## Search across providers
Because all providers normalise into **one `email_messages` store scoped by `UserId`**,
search (structured + FTS + semantic) **already spans every account a user has connected**
no per-provider search code. An optional `AccountId` facet lets users scope to one mailbox.
## Extensibility
Adding a provider = one `IEmailProvider` adapter + one normaliser + register in
`ProviderFactory` + a feature flag to enable it. **Zero changes to Domain, search, or AI.**
@@ -0,0 +1,70 @@
# 02 — Auth & Sign-in (Part 2)
**OAuth is the login.** No passwords. A user's identity is the set of provider accounts
linked to them; any one can authenticate the session. Identity key is **`(provider, sub)`**
— never email (emails change; `sub` is stable, and the same email can exist on Google *and*
Microsoft as distinct accounts).
## Provider-selection sign-in (first-time)
```
[ Choose how to sign in ]
▸ Continue with Google ▸ Continue with Microsoft ( ▸ IMAP — future )
```
1. User picks a provider → redirect to provider OAuth (**PKCE**, `state`, `nonce`).
2. Callback → exchange code → `GetIdentityAsync` returns `(provider, sub, email, name)`.
3. **Resolve:** look up `accounts (provider, sub)`.
- **No match →** first-time. Create `user` (**first user ever = Admin**, otherwise `Member`
if `system_settings.registration_open`, else reject) + `account` (`is_login_identity=true`)
+ encrypted `provider_tokens`. Start session.
- **Match →** existing user. Refresh tokens, start session.
4. Kick off the account's initial sync.
## Adding another account later (linking) — the security-critical flow
The user is **already authenticated**. "Add account" → provider OAuth in **link mode**:
- Callback identity `(provider, sub)`:
- **Unlinked →** attach a new `account` (mailbox) to the **current** user. ✅
- **Already linked to *this* user →** no-op / "already connected."
- **Already linked to *another* user →** **blocked** by the unique `(provider, provider_account_id)`
constraint + explicit check → error "This mailbox is connected to a different InboxIntel
user." **This is the anti-hijack guarantee** — you can only link an identity you can
authenticate *and* that no one else owns.
- A single user can hold **N accounts** across Google/Microsoft/IMAP; each can also serve as a
login identity (any of them signs you into the same user).
## Switching
- **Switch mailbox (same user):** an **account switcher** changes the active mailbox context
(or "All accounts" unified view). No re-auth — it's all one user. Search can scope to one
account or span all.
- **Switch user (different person):** full sign-out → sign-in. Optional "fast switch" could
hold multiple sessions, but for a small team, explicit re-auth is simplest and safest.
## Sessions
- **Opaque server-side session** (`sessions` table) referenced by an **HttpOnly · Secure ·
SameSite=Lax** cookie. **Provider tokens are never exposed to the browser.**
- Rotate session id on login (anti-fixation); **idle (e.g., 7d) + absolute (e.g., 30d)**
expiry; revoke on logout; **"sign out everywhere"** and **admin revoke** delete session rows.
- CSRF: SameSite + anti-CSRF token on state-changing requests.
## Token lifecycle
- Stored **encrypted at rest** (Data Protection); decrypted only in-memory for API calls.
- **Refresh** on expiry/401 via `ITokenStore` → re-encrypt + persist; failure flips account to
**`ReauthNeeded`** (banner + "Reconnect" CTA, see [07](07-ux-flows.md)) — sync/AI for that
account pause, the rest of the app is unaffected.
## Provider OAuth specifics
| | Google | Microsoft (Graph) |
|--|--------|-------------------|
| Endpoint | accounts.google.com | login.microsoftonline.com (`common`) |
| Scopes | `openid email profile gmail.readonly gmail.modify` | `openid email profile offline_access Mail.Read Mail.ReadWrite` |
| Identity | `sub` (+ verified email) | `oid`/`sub` (+ email) |
| Refresh | refresh_token (offline) | refresh_token (`offline_access`) |
| Redirect | `/signin/google` | `/signin/microsoft` |
- **Least privilege:** request read/modify only (no send today — matches current posture).
Extra scopes are added per-feature with consent, never up-front.
## Edge cases
- **Same email, two providers** → two distinct accounts (identity is `sub`), unless the user
links both to one InboxIntel user.
- **Provider disabled by admin flag** (`provider.microsoft=false`) → hide it on the picker;
existing accounts of that provider pause sync and show a notice.
- **Reused browser / stale cookie** → session validated server-side each request; revoked/expired → re-auth.
@@ -0,0 +1,79 @@
# 03 — Database Design (Part 6)
Schema for a **small-team, self-hosted, one-org** platform: multi-provider accounts per
user, a normalised email store scaling to **millions of messages**, settings, feature
flags, and audit. PostgreSQL + EF Core (+ pgvector for the blueprint's semantic tier).
## Entity map
```
users ─┬─< accounts ─┬─< provider_tokens (1:1, encrypted)
│ ├─< email_threads ─< email_messages ─┬─< attachments
│ │ └─< message_labels >─ labels
│ └─ sync_state (cursor)
├─< user_settings (1:1)
└─< audit_logs (actor)
system_settings (singleton) feature_flags
```
## Tables
### Identity & access
- **`users`** — the app identity (from OAuth).
`id (uuid pk) · primary_email (citext, unique) · display_name · avatar_url · role (enum: Admin|Member) · status (enum: Active|Suspended) · created_at · last_login_at`
*First-ever user is bootstrapped as **Admin** (see [05](05-admin-system.md)).*
- **`accounts`** — a connected mailbox **and** a login identity (OAuth-is-login).
`id (uuid pk) · user_id (fk) · provider (enum: Google|Microsoft|Imap) · provider_account_id (text, the OAuth 'sub' — immutable) · email (citext) · display_name · is_login_identity (bool) · status (enum: Active|ReauthNeeded|Disabled) · scopes (text[]) · added_at · last_sync_at`
**Unique:** `(provider, provider_account_id)` → resolves an OAuth login to exactly one account→user; prevents the same mailbox linking twice.
- **`provider_tokens`** — 1:1 with `accounts`, **encrypted at rest** (Data Protection API).
`account_id (pk/fk) · access_token_enc (bytea) · refresh_token_enc (bytea) · expires_at_utc · token_type · rotated_at`
*Never logged; see [06](06-security-model.md).*
- **`sessions`** — server-side app sessions (opaque cookie).
`id · user_id · created_at · expires_at · ip · user_agent · revoked_at` (supports "sign out everywhere" + admin revoke).
### Email (normalised, provider-agnostic)
- **`email_threads`** — per account.
`id (uuid pk) · account_id (fk) · user_id (denorm) · provider_thread_id · subject · participants (jsonb) · message_count · last_message_at`
**Unique:** `(account_id, provider_thread_id)`.
- **`email_messages`** — the big table.
`id (uuid pk) · account_id (fk) · user_id (denorm) · thread_id (fk) · provider_message_id · sender_id (fk) · subject · snippet · body_text · sent_at_utc · received_at_utc · size_bytes · flags (unread/starred/important/in_inbox/trashed as bits or bools) · has_attachments · category (enum) · has_list_unsubscribe · supports_one_click_unsub · search_vector (tsvector, generated, weighted) · embedding (vector(768), nullable) · created_at`
**Unique:** `(account_id, provider_message_id)` (idempotent upsert).
- **`labels`** (`id · account_id · provider_label_id · name · type`) + **`message_labels`** (`message_id · label_id`, pk both).
- **`attachments`** (`id · message_id · filename · mime · size · provider_attachment_id · content_text nullable` for future OCR/search).
- **`senders`** / **`domains`** (existing) — kept, scoped per user (or global with per-user stats materialised in `sender_importance`).
### Settings, flags, audit
- **`user_settings`** — 1:1 with `users`.
`user_id (pk) · theme (enum: system|light|dark) · inbox_layout (jsonb) · notifications (jsonb) · ai_prefs (jsonb) · provider_prefs (jsonb) · updated_at`
- **`system_settings`** — singleton (org-wide, admin-managed).
`id (const) · maintenance_mode (bool) · default_theme · registration_open (bool) · updated_by · updated_at` (+ arbitrary `values jsonb` for growth).
- **`feature_flags`** — the flag system (drives AI gating).
`key (pk text) · enabled (bool) · scope (enum: SystemOnly|UserOverridable) · description · rollout (jsonb, e.g. per-role) · updated_by · updated_at`
Seeded flags: `ai.enabled`, `ai.semantic_search`, `ai.ask_inbox`, `provider.google`, `provider.microsoft`, `provider.imap`, `maintenance.readonly`.
- **`audit_logs`** — admin + security events.
`id (bigserial) · actor_user_id (fk, nullable for system) · action (text) · target_type · target_id · metadata (jsonb) · ip · created_at`
Append-only; indexed on `(created_at)`, `(actor_user_id)`, `(action)`.
## Indexing & performance
| Index | Column(s) | For |
|-------|-----------|-----|
| GIN | `email_messages.search_vector` | FTS |
| GIN `pg_trgm` | sender/subject | fuzzy (fixes non-sargable `.Contains`) |
| HNSW | `email_messages.embedding` | semantic k-NN |
| btree | `(user_id, sent_at_utc desc)`, `(account_id, sent_at_utc desc)`, `(thread_id)` | keyset pagination, scoping, threading |
| unique | `(account_id, provider_message_id)`, `(provider, provider_account_id)` | idempotency, login resolution |
## Scalability (millions of emails, multi-account, incremental)
- **Multi-account** = first-class via `accounts`; every email carries `account_id` + denormalised `user_id` (so cross-account per-user search is a single indexed scan).
- **Millions of rows:** keyset (cursor) pagination, top-N-by-score ranking, `pg_trgm`/GIN/HNSW indexes. If a single user exceeds ~12M messages, **list-partition `email_messages` by `account_id`** (or hash by `user_id`).
- **Incremental sync:** per-account `sync_state` cursor (`historyId` / `deltaLink` / `uidvalidity+modseq`) → only deltas fetched; idempotent upserts on the unique key.
- **Idempotency & resumability:** all sync writes keyed on `(account_id, provider_message_id)`; cursor advanced atomically with the batch.
## RBAC in the schema
Small-team model = a `role` column on `users` (`Admin|Member`) — no separate roles/permissions tables yet. **Extensible** to `roles`/`permissions`/`org_id` if this ever grows to multi-tenant, without reshaping the email tables.
## Migration from today (see full [Migration Guide](12-migration-guide.md))
1. Create `users` from existing OAuth identity; set first user = **Admin**.
2. Create one `Google` **`account`** per existing user; move current Gmail tokens → `provider_tokens`.
3. Backfill `email_messages.account_id`/`user_id`, rename/extend from the current `Email` table; widen `search_vector`; add nullable `embedding`.
4. Add `user_settings`, `system_settings`, `feature_flags` (seed `ai.enabled` from the current `Ai:Mode`), `audit_logs`.
5. All additive + backfill; no destructive step — safe to run behind a maintenance window.
@@ -0,0 +1,68 @@
# 04 — Settings & Feature Flags (Part 3)
Two layers of configuration — **per-user preferences** and **org-wide system settings /
feature flags** — with a clear precedence. Critically: **AI is governed by a feature flag
(admin, global), not merely a user preference.**
## User settings (per user)
Stored in `user_settings`; editable by the user.
| Setting | Values |
|---------|--------|
| `theme` | system · light · dark (dark-first default) |
| `inbox_layout` | density, pane layout, default view/lane, per-account or unified |
| `notifications` | channels, quiet hours, priority-only |
| `ai_prefs` | per-feature opt-in (summaries, replies, semantic, ask-inbox…) — **only effective if the flag allows** |
| `provider_prefs` | default account, sync frequency, signature per account |
## System settings (admin, org-wide)
Stored in `system_settings` (singleton); Admin-only ([05](05-admin-system.md)).
- `maintenance_mode` (off · read-only · locked-except-admin) · `default_theme` ·
`registration_open` · org display name · retention defaults.
## Feature flag system
`feature_flags` rows: `key · enabled · scope · rollout · description · updated_by/at`.
- **`scope = SystemOnly`** — a hard org-wide switch; users cannot override (e.g., `provider.microsoft`, `maintenance.readonly`).
- **`scope = UserOverridable`** — a default that a user preference can turn *off* (never *on* beyond what the flag permits) — e.g., `ai.summaries`.
- **`rollout`** (jsonb) — optional per-role/percentage gating (e.g., enable a beta for Admins first).
### Evaluation service
```csharp
public interface IFeatureFlags {
bool IsEnabled(string key, UserContext user); // system flag ∧ scope ∧ role rollout
}
public interface IAiGate { // the AI-specific resolver
bool IsAiEnabled(UserContext user); // ai.enabled (system) ∧ user.ai_prefs.master
bool IsAiFeatureEnabled(string feature, UserContext user); // ∧ ai.<feature> ∧ user opt-in
}
```
- Flags are **cached** with change notification (hot-reload on admin edit); every read is cheap.
- All flag reads are **fail-closed**: unknown/errored flag ⇒ treated as **off**.
## AI gating precedence (the key requirement)
Effective AI availability is an **AND** down a chain — the system flag is the master gate:
```
AI feature X is available for user U ⇔
feature_flags["ai.enabled"].enabled (admin master switch — SYSTEM)
∧ feature_flags["ai." + X].enabled (per-feature flag — SYSTEM)
∧ providerCapability(X) (Ollama/provider actually available)
∧ user.ai_prefs.master_opt_in (user hasn't disabled AI for themselves)
∧ user.ai_prefs[X] (user opted into this feature)
```
- **Admin turns `ai.enabled` off ⇒ AI vanishes for everyone**, regardless of any user
preference. This is the behaviour the brief mandates.
- With AI on at the system level, users still choose per-feature. The **Null AI provider +
capability flags** (blueprint [06](../06-ai-strategy.md)) mean a disabled path **falls back
or hides** — never errors, never blocks core email.
## Settings precedence (general)
```
system default → feature flag (may hard-disable) → user override (only where UserOverridable)
```
## Maintenance mode
- `read-only`: mutations (send/cleanup/label) blocked with a banner; browsing/search stay up.
- `locked-except-admin`: only Admins can use the app (for migrations/upgrades).
- Enforced at the API via a middleware policy + surfaced as a global banner in the UI.
## Auditing
Flag and system-setting changes are **admin actions → audit-logged** (who/what/old→new/when).
@@ -0,0 +1,45 @@
# 05 — Admin System (Part 4)
An Admin-only panel to run the instance. **Admins manage the platform, not people's
inboxes** — no admin route can read another user's mail (see [06](06-security-model.md)).
## Sections
| Section | Admin can | Notes |
|---------|-----------|-------|
| **Users** | List users; view role/status/last-login; **promote/demote** (Admin↔Member); **suspend/reactivate**; **revoke sessions**; remove user (with data-deletion policy) | **Never** view a user's email contents |
| **Feature flags** | List all flags; toggle `enabled`; set scope/rollout; per-role rollout | Includes AI + provider flags |
| **AI (global)** | Master `ai.enabled` toggle + per-feature (`ai.summaries`, `ai.semantic_search`, `ai.ask_inbox`…); see Ollama/model status | Off ⇒ AI hidden for everyone ([04](04-settings-and-flags.md)) |
| **Providers** | Enable/disable `provider.google` / `provider.microsoft` / `provider.imap` | Disabled ⇒ hidden on login picker; existing accounts pause |
| **System config** | Maintenance mode (off/read-only/locked); `registration_open`; org name; default theme; retention | Sensitive → step-up + audit |
| **Monitoring** | Basic health overview (below) | Read-only |
| **Audit log** | Search/filter admin + security events | Append-only |
## Monitoring overview (basic)
- **Sync health:** per-account last-sync time, `ReauthNeeded` count, error rate; job-queue depth.
- **AI/Ollama:** reachable? loaded models, VRAM headroom, recent latency, failure rate.
- **Sessions:** active session count; recent logins.
- **System:** DB size / message count; background-job backlog; recent errors (from Serilog).
- Deliberately **overview-only** — deep observability is a future opportunity, not v1.
## Access control & bootstrap
- Every admin route requires the **Admin policy**; sensitive mutations require **confirmation/
step-up** + are **rate-limited** and **audited**.
- **Bootstrap:** the first user to sign in becomes **Admin** (one-time). Afterwards, admin is
granted only by an existing Admin (audited, forces target session refresh so new/removed
privileges take effect immediately).
- Guardrails: an Admin cannot demote/suspend the **last remaining Admin** (lock-out prevention).
## Audit logging (what's recorded)
Actor · action · target (user/flag/setting/provider) · old→new · ip · timestamp — for **all**
admin mutations and security events (role change, flag toggle, provider disable, maintenance
on/off, session revoke, user suspend). Append-only `audit_logs`; visible in the Audit section;
exportable.
## API surface (Admin-scoped, all audited)
```
GET/PATCH /admin/users /admin/users/{id}/role /admin/users/{id}/status
GET/PATCH /admin/flags /admin/flags/{key}
GET/PATCH /admin/system-settings
GET /admin/monitoring /admin/audit
```
All behind the Admin policy + maintenance-aware middleware.
@@ -0,0 +1,79 @@
# 06 — Security Model (Part 5)
Builds on the existing hardening (read-only scope, encrypted tokens, IDOR global query
filters, SSRF egress guard, non-root containers, confirmed destructive actions) and adds
what multi-user + admin + multi-provider require.
## RBAC
- Roles: **Admin** · **Member** (small-team, one org). First user bootstraps as Admin.
- Enforced by **policy-based authorization** at the API (ASP.NET Core policies), not in the UI.
| Capability | Member | Admin |
|------------|:------:|:-----:|
| Read/manage **own** mail & accounts | ✅ | ✅ |
| Own user settings | ✅ | ✅ |
| Link/unlink **own** provider accounts | ✅ | ✅ |
| View/manage **other users** | ❌ | ✅ |
| Toggle **feature flags** (incl. AI global) | ❌ | ✅ |
| Enable/disable **providers** | ❌ | ✅ |
| **Maintenance mode**, system settings | ❌ | ✅ |
| View **audit log** & monitoring | ❌ | ✅ |
- **No cross-user data access, ever** — Admin manages *accounts/flags/system*, **not** other
users' email contents (privacy). Admin power is over the *platform*, not people's inboxes.
## OAuth token storage
- Refresh/access tokens **encrypted at rest** with the Data Protection API (AES); keys persist
to the mounted `/keys` volume (existing). Decrypted **only in-memory** for the moment of an
API call. **Never logged, never sent to the browser.**
- 1:1 `provider_tokens` per account; rotation timestamped; a compromised/rotated token is
replaced atomically. Token columns are `bytea` ciphertext, not readable in DB dumps.
## Session handling
- Opaque **server-side sessions** (DB-backed) + HttpOnly/Secure/SameSite cookie; **id rotated
on login** (anti-fixation); idle + absolute expiry; server-side **revocation** (logout,
sign-out-everywhere, admin revoke, role change). CSRF via SameSite + token.
## Admin access protection
- Admin routes require the **Admin policy**; sensitive mutations (toggle AI global, disable a
provider, suspend a user, enter maintenance) require a **confirmation / step-up** and are
**rate-limited**.
- **Every admin action is audit-logged** (`audit_logs`: actor, action, target, metadata, ip,
time) — append-only.
- First-admin bootstrap is one-time; afterwards admin is grant-only by an existing Admin
(logged). Guard against privilege escalation: role changes are Admin-only + audited + force
session refresh.
## API security boundaries
- **Per-user isolation** via EF **global query filters** (extended to `account_id`/`user_id`)
so a query can *never* return another user's rows — the IDOR safeguard, now multi-account.
- **Input validation** (FluentValidation) on all DTOs; **mass-assignment safe** (explicit DTOs,
no entity binding).
- **Rate limiting** on auth, admin, search, and AI endpoints.
- **SSRF egress guard** (existing) constrains all outbound calls — provider APIs, IMAP hosts,
Ollama, and any opt-in cloud AI — to an allowlist; user-supplied IMAP hosts are validated.
- **Security headers** (CSP, HSTS, X-Frame-Options, etc.) via the reverse proxy/API; strict CORS.
## Multi-provider & AI specifics
- **Least-privilege scopes** per provider; extra scopes added per-feature with consent.
- **Provider isolation:** disabling a provider flag revokes its use cleanly; per-account tokens
are independent (one reauth doesn't affect others).
- **Prompt injection:** email content is untrusted → LLM output is **advisory only, never
triggers actions**; a human/rule confirms. AI runs **local by default**; cloud AI is explicit
opt-in with per-feature consent + egress logging.
- **Attachments/vision:** sandboxed parsing, size/type limits, never executed.
## Threat model (summary)
| Threat | Mitigation |
|--------|------------|
| Account hijack via linking | Must authenticate as target user; unique `(provider, sub)`; linking an owned identity blocked |
| Token theft / DB exposure | Encryption at rest; tokens never in logs/browser; rotation |
| Privilege escalation | Admin-only role changes, audited, session refresh; policy checks server-side |
| IDOR / cross-user leakage | Global query filters on user_id/account_id |
| CSRF / session fixation | SameSite + token; session id rotation; server-side revoke |
| SSRF (providers/IMAP/AI) | Egress allowlist guard; validate user-supplied hosts |
| Prompt injection | AI advisory-only; never acts; local-first |
| Mass admin abuse | Rate limit + step-up + full audit trail |
## Non-negotiables
Admins manage the platform, **not** users' inboxes · tokens encrypted & browser-invisible ·
every privileged action audited · AI never required and never acts autonomously.
@@ -0,0 +1,75 @@
# 07 — UX Flows (Part 7)
Applies the [Design Brief](../00-design-brief.md) (Notion/Arc-professional, dark-first,
green accent, pointer-first, responsive). **Keep it simple** — these are utility surfaces,
not the daily driver.
## 1. Provider selection on login
A calm, centred card — the *only* thing on screen.
```
InboxIntel
───────────────────────────
Sign in to continue
[ ▸ Continue with Google ]
[ ▸ Continue with Microsoft ]
( IMAP — coming soon, disabled )
───────────────────────────
Your email stays on your machine.
```
- Only **enabled** providers show (driven by `provider.*` flags).
- One click → provider OAuth → back into the app. First-timer lands on an empty, friendly
inbox with a "syncing your mail…" state.
## 2. Connected accounts page
Reached from the account switcher or Settings → Accounts.
```
Accounts [ + Add account ]
┌─────────────────────────────────────────────────────────┐
│ 🟢 Google me@gmail.com Synced 2m ago ⋯ │
│ 🟢 Microsoft me@outlook.com Synced 5m ago ⋯ │
│ 🟠 Google old@gmail.com Reconnect needed → [Reconnect]│
└─────────────────────────────────────────────────────────┘
```
- Status badges: 🟢 Active · 🟠 ReauthNeeded (with **Reconnect**) · ⚪ Disabled.
- Per-account row menu (⋯): set as default · sync now · rename · **remove account** (confirm +
explains local data deletion).
- **+ Add account** → provider picker → OAuth in link-mode → new row appears.
- **Account switcher** (top bar): "All accounts" (unified) or pick one to scope the inbox/search.
## 3. Settings page (user)
Left sub-nav, one panel at a time — no overwhelm.
```
Settings
Appearance ▸ Theme (System/Light/Dark) · density · layout
Accounts ▸ (the page above)
Notifications ▸ channels · quiet hours · priority-only
AI ▸ (visible only if ai.enabled; see below)
Privacy ▸ data, export, clear search history
```
## 4. AI toggle visibility
- If **`ai.enabled` (system) is OFF** → the **AI section is hidden entirely** (or shown as a
single disabled note: "AI features are turned off by your administrator"). No dead toggles.
- If **ON** → a master **"Use AI features"** switch (user opt-in) + per-feature toggles
(Summaries · Reply suggestions · Semantic search · Ask your inbox), each reflecting its
`ai.<feature>` flag. Toggling off a feature instantly falls back to the non-AI path.
- A small **status chip** ("Local · Ollama · ready") reassures it's on-device.
## 5. Admin dashboard
Only visible to Admins (nav item appears for the Admin role).
```
Admin
Overview ▸ sync health · Ollama/VRAM · sessions · errors (cards)
Users ▸ table: name · role · status · last login · [actions]
Flags ▸ toggles grouped: AI · Providers · Maintenance
System ▸ maintenance mode · registration · defaults
Audit ▸ searchable event log
```
- Clean tables + toggles; destructive/sensitive actions show a **confirm dialog** (step-up).
- Maintenance mode shows a **global banner** to all users while active.
## Cross-cutting
- **Reduced-motion & keyboard** reachability on all of the above (baseline a11y).
- **Empty/loading/error states** per the design system (skeletons, friendly empties, calm errors).
- Fully **responsive**: settings/admin sub-nav collapses to a top tab bar on mobile; the login
card is centred on all sizes.
@@ -0,0 +1,68 @@
# 08 — AI Feature-Flag Integration (Part 8)
How the AI layer plugs into the flag system while staying **completely separable** from core
email logic. Extends the blueprint AI strategy ([../06](../06-ai-strategy.md)); the gate
math lives in [04](04-settings-and-flags.md).
## Principle: AI is a guest, never a host
Core email (sync, search-lexical, cleanup, settings, admin) **never references an AI type**.
It calls domain services; those *optionally* consult AI through a single gate + facade. Remove
AI entirely and nothing in the core path breaks.
```
Core feature code
│ (never touches Ollama/IAiProvider directly)
IAiGate.IsAiFeatureEnabled("summaries", user) ──► false ─► non-AI path / hide
│ true
IInboxAi facade (Application) ──► model router ──► IAiProvider / IEmbeddingProvider
(Null | Ollama | future)
```
## Toggle behaviour (flag-driven)
- Before *any* AI call, code asks `IAiGate` (which folds in `ai.enabled` + `ai.<feature>` +
provider capability + user opt-in — the AND-chain from [04](04-settings-and-flags.md)).
- **Admin `ai.enabled` = off** ⇒ gate returns false everywhere ⇒ AI UI hidden, AI code paths
skipped. Flip on ⇒ features reappear (hot-reloaded flag cache) with **no redeploy**.
- Per-feature flags allow shipping AI features **dark** and enabling gradually (rollout).
## Fallback when AI is disabled (per feature)
| AI feature | Fallback with AI off |
|------------|----------------------|
| Semantic / NL search | Lexical + structured + fuzzy search (still excellent) |
| Thread summary | Hidden; show first snippet + metadata |
| Reply suggestions | Hidden; normal compose |
| Follow-up detection | Heuristic-only (sent + question + no reply in N days) |
| Categorisation | `HeuristicClassifier` rules only |
| Ask-your-inbox | Feature hidden |
| Dedup | Exact-hash only (no near-dup) |
Every fallback is **first-class**, not a broken/greyed feature — this satisfies "AI must never
be required for core functionality."
## Ollama integration layer (local models)
- `OllamaProvider` (`IAiProvider`) + `OllamaEmbeddingProvider` (`IEmbeddingProvider`) talk to a
local Ollama (own container, optional Compose `ai` profile).
- **Model router** maps logical task → model via config (`Ai:Models:{Chat,Embed,Vision}`);
swapping a model is a config change, not code.
- **VRAM guard** (RTX 3080 / 10 GB): embeddings hot, 7B warm, vision on-demand
([../06](../06-ai-strategy.md)).
- **Health surfaced to admin** ([05](05-admin-system.md)): reachable? models loaded? VRAM?
latency? If Ollama is down, capability = false ⇒ gate falls back gracefully (no user errors).
## Safe abstraction (`IAIProvider`) — separation guarantees
1. **Interface boundary:** only Infrastructure implements providers; Application depends on
`IInboxAi`/`IAiGate` abstractions.
2. **Null objects:** `NullAiProvider`/`NullEmbeddingProvider` return "unavailable" so the DI
graph is always valid, AI on or off.
3. **Analyzer pipeline:** AI enrichers (`IEmailAnalyzer`) declare required capabilities and are
**skipped** when unavailable — adding/removing AI features never touches core sync/search.
4. **Bounded:** every AI call has timeout + `CancellationToken` + Polly fallback to the Null
path; AI can never hang or crash the core.
5. **Provider-swap:** adding a future AI provider = one class + config + (optionally) a flag —
no feature-code changes.
## Precedence recap (single source of truth)
The effective availability chain and admin master-switch semantics are defined once in
[04 — Settings & Feature Flags](04-settings-and-flags.md#ai-gating-precedence-the-key-requirement);
this document is the *architecture* of how features consume that decision.
@@ -0,0 +1,57 @@
# 09 — Implementation Plan (Part 9)
Six phases (from the brief), each **shippable behind feature flags** so `main`/staging never
break and providers activate only when ready. Follows the established
[../../WORKFLOW.md](../../WORKFLOW.md) pipeline (PR → checks → staging → tag→prod).
## Phase 1 — Provider abstraction + Google login refactor
- **Goal:** introduce the seam and move the *existing* Gmail behaviour behind it, with
OAuth-as-login and the multi-user identity foundation.
- **Deliverables:** `IEmailProvider` + `ProviderFactory`; **`GmailProvider`** adapter wrapping
today's Gmail code; `users` / `accounts` / `provider_tokens` / `sessions` tables;
OAuth-as-login for Google; first-user→Admin bootstrap.
- **Flags:** `provider.google` (on). **Exit:** existing Gmail users function unchanged through
the new abstraction; sign-in creates a `user`+`account`; all tests green.
## Phase 2 — Microsoft Outlook integration
- **Goal:** prove the abstraction with a second provider.
- **Deliverables:** **`OutlookProvider`** (Microsoft Graph, delta query); Microsoft OAuth login
+ link-mode; scope config; normaliser mappings.
- **Flags:** `provider.microsoft` (**off** until verified, then rollout). **Exit:** a user can
link an Outlook mailbox; it syncs and searches alongside Gmail; no core changes needed.
## Phase 3 — Unified email model + sync engine
- **Goal:** formalise the normalised store and provider-agnostic sync.
- **Deliverables:** normalised `email_messages`/`email_threads` (widened `search_vector`,
nullable `embedding`); **`ISyncOrchestrator`** + `AccountSyncWorker` (per-account cursors,
idempotent upserts, incremental); **data migration** of existing Gmail rows → default account.
- **Flags:** none user-facing; migration behind a maintenance window. **Exit:** all providers
sync through one orchestrator into one store; cross-account search works.
## Phase 4 — Settings system
- **Goal:** user + system settings + the flag engine.
- **Deliverables:** `user_settings`, `system_settings`, **`feature_flags`** + `IFeatureFlags`/
`IAiGate` (cached, fail-closed); settings UI; maintenance-mode middleware.
- **Flags:** self-hosting (the engine that hosts the rest). **Exit:** users edit prefs; admins
can flip flags; AI gate resolves the AND-chain.
## Phase 5 — Admin panel + feature flags
- **Goal:** the Admin surface + RBAC + audit.
- **Deliverables:** Admin API (policy-gated) + UI (Users/Flags/AI/Providers/System/Monitoring/
Audit); `audit_logs`; role management; basic monitoring.
- **Flags:** admin nav shown by role. **Exit:** an Admin can manage users/flags/providers,
every action audited; step-up + rate-limit enforced.
## Phase 6 — AI integration layer
- **Goal:** wire optional AI behind the gate.
- **Deliverables:** extend `IAiProvider` (+`CompleteStructuredAsync`, `IEmbeddingProvider`);
`IInboxAi` facade + model router + VRAM guard; analyzer pipeline; first AI features
(summaries, reply, follow-up-confirm) each **flag-gated + fallback**.
- **Flags:** `ai.enabled` + `ai.<feature>` (rollout). **Exit:** AI features work when enabled,
**vanish/fallback** when off; core unaffected; Ollama health in admin.
## Sequencing notes
- Phases 13 are the platform spine; 45 the control plane; 6 the optional intelligence.
- **Nothing activates on merge** — flags gate everything, so partial phases are safe on `develop`/`main`.
- Aligns with the blueprint roadmap ([../09](../09-roadmap.md)): this multi-provider work is a
**v1.x platform epic** that the search/AI features then build on.
@@ -0,0 +1,50 @@
# 10 — Git Workflow (Part 11)
Extends [../../WORKFLOW.md](../../WORKFLOW.md) for multi-phase, multi-provider work. The
core idea: **feature flags decouple *merging* from *activating*, which makes every
integration safe to land and trivial to roll back.**
## Branching per phase
| Phase | Milestone | Branches |
|-------|-----------|----------|
| 1 Provider abstraction + Google | `v1.x` | `epic/provider-platform``feature/email-provider-interface` · `feature/gmail-adapter` · `feature/oauth-login-users` |
| 2 Microsoft | `v1.x` | `feature/outlook-provider` · `feature/microsoft-oauth` |
| 3 Unified model + sync | `v1.x` | `feature/normalised-email-model` · `feature/sync-orchestrator` · `feature/data-migration` |
| 4 Settings | `v1.x` | `feature/settings-store` · `feature/feature-flags-engine` |
| 5 Admin | `v1.x` | `feature/admin-api` · `feature/admin-ui` · `feature/audit-log` |
| 6 AI layer | `v1.x` | `feature/ai-abstraction-ext` · `feature/ai-analyzers` · `feature/ai-flag-gating` |
- `feature/* → develop` (squash), `develop → main` (merge commit) — as established. Epics are
tracked by milestone/label; features integrate continuously (no long epic branch).
## PR structure per provider integration
Each provider is a self-contained PR set that lands **dark**:
1. **Adapter PR**`IEmailProvider` impl + normaliser + unit tests (mocked provider).
2. **Auth PR** — OAuth login/link for that provider.
3. **Enablement PR** — register in `ProviderFactory` + seed `provider.<x>` flag **OFF**.
4. **Activation** — flip the flag on in staging → verify end-to-end → roll out in prod.
- **PR checklist adds:** provider behind a flag (off by default) · normaliser tests · token
encryption verified · no Domain leakage · docs updated.
## Feature flags prevent breaking changes
- Merge = code present but **inert** until its flag is on. So half-finished providers/AI can
live on `main` safely; CI stays green; no long-lived divergence.
- AI ships behind `ai.*`; providers behind `provider.*`; risky changes behind their own flag.
## Rollback strategy (per provider / per feature)
| Level | Action | Speed |
|-------|--------|-------|
| **Flag** (first resort) | Admin flips `provider.<x>` / `ai.<x>` **off** | **Instant, no deploy** — feature disappears, existing data untouched |
| **Deploy** | Redeploy the previous **tag** (`vX.Y.Z-1`) | Minutes (pipeline) |
| **Revert** | `git revert` the PR → PR → merge → deploy | Minuteshours |
| **Data** | Provider accounts are isolated; disabling a provider **pauses** its sync — no destructive change to migrate back | Safe by design |
- Because providers are isolated and flag-gated, a bad integration **never blocks the others**
and never requires a risky data rollback.
## Release milestones
- Cut a tag when a phase reaches its exit criteria (`deploy-prod.yml` fires on `v*`).
- Suggested: `v1.1` provider platform + Google · `v1.2` +Outlook · `v1.3` unified sync ·
`v1.4` settings+admin · `v1.5` AI layer — folded into the blueprint roadmap ([../09](../09-roadmap.md)).
## Docs alongside code
Every feature PR updates the relevant `multi-provider/*` doc + `CHANGELOG.md`; on approval the
design docs graduate into living `docs/` references (provider system, settings, admin, security).
@@ -0,0 +1,31 @@
# 11 — Risk Analysis
Risks specific to the multi-provider + multi-user + admin evolution (the blueprint-wide risks
are in [../11](../11-risks-and-future.md)). L/I = Likelihood/Impact (H/M/L).
| # | Risk | L | I | Mitigation |
|---|------|---|---|------------|
| M1 | **Account-linking hijack** (attach someone's mailbox to your user) | L | H | Must authenticate as the target user; unique `(provider, sub)`; explicit "already owned" block; audited ([02](02-auth-and-signin.md)) |
| M2 | **Cross-user data leakage** (multi-user IDOR) | M | H | EF global query filters on `user_id`/`account_id`; policy-based authz; no admin route reads user mail; tests for isolation |
| M3 | **OAuth token theft / exposure** | L | H | Encrypted at rest (Data Protection); never logged or sent to browser; rotation; `bytea` ciphertext |
| M4 | **Provider quirks break sync** (Graph delta resets, IMAP `UIDVALIDITY` change, Gmail history gaps) | M | M | Per-provider cursor handling with **full-resync fallback**; idempotent upserts; capability flags; account flips to needs-attention, never crashes |
| M5 | **Migration corrupts existing Gmail data** | L | H | Additive-only schema; backfill is idempotent; maintenance window; tested on a staging copy; reversible ([12](12-migration-guide.md)) |
| M6 | **RBAC bug grants Member admin powers** | L | H | Server-side policies (not UI); admin-only role changes audited + force session refresh; can't demote last Admin; authz tests |
| M7 | **Feature-flag misconfiguration** (AI/provider on when not ready) | M | M | Flags default **off/fail-closed**; land dark; enable in staging first; audited toggles; rollout by role |
| M8 | **AI gate bypass** (feature runs when disabled) | L | M | Single `IAiGate` chokepoint before any AI call; Null providers; capability checks; no direct provider refs in core |
| M9 | **Session/CSRF weaknesses** across new surfaces | M | M | Server-side sessions, id rotation, SameSite + CSRF token, revoke-all, short idle expiry |
| M10 | **Admin abuse / mistake** (mass suspend, wrong flag) | M | M | Step-up confirm + rate-limit + full audit trail + reversible flags |
| M11 | **Scaling: millions of msgs × multiple accounts** | M | M | Denormalised `user_id`, keyset pagination, GIN/trgm/HNSW indexes, optional `account_id` partitioning, per-account sync throttling |
| M12 | **Provider OAuth app setup burden** (separate Google + Microsoft app registrations, redirect URIs, verification) | M | L | Documented setup per provider; providers flag-gated so an unconfigured one is simply hidden |
| M13 | **Scope/verification friction** (Google restricted scopes, MS admin consent) | M | M | Least-privilege scopes; document the consent/verification path; self-host uses the operator's own OAuth apps |
## Top watch-items
- **M2 (isolation)** and **M6 (RBAC)** — the two ways multi-user can go wrong; both mitigated
by server-side authz + query filters + isolation tests as a release gate.
- **M4 (provider sync quirks)** — the most likely *operational* pain; the full-resync fallback
and per-account isolation contain it.
## Overall
The flag-gated, additive, isolated design makes this evolution **low-blast-radius**: each
provider and the AI layer land dark and roll back by flag, migrations are additive, and the
security model closes the new multi-user gaps. Proceed **phase by phase behind flags**.
@@ -0,0 +1,52 @@
# 12 — Migration Guide (Part 10)
Moving the current **single-account Gmail** app to the **multi-provider, multi-user** model
**additive and reversible**, no destructive step. Executed as ordered EF Core migrations +
idempotent backfills, behind a short maintenance window.
## Principles
- **Additive first:** create new tables/columns before moving data; keep old columns until parity is verified.
- **Idempotent backfills:** safe to re-run; keyed on stable ids.
- **Flag-gated cutover:** the new sign-in/model activates behind flags; the old path stays until removed.
- **Reversible:** each step has a documented rollback; no data is deleted during migration.
## Step-by-step
1. **Schema (additive migration)**
- Create `users`, `accounts`, `provider_tokens`, `sessions`, `user_settings`,
`system_settings`, `feature_flags`, `audit_logs`.
- Add `account_id`, `user_id` (nullable) to the current email/thread tables; widen
`search_vector`; add nullable `embedding vector(768)` + `pgvector` extension.
2. **Identity backfill**
- For the existing operator/user, create a `users` row; mark the **first user = Admin**.
- Create one **`Google` `account`** per existing identity (`is_login_identity=true`); move
current encrypted Gmail tokens → `provider_tokens`.
3. **Email backfill**
- Set `account_id`/`user_id` on all existing `Email`/thread rows to the default Google account.
- Regenerate the widened `search_vector`; leave `embedding` null (backfilled later by the AI phase).
- Enforce the new unique keys `(account_id, provider_message_id)` / `(account_id, provider_thread_id)`.
4. **Config seed**
- Seed `feature_flags`: `provider.google=on`, `provider.microsoft=off`, `provider.imap=off`,
`ai.enabled` = derived from the current `Ai:Mode` (Disabled→off), `ai.*`=off, `maintenance.*`=off.
- Create `system_settings` singleton; create `user_settings` from any existing per-user prefs (else defaults).
5. **Cutover**
- Enable the new OAuth-as-login + unified sync behind their flags; verify on **staging** first
(the pipeline we built), then production via a tagged release.
6. **Cleanup (later, separate migration)**
- Once parity is confirmed in production, drop obsolete columns/paths. Not part of the cutover.
## Verification checklist
- Existing user signs in via Google → lands on their mail unchanged.
- Email counts match pre/post; search returns identical results for sample queries.
- Tokens decrypt and refresh; sync resumes from the correct cursor.
- No cross-user rows visible (isolation test).
## Rollback
- **Pre-cutover:** additive changes are inert → simply don't flip the flags; drop new tables if aborting.
- **Post-cutover issue:** flip flags off / redeploy previous **tag**; old columns still present →
the legacy path still works. No data was deleted, so no data rollback is needed.
## Provider-app prerequisites (operator setup)
- **Google:** OAuth client (existing) + redirect `/(…)/signin/google`; scopes `gmail.readonly gmail.modify`.
- **Microsoft:** register an Entra app; redirect `/(…)/signin/microsoft`; scopes `Mail.Read Mail.ReadWrite offline_access`; admin consent if required.
- **IMAP (future):** per-account host/credentials; validated against the SSRF allowlist.
- Because providers are flag-gated, an unconfigured provider is simply hidden — configure, then enable.
+48
View File
@@ -0,0 +1,48 @@
# Multi-Provider Email Platform + Admin/Settings — Design
**Design + architecture phase. No implementation until approved.**
Evolves InboxIntel from a single-account Gmail tool into a **multi-provider platform**
(Gmail · Outlook/Graph · future IMAP) for a **small self-hosted team**, with settings,
feature flags, and an admin panel. Extends — does not discard — the
[discovery blueprint](../README.md).
## Locked decisions (from interview)
1. **Tenancy:** **small team, self-hosted, one org.** Roles = **Admin / Member**. Shared
system settings + feature flags; each member's mail is private to them. No multi-tenant
org table (one implicit org); the model stays extensible to multi-org later.
2. **App identity = provider OAuth.** The first Google/Microsoft sign-in **creates/authenticates
the InboxIntel user**; additional mailboxes **link** to that same user. **No passwords stored.**
## How this reshapes the blueprint (the "review" deltas)
| Blueprint assumption | New reality |
|----------------------|-------------|
| Single-user, local-first | **Multi-user (small team)** with Admin/Member RBAC + admin panel |
| Gmail-centric `Email`/`Sender` | **Account-scoped, provider-normalised** model (`IEmailProvider`) |
| AI gated by `Ai:Mode` + user pref | **AI gated by system feature flag → user pref → capability** (flag wins) |
| One implicit mailbox | **N provider accounts per user** (`accounts` table + per-account sync cursors) |
| Sync = `GmailSyncWorker` | **Provider-agnostic sync orchestrator** dispatching to provider adapters |
These deltas will be back-ported into main-blueprint docs [08](../08-technical-architecture.md)
and [09](../09-roadmap.md) when this design is approved.
## Documents
| # | Doc | Covers (brief part) | Status |
|---|-----|---------------------|--------|
| 01 | [Provider Abstraction](01-provider-abstraction.md) | Part 1 | ✅ draft |
| 02 | [Auth & Sign-in](02-auth-and-signin.md) | Part 2 | ✅ draft |
| 03 | [Database Design](03-database-design.md) | Part 6 | ✅ draft |
| 04 | [Settings & Feature Flags](04-settings-and-flags.md) | Part 3 | ✅ draft |
| 05 | [Admin System](05-admin-system.md) | Part 4 | ✅ draft |
| 06 | [Security Model](06-security-model.md) | Part 5 | ✅ draft |
| 07 | [UX Flows](07-ux-flows.md) | Part 7 | ✅ draft |
| 08 | [AI Feature-Flag Integration](08-ai-feature-flags.md) | Part 8 | ✅ draft |
| 09 | [Implementation Plan](09-implementation-plan.md) | Part 9 | ✅ draft |
| 10 | [Git Workflow](10-git-workflow.md) | Part 11 | ✅ draft |
| 11 | [Risk Analysis](11-risk-analysis.md) | output | ✅ draft |
| 12 | [Migration Guide](12-migration-guide.md) | Part 10 | ✅ draft |
## Non-negotiables carried forward
Provider logic **never leaks into Domain** · search works **across all a user's accounts**
· emails stored in **one unified format** · **AI never required** for core function ·
tokens **encrypted at rest** · admin actions **audit-logged**.
+2 -2
View File
@@ -9,8 +9,8 @@
// Apply the saved theme before first paint to avoid a flash of the wrong mode. // Apply the saved theme before first paint to avoid a flash of the wrong mode.
(function () { (function () {
try { try {
var t = localStorage.getItem('ii:theme'); // Dark-first: default new users to dark unless they've chosen light.
if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; var t = localStorage.getItem('ii:theme') || 'dark';
if (t === 'dark') document.documentElement.classList.add('dark'); if (t === 'dark') document.documentElement.classList.add('dark');
} catch (e) {} } catch (e) {}
})(); })();
+10
View File
@@ -8,6 +8,7 @@
"name": "inboxintel-frontend", "name": "inboxintel-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@radix-ui/react-avatar": "^1.2.1", "@radix-ui/react-avatar": "^1.2.1",
"@radix-ui/react-checkbox": "^1.3.6", "@radix-ui/react-checkbox": "^1.3.6",
"@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-dialog": "^1.1.18",
@@ -764,6 +765,15 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview --host" "preview": "vite preview --host"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@radix-ui/react-avatar": "^1.2.1", "@radix-ui/react-avatar": "^1.2.1",
"@radix-ui/react-checkbox": "^1.3.6", "@radix-ui/react-checkbox": "^1.3.6",
"@radix-ui/react-dialog": "^1.1.18", "@radix-ui/react-dialog": "^1.1.18",
+22 -1
View File
@@ -16,6 +16,25 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`; return `${(b / 1048576).toFixed(1)} MB`;
}; };
// "Why this matched": the API wraps matched terms in U+E000/U+E001 sentinels (NOT HTML).
// We tokenise and render the highlighted parts as <mark> React elements — React escapes
// all text nodes, so untrusted email content can never inject markup (no dangerouslySetInnerHTML).
const HL_START = String.fromCharCode(0xE000);
const HL_STOP = String.fromCharCode(0xE001);
const HL_RE = new RegExp(HL_START + '([\s\S]*?)' + HL_STOP, 'g');
function renderHighlight(s) {
const out = [];
let last = 0, key = 0, m;
HL_RE.lastIndex = 0;
while ((m = HL_RE.exec(s)) !== null) {
if (m.index > last) out.push(s.slice(last, m.index));
out.push(<mark key={key++}>{m[1]}</mark>);
last = HL_RE.lastIndex;
}
if (last < s.length) out.push(s.slice(last));
return out;
}
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) { export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -83,7 +102,9 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
</td> </td>
<td className="el-subject"> <td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span> <span className="el-subj-text">{email.subject || '(no subject)'}</span>
{email.snippet && <span className="el-snippet"> {email.snippet}</span>} {email.matchHighlight
? <span className="el-snippet"> {renderHighlight(email.matchHighlight)}</span>
: email.snippet && <span className="el-snippet"> {email.snippet}</span>}
</td> </td>
<td className="el-meta"> <td className="el-meta">
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>} {email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
+1 -1
View File
@@ -256,7 +256,7 @@ export default function Layout() {
<Input <Input
ref={searchInputRef} ref={searchInputRef}
type="search" type="search"
placeholder="Search… from: is:unread has:attachment ( / )" placeholder="Search your inbox…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search emails" aria-label="Search emails"
+70 -29
View File
@@ -8,46 +8,55 @@
two variables at runtime. See docs/specs/ui-overhaul.md. two variables at runtime. See docs/specs/ui-overhaul.md.
*/ */
@layer base { @layer base {
/*
Design tokens — v2 (per docs/discovery/04). Dark-first, warm-leaning neutrals,
green brand accent derived from #3ba31f. Light is a genuine white (not grey).
Green is never the SOLE state signal (pair with icon/shape) — colour-blind-safe
by construction. Values are HSL triples (space-separated) for Tailwind's
`hsl(var(--x) / <alpha-value>)` mapping.
*/
:root { :root {
/* Neutrals — warm-tinted slate (Notion-ish paper) */ /* Light — genuine white, warm off-white surfaces */
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 222 22% 12%; --foreground: 30 10% 12%;
--card: 0 0% 100%; --card: 40 33% 99%;
--muted: 220 16% 96%; --muted: 40 24% 96%;
--muted-foreground: 220 9% 46%; --muted-foreground: 35 8% 42%;
--border: 220 16% 90%; --border: 38 18% 89%;
--input: 220 16% 90%; --input: 38 18% 89%;
--ring: 245 75% 60%; --ring: 110 62% 38%;
/* Brand accent — Indigo #5b5bf0 */ /* Brand accent — green (from #3ba31f), darkened for AA white-on-green */
--primary: 245 75% 59%; --primary: 110 62% 33%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
/* Semantic */ /* Semantic */
--success: 152 56% 40%; --success: 145 55% 38%;
--warning: 38 92% 50%; --warning: 38 92% 45%;
--danger: 0 72% 51%; --danger: 4 74% 50%;
--danger-foreground: 0 0% 100%; --danger-foreground: 0 0% 100%;
--radius: 0.625rem; --radius: 0.5rem; /* lg 8px · md 6px · sm 4px */
} }
.dark { .dark {
--background: 224 32% 9%; /* Dark (default) — warm charcoal layers, NOT pure black */
--foreground: 220 18% 92%; --background: 30 7% 10%;
--card: 224 28% 12%; --foreground: 40 22% 93%;
--muted: 223 22% 17%; --card: 30 7% 13%;
--muted-foreground: 220 12% 64%; --muted: 30 6% 17%;
--border: 223 20% 20%; --muted-foreground: 35 9% 64%;
--input: 223 20% 22%; --border: 30 7% 22%;
--ring: 245 80% 66%; --input: 30 7% 24%;
--ring: 110 50% 46%;
--primary: 245 80% 67%; /* Brand accent — luminous green for dark surfaces */
--primary-foreground: 224 32% 9%; --primary: 110 52% 42%;
--primary-foreground: 0 0% 100%;
--success: 152 50% 50%; --success: 145 50% 48%;
--warning: 38 92% 58%; --warning: 38 90% 56%;
--danger: 0 70% 60%; --danger: 4 72% 58%;
--danger-foreground: 0 0% 100%; --danger-foreground: 0 0% 100%;
} }
@@ -55,10 +64,19 @@
border-color: hsl(var(--border)); border-color: hsl(var(--border));
} }
/* Search "why this matched" highlight — subtle accent tint, not the default yellow. */
mark {
background: hsl(var(--primary) / 0.22);
color: inherit;
border-radius: 3px;
padding: 0 1px;
}
body { body {
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, /* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */
Helvetica, Arial, sans-serif; font-family: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, -apple-system,
'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
} }
/* Subtle, modern scrollbars that respect the theme. */ /* Subtle, modern scrollbars that respect the theme. */
@@ -82,6 +100,29 @@
} }
} }
@layer base {
/* Consistent, visible focus ring using the accent token (keyboard users). */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/*
Respect the OS "reduce motion" setting — near-instant, no large transitions.
Baseline accessibility (the deeper a11y pass is deferred; see the design brief).
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer utilities { @layer utilities {
.sr-only { .sr-only {
position: absolute; position: absolute;
+3
View File
@@ -9,7 +9,9 @@ import Unsubscribe from './pages/Unsubscribe.jsx';
import FolderView from './pages/FolderView.jsx'; import FolderView from './pages/FolderView.jsx';
import SearchResults from './pages/SearchResults.jsx'; import SearchResults from './pages/SearchResults.jsx';
import Layout from './components/Layout.jsx'; import Layout from './components/Layout.jsx';
import DesignSystem from './pages/DesignSystem.jsx';
import { ToastProvider, TooltipProvider } from './components/ui'; import { ToastProvider, TooltipProvider } from './components/ui';
import '@fontsource-variable/inter';
import './index.css'; import './index.css';
import './styles.css'; import './styles.css';
@@ -30,6 +32,7 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<Route path="unsubscribe" element={<Unsubscribe />} /> <Route path="unsubscribe" element={<Unsubscribe />} />
<Route path="folder/:slug" element={<FolderView />} /> <Route path="folder/:slug" element={<FolderView />} />
<Route path="search" element={<SearchResults />} /> <Route path="search" element={<SearchResults />} />
<Route path="design" element={<DesignSystem />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+177
View File
@@ -0,0 +1,177 @@
import React from 'react';
import {
Button, Badge, Input, Textarea, Switch, Skeleton, Separator,
Card, CardHeader, CardTitle, CardDescription, CardContent,
Tabs, TabsList, TabsTrigger, TabsContent, EmptyState, ThemeToggle,
} from '../components/ui';
/*
Design-system preview (v2). A living reference for the tokens + primitives so the
redesign can be visually verified in both themes. Route: /app/design.
Not linked in primary nav — it's a developer/design surface.
*/
const TOKENS = [
['background', 'App background'],
['card', 'Card / surface'],
['muted', 'Muted surface'],
['border', 'Border'],
['primary', 'Brand (green)'],
['success', 'Success'],
['warning', 'Warning'],
['danger', 'Danger'],
];
// Full literal class names so Tailwind's JIT includes them.
const GREEN = [
'bg-green-50', 'bg-green-100', 'bg-green-200', 'bg-green-300', 'bg-green-400',
'bg-green-500', 'bg-green-600', 'bg-green-700', 'bg-green-800', 'bg-green-900',
];
const TYPE = [
['text-3xl', '30px — Display'],
['text-2xl', '24px — Heading'],
['text-xl', '20px — Subheading'],
['text-lg', '18px — Large'],
['text-base', '16px — Body'],
['text-sm', '14px — UI base'],
['text-xs', '12px — Caption'],
];
function Swatch({ token, label }) {
return (
<div className="flex flex-col gap-1">
<div
className="h-14 w-full rounded-lg border"
style={{ background: `hsl(var(--${token}))` }}
/>
<div className="text-xs font-medium">{label}</div>
<div className="text-xs text-muted-foreground">--{token}</div>
</div>
);
}
function Section({ title, children }) {
return (
<section className="mb-10">
<h2 className="mb-3 text-lg font-semibold">{title}</h2>
{children}
</section>
);
}
export default function DesignSystem() {
return (
<div className="mx-auto max-w-4xl p-2">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Design system</h1>
<p className="text-sm text-muted-foreground">
v2 tokens &amp; primitives green accent, warm neutrals, dark-first. Toggle the
theme to verify both.
</p>
</div>
<ThemeToggle />
</div>
<Section title="Semantic tokens">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{TOKENS.map(([t, l]) => <Swatch key={t} token={t} label={l} />)}
</div>
</Section>
<Section title="Brand ramp (green, from #3ba31f)">
<div className="flex overflow-hidden rounded-lg border">
{GREEN.map((cls) => (
<div key={cls} className={`h-12 flex-1 ${cls}`} title={cls} />
))}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>50</span><span>500 (brand)</span><span>900</span>
</div>
</Section>
<Section title="Typography (Inter)">
<div className="space-y-2">
{TYPE.map(([cls, label]) => (
<div key={cls} className={`${cls} font-medium`}>
{label}
</div>
))}
</div>
</Section>
<Section title="Buttons">
<div className="flex flex-wrap items-center gap-3">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Danger</Button>
<Button disabled>Disabled</Button>
</div>
</Section>
<Section title="Badges">
<div className="flex flex-wrap items-center gap-3">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Danger</Badge>
</div>
</Section>
<Section title="Inputs">
<div className="grid max-w-md gap-3">
<Input placeholder="Search your inbox…" />
<Textarea placeholder="Write a reply…" rows={3} />
<label className="flex items-center gap-2 text-sm">
<Switch defaultChecked /> Use AI features
</label>
</div>
</Section>
<Section title="Card & tabs">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Thread summary</CardTitle>
<CardDescription>AI-generated · local</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="summary">
<TabsList>
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="actions">Actions</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="pt-3 text-sm text-muted-foreground">
A concise overview of the conversation would appear here.
</TabsContent>
<TabsContent value="actions" className="pt-3 text-sm text-muted-foreground">
Reply to the client · Follow up in 3 days
</TabsContent>
</Tabs>
</CardContent>
</Card>
</Section>
<Section title="Loading & empty states">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
<div className="rounded-lg border p-4">
<EmptyState
title="No results"
description="Try broader terms or clear a filter."
/>
</div>
</div>
</Section>
<Separator className="my-8" />
<p className="text-xs text-muted-foreground">
InboxIntel design system · see docs/discovery/04-ux-redesign-and-design-system.md
</p>
</div>
);
}
+43 -41
View File
@@ -1,18 +1,20 @@
:root { :root {
--bg: #0f1420; /* v2 brand — warm charcoal + green (aligns with the token system in index.css).
--panel: #1a2030; Legacy classes still read these; the v2 shell/components use the token system. */
--panel-2: #222a3d; --bg: #1a1917;
--text: #e6e9f0; --panel: #211f1d;
--muted: #8b93a7; --panel-2: #2a2724;
--accent: #4f8cff; --text: #f2efe9;
--danger: #eb5757; --muted: #a8a29a;
--ok: #6fcf97; --accent: #46ad27;
--danger: #d95a4a;
--ok: #5bbf4a;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); } body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
.topbar { display: flex; align-items: center; gap: 18px; padding: 12px 20px; background: var(--panel); border-bottom: 1px solid #2c3550; } .topbar { display: flex; align-items: center; gap: 18px; padding: 12px 20px; background: var(--panel); border-bottom: 1px solid #332f2b; }
.brand { font-weight: 700; font-size: 18px; } .brand { font-weight: 700; font-size: 18px; }
.topbar nav a { color: var(--muted); text-decoration: none; margin-right: 14px; } .topbar nav a { color: var(--muted); text-decoration: none; margin-right: 14px; }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); } .topbar nav a.active, .topbar nav a:hover { color: var(--text); }
@@ -21,13 +23,13 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
/* ── Search bar ── */ /* ── Search bar ── */
.search-form { display: flex; align-items: center; gap: 0; flex: 1; max-width: 420px; } .search-form { display: flex; align-items: center; gap: 0; flex: 1; max-width: 420px; }
.search-input { .search-input {
flex: 1; background: var(--panel-2); border: 1px solid #2c3550; border-right: none; flex: 1; background: var(--panel-2); border: 1px solid #332f2b; border-right: none;
color: var(--text); border-radius: 6px 0 0 6px; padding: 7px 10px; font-size: 13px; color: var(--text); border-radius: 6px 0 0 6px; padding: 7px 10px; font-size: 13px;
min-width: 0; min-width: 0;
} }
.search-input:focus { outline: none; border-color: var(--accent); } .search-input:focus { outline: none; border-color: var(--accent); }
.search-btn { .search-btn {
background: var(--panel-2); border: 1px solid #2c3550; border-left: none; background: var(--panel-2); border: 1px solid #332f2b; border-left: none;
color: var(--muted); border-radius: 0 6px 6px 0; padding: 7px 10px; cursor: pointer; font-size: 13px; color: var(--muted); border-radius: 0 6px 6px 0; padding: 7px 10px; cursor: pointer; font-size: 13px;
} }
.search-btn:hover { color: var(--text); border-color: var(--accent); } .search-btn:hover { color: var(--text); border-color: var(--accent); }
@@ -38,7 +40,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
.sidebar { .sidebar {
width: 216px; flex-shrink: 0; width: 216px; flex-shrink: 0;
background: var(--panel); border-right: 1px solid #2c3550; background: var(--panel); border-right: 1px solid #332f2b;
display: flex; flex-direction: column; overflow-y: auto; display: flex; flex-direction: column; overflow-y: auto;
transition: width 0.2s ease; transition: width 0.2s ease;
} }
@@ -47,7 +49,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
.sidebar-header { .sidebar-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 12px 8px 10px; padding: 12px 8px 10px;
border-bottom: 1px solid #2c3550; border-bottom: 1px solid #332f2b;
min-height: 42px; flex-shrink: 0; min-height: 42px; flex-shrink: 0;
} }
.sidebar-brand { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding-left: 6px; } .sidebar-brand { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding-left: 6px; }
@@ -57,7 +59,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
/* Section headings */ /* Section headings */
.section-head { .section-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
width: 100%; background: none; border: none; border-bottom: 1px solid #2c3550; width: 100%; background: none; border: none; border-bottom: 1px solid #332f2b;
padding: 7px 10px 7px 12px; cursor: pointer; padding: 7px 10px 7px 12px; cursor: pointer;
color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
} }
@@ -87,7 +89,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
background: var(--panel-2); border-radius: 8px; padding: 1px 5px; background: var(--panel-2); border-radius: 8px; padding: 1px 5px;
flex-shrink: 0; flex-shrink: 0;
} }
.folder-item--active .folder-badge { background: #2c3550; color: var(--accent); } .folder-item--active .folder-badge { background: #332f2b; color: var(--accent); }
/* Favorite pin/unpin button */ /* Favorite pin/unpin button */
.fav-pin { .fav-pin {
@@ -108,7 +110,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; } .toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.widget-toggles { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); } .widget-toggles { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); }
.grid-item { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; overflow: hidden; } .grid-item { background: var(--panel); border: 1px solid #332f2b; border-radius: 10px; overflow: hidden; }
.widget { padding: 14px; height: 100%; display: flex; flex-direction: column; } .widget { padding: 14px; height: 100%; display: flex; flex-direction: column; }
.widget h3 { margin: 0 0 10px; font-size: 14px; cursor: move; } .widget h3 { margin: 0 0 10px; font-size: 14px; cursor: move; }
.widget--link { cursor: pointer; } .widget--link { cursor: pointer; }
@@ -131,7 +133,7 @@ button:disabled { opacity: 0.5; cursor: default; }
table.mini, table.grid { width: 100%; border-collapse: collapse; font-size: 13px; } table.mini, table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
table.mini td { padding: 3px 0; } table.mini td { padding: 3px 0; }
table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; text-align: left; } table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #332f2b; text-align: left; }
.num { text-align: right; } .num { text-align: right; }
.muted { color: var(--muted); font-size: 12px; } .muted { color: var(--muted); font-size: 12px; }
@@ -172,7 +174,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sl-panel { .sl-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 1px solid #2c3550; border-right: 1px solid #332f2b;
overflow: hidden; overflow: hidden;
} }
.sl-search-wrap { padding: 10px 12px 6px; } .sl-search-wrap { padding: 10px 12px 6px; }
@@ -180,7 +182,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background: var(--panel-2); background: var(--panel-2);
border: 1px solid #2c3550; border: 1px solid #332f2b;
color: var(--text); color: var(--text);
border-radius: 6px; border-radius: 6px;
padding: 7px 10px; padding: 7px 10px;
@@ -191,7 +193,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sl-item { .sl-item {
display: block; width: 100%; text-align: left; display: block; width: 100%; text-align: left;
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
padding: 10px 14px; border-bottom: 1px solid #1e2540; padding: 10px 14px; border-bottom: 1px solid #26231f;
color: var(--text); color: var(--text);
} }
.sl-item:hover { background: var(--panel-2); } .sl-item:hover { background: var(--panel-2); }
@@ -211,7 +213,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
} }
.sd-panel-header { .sd-panel-header {
padding: 16px 20px 12px; padding: 16px 20px 12px;
border-bottom: 1px solid #2c3550; border-bottom: 1px solid #332f2b;
background: var(--panel); background: var(--panel);
position: sticky; top: 0; z-index: 1; position: sticky; top: 0; z-index: 1;
} }
@@ -223,7 +225,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sd-detail { padding: 20px; max-width: 760px; } .sd-detail { padding: 20px; max-width: 760px; }
.sd-back { .sd-back {
display: inline-flex; align-items: center; gap: 4px; display: inline-flex; align-items: center; gap: 4px;
background: none; border: 1px solid #2c3550; border-radius: 6px; background: none; border: 1px solid #332f2b; border-radius: 6px;
color: var(--text); font-size: 13px; cursor: pointer; color: var(--text); font-size: 13px; cursor: pointer;
padding: 5px 12px; margin-bottom: 18px; padding: 5px 12px; margin-bottom: 18px;
} }
@@ -232,9 +234,9 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sd-detail-subject { font-size: 18px; font-weight: 700; margin-bottom: 6px; } .sd-detail-subject { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.sd-detail-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } .sd-detail-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.sd-detail-sep { opacity: 0.4; } .sd-detail-sep { opacity: 0.4; }
.sd-snippet { background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; } .sd-snippet { background: var(--panel); border: 1px solid #332f2b; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; }
.sd-ai-summary { margin-bottom: 14px; } .sd-ai-summary { margin-bottom: 14px; }
.sd-ai-summary-text { background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--text); } .sd-ai-summary-text { background: var(--panel-2); border: 1px solid #332f2b; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--text); }
.sd-detail-actions { display: flex; gap: 10px; } .sd-detail-actions { display: flex; gap: 10px; }
.btn-sm { .btn-sm {
background: var(--accent); color: #fff; border: none; border-radius: 6px; background: var(--accent); color: #fff; border: none; border-radius: 6px;
@@ -245,8 +247,8 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.page { max-width: 900px; } .page { max-width: 900px; }
.form-row { display: flex; gap: 10px; margin: 14px 0; } .form-row { display: flex; gap: 10px; margin: 14px 0; }
.form-row input { flex: 1; } .form-row input { flex: 1; }
input, select { background: var(--panel-2); border: 1px solid #2c3550; color: var(--text); border-radius: 6px; padding: 8px; } input, select { background: var(--panel-2); border: 1px solid #332f2b; color: var(--text); border-radius: 6px; padding: 8px; }
.card { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; padding: 14px; margin-top: 12px; } .card { background: var(--panel); border: 1px solid #332f2b; border-radius: 10px; padding: 14px; margin-top: 12px; }
.card.success { border-color: var(--ok); } .card.success { border-color: var(--ok); }
.card ul { font-size: 13px; color: var(--muted); } .card ul { font-size: 13px; color: var(--muted); }
@@ -259,14 +261,14 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
/* ── Shared buttons ── */ /* ── Shared buttons ── */
.brand-link { text-decoration: none; color: var(--text); display: inline-flex; } .brand-link { text-decoration: none; color: var(--text); display: inline-flex; }
.ghost { background: transparent; border: 1px solid #36405c; color: var(--text); } .ghost { background: transparent; border: 1px solid #3d3833; color: var(--text); }
.ghost:hover { border-color: var(--accent); } .ghost:hover { border-color: var(--accent); }
.cta { .cta {
background: var(--accent); color: #fff; border: none; border-radius: 8px; background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 12px 22px; font-size: 16px; font-weight: 700; cursor: pointer; padding: 12px 22px; font-size: 16px; font-weight: 700; cursor: pointer;
text-decoration: none; display: inline-block; text-decoration: none; display: inline-block;
} }
.cta:hover { background: #5d97ff; } .cta:hover { background: #57c231; }
/* ── Landing page ── */ /* ── Landing page ── */
.landing { max-width: 1080px; margin: 0 auto; padding: 0 20px 60px; } .landing { max-width: 1080px; margin: 0 auto; padding: 0 20px 60px; }
@@ -277,13 +279,13 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.hero-cta { margin: 8px 0; } .hero-cta { margin: 8px 0; }
.fineprint { color: var(--muted); font-size: 12px; margin-top: 14px; } .fineprint { color: var(--muted); font-size: 12px; margin-top: 14px; }
.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 24px; } .features { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 24px; }
.feature { background: var(--panel); border: 1px solid #2c3550; border-radius: 12px; padding: 20px; } .feature { background: var(--panel); border: 1px solid #332f2b; border-radius: 12px; padding: 20px; }
.feature-icon { font-size: 26px; } .feature-icon { font-size: 26px; }
.feature h3 { margin: 10px 0 6px; font-size: 17px; } .feature h3 { margin: 10px 0 6px; font-size: 17px; }
.feature p { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; } .feature p { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; }
.closing { text-align: center; margin: 56px 0 20px; } .closing { text-align: center; margin: 56px 0 20px; }
.closing h2 { font-size: 28px; margin-bottom: 18px; } .closing h2 { font-size: 28px; margin-bottom: 18px; }
.landing-footer { display: flex; align-items: center; gap: 12px; justify-content: center; margin-top: 40px; padding-top: 20px; border-top: 1px solid #2c3550; } .landing-footer { display: flex; align-items: center; gap: 12px; justify-content: center; margin-top: 40px; padding-top: 20px; border-top: 1px solid #332f2b; }
/* ── Folder view ── */ /* ── Folder view ── */
.folder-view { max-width: 960px; } .folder-view { max-width: 960px; }
@@ -294,7 +296,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; } .fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; }
.email-list { width: 100%; border-collapse: collapse; font-size: 13px; } .email-list { width: 100%; border-collapse: collapse; font-size: 13px; }
.email-row { border-bottom: 1px solid #2c3550; cursor: pointer; } .email-row { border-bottom: 1px solid #332f2b; cursor: pointer; }
.email-row:hover { background: var(--panel); } .email-row:hover { background: var(--panel); }
.email-row--unread .el-sender, .email-row--unread .el-sender,
.email-row--unread .el-subj-text { font-weight: 700; color: var(--text); } .email-row--unread .el-subj-text { font-weight: 700; color: var(--text); }
@@ -337,7 +339,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.splash-card { text-align: center; max-width: 440px; padding: 32px; } .splash-card { text-align: center; max-width: 440px; padding: 32px; }
.splash-card h2 { margin: 16px 0 6px; } .splash-card h2 { margin: 16px 0 6px; }
.progress-track { height: 10px; background: var(--panel-2); border-radius: 6px; overflow: hidden; margin: 20px 0 10px; } .progress-track { height: 10px; background: var(--panel-2); border-radius: 6px; overflow: hidden; margin: 20px 0 10px; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #4f8cff, #6fcf97); border-radius: 6px; transition: width 0.4s ease; } .progress-fill { height: 100%; background: linear-gradient(90deg, #46ad27, #6fcf97); border-radius: 6px; transition: width 0.4s ease; }
.progress-fill[data-indeterminate="true"] { animation: indet 1.2s ease-in-out infinite; } .progress-fill[data-indeterminate="true"] { animation: indet 1.2s ease-in-out infinite; }
@keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } @keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
.progress-label { color: var(--muted); font-size: 13px; } .progress-label { color: var(--muted); font-size: 13px; }
@@ -348,7 +350,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.sync-label--err { color: var(--danger); } .sync-label--err { color: var(--danger); }
.sync-spinner { .sync-spinner {
width: 10px; height: 10px; border-radius: 50%; width: 10px; height: 10px; border-radius: 50%;
border: 2px solid #36405c; border-top-color: var(--accent); border: 2px solid #3d3833; border-top-color: var(--accent);
animation: sync-spin 0.7s linear infinite; animation: sync-spin 0.7s linear infinite;
} }
@keyframes sync-spin { to { transform: rotate(360deg); } } @keyframes sync-spin { to { transform: rotate(360deg); } }
@@ -358,7 +360,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
/* ── Email detail body (Senders page) ──────────────────────────────────── */ /* ── Email detail body (Senders page) ──────────────────────────────────── */
.sd-body-loading { padding: 8px 0 18px; } .sd-body-loading { padding: 8px 0 18px; }
.sd-body { .sd-body {
background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; background: var(--panel); border: 1px solid #332f2b; border-radius: 8px;
padding: 16px 18px; font-size: 13px; line-height: 1.7; color: var(--text); padding: 16px 18px; font-size: 13px; line-height: 1.7; color: var(--text);
white-space: pre-wrap; word-break: break-word; margin-bottom: 18px; white-space: pre-wrap; word-break: break-word; margin-bottom: 18px;
max-height: 60vh; overflow-y: auto; max-height: 60vh; overflow-y: auto;
@@ -369,7 +371,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.unsub-toast { color: var(--ok); font-size: 13px; margin-left: 6px; } .unsub-toast { color: var(--ok); font-size: 13px; margin-left: 6px; }
.unsub-tabs { display: flex; gap: 6px; margin: 16px 0 10px; } .unsub-tabs { display: flex; gap: 6px; margin: 16px 0 10px; }
.unsub-tab { .unsub-tab {
background: var(--panel); border: 1px solid #2c3550; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; color: var(--muted);
border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer;
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
} }
@@ -377,7 +379,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.unsub-tab--active { color: var(--text); border-color: var(--accent); background: var(--panel-2); } .unsub-tab--active { color: var(--text); border-color: var(--accent); background: var(--panel-2); }
.unsub-tab-count { font-size: 11px; color: var(--muted); background: var(--bg); border-radius: 8px; padding: 1px 6px; } .unsub-tab-count { font-size: 11px; color: var(--muted); background: var(--bg); border-radius: 8px; padding: 1px 6px; }
.unsub-grid { width: 100%; } .unsub-grid { width: 100%; }
.unsub-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; border: 1px solid #36405c; } .unsub-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; border: 1px solid #3d3833; }
.unsub-badge--detected { color: var(--muted); } .unsub-badge--detected { color: var(--muted); }
.unsub-badge--queued { color: #f2c94c; border-color: #f2c94c66; } .unsub-badge--queued { color: #f2c94c; border-color: #f2c94c66; }
.unsub-badge--progress { color: var(--accent); border-color: var(--accent); } .unsub-badge--progress { color: var(--accent); border-color: var(--accent); }
@@ -392,13 +394,13 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
/* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */ /* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */
.bulk-toolbar { .bulk-toolbar {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px; background: var(--panel-2); border: 1px solid #332f2b; border-radius: 8px;
padding: 8px 14px; margin-bottom: 10px; font-size: 13px; padding: 8px 14px; margin-bottom: 10px; font-size: 13px;
} }
.bulk-toolbar .muted { font-size: 12px; } .bulk-toolbar .muted { font-size: 12px; }
.bulk-toolbar-spacer { flex: 1; } .bulk-toolbar-spacer { flex: 1; }
.bulk-btn { .bulk-btn {
background: var(--panel); border: 1px solid #2c3550; color: var(--text); background: var(--panel); border: 1px solid #332f2b; color: var(--text);
border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer; border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer;
} }
.bulk-btn:hover { border-color: var(--accent); } .bulk-btn:hover { border-color: var(--accent); }
@@ -406,13 +408,13 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.el-select { width: 28px; text-align: center; } .el-select { width: 28px; text-align: center; }
/* ── Keyboard shortcut help ──────────────────────────────────────────────── */ /* ── Keyboard shortcut help ──────────────────────────────────────────────── */
.kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #2c3550; border-radius: 6px; padding: 4px 10px; opacity: 0.7; } .kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; }
.kbd-hint kbd { background: var(--panel-2); border: 1px solid #36405c; border-radius: 3px; padding: 0 4px; font-family: inherit; } .kbd-hint kbd { background: var(--panel-2); border: 1px solid #3d3833; border-radius: 3px; padding: 0 4px; font-family: inherit; }
/* ── Saved searches ─────────────────────────────────────────────────────── */ /* ── Saved searches ─────────────────────────────────────────────────────── */
.saved-search-row { display: flex; align-items: center; gap: 8px; } .saved-search-row { display: flex; align-items: center; gap: 8px; }
.saved-search-save-btn { .saved-search-save-btn {
background: none; border: 1px solid #2c3550; color: var(--muted); background: none; border: 1px solid #332f2b; color: var(--muted);
border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer;
} }
.saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); } .saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); }
+26
View File
@@ -31,6 +31,32 @@ export default {
DEFAULT: 'hsl(var(--danger) / <alpha-value>)', DEFAULT: 'hsl(var(--danger) / <alpha-value>)',
foreground: 'hsl(var(--danger-foreground) / <alpha-value>)', foreground: 'hsl(var(--danger-foreground) / <alpha-value>)',
}, },
// Static brand ramp (from #3ba31f) for utility use (bg-green-500, etc.).
// The theme-aware accent above (`primary`) is what most UI should use.
green: {
50: '#f1f9ec',
100: '#dcf0d0',
200: '#bde3ab',
300: '#93d07d',
400: '#63b84a',
500: '#3ba31f',
600: '#2f8419',
700: '#266a15',
800: '#1e5312',
900: '#163a0e',
},
},
fontFamily: {
sans: [
'Inter Variable',
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'sans-serif',
],
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env sh
# InboxIntel pre-commit hook — fast checks only (keep it under a few seconds).
# Heavier build/test verification runs in pre-push. Bypass in an emergency with:
# git commit --no-verify
set -eu
echo "[pre-commit] running fast checks..."
# 1) Block obvious secrets from being committed. Scans only staged, added lines.
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$STAGED" ]; then
# Never allow a real .env (only *.example templates are tracked).
echo "$STAGED" | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' >/tmp/ii_env_hits 2>/dev/null || true
if [ -s /tmp/ii_env_hits ]; then
echo "[pre-commit] BLOCKED: attempting to commit an env/secret file:"
cat /tmp/ii_env_hits
echo " -> add it to .gitignore or commit .env.example instead."
exit 1
fi
# Heuristic secret scan on added lines (private keys, obvious credential assigns).
if git diff --cached --unified=0 -- $STAGED \
| grep -E '^\+' \
| grep -Ei 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|(client_secret|password|api[_-]?key|secret)\s*[:=]\s*["'"'"']?[A-Za-z0-9/_+=-]{16,}' \
| grep -Evi 'change-me|your-|example|placeholder|\$\{' >/tmp/ii_secret_hits 2>/dev/null; then
echo "[pre-commit] BLOCKED: possible hard-coded secret in staged changes:"
cat /tmp/ii_secret_hits
echo " -> use deploy/.env / user-secrets. Override with 'git commit --no-verify' if this is a false positive."
exit 1
fi
fi
# 2) Format .NET code (only if the tool + staged .cs files are present). Verify-only,
# non-mutating, so it never rewrites files out from under your staged diff.
if echo "$STAGED" | grep -q '\.cs$'; then
if command -v dotnet >/dev/null 2>&1 && dotnet format --help >/dev/null 2>&1; then
echo "[pre-commit] dotnet format --verify-no-changes"
dotnet format InboxIntel.sln --verify-no-changes --verbosity quiet \
|| { echo " -> run 'dotnet format InboxIntel.sln' and re-stage."; exit 1; }
fi
fi
echo "[pre-commit] OK"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# InboxIntel pre-push hook — build + test gate. Mirrors what Gitea CI runs so you
# catch failures before they reach the server. Bypass with: git push --no-verify
set -eu
echo "[pre-push] building solution (Release)..."
dotnet build InboxIntel.sln -c Release --nologo \
|| { echo "[pre-push] BLOCKED: backend build failed."; exit 1; }
echo "[pre-push] running tests..."
dotnet test InboxIntel.sln -c Release --no-build --nologo \
|| { echo "[pre-push] BLOCKED: tests failed."; exit 1; }
# Frontend build (only if the app is present and npm is installed).
if [ -f frontend/package.json ] && command -v npm >/dev/null 2>&1; then
echo "[pre-push] frontend build..."
( cd frontend && npm run build --silent ) \
|| { echo "[pre-push] BLOCKED: frontend build failed."; exit 1; }
fi
echo "[pre-push] OK — safe to push."
+24
View File
@@ -0,0 +1,24 @@
<#
.SYNOPSIS
Point git at the version-controlled hooks in scripts/git-hooks.
.DESCRIPTION
Uses `git config core.hooksPath` so the hooks live in the repo (reviewable,
shared, updatable) instead of the un-tracked .git/hooks directory. Run once
per clone. Git for Windows ships the bash needed to execute the POSIX hooks.
.EXAMPLE
./scripts/install-hooks.ps1
#>
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Push-Location $root
try {
git config core.hooksPath scripts/git-hooks
# Best-effort exec bit (matters on Linux/WSL; harmless on Windows). Only applies
# once the hooks are tracked; ignored on a first run before they're committed.
foreach ($h in 'pre-commit','pre-push') {
try { git update-index --chmod=+x "scripts/git-hooks/$h" 2>$null } catch {}
}
Write-Host "Installed git hooks -> scripts/git-hooks (core.hooksPath set)." -ForegroundColor Green
Write-Host "Bypass in an emergency with --no-verify." -ForegroundColor DarkGray
}
finally { Pop-Location }
@@ -42,8 +42,15 @@ public class WidgetLayoutController : ApiControllerBase
{ {
_db.WidgetLayouts.Add(new WidgetLayout _db.WidgetLayouts.Add(new WidgetLayout
{ {
UserId = UserId, WidgetKey = dto.WidgetKey, X = dto.X, Y = dto.Y, W = dto.W, H = dto.H, UserId = UserId,
Visible = dto.Visible, SortOrder = dto.SortOrder, SettingsJson = dto.SettingsJson WidgetKey = dto.WidgetKey,
X = dto.X,
Y = dto.Y,
W = dto.W,
H = dto.H,
Visible = dto.Visible,
SortOrder = dto.SortOrder,
SettingsJson = dto.SettingsJson
}); });
} }
} }
+6 -1
View File
@@ -16,7 +16,12 @@ public record EmailSummaryDto(
long SizeEstimateBytes, long SizeEstimateBytes,
EmailCategory Category, EmailCategory Category,
bool HasListUnsubscribe, bool HasListUnsubscribe,
bool SupportsOneClick); 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> /// <summary>Full single-email view, including body text, for the detail pane.</summary>
public record EmailDetailDto( public record EmailDetailDto(
@@ -6,10 +6,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.2" /> <PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query. <!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. --> 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="8.0.4" />
<!-- 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" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" /> <ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
@@ -19,7 +19,13 @@
<PackageReference Include="QuestPDF" Version="2024.7.0" /> <PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="MailKit" Version="4.13.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
<!-- 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" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" /> <ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
@@ -0,0 +1,742 @@
// <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("20260701192100_WeightSearchVectorSubjectBody")]
partial class WeightSearchVectorSubjectBody
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
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,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NpgsqlTypes;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class WeightSearchVectorSubjectBody : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<NpgsqlTsVector>(
name: "SearchVector",
table: "emails",
type: "tsvector",
nullable: true,
computedColumnSql: "setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
stored: true,
oldClrType: typeof(NpgsqlTsVector),
oldType: "tsvector",
oldNullable: true,
oldComputedColumnSql: "to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))",
oldStored: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<NpgsqlTsVector>(
name: "SearchVector",
table: "emails",
type: "tsvector",
nullable: true,
computedColumnSql: "to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))",
stored: true,
oldClrType: typeof(NpgsqlTsVector),
oldType: "tsvector",
oldNullable: true,
oldComputedColumnSql: "setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
oldStored: true);
}
}
}
@@ -160,7 +160,7 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property<NpgsqlTsVector>("SearchVector") b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector") .HasColumnType("tsvector")
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", true); .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId") b.Property<Guid>("SenderId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -59,15 +59,17 @@ public class AppDbContext : DbContext, IAppDbContext
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// PostgreSQL full-text search: generated tsvector over subject + body with a // PostgreSQL full-text search: generated tsvector over subject + body with a
// GIN index, maintained by the DB and read-only in code. The tsvector type is // GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
// Npgsql-only, so map it only for relational providers and ignore it otherwise // and body 'B' so ts_rank_cd ranks a subject match above a body-only mention. The
// (e.g. the InMemory provider used by tests). Production behaviour is unchanged. // tsvector type is Npgsql-only, so map it only for relational providers and ignore
// it otherwise (e.g. the InMemory provider used by tests).
if (Database.IsRelational()) if (Database.IsRelational())
{ {
modelBuilder.Entity<Email>().Property(e => e.SearchVector) modelBuilder.Entity<Email>().Property(e => e.SearchVector)
.HasColumnType("tsvector") .HasColumnType("tsvector")
.HasComputedColumnSql( .HasComputedColumnSql(
"to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", "setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || " +
"setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
stored: true); stored: true);
modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN"); modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN");
} }
@@ -10,7 +10,9 @@ namespace InboxIntel.Infrastructure.Search;
/// <summary> /// <summary>
/// Structured + full-text search. Structured filters compose as SQL WHERE /// Structured + full-text search. Structured filters compose as SQL WHERE
/// clauses; free text uses PostgreSQL FTS via the generated SearchVector column /// clauses; free text uses PostgreSQL FTS via the generated SearchVector column
/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery). /// (websearch_to_tsquery / @@ / ts_rank_cd). Results are relevance-ranked when a
/// free-text query is present, date-ordered otherwise. See
/// docs/discovery/05-search-redesign.md for the full multi-layer search design.
/// </summary> /// </summary>
public class SearchService : ISearchService public class SearchService : ISearchService
{ {
@@ -58,28 +60,63 @@ public class SearchService : ISearchService
return new PagedResult<EmailSummaryDto> { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 }; return new PagedResult<EmailSummaryDto> { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 };
} }
if (!string.IsNullOrWhiteSpace(r.Query)) // websearch_to_tsquery (vs. plainto_tsquery) understands quotes ("exact phrase"),
{ // OR, and -exclusions — the syntax users already expect from web search boxes.
// PostgreSQL full-text match against the generated tsvector. var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query);
var term = r.Query.Trim(); var term = r.Query?.Trim() ?? string.Empty;
q = q.Where(e => e.SearchVector!.Matches(EF.Functions.PlainToTsQuery("english", term))); if (hasFreeTextQuery)
} q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)));
var total = await q.CountAsync(ct); var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(e => e.SentAtUtc) // Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity,
.Skip((r.Page - 1) * r.PageSize) // recency as a tiebreaker); date-only otherwise — matches the existing browse
.Take(r.PageSize) // 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 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)
{
// "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 headlineOpts =
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
items = await paged
.Select(e => new EmailSummaryDto( .Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category, e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe)) e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
.ToListAsync(ct); .ToListAsync(ct);
}
else
{
items = await paged
.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,
null))
.ToListAsync(ct);
}
return new PagedResult<EmailSummaryDto> return new PagedResult<EmailSummaryDto>
{ {
Items = items, Page = r.Page, PageSize = r.PageSize, TotalCount = total Items = items,
Page = r.Page,
PageSize = r.PageSize,
TotalCount = total
}; };
} }
} }
@@ -268,8 +268,12 @@ public class SyncService : ISyncService
{ {
_db.Attachments.Add(new Attachment _db.Attachments.Add(new Attachment
{ {
UserId = userId, EmailId = email.Id, FileName = Trunc(fileName, 512) ?? string.Empty, UserId = userId,
MimeType = Trunc(mime, 255), SizeBytes = size, GmailAttachmentId = attId EmailId = email.Id,
FileName = Trunc(fileName, 512) ?? string.Empty,
MimeType = Trunc(mime, 255),
SizeBytes = size,
GmailAttachmentId = attId
}); });
} }
@@ -338,8 +342,11 @@ public class SyncService : ISyncService
if (thread is not null) return thread; if (thread is not null) return thread;
thread = new MailThread thread = new MailThread
{ {
UserId = userId, GmailThreadId = gmailThreadId, Subject = Trunc(subject, 1024), UserId = userId,
Snippet = Trunc(snippet, 2048), FirstMessageUtc = sentAt GmailThreadId = gmailThreadId,
Subject = Trunc(subject, 1024),
Snippet = Trunc(snippet, 2048),
FirstMessageUtc = sentAt
}; };
_db.Threads.Add(thread); _db.Threads.Add(thread);
return thread; return thread;
@@ -0,0 +1,53 @@
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 Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// SearchService now relevance-ranks (ts_rank_cd via RankCoverDensity) when a free-text
/// query is present, falling back to date order otherwise. The ranked path requires a
/// live PostgreSQL instance to exercise (EF's InMemory provider cannot translate
/// websearch_to_tsquery/RankCoverDensity) — this project deliberately avoids a hard
/// Postgres/Testcontainers dependency for tests (see AuthEndpointsTests.TestAppFactory),
/// so ranking correctness itself is verified manually/in staging, not here. What IS
/// covered: the date-order fallback, which is plain LINQ and must not regress.
/// </summary>
public class SearchRankingTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
[Fact]
public async Task No_query_falls_back_to_date_descending_order()
{
var user = Guid.NewGuid();
var opts = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(nameof(No_query_falls_back_to_date_descending_order)).Options;
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
{
var sender = new Sender { UserId = user, Address = "sender@example.com", DisplayName = "Sender" };
seed.Senders.Add(sender);
var baseline = DateTimeOffset.UtcNow;
seed.Emails.Add(new Email { UserId = user, GmailMessageId = "oldest", SentAtUtc = baseline.AddDays(-2), Sender = sender });
seed.Emails.Add(new Email { UserId = user, GmailMessageId = "newest", SentAtUtc = baseline, Sender = sender });
seed.Emails.Add(new Email { UserId = user, GmailMessageId = "middle", SentAtUtc = baseline.AddDays(-1), Sender = sender });
await seed.SaveChangesAsync();
}
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user });
var request = GmailQueryParser.Parse(null, page: 1, pageSize: 50);
var result = await new SearchService(ctx).SearchAsync(user, request);
result.Items.Select(i => i.GmailMessageId).Should().ContainInOrder("newest", "middle", "oldest");
}
}