Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dfb69c26c | |||
| 5191dd010f | |||
| 7ec4f1ddb8 | |||
| a3d8654198 | |||
| 86ecf03963 | |||
| dc9d5fc301 | |||
| 59522a6e96 | |||
| b7f3a42812 | |||
| 7dbe1469d7 | |||
| 2b19bddf7b | |||
| b905c93884 | |||
| c5c33f7023 | |||
| 2056548702 | |||
| d78fe601ff | |||
| 06b050a5ed | |||
| c0c1777d3f | |||
| 9bbab5d32a | |||
| fcf290a83b | |||
| a9be48daee | |||
| dfd8579899 | |||
| 4d6f10c707 | |||
| dde23c980c | |||
| e482d60b69 | |||
| 4ce2df0a2b | |||
| 9ee5d757f5 | |||
| 045be2bb81 | |||
| 11e6768d17 | |||
| d9a07a01b2 | |||
| 8727be9e94 | |||
| ae8e6b672e | |||
| 8c2e52ad60 | |||
| d38bcf766c | |||
| 94529df775 | |||
| 1e15b84dce | |||
| c3bca051ea | |||
| e6a0239436 | |||
| 626a9f8454 | |||
| 9ae61432ba | |||
| 1bd8143a87 | |||
| 6af03ea807 | |||
| be6cbf90d7 | |||
| 64f835f719 | |||
| 0163165beb | |||
| 9bd3799fbc | |||
| c7ead8caae | |||
| 7710d49c77 | |||
| c11d747919 | |||
| 8c92263cc3 | |||
| a24c48179a | |||
| 0dfd739430 | |||
| 21606c5f91 | |||
| 95dc835651 | |||
| 2c9b402b08 | |||
| 80c2167b89 | |||
| 2f6d6abdd3 | |||
| 498536451e | |||
| 4d15a3a8fb | |||
| 4b1d1b1b6e | |||
| 20d51b2c15 |
@@ -15,3 +15,6 @@ FRONTEND_ORIGIN=http://localhost:8081
|
||||
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
|
||||
DEV_MODE=false
|
||||
MAX_MESSAGES=0
|
||||
|
||||
# Nightly DB backup rotation (days of dumps to keep in ./backups)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
name: CI
|
||||
|
||||
# Build + test gate. Runs on pushes to the long-lived branches and on every PR so
|
||||
# the 39-test suite (and both builds) must pass before merge.
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- name: Restore
|
||||
run: dotnet restore InboxIntel.sln
|
||||
- name: Build
|
||||
run: dotnet build InboxIntel.sln -c Release --no-restore
|
||||
- name: Test
|
||||
run: dotnet test InboxIntel.sln -c Release --no-build --verbosity normal
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Install
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# AUDIT L-10: the pre-commit hook enforces formatting locally, but --no-verify or web edits
|
||||
# can bypass it — this makes the same check a server-side gate.
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- name: dotnet format (verify only)
|
||||
run: dotnet format InboxIntel.sln --verify-no-changes
|
||||
|
||||
# AUDIT M-7: the search paths the InMemory provider can't translate (FTS ranking,
|
||||
# ts_headline, pg_trgm, pgvector) previously had only manual verification. This job runs
|
||||
# the Category=LiveDb tests against a real pgvector Postgres service container.
|
||||
db-tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
env:
|
||||
LIVEDB_CONNECTION: "Host=postgres;Port=5432;Database=test;Username=test;Password=test"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- name: Wait for Postgres
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
(echo > /dev/tcp/postgres/5432) 2>/dev/null && exit 0
|
||||
sleep 1
|
||||
done
|
||||
echo "Postgres service did not become reachable" >&2
|
||||
exit 1
|
||||
- name: Live-DB tests
|
||||
run: dotnet test InboxIntel.sln --filter "Category=LiveDb" --verbosity normal
|
||||
@@ -0,0 +1,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; }
|
||||
@@ -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"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Renovate
|
||||
|
||||
# RECOMMENDATIONS #2: automated dependency-update PRs (NuGet, npm, Dockerfiles, Actions)
|
||||
# that ride the existing required CI gates. Runs weekly + on demand.
|
||||
#
|
||||
# ONE-TIME SETUP (manual): create a Gitea personal access token with scopes
|
||||
# repo (rw) + user (r) + issue (rw) + organization (r), and add it as the Actions
|
||||
# secret RENOVATE_TOKEN (repo Settings -> Actions -> Secrets). Without the secret this
|
||||
# workflow fails fast with a clear message. See https://docs.renovatebot.com/modules/platform/gitea/
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 4 * * 1' # Mondays 04:30 UTC
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require RENOVATE_TOKEN
|
||||
run: |
|
||||
if [ -z "${{ secrets.RENOVATE_TOKEN }}" ]; then
|
||||
echo "RENOVATE_TOKEN secret is not set — see the comment at the top of this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Run Renovate
|
||||
uses: https://github.com/renovatebot/github-action@v40.3.6
|
||||
with:
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.cesnimda.uk/api/v1
|
||||
RENOVATE_REPOSITORIES: cesnimda/Inboxintel
|
||||
RENOVATE_ONBOARDING: "false"
|
||||
RENOVATE_REQUIRE_CONFIG: optional
|
||||
LOG_LEVEL: info
|
||||
@@ -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: '10.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
|
||||
@@ -21,6 +21,9 @@ frontend/.vite/
|
||||
appsettings.*.local.json
|
||||
secrets.json
|
||||
|
||||
## DB backups (never commit dumps)
|
||||
backups/
|
||||
|
||||
## Logs
|
||||
logs/
|
||||
*.log
|
||||
@@ -54,6 +57,7 @@ lpt[1-9].*
|
||||
*.code-workspace
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.staging.example
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
\# Project Rules
|
||||
|
||||
|
||||
|
||||
\## Code Quality
|
||||
|
||||
|
||||
|
||||
\- Prefer readability over cleverness.
|
||||
|
||||
\- Keep methods under \~40 lines where practical.
|
||||
|
||||
\- Avoid duplicated logic.
|
||||
|
||||
\- Follow SOLID principles.
|
||||
|
||||
\- Keep files focused on a single responsibility.
|
||||
|
||||
|
||||
|
||||
\## Safety
|
||||
|
||||
|
||||
|
||||
\- Never commit secrets.
|
||||
|
||||
\- Never disable tests to make them pass.
|
||||
|
||||
\- Never remove functionality without explaining why.
|
||||
|
||||
|
||||
|
||||
\## Testing
|
||||
|
||||
|
||||
|
||||
Every change must include:
|
||||
|
||||
\- Unit tests where appropriate.
|
||||
|
||||
\- Integration tests for API changes.
|
||||
|
||||
\- Build verification.
|
||||
|
||||
|
||||
|
||||
\## Architecture
|
||||
|
||||
|
||||
|
||||
Prefer:
|
||||
|
||||
\- Dependency Injection
|
||||
|
||||
\- Composition over inheritance
|
||||
|
||||
\- Async APIs
|
||||
|
||||
\- Immutable models where practical
|
||||
|
||||
|
||||
|
||||
\## Documentation
|
||||
|
||||
|
||||
|
||||
Update documentation whenever public behaviour changes.
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
# InboxIntel — Full Application Audit Report
|
||||
|
||||
**Date:** 2026-07-02 · **Scope:** entire repository (backend `src/`, `frontend/`, migrations,
|
||||
CI/CD `.gitea/workflows/`, `docker-compose*`, `deploy/`, git history) · **Phase:** 1 (read-only)
|
||||
|
||||
**Stack:** .NET 8 / ASP.NET Core (Clean Architecture) · EF Core + PostgreSQL (pgvector image)
|
||||
· React 18 + Vite SPA · Google OAuth2 (cookie session) · Gitea Actions CI/CD · self-hosted
|
||||
Docker Compose (single-operator deployment).
|
||||
|
||||
## Executive summary
|
||||
This codebase is **markedly more hardened than typical** — previous hardening phases (V-01…V-12,
|
||||
visible in code comments) already addressed the classic killers: IDOR (EF global query filters),
|
||||
SSRF (a genuinely thorough `SafeHttpGuard` with DNS-rebinding defence), token encryption at rest,
|
||||
non-root containers, stack-trace suppression, forwarded-header trust, secret hygiene (nothing in
|
||||
git history), and confirmed-destructive-actions. **No Critical findings.** The most significant
|
||||
issues are: **registered input validators that never execute (H-1)**, **no rate limiting (H-2)**,
|
||||
and a **data-at-rest posture** for email bodies that should be a deliberate, documented decision
|
||||
(H-3). CI has real security gates (gitleaks + dependency audit) already.
|
||||
|
||||
Severity counts: **Critical 0 · High 3 · Medium 6 · Low 7**
|
||||
|
||||
---
|
||||
|
||||
## 1. Security
|
||||
|
||||
### H-1 · FluentValidation validators are registered but NEVER executed
|
||||
- **File:** `src/InboxIntel.Application/DependencyInjection.cs:11` (registration) vs
|
||||
`src/InboxIntel.Api/Program.cs` (no `AddFluentValidationAutoValidation()` anywhere; no
|
||||
controller injects `IValidator<>`).
|
||||
- **Why it's a problem:** `Validators.cs` defines real safety rules — `CleanupRequestValidator`
|
||||
requires `Confirmed=true` for Trash/HardDelete, `SearchRequestValidator` bounds paging, etc. —
|
||||
but FluentValidation 11.x requires explicit auto-validation enablement, which is absent. The
|
||||
rules are dead code; requests reach services unvalidated. *Mitigating factor:* the services
|
||||
re-check the most dangerous rules (`CleanupService.cs:48` re-enforces `Confirmed`), and the
|
||||
controller clamps paging — so this is defence-in-depth loss, not an open hole. But rules that
|
||||
exist **only** in the validators (e.g. `From <= To`, LabelId-required) are silently unenforced.
|
||||
- **Fix:** add `services.AddFluentValidationAutoValidation()` (package already referenced) in
|
||||
`Program.cs`; add a regression test posting an invalid DTO and asserting 400.
|
||||
- **Blast radius:** isolated (1 line + tests). Verify no existing client sends payloads that
|
||||
would now 400.
|
||||
|
||||
### H-2 · No rate limiting on any endpoint
|
||||
- **File:** `src/InboxIntel.Api/Program.cs` (no `AddRateLimiter`/middleware anywhere).
|
||||
- **Why:** login (`/auth/login` → OAuth), search (now with FTS + trigram + future AI), export
|
||||
(PDF generation), unsubscribe (server-side outbound HTTP), and AI endpoints are all
|
||||
unthrottled. A single authenticated user (or an unauthenticated client hammering
|
||||
`/auth/login`/`/appinfo`) can exhaust CPU/DB/outbound quota. For the future multi-user platform
|
||||
(see `docs/discovery/multi-provider/`), this is a prerequisite.
|
||||
- **Fix:** ASP.NET Core's built-in `RateLimiter` — a global fixed-window per-IP policy + stricter
|
||||
policies on `auth`, `export`, `unsubscribe`, `ai`. Return 429 with Retry-After.
|
||||
- **Blast radius:** isolated (Program.cs + policy constants + tests).
|
||||
|
||||
### Verified-good (no finding)
|
||||
- **Authorization / IDOR:** `[Authorize]` on `ApiControllerBase`; only `AppInfo` + `Auth.login`
|
||||
are `[AllowAnonymous]` (correct). Global query filters on every tenant-scoped entity enforce
|
||||
per-user isolation even if a query forgets its `Where` — covered by `TenantIsolationTests`.
|
||||
- **Injection:** all data access via EF parameterisation; no raw SQL string concatenation in app
|
||||
code; FTS uses `websearch_to_tsquery` parameters. Frontend has **zero**
|
||||
`dangerouslySetInnerHTML`/`innerHTML`/`eval`; the search-highlight feature deliberately uses
|
||||
non-HTML sentinels rendered as escaped React elements (XSS-safe by construction).
|
||||
- **SSRF:** `SafeHttpGuard` validates scheme, resolves DNS and checks **every** address against
|
||||
loopback/private/link-local/CGNAT/metadata/multicast (v4+v6, v4-mapped), fails closed on
|
||||
unknown families, and is paired with `AllowAutoRedirect=false` (`DependencyInjection.cs:65`).
|
||||
This is better than most production code.
|
||||
- **Secrets:** none hardcoded (empty placeholders in `appsettings.json`); no `.env` ever
|
||||
committed (git history checked); real secrets live in git-ignored `deploy/.env*` and Gitea
|
||||
Actions secrets; pre-commit hook + CI gitleaks scan both guard regressions.
|
||||
- **Session cookies:** HttpOnly, SameSite=Lax, Secure-always outside dev, 401-not-redirect for
|
||||
XHR. Forwarded headers only trusted from configured proxy CIDRs (V-08).
|
||||
- **Headers:** nosniff, X-Frame-Options DENY, Referrer-Policy, COOP on API; HSTS in prod.
|
||||
|
||||
### M-1 · No absolute session lifetime (sliding-only)
|
||||
- **File:** `Program.cs:58-59` — `ExpireTimeSpan = 7d` with `SlidingExpiration = true`.
|
||||
- **Why:** a session that's touched at least weekly renews forever; a stolen cookie never
|
||||
expires as long as the attacker uses it. No server-side revocation list exists either
|
||||
(cookie is self-contained).
|
||||
- **Fix:** add an absolute cap via an `issued-at` claim checked in `OnValidatePrincipal`
|
||||
(e.g. re-auth after 30 days), and/or a session-stamp validated against the DB to enable
|
||||
revocation. (The multi-provider design doc 02/06 already specs DB-backed sessions — this
|
||||
aligns.)
|
||||
- **Blast radius:** isolated.
|
||||
|
||||
### M-2 · No Content-Security-Policy on the SPA or API
|
||||
- **File:** `Program.cs:156-167` (comment says CSP "report-only for now" but none is actually
|
||||
set); `frontend/nginx.conf` serves the SPA without security headers.
|
||||
- **Why:** CSP is the main mitigation layer against any future XSS slip; currently absent.
|
||||
- **Fix:** add CSP (default-src 'self'; connect-src API origin; etc.) + nosniff/XFO to the SPA
|
||||
nginx config; optionally a report-only CSP on the API.
|
||||
- **Blast radius:** isolated (nginx conf + one middleware line), needs SPA smoke-test (Chart.js
|
||||
inline styles etc.).
|
||||
|
||||
### L-1 · CSRF: no antiforgery tokens (accepted-risk, documented here)
|
||||
- SameSite=Lax cookies + strict CORS allowlist + JSON-only POST bodies make classic CSRF
|
||||
impractical in modern browsers. Acceptable for now; revisit if cookie SameSite is ever
|
||||
relaxed or non-JSON form endpoints are added.
|
||||
|
||||
### L-2 · `AllowedHosts: "*"` (`appsettings.json`)
|
||||
- Host-header filtering disabled; low risk behind the proxy but set it to the real hostnames
|
||||
at production deployment.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data protection (top priority)
|
||||
|
||||
**Inventory of sensitive data:**
|
||||
| Data | Where | At-rest protection | Verdict |
|
||||
|---|---|---|---|
|
||||
| Google OAuth **refresh/access tokens** | `users.EncryptedRefreshToken` | **Encrypted** (Data Protection API, keys on `/keys` volume) | ✅ good |
|
||||
| **Passwords** | — | **None stored** (OAuth-only login; no password column exists) | ✅ best-possible |
|
||||
| **Email bodies / subjects / snippets** | `emails.BodyText` etc. | **Plaintext** in Postgres | ⚠️ **H-3** below |
|
||||
| Sender names/addresses (PII) | `senders`, `emails` | Plaintext | part of H-3 |
|
||||
| User email + display name | `users` | Plaintext | part of H-3 |
|
||||
| DB password | `deploy/.env` (git-ignored) + Actions secret | not in repo | ✅ |
|
||||
| Data Protection keys | `/keys` Docker volume | filesystem | M-3 below |
|
||||
| IPs / payment / health data | — | not collected | ✅ n/a |
|
||||
|
||||
### H-3 · Email content stored in plaintext at rest, with unbounded retention
|
||||
- **File:** `src/InboxIntel.Domain/Entities/Email.cs` (`BodyText`, `Snippet`, `Subject`);
|
||||
Postgres `pgdata` volume; also flows into PDF/CSV/JSON **exports** and (when enabled) to the
|
||||
local Ollama process.
|
||||
- **Why:** the entire product is a copy of the user's mailbox. On this self-hosted,
|
||||
single-operator deployment the DB lives on the operator's own disk — a defensible posture —
|
||||
but: (a) anyone with disk/volume/backup access reads all mail; (b) there is **no retention or
|
||||
purge policy** (mail persists even after unsubscribe/cleanup in Gmail; "remove account"
|
||||
flows don't exist yet); (c) the planned **multi-user platform** (docs/discovery/multi-provider)
|
||||
makes plaintext-bodies-readable-by-host-admin a real privacy issue (its own security doc
|
||||
promises "admins never read users' mail" — the DB must back that up).
|
||||
- **Fix (phased):** 1) *Document* the current posture in README/threat model (deliberate,
|
||||
local-first). 2) Enable **pgcrypto/field-level encryption or full-disk/volume encryption**
|
||||
before any multi-user deployment. 3) Add a **data-retention setting** + purge job and an
|
||||
account-deletion path (GDPR-style erasure). 4) Ensure DB **backups** inherit the same
|
||||
protection.
|
||||
- **Blast radius:** documentation = trivial; field-level encryption = **large** (touches search
|
||||
— FTS can't index encrypted columns; would need architectural decision). Recommend
|
||||
volume-level encryption + retention/deletion first.
|
||||
|
||||
### M-3 · Data Protection keys stored unencrypted on the `/keys` volume
|
||||
- **File:** `Program.cs:26-28` — `PersistKeysToFileSystem` without `ProtectKeysWith*`.
|
||||
- **Why:** whoever reads the volume can decrypt all stored refresh tokens. Same-disk-as-DB
|
||||
caveat applies, but keys and ciphertext living side-by-side weakens the encryption's value.
|
||||
- **Fix:** `ProtectKeysWithCertificate(...)` (cert from env/secret), or OS-level DPAPI on
|
||||
Windows hosts; at minimum document the volume-permissions requirement.
|
||||
- **Blast radius:** isolated, but requires a key-migration step for existing tokens.
|
||||
|
||||
### M-4 · Default DB credentials in `appsettings.json`
|
||||
- **File:** `appsettings.json:3` — `Password=inboxintel` as the fallback connection string.
|
||||
- **Why:** if a deployment forgets the env override, the app happily connects with a guessable
|
||||
password (compose enforces `POSTGRES_PASSWORD` but a non-compose deployment might not).
|
||||
- **Fix:** empty the default and fail fast at startup with a clear message when unset.
|
||||
- **Blast radius:** isolated (plus updating dev docs to use user-secrets).
|
||||
|
||||
### Logging & transit — verified
|
||||
- **Logs:** no token/password logging found; the one PII-ish log is `SmtpEmailSender.cs:28`
|
||||
(recipient address + subject at Info when SMTP is unconfigured) — acceptable, downgrade to
|
||||
Debug if desired (L-3). Serilog request logging does not include query strings or bodies.
|
||||
- **Transit:** TLS terminates at nginx (HSTS enabled in prod); API+DB bound to loopback/compose
|
||||
network only. **DB connection itself is non-TLS** — fine while Postgres is co-located on the
|
||||
compose network; **must add `SSL Mode=Require` if the DB ever moves to another host** (L-4).
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependencies & supply chain
|
||||
- **`dotnet list package --vulnerable --include-transitive`:** **clean** (all projects) — the
|
||||
earlier round of CVE pins (System.Text.Json 8.0.6 etc.) is holding, and CI re-checks on every PR.
|
||||
- **`npm audit` (production deps):** **0 vulnerabilities**.
|
||||
- **M-5 · `npm audit` (dev deps):** vite 5.x depends on a vulnerable **esbuild** (1 moderate,
|
||||
1 high, dev-server-only vectors). Not shipped to users and CI already scopes to prod deps —
|
||||
but the fix is a routine **vite 5→6/7 upgrade**. Effort S.
|
||||
- **Duplication (L-5):** both `MailKit` (Infrastructure) and the frontend carry sizeable deps;
|
||||
`react-grid-layout` exists solely for the draggable dashboard the redesign plans to retire —
|
||||
candidate for removal with the Analytics migration. No abandoned packages spotted.
|
||||
- CI supply-chain posture: gitleaks (full history) + NuGet/npm audits are **required checks** —
|
||||
good. No SAST/CodeQL (L-6, nice-to-have).
|
||||
|
||||
## 4. Error handling & reliability
|
||||
- **Good:** global `UseExceptionHandler` + RFC7807 ProblemDetails (no stack traces to clients);
|
||||
Polly retry/backoff on Gmail sync; idempotent sync upserts; AI calls wrapped in try/catch with
|
||||
non-AI fallbacks ("sync must never fail because the AI provider is down").
|
||||
- **M-6 · Known EF model warning:** `Email` has a global query filter but is the required end
|
||||
of the `Email↔EmailLabel` relationship — logged on every boot; can yield surprising results
|
||||
when filtered parents are excluded. Fix: matching filter on `EmailLabel` (one line) + test.
|
||||
(Long-noted in logs; this is the nudge to actually do it.)
|
||||
- **L-7 · Multi-step writes without explicit transactions:** e.g. `GoogleAuthEvents` upsert and
|
||||
sync batches rely on EF's single-SaveChanges transactionality — mostly fine; the sync
|
||||
cursor-advance + batch-upsert pairing is the one place an explicit transaction would guard a
|
||||
mid-batch crash (currently self-heals via idempotent re-sync — acceptable).
|
||||
|
||||
## 5. Performance
|
||||
- **Fixed this cycle (verified live):** relevance-ranked FTS with weighted tsvector + GIN;
|
||||
trigram GIN indexes for the previously non-sargable sender/domain `.Contains()`; HNSW vector
|
||||
index ready for semantic search.
|
||||
- **Remaining (all Low, roadmapped):** offset pagination degrades on deep pages (keyset planned
|
||||
in `docs/discovery/05`); frontend ships one **678 KB JS bundle** (no code-splitting — vite
|
||||
`manualChunks`/dynamic import, effort S); no HTTP caching/ETags on read-heavy endpoints
|
||||
(sidebar counts are fetched per page-load); `docker-compose` frontend nginx lacks gzip/brotli
|
||||
confirmation for the bundle.
|
||||
- No N+1 patterns found (queries project with joins; aggregates precomputed in
|
||||
`AnalyticsAggregate`).
|
||||
|
||||
## 6. Code quality & architecture
|
||||
- Clean Architecture discipline is genuinely observed (dependency rule intact; thin controllers;
|
||||
DTO mapping at boundaries). Config is env-driven throughout.
|
||||
- **L-8:** `AiService.GenerateQueryAsync` returns raw LLM output as a search query (advisory-only,
|
||||
becomes a search string — harmless today; keep it that way when NL search lands: model output
|
||||
must stay data, never an executable/action).
|
||||
- Dead-ish code: none significant; `docs/specs/*` and discovery docs are current.
|
||||
|
||||
## 7. Testing & CI
|
||||
- **41 tests** (unit: parser/guard/unsubscribe/embeddings-null; integration: authz challenge,
|
||||
tenant isolation, pagination clamp, search fallback ordering). Critical security invariants
|
||||
(IDOR filter, SSRF guard, confirmed-destructive) **are tested** — better than most.
|
||||
- **Gap (M-7):** no live-Postgres test harness — FTS/ranking/fuzzy/pgvector paths are verified
|
||||
manually against staging (documented in commit messages) but not repeatably in CI. A
|
||||
Testcontainers-Postgres (pgvector image) job would convert those throwaway verifications into
|
||||
permanent regression tests. Effort M.
|
||||
- Gap (L-9): no coverage for `CleanupService`/`SyncService` beyond compilation; no E2E of the
|
||||
OAuth flow (hard without creds — acceptable).
|
||||
- CI: build+test+secrets+deps as **required** PR checks; auto-deploy to staging with a
|
||||
post-deploy health gate; prod is tag-gated. Solid. Missing: lint/format check in CI (the
|
||||
pre-commit hook enforces locally; add `dotnet format --verify-no-changes` job, effort S) (L-10).
|
||||
|
||||
---
|
||||
|
||||
## Prioritized remediation plan
|
||||
|
||||
| # | Finding | Sev | Fix effort | Blast radius |
|
||||
|---|---------|-----|-----------|--------------|
|
||||
| 1 | **H-1** Enable FluentValidation auto-validation + 400 tests | High | S | Isolated (1 line + tests; verify clients) |
|
||||
| 2 | **H-2** Rate limiting (global + auth/export/unsub/AI policies) | High | S–M | Isolated (Program.cs + tests) |
|
||||
| 3 | **H-3** Data-at-rest posture: document now; retention setting + purge job + account-deletion; volume-encryption guidance; (defer field-level encryption decision) | High | S (doc) → M (retention) → L (encryption) | Doc: none · Retention: moderate · Encryption: large/architectural |
|
||||
| 4 | **M-3** Protect Data Protection keys at rest | Med | S–M | Isolated + key migration |
|
||||
| 5 | **M-1** Absolute session lifetime (+ revocation groundwork) | Med | S | Isolated |
|
||||
| 6 | **M-4** Remove default DB password; fail fast | Med | S | Isolated |
|
||||
| 7 | **M-2** CSP + security headers on the SPA nginx | Med | S | Isolated (needs SPA smoke test) |
|
||||
| 8 | **M-6** Fix EF query-filter warning (EmailLabel filter) | Med | S | Isolated + test |
|
||||
| 9 | **M-5** Vite upgrade (dev-dep CVEs) | Med | S | Frontend build only |
|
||||
| 10 | **M-7** Testcontainers live-Postgres CI job | Med | M | CI + new test project wiring |
|
||||
| 11 | L-2/L-3/L-4/L-10 (AllowedHosts, SMTP log level, DB TLS note, CI format check) | Low | S each | Isolated |
|
||||
| 12 | L-5 bundle split / grid-layout removal (with redesign) | Low | S–M | Frontend |
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 — Remediation status (2026-07-02, all items approved & implemented)
|
||||
|
||||
Shipped as four PRs (#20–#23), each with tests, green CI, and a verified staging deploy.
|
||||
|
||||
| Finding | Status | How it was resolved | PR |
|
||||
|---------|--------|---------------------|----|
|
||||
| **H-1** validators never ran | ✅ **Resolved** | `AddFluentValidationAutoValidation()`; invalid DTOs now 400 at the boundary. Proven by 2 integration tests (pageSize=0, From>To → 400) via a new test-auth scheme | #20 |
|
||||
| **H-2** no rate limiting | ✅ **Resolved** | Global 300/min per-user (per-IP anonymous) + `auth` 10/min + `expensive` 20/min (export/unsubscribe/AI), config-driven, 429/no-queue. Proven by a 429-on-3rd-request test | #20 |
|
||||
| **H-3** plaintext bodies + unbounded retention | ✅ **Resolved (as scoped)** | `SECURITY.md` documents the deliberate posture (volume-encryption + backup guidance, delete-my-data procedure); **opt-in retention** (`DataRetention:*`, default off) + daily purge worker with 3 tests. Field-level encryption deliberately deferred (FTS can't index encrypted columns) — revisit before any multi-user deployment | #22 |
|
||||
| **M-1** sliding-only sessions | ✅ **Resolved** | Absolute 30 d cap (`Auth:AbsoluteSessionDays`) via issued-at stamp checked in `OnValidatePrincipal`; 3 unit tests (incl. missing-stamp = expired) | #20 |
|
||||
| **M-2** no CSP on the SPA | ✅ **Resolved** | Full CSP (`script-src 'self'`, `frame-ancestors 'none'`, …) + nosniff/XFO/Referrer-Policy + gzip on the SPA nginx; inline theme script moved to `/theme-init.js` to keep `script-src 'self'` honest. **Verified serving on staging** | #21 |
|
||||
| **M-3** unprotected DP key ring | ✅ **Resolved (opt-in)** | `DataProtection:CertificatePath/Password` → `ProtectKeysWithCertificate`; documented in SECURITY.md as recommended for shared hosts | #22 |
|
||||
| **M-4** default DB password | ✅ **Resolved** | Guessable default removed from `appsettings.json`; startup fails fast with a clear message when the connection string has no password | #20 |
|
||||
| **M-5** dev-dep esbuild CVEs | ✅ **Resolved** | vite 5→8 + plugin-react 6; `npm audit` now clean **including dev deps**; build verified | #21 |
|
||||
| **M-6** EF query-filter warning | ✅ **Resolved** | Matching tenant filter on `EmailLabel` (via Email navigation); boot warning confirmed gone from staging logs; cross-user invisibility test added | #20 |
|
||||
| **M-7** no live-Postgres tests | ✅ **Resolved** | CI `db-tests` job with a `pgvector/pg16` service container runs 3 permanent `Category=LiveDb` regression tests (FTS weighting, ts_headline sentinels, trigram typo fallback, pgvector cosine). **Confirmed green on the actual runner** | #23 |
|
||||
| **L-2** AllowedHosts `*` | ✅ Documented | Production checklist item in SECURITY.md (set at deployment) | #22 |
|
||||
| **L-3** SMTP recipient at Info | ✅ **Resolved** | Downgraded to Debug | #20 |
|
||||
| **L-4** DB TLS note | ✅ Documented | SECURITY.md: add `SSL Mode=Require` if Postgres ever leaves the host | #22 |
|
||||
| **L-10** no CI format gate | ✅ **Resolved** | `format` CI job (`dotnet format --verify-no-changes`) — `--no-verify` pushes can no longer bypass formatting | #23 |
|
||||
| L-1 CSRF (accepted risk) | ✅ Documented | SECURITY.md rationale (SameSite=Lax + CORS + JSON) with revisit conditions | #22 |
|
||||
| L-5/L-6/L-7/L-8/L-9 | ⏸ Deferred by design | Bundle-split & grid-layout removal ride the UI redesign; SAST/CodeQL, sync transaction hardening, and broader service coverage are Phase 3 recommendation candidates | — |
|
||||
|
||||
**Test suite: 40 → 54 tests** (48 always-on + 3 retention + 3 live-DB in CI).
|
||||
Every deploy through the pipeline stayed green; staging verified after each batch.
|
||||
@@ -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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# InboxIntel — Phase 3 Recommendations
|
||||
|
||||
**Date:** 2026-07-02 · Follows the completed audit remediation ([AUDIT_REPORT.md](AUDIT_REPORT.md)).
|
||||
Each item: what · concrete benefit · effort (S/M/L) · risk · sources. **Ranked by value-to-effort.**
|
||||
|
||||
> Research notes: grounded in official primary sources (fetched 2026-07-02) plus the
|
||||
> competitor/feature research already performed in `docs/discovery/02-competitor-analysis.md`.
|
||||
> (Live web *search* was quota-limited this session; the load-bearing facts below — support
|
||||
> dates, EF 10 features, Npgsql 10, Renovate/Gitea — were verified against primary docs.)
|
||||
|
||||
---
|
||||
|
||||
## 1. Migrate .NET 8 → .NET 10 LTS ⚠️ deadline-driven
|
||||
- **What:** move the solution to .NET 10 / EF Core 10 / Npgsql provider 10; bump
|
||||
`Pgvector.EntityFrameworkCore` off the 0.2.0 EF8-pin at the same time.
|
||||
- **Why (hard fact):** **.NET 8 support ends 2026-11-10 — ~4 months away.** After that: no
|
||||
security patches. .NET 10 is LTS until Nov 2028. This is not optional, only *when*.
|
||||
- **Bonus value:** EF 10 brings **named query filters** (exactly our multi-filter tenant
|
||||
scenario — e.g. tenant + soft-delete filters, selectively ignorable), **redacted inlined
|
||||
constants in SQL logs** (privacy win for an email app), first-class `LeftJoin`, and
|
||||
better parameterized-collection SQL (plan-cache friendly).
|
||||
- **Watch-outs:** Npgsql 10 changes `array.Contains(x)` translation to `= ANY(...)` (check
|
||||
our GIN-indexed paths); the migration touches every csproj + CI images + Dockerfiles.
|
||||
The live-DB CI job we just added is the safety net for the search paths.
|
||||
- **Effort: M** (mechanical + verify) · **Risk: M** · Blast radius: whole repo, but staged
|
||||
behind the pipeline.
|
||||
- Sources: [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core),
|
||||
[EF Core 10 what's-new](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew),
|
||||
[Npgsql EF 10 release notes](https://www.npgsql.org/efcore/release-notes/10.0.html).
|
||||
|
||||
## 2. Automated dependency updates via Renovate (self-hosted Gitea)
|
||||
- **What:** run Renovate against the Gitea instance (PAT with repo/user/issue scopes,
|
||||
`platform=gitea`); it opens update PRs that ride the existing required CI gates
|
||||
(build, tests, gitleaks, vuln scan, live-DB).
|
||||
- **Benefit:** closes the audit's supply-chain gap permanently — the MailKit/System.Text.Json
|
||||
CVE round we did by hand becomes an automated PR you just merge. NuGet + npm + Dockerfile
|
||||
+ Actions all covered.
|
||||
- **Effort: S** (a config + a scheduled runner job) · **Risk: L** (PRs are gated by CI).
|
||||
- Source: [Renovate Gitea platform docs](https://docs.renovatebot.com/modules/platform/gitea/).
|
||||
|
||||
## 3. Database backups (currently none!)
|
||||
- **What:** nightly `pg_dump` sidecar/cron in compose, rotating N days, written to a
|
||||
host path covered by your disk-encryption/backup regime (per SECURITY.md).
|
||||
- **Benefit:** today a bad migration or volume loss = total data loss; the audit fixed
|
||||
security but the **availability** story is a single Docker volume. Highest
|
||||
value-per-line-of-config item on this list.
|
||||
- **Effort: S** · **Risk: L**. Pair with a documented restore drill.
|
||||
|
||||
## 4. Activate semantic search (Ollama + embedding backfill + hybrid ranking)
|
||||
- **What:** the pgvector column, HNSW index, `IEmbeddingProvider`, and live-DB tests are
|
||||
already shipped. Remaining: an optional `ollama` compose profile, the embedding
|
||||
backfill worker (batched, VRAM-aware), and RRF hybrid merge in `SearchService`
|
||||
(design: `docs/discovery/05/06`).
|
||||
- **Benefit:** the flagship differentiator from the discovery blueprint — *"gym receipt
|
||||
march"* finds the email; nobody mainstream offers this locally/privately.
|
||||
- **Effort: M–L** · **Risk: M** (quality tuning) · Needs the RTX-3080 box to pull
|
||||
`nomic-embed-text` (~0.5 GB, always-on per the AI strategy).
|
||||
|
||||
## 5. Observability: OpenTelemetry + a dashboard
|
||||
- **What:** wire .NET's built-in OTel (traces/metrics for ASP.NET, EF, HttpClient) exported
|
||||
to a compose-profile Prometheus+Grafana (or an OTLP endpoint later). Keep Serilog for logs.
|
||||
- **Benefit:** today diagnosis = `docker logs`. This gives request latency, sync-job
|
||||
timings, rate-limit hits, and search-performance baselines — the "what will break first
|
||||
as usage grows" early-warning system.
|
||||
- **Effort: M** · **Risk: L** (additive).
|
||||
|
||||
## 6. Named query filters for tenancy (after #1)
|
||||
- **What:** convert the hand-rolled `CurrentUserId == Guid.Empty || …` filters to EF 10
|
||||
named filters (`"Tenant"`, future `"SoftDelete"`), selectively ignorable per-query.
|
||||
- **Benefit:** cleaner + safer than the worker-bypass convention; directly feeds the
|
||||
multi-provider platform's isolation model.
|
||||
- **Effort: S** (post-migration) · **Risk: L** (isolation tests already exist).
|
||||
|
||||
## 7. Frontend bundle code-splitting
|
||||
- **What:** vite `manualChunks`/dynamic imports to split the 678 KB bundle (charts,
|
||||
grid-layout, per-route chunks); drop `react-grid-layout` when the Analytics redesign
|
||||
lands (it's the sole consumer).
|
||||
- **Benefit:** faster cold loads; audit L-5 closed. Gzip is already on (batch B), so this
|
||||
is the remaining lever.
|
||||
- **Effort: S–M** · **Risk: L**.
|
||||
|
||||
## 8. Keyset (cursor) pagination for search
|
||||
- **What:** replace offset `Skip/Take` with keyset pagination for browse/date-ordered
|
||||
paths; ranked paths already effectively top-N (design in `docs/discovery/05`).
|
||||
- **Benefit:** deep-page latency stops degrading linearly at 100k+ mailboxes.
|
||||
- **Effort: M** (API shape + frontend infinite-scroll cursor) · **Risk: M** (API change).
|
||||
|
||||
## 9. SAST in CI (Semgrep)
|
||||
- **What:** a `semgrep` job (OSS rules for C#/JS + secrets/OWASP packs) in `ci.yml` —
|
||||
CodeQL is GitHub-centric; Semgrep runs anywhere Docker does.
|
||||
- **Benefit:** closes audit L-6; catches injection/crypto misuse patterns the current
|
||||
gates (gitleaks + dep-audit + tests) don't look for.
|
||||
- **Effort: S–M** (tuning noise) · **Risk: L** (advisory job first, required later).
|
||||
|
||||
## 10. Settings + feature-flag platform (multi-provider Phase 4)
|
||||
- **What:** implement `docs/discovery/multi-provider/04` — `feature_flags`/`user_settings`
|
||||
tables, `IFeatureFlags`/`IAiGate`, admin toggles later.
|
||||
- **Benefit:** unblocks shipping AI features dark (`ai.enabled` master switch), the
|
||||
Settings UI, and everything in the multi-provider plan; prerequisite for #4 to be
|
||||
properly gated per the approved design.
|
||||
- **Effort: L** · **Risk: M** — the biggest item, but the one the roadmap already commits to.
|
||||
|
||||
---
|
||||
|
||||
## Value-to-effort ranking (summary)
|
||||
| # | Item | Effort | Why this rank |
|
||||
|---|------|--------|---------------|
|
||||
| 1 | .NET 10 migration | M | **EOL deadline Nov 2026**; unlocks #6 |
|
||||
| 2 | Renovate | S | Permanent supply-chain automation for one config file |
|
||||
| 3 | Backups | S | Only protection against total data loss |
|
||||
| 4 | Semantic search activation | M–L | Flagship product differentiator; infra already live |
|
||||
| 5 | OpenTelemetry | M | Can't manage what you can't see |
|
||||
| 6 | Named query filters | S | Cheap once #1 lands |
|
||||
| 7 | Bundle splitting | S–M | Perceived speed; last audit-perf leftover |
|
||||
| 8 | Keyset pagination | M | Scales search; roadmap item |
|
||||
| 9 | Semgrep SAST | S–M | Last unautomated security layer |
|
||||
| 10 | Settings/flags platform | L | Roadmap-committed foundation |
|
||||
|
||||
**Suggested sequencing:** 2+3 immediately (tiny, standalone) → 1 (deadline) → 6 → 4 (+10 gating if you want flags first) → 5 → 7/8/9 opportunistically.
|
||||
|
||||
**STOP — awaiting your selections before implementing anything (Phase 4).**
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# InboxIntel — Security & Data Posture
|
||||
|
||||
The deliberate security posture of this application, so operators know exactly what is and
|
||||
isn't protected. Complements [AUDIT_REPORT.md](AUDIT_REPORT.md) (point-in-time audit) and
|
||||
`docs/discovery/multi-provider/06-security-model.md` (future multi-user design).
|
||||
|
||||
## Deployment model this posture assumes
|
||||
Self-hosted, **single-operator** instance: the person running the server is the person whose
|
||||
mailbox is synced. All services (API, Postgres, frontend) run in Docker on the operator's own
|
||||
machine; Postgres and the API bind to loopback/compose-internal only; TLS terminates at the
|
||||
reverse proxy.
|
||||
|
||||
## What is protected, and how
|
||||
| Asset | Protection |
|
||||
|---|---|
|
||||
| Google OAuth refresh/access tokens | Encrypted at rest (ASP.NET Data Protection, AES); never logged; never sent to the browser |
|
||||
| Data Protection key ring | Optionally encrypted with an operator-supplied X.509 certificate — set `DataProtection:CertificatePath`/`CertificatePassword`. **Without it, keys sit in plaintext on the `/keys` volume** and anyone with volume access can decrypt stored tokens. Recommended for any shared host. |
|
||||
| App session | HttpOnly/SameSite=Lax/Secure cookie · sliding 7 d **with an absolute 30 d cap** (`Auth:AbsoluteSessionDays`) |
|
||||
| Login/abuse | Rate limiting: global 300 req/min per user (or IP when anonymous); `auth` 10/min; export/unsubscribe/AI 20/min (`RateLimiting:*`) |
|
||||
| Cross-user access | EF global query filters on every tenant-scoped entity (incl. the EmailLabel join) — tested |
|
||||
| Outbound fetches (unsubscribe etc.) | `SafeHttpGuard` SSRF allowlisting (DNS-rebinding-safe) + redirects disabled |
|
||||
| Untrusted email content in the UI | Rendered only as escaped React text (no `dangerouslySetInnerHTML`); search highlights use non-HTML sentinels; SPA ships CSP with `script-src 'self'` |
|
||||
| Secrets | Never committed (`deploy/.env*` git-ignored; pre-commit + CI gitleaks scans); no passwords stored at all (OAuth-only login) |
|
||||
|
||||
## What is deliberately NOT protected (accepted risks — read this)
|
||||
1. **Email bodies are stored in plaintext in Postgres.** Full-text and semantic search index
|
||||
the body; encrypted columns cannot be indexed this way. On the assumed single-operator
|
||||
deployment, the database lives on the operator's own disk, so the threat this would
|
||||
mitigate (a third party reading the DB files) reduces to "someone with access to your
|
||||
machine" — mitigate it at the layer that actually works:
|
||||
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
|
||||
- **Encrypt backups**: nightly `pg_dump` rotation runs via the compose `backup` service
|
||||
into `./backups/` (git-ignored) — keep that directory on an encrypted disk and copy it
|
||||
off-machine. Restore: `docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql`.
|
||||
- Before any **multi-user** deployment, revisit per the multi-provider security design
|
||||
(host admins must not be able to read members' mail — plaintext bodies break that promise).
|
||||
2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
|
||||
loopback. If you ever move Postgres to another host, add `SSL Mode=Require` to the
|
||||
connection string (audit L-4).
|
||||
3. **No CSRF tokens** — SameSite=Lax cookies + strict CORS + JSON-only bodies make classic
|
||||
CSRF impractical; revisit if either changes (audit L-1).
|
||||
|
||||
## Data retention (opt-in)
|
||||
By default the local mailbox copy is kept indefinitely. Two knobs enable automatic purging of
|
||||
the **local copy only** (your actual Gmail is never touched):
|
||||
```json
|
||||
"DataRetention": {
|
||||
"PurgeTrashedAfterDays": 0, // e.g. 30 — purge local copies of trashed mail after 30 days
|
||||
"PurgeAllAfterDays": 0 // e.g. 730 — keep at most ~2 years of mail locally
|
||||
}
|
||||
```
|
||||
`0` disables a knob. A daily background worker applies them. For a full "delete my data"
|
||||
operation: stop the stack and remove the `pgdata` + `keys` volumes
|
||||
(`docker compose down -v`), and revoke the app's access in your Google account.
|
||||
|
||||
## Production checklist (beyond compose defaults)
|
||||
- Set `AllowedHosts` to your real hostname(s) (audit L-2).
|
||||
- Terminate TLS at the proxy; HSTS is enabled automatically outside Development.
|
||||
- Provide `DataProtection:CertificatePath` to encrypt the key ring.
|
||||
- Keep `deploy/.env` readable only by the service user; rotate the DB password if exposed.
|
||||
- Dependency + secret scanning run in CI on every PR (required checks).
|
||||
|
||||
## Reporting
|
||||
Single-operator project — if you find a vulnerability, open a private issue or contact the
|
||||
repository owner directly.
|
||||
+13
-4
@@ -5,13 +5,22 @@
|
||||
./deploy/down.ps1
|
||||
./deploy/down.ps1 -Volumes
|
||||
#>
|
||||
param([switch]$Volumes)
|
||||
param([switch]$Volumes, [switch]$Staging)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
$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' }
|
||||
|
||||
Push-Location $root
|
||||
|
||||
+27
-10
@@ -8,15 +8,28 @@
|
||||
#>
|
||||
param(
|
||||
[switch]$Proxy,
|
||||
[switch]$Foreground
|
||||
[switch]$Foreground,
|
||||
[switch]$Staging # production-shaped staging stack: separate env, ports, volumes
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
|
||||
$envFile = Join-Path $PSScriptRoot '.env'
|
||||
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
|
||||
|
||||
if (-not (Test-Path $envFile)) {
|
||||
throw "Missing $envFile. Create it from .env.example with your real secrets."
|
||||
# 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)) {
|
||||
throw "Missing $envFile. Create it from .env.example with your real secrets."
|
||||
}
|
||||
}
|
||||
|
||||
# Fail fast if a required key is absent or blank.
|
||||
@@ -28,19 +41,23 @@ Get-Content $envFile | ForEach-Object {
|
||||
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
|
||||
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') }
|
||||
$composeArgs += @('up', '--build')
|
||||
if (-not $Foreground) { $composeArgs += '-d' }
|
||||
|
||||
Push-Location $root
|
||||
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
|
||||
if (-not $Foreground) {
|
||||
& docker compose --env-file $envFile ps
|
||||
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
|
||||
& 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 "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1$(if($Staging){' -Staging'})" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
finally { Pop-Location }
|
||||
|
||||
@@ -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"
|
||||
+78
-5
@@ -1,20 +1,58 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# pgvector-enabled Postgres 16 (semantic search). Drop-in for postgres:16 data;
|
||||
# the 'vector' extension is created by the AddEmbeddingColumn migration.
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_DB: inboxintel
|
||||
POSTGRES_USER: inboxintel
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-inboxintel}
|
||||
# V-03: require an explicit strong password (fail fast if POSTGRES_PASSWORD is unset)
|
||||
# rather than silently defaulting to a guessable one.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# V-03: bind to loopback only so the database is reachable from the host for local
|
||||
# tooling but NOT from other machines on the network. The api container reaches it
|
||||
# over the internal compose network regardless of this published port.
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U inboxintel"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
# Nightly logical backups (RECOMMENDATIONS #3 — previously there were NONE). Dumps
|
||||
# rotate after BACKUP_KEEP_DAYS. The ./backups host directory should live on an
|
||||
# encrypted disk and be included in your off-machine backup regime (see SECURITY.md).
|
||||
# Restore: docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql
|
||||
backup:
|
||||
image: pgvector/pgvector:pg16
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
while true; do
|
||||
ts=$$(date -u +%Y%m%d-%H%M%S)
|
||||
if pg_dump -h postgres -U inboxintel -d inboxintel > /backups/inboxintel-$$ts.sql.tmp; then
|
||||
mv /backups/inboxintel-$$ts.sql.tmp /backups/inboxintel-$$ts.sql
|
||||
echo "backup OK: inboxintel-$$ts.sql"
|
||||
else
|
||||
rm -f /backups/inboxintel-$$ts.sql.tmp
|
||||
echo "backup FAILED at $$ts" >&2
|
||||
fi
|
||||
find /backups -name 'inboxintel-*.sql' -mtime +$${BACKUP_KEEP_DAYS:-7} -delete
|
||||
sleep 86400
|
||||
done
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
|
||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-7}
|
||||
volumes:
|
||||
- ./backups:/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -22,11 +60,15 @@ services:
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ASPNETCORE_URLS: http://+:8080
|
||||
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=inboxintel;Username=inboxintel;Password=${POSTGRES_PASSWORD:-inboxintel}"
|
||||
ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=inboxintel;Username=inboxintel;Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}"
|
||||
DataProtection__KeyPath: /keys
|
||||
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
|
||||
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
||||
Ai__Mode: ${AI_MODE:-Disabled}
|
||||
# Points at the compose 'ollama' service when the ai profile is up; harmless otherwise.
|
||||
Ai__OllamaBaseUrl: ${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
# OTLP export activates only when set (e.g. http://lgtm:4317 with the observability profile).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
|
||||
# Dev mode shows the dev banner and caps the initial sync. Set DEV_MODE=true
|
||||
# and MAX_MESSAGES=1000 in deploy/.env to exercise it in this Docker setup.
|
||||
App__DevMode: ${DEV_MODE:-false}
|
||||
@@ -37,8 +79,11 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# V-08: bind to loopback so the API is not directly reachable from the network
|
||||
# (only via the frontend/nginx proxy over the internal compose network). This
|
||||
# prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers.
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -49,6 +94,33 @@ services:
|
||||
ports:
|
||||
- "8081:80"
|
||||
|
||||
# Local AI (semantic search + assistants). Enable with:
|
||||
# docker compose --profile ai up -d && set AI_MODE=LocalOllama in deploy/.env
|
||||
# First run: docker compose exec ollama ollama pull nomic-embed-text
|
||||
# GPU (RTX 3080): uncomment the deploy block to pass the GPU through.
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
profiles: ["ai"]
|
||||
volumes:
|
||||
- ollama:/root/.ollama
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: all
|
||||
# capabilities: [gpu]
|
||||
|
||||
# Observability (RECOMMENDATIONS #5): all-in-one Grafana+Tempo+Prometheus+Loki.
|
||||
# Enable with: docker compose --profile observability up -d
|
||||
# then set OTEL_ENDPOINT=http://lgtm:4317 in deploy/.env and restart the api.
|
||||
# Grafana UI: http://localhost:3000 (admin/admin on first run).
|
||||
lgtm:
|
||||
image: grafana/otel-lgtm
|
||||
profiles: ["observability"]
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
|
||||
# Optional reverse proxy. Enable with: docker compose --profile proxy up
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
@@ -64,3 +136,4 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
keys:
|
||||
ollama:
|
||||
|
||||
@@ -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/*` | hours–days| One feature or refactor | dev (local)|
|
||||
| `fix/*` | hours | Non-urgent bug fix | dev (local)|
|
||||
| `hotfix/*` | minutes–hrs| 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.
|
||||
@@ -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**.
|
||||
@@ -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.
|
||||
@@ -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 $10–30/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.
|
||||
@@ -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, 8–12px 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.
|
||||
@@ -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 | 3–5d | 🔴🔴🔴 |
|
||||
| **Multi-field FTS** | Only Subject+Body indexed | Extend generated `tsvector` (sender name/addr, attachment filenames, labels) with weights (A/B/C/D) | S | 2–3d | 🔴🔴 |
|
||||
| **Fuzzy / typo tolerance** | "recieved" finds nothing | `pg_trgm` GIN + similarity fallback when FTS is empty | S | 1–2d | 🔴🔴 |
|
||||
| **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 | 3–4d | 🔴🔴🔴 |
|
||||
| **Advanced query builder** | Power users want precision | Visual builder → same DTO; operators still work | M | 3–4d | 🔵🔵 |
|
||||
| **Natural-language search** | "gym receipt March" | Local LLM (or rules-first) parses intent → chips + terms, shown editable | L | 5–8d | 🔴🔴🔴 |
|
||||
| **Semantic search** | Recall by *meaning*, not keywords | `IEmbeddingProvider` (Ollama) → `pgvector` (HNSW); backfill job; hybrid with FTS | L | 8–12d | 🔴🔴🔴 |
|
||||
| **Conversational "ask your inbox"** | "What did Sarah say about the invoice?" | RAG: semantic retrieve → local LLM answers with **citations** to source emails | XL | 10–15d | 🔴🔴🔴 |
|
||||
| **"Why this matched"** | Trust + learnability | `ts_headline` highlights (lexical); nearest-concept + snippet (semantic); parsed-intent chips (NL) | M | 3–4d | 🔴🔴 |
|
||||
| **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 | 1–2d | 🔴 |
|
||||
| **Suggested searches** | Blank-box paralysis | Rules over user's own stats (top senders, unreplied, big attachments) | S | 2–3d | 🔴🔴 |
|
||||
| **People search** | "Everything with this person" | Sender/recipient facet + aggregation; person profile panel | M | 3–5d | 🔴🔴 |
|
||||
| **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 | 6–10d | 🔴🔴 |
|
||||
| **Timeline search** | "our thread over time" | Date-bucketed results + a timeline scrubber UI | M | 3–4d | 🔵🔵 |
|
||||
| **Related conversations** | Surface adjacent context | Vector nearest-neighbours + same-participants/thread heuristics | M | 3–5d | 🔴🔴 |
|
||||
| **Duplicate / near-dup detection** | Clutter, repeated sends | Hash (exact) + embedding similarity (near) → group/cleanup | M | 4–6d | 🔵🔵 |
|
||||
| **Conversation intelligence** | Long threads are walls | Thread summary + extracted actions/decisions (local LLM) surfaced in results & reading | L | 8–12d | 🔴🔴🔴 |
|
||||
|
||||
### 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 1–2 (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.
|
||||
@@ -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 7–8B 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 — **7–8B 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 | ~30–60 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 | ~0–2 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 ~5–10 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.
|
||||
@@ -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**; ~1–3s | 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 70–90% 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)).
|
||||
@@ -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.
|
||||
@@ -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 (1–5) × (6 − Effort 1–5)**, 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.
|
||||
@@ -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) §5–6);
|
||||
`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.
|
||||
@@ -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 | 7–8B 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.
|
||||
@@ -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
|
||||
(**7–8B 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)
|
||||
@@ -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 ~1–2M 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 1–3 are the platform spine; 4–5 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 | Minutes–hours |
|
||||
| **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.
|
||||
@@ -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**.
|
||||
@@ -0,0 +1,69 @@
|
||||
# InboxIntel — Feature Build-Out & UI Overhaul Specs
|
||||
|
||||
This folder is the design contract for the Clean.Email-parity feature build-out plus
|
||||
a full UI overhaul. Specs are written to be executable: each names real types,
|
||||
files, and the patterns already in the codebase.
|
||||
|
||||
## Locked decisions (2026-06-30)
|
||||
|
||||
| Area | Decision |
|
||||
|------|----------|
|
||||
| **Automation safety** | **Hybrid.** Safe, reversible actions (label, archive/skip-inbox, mark-read, star, move-to-label) apply automatically. Destructive actions (trash, delete, keep-newest culling, trash-by-age) are **proposed** and require one-click user approval before touching Gmail. |
|
||||
| **Scope** | **Tier 1 + 2 only.** Gmail-only. **No sending** (no Compose/Reply/Forward). No multi-provider. |
|
||||
| **Gmail vs in-app** | **Touch real Gmail.** Screener/Block/Pause/Read-Later/Deliver-To use managed `InboxIntel/…` labels + skip-inbox so the inbox is clean everywhere (phone, web). All reversible. |
|
||||
| **Privacy Monitor** | Use **XposedOrNot** (free, no API key for email endpoints) as the default provider, **on by default**. HIBP kept as a swappable key-based alternative. Only ever checks the signed-in user's own address. |
|
||||
| **UI aesthetic** | **Stripe / Notion** — light, airy, generous whitespace, soft shadows. |
|
||||
| **Color modes** | **Light + dark toggle**, light-first, driven by CSS-variable design tokens. |
|
||||
| **UI stack** | **Tailwind CSS + shadcn-style** headless primitives (Radix + cva + tailwind-merge), hand-built component set. |
|
||||
| **UI rollout** | **Incremental** — stand up the design system, then convert page-by-page. App stays working throughout. |
|
||||
| **Brand** | Keep existing logo. **Propose a new accent + neutral palette** (see `ui-overhaul.md`) for approval. |
|
||||
| **Mobile** | **Desktop-first, responsive-ok** — usable on phones, but desktop is the primary target. |
|
||||
|
||||
## The unifying idea
|
||||
|
||||
Clean.Email's whole "keeps your inbox clean automatically" story reduces to **one
|
||||
engine** plus a few specializations:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Automation Engine │
|
||||
│ (conditions → action) │
|
||||
└──────────────┬──────────────┘
|
||||
┌──────────────────────────┼──────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
AutomationRule SenderPolicy per-email / per-thread
|
||||
(custom rules, (Block, Whitelist, Pinned (Email.IsPinned)
|
||||
Trash-by-Age) Screener, Pause, Mute (MailThread.IsMuted)
|
||||
Read-Later, Keep-Newest,
|
||||
Deliver-To)
|
||||
```
|
||||
|
||||
Everything writes through the existing `CleanupService` / `IGmailService`, logs to a
|
||||
single **`AutomationAction`** table that doubles as the **approval queue** (Proposed)
|
||||
and the **Activity Log** (Applied / Rejected / Undone).
|
||||
|
||||
## Spec index
|
||||
|
||||
| File | Covers |
|
||||
|------|--------|
|
||||
| [`feature-rules-engine.md`](feature-rules-engine.md) | Auto Clean Rules, the execution engine, hybrid approval queue, Pinned, Mute, Trash-by-Age, Keep-Newest, the `AutomationWorker` |
|
||||
| [`feature-sender-policy.md`](feature-sender-policy.md) | Block, Whitelist, Screener, Pause, Read-Later, Deliver-To (per-sender) |
|
||||
| [`feature-activity-log.md`](feature-activity-log.md) | Unified `AutomationAction` log, undo, Activity Summaries |
|
||||
| [`feature-privacy-monitor.md`](feature-privacy-monitor.md) | HIBP breach checking behind a flag |
|
||||
| [`ui-overhaul.md`](ui-overhaul.md) | Design tokens, palette proposal, Tailwind+shadcn setup, page-by-page migration |
|
||||
| [`build-plan.md`](build-plan.md) | Increment sequencing, commit plan, what depends on what |
|
||||
|
||||
## Cross-cutting invariants (apply to every feature)
|
||||
|
||||
1. **User-scoping.** Every query, mutation, and background pass filters by `UserId`. No
|
||||
cross-user data path may exist. (Standing security instruction.)
|
||||
2. **AI never destroys.** The existing `AiService` doc invariant holds: AI only reads
|
||||
and suggests. Automation destructive actions come from deterministic rules + user
|
||||
approval, never directly from an LLM.
|
||||
3. **Reversibility & managed labels.** Anything that hides mail uses Gmail labels under
|
||||
the `InboxIntel/` namespace and `skip-inbox` (remove `INBOX`), never hard-delete.
|
||||
Hard delete is never an automatic or proposed action — it stays manual-only.
|
||||
4. **Graceful degradation.** A failing Gmail/AI/HIBP call logs and continues; it never
|
||||
crashes the worker or a sync.
|
||||
5. **Pinned & Whitelisted are sacrosanct.** No rule, policy, or sweep may act on a
|
||||
pinned email or a whitelisted sender.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Build Plan — sequencing & commits
|
||||
|
||||
Two tracks run in parallel and rarely touch the same files: **Backend (automation)** and
|
||||
**Frontend (UI overhaul)**. Backend can progress regardless of palette sign-off; the new
|
||||
feature *pages* wait for the design system (Increment F1).
|
||||
|
||||
Each step ends with a build (`dotnet build` and/or `npm run build`) and a commit, per the
|
||||
established pattern.
|
||||
|
||||
## Backend track (B)
|
||||
|
||||
| Step | Deliverable | Key files | Migration |
|
||||
|------|-------------|-----------|-----------|
|
||||
| **B1** | Automation core domain + engine skeleton | enums, `AutomationRule`, `AutomationAction`, `SenderPolicy`, `Email.IsPinned`, `MailThread.IsMuted`, `User.Screener*` | `AddAutomationCore` |
|
||||
| **B2** | `EnsureLabelAsync` on Gmail service + label cache | `IGmailService`, `GmailApiService` | — |
|
||||
| **B3** | `AutomationEngine.RunAsync` (matching, safe-apply, propose) + `Matches()` unit tests | `AutomationEngine`, `RuleMatcher` | — |
|
||||
| **B4** | `IRuleService` + `AutomationController` (rules CRUD + preview) | service, controller, `AutomationDtos` | — |
|
||||
| **B5** | Approval queue + activity + undo (`IAutomationActionService`) | service, controller endpoints | — |
|
||||
| **B6** | `ISenderPolicyService` + Screener + policy controller | service, controller | — |
|
||||
| **B7** | `AutomationWorker` + hook engine into incremental sync; `AutomationOptions`; DI wiring | worker, `SyncService`, `DependencyInjection`, `Options`, `appsettings` | — |
|
||||
| **B8** | Activity Summaries + Cleanup Reminders into `DigestService` | `DigestService` | — |
|
||||
| **B9** | Privacy Monitor (provider, service, controller, options, Null provider) | `Privacy/*`, DI, `appsettings` | `AddPrivacyFields` |
|
||||
|
||||
> Commit boundaries roughly per step (B1–B2 may bundle; B3 stands alone with tests).
|
||||
|
||||
## Frontend track (F)
|
||||
|
||||
| Step | Deliverable | Notes |
|
||||
|------|-------------|-------|
|
||||
| **F0** | Tailwind + PostCSS + Radix + cva/lucide installed; `index.css` tokens; `darkMode:class`; `ThemeToggle`; build green | no page swaps yet |
|
||||
| **F1** | `components/ui/*` primitive set | button/card/input/dialog/sheet/dropdown/tabs/tooltip/toast/table/badge/switch/skeleton |
|
||||
| **F2** | App shell (`Layout`) on new system + theme toggle in topbar | biggest visual win |
|
||||
| **F3** | Dashboard | theme chart.js colors via tokens |
|
||||
| **F4** | Senders (+ policy dropdown, wired to B6) | |
|
||||
| **F5** | Unsubscribe (confidence meter) | |
|
||||
| **F6** | Search / Folders / Cleanup | |
|
||||
| **F7** | New pages: Rules editor, Review queue, Screener, Activity, Read-Later, Privacy | depends on B4–B9 |
|
||||
| **F8** | Landing polish; delete `styles.css` | |
|
||||
|
||||
## Suggested interleave
|
||||
|
||||
```
|
||||
B1+B2 ─▶ B3 ─▶ B4 ─▶ B5 ─▶ B6 ─▶ B7 ─▶ B8 ─▶ B9
|
||||
F0 ─▶ F1 ─▶ F2 ─▶ F3 ─▶ F4 ─▶ F5 ─▶ F6 ─▶ F7(needs B4–B9) ─▶ F8
|
||||
```
|
||||
|
||||
Practical order to actually build in: **F0 → F1 → F2** (get the app looking modern fast
|
||||
and de-risk the stack), then **B1→B3** (the engine core), then alternate
|
||||
feature-by-feature (B4+F7-rules, B5+F7-review, B6+F7-screener, …), finishing with B8/B9
|
||||
+ their pages, then F3–F6 restyles and F8 cleanup.
|
||||
|
||||
## Definition of done (per feature)
|
||||
|
||||
- Backend builds (0 errors), unit/integration tests for engine logic pass.
|
||||
- Frontend builds; page works in light **and** dark.
|
||||
- Every new endpoint scoped to `UserId`; destructive paths go through approval.
|
||||
- Spec checklist items ticked.
|
||||
- Committed (push remains blocked by the known `git.cesnimda.uk` credential issue — local only).
|
||||
|
||||
## Open items to confirm before/while building
|
||||
|
||||
1. **Accent color** sign-off (indigo proposed; alternatives listed in `ui-overhaul.md`).
|
||||
2. Whether Activity Summaries need a **separate toggle** from the analytics digest (default: fold in).
|
||||
3. First-match-wins vs all-matching-rules for rule evaluation (default: **first-match-wins** by priority).
|
||||
@@ -0,0 +1,75 @@
|
||||
# Spec: Activity Log, Undo & Activity Summaries
|
||||
|
||||
A unified, trustworthy record of everything automation did — and a way to take it back.
|
||||
Built entirely on the `AutomationAction` table from `feature-rules-engine.md`; no new
|
||||
storage.
|
||||
|
||||
## 1. Activity Log
|
||||
|
||||
- Backed by `AutomationAction` rows with `Status in (Applied, Rejected, Undone, Failed)`.
|
||||
- `IAutomationActionService.GetActivityAsync(userId, take)` returns
|
||||
`ActivityLogEntryDto`, newest first, grouped where it reads naturally
|
||||
(e.g. "Archived 38 emails from LinkedIn — Rule: Social noise").
|
||||
- Each entry exposes `CanUndo`:
|
||||
- Safe actions (Archive/SkipInbox/ApplyLabel/MarkRead/Star) — always undoable while we
|
||||
still hold `UndoStateJson` and the message exists.
|
||||
- Trash — undoable (Gmail untrash) within Gmail's 30-day window.
|
||||
- Hard delete — N/A (never automated).
|
||||
|
||||
### UI (`/app/activity`)
|
||||
- Reverse-chronological feed with source chips (Rule / Policy / Screener / Age sweep),
|
||||
action icon, affected count, timestamp, and an **Undo** button where `CanUndo`.
|
||||
- Filter by source and action type. Date range. Search by sender.
|
||||
|
||||
## 2. Undo
|
||||
|
||||
`UndoAsync(userId, actionIds)`:
|
||||
1. Load the `Applied` actions (verify `UserId`).
|
||||
2. For each, parse `UndoStateJson` (captured pre-apply: which labels were present,
|
||||
whether `INBOX` was set, whether it was in Trash).
|
||||
3. Issue the inverse `BatchModifyAsync` / `BatchUntrash` to restore prior state.
|
||||
4. Set `Status = Undone`, stamp `AppliedUtc = now` on the undo.
|
||||
5. Log is append-only in spirit: the original row flips to `Undone` rather than being deleted.
|
||||
|
||||
`UndoStateJson` shape (kept tiny):
|
||||
```json
|
||||
{ "hadInbox": true, "labels": ["Label_12","Label_88"], "wasTrashed": false }
|
||||
```
|
||||
Captured by the engine/approval step immediately before mutating.
|
||||
|
||||
## 3. Activity Summaries (extends existing digest)
|
||||
|
||||
Clean.Email's "Activity Summaries" = periodic notification of what automation did. We
|
||||
already have the SMTP digest infra (`IDigestService`, `DigestWorker`,
|
||||
`User.DigestEnabled`). Extend rather than add:
|
||||
|
||||
- `DigestService.BuildHtml` gains an **"Automation activity since last digest"** section:
|
||||
counts of archived/labeled/screened, pending-approval count (with a nudge to review),
|
||||
top rules by volume, new screener senders.
|
||||
- Pull from `AutomationAction` where `AppliedUtc > user.LastDigestSentUtc`.
|
||||
- No new toggle — folds into the existing digest opt-in. (Optional later: a separate
|
||||
`User.ActivitySummaryEnabled` if users want activity summaries without the analytics digest.)
|
||||
|
||||
## 4. Cleanup Reminders
|
||||
|
||||
A lightweight nudge when the inbox needs attention, reusing `DigestWorker`'s tick:
|
||||
|
||||
- If a user has **pending destructive approvals** older than `ReminderAfterDays` (default 3)
|
||||
and digests are on, the next digest leads with "You have N actions awaiting approval."
|
||||
- If automation is **off** but the dashboard health score is poor / unsubscribe backlog
|
||||
is large, include a "Time to clean up" prompt with a deep link.
|
||||
|
||||
No new infrastructure — just content rules inside `DigestService`.
|
||||
|
||||
## 5. API
|
||||
|
||||
Covered by `feature-rules-engine.md` §7:
|
||||
`GET /automation/activity`, `POST /automation/activity/undo`. Add query params for
|
||||
filtering: `?source=&action=&from=&to=&q=`.
|
||||
|
||||
## 6. Security & safety
|
||||
|
||||
- [ ] Activity + undo scoped to `UserId`; action ownership re-checked on undo.
|
||||
- [ ] Undo is best-effort and idempotent — undoing an already-undone/missing message
|
||||
logs and no-ops rather than erroring.
|
||||
- [ ] Log never exposes another user's senders/emails.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Spec: Privacy Monitor (data-breach checking)
|
||||
|
||||
Checks the user's email address against known data breaches. **Default provider:
|
||||
[XposedOrNot](https://xposedornot.com/api_doc)** — free, no API key required for email
|
||||
endpoints. HIBP is kept as a swappable alternative behind a key. Because it's free and
|
||||
keyless, Privacy Monitor ships **enabled by default** (it only ever checks the
|
||||
signed-in user's own address).
|
||||
|
||||
## 1. Provider details (XposedOrNot)
|
||||
|
||||
No auth for email endpoints. Rate limit: **2 req/sec per IP** (we cache, so this is a
|
||||
non-issue). Two endpoints:
|
||||
|
||||
| Purpose | Request | Returns |
|
||||
|---------|---------|---------|
|
||||
| Quick check | `GET https://api.xposedornot.com/v1/check-email/{email}` | `{ "breaches": [["Name1","Name2",…]], "email", "status":"success" }`; or `{ "Error":"Not found", "email":null }` when clean |
|
||||
| **Rich analytics** (what we use) | `GET https://api.xposedornot.com/v1/breach-analytics?email={email}` | `BreachesSummary`, **`ExposedBreaches`** (entity name, industry, risk level, exposed data types, year, record count), `BreachMetrics`, `ExposedPastes` |
|
||||
|
||||
We call **`breach-analytics`** to populate the rich UI. A `404`/`Error:"Not found"`
|
||||
means "no breaches" → return empty, not an error. We call the REST API directly via a
|
||||
named `HttpClient` (consistent with the existing `ollama`/`openai`/`unsubscribe`
|
||||
clients); the official `XposedOrNot-DotNet` SDK exists but we avoid the extra dependency
|
||||
+ audit surface.
|
||||
|
||||
## 2. Configuration
|
||||
|
||||
`PrivacyOptions` (new, `Configuration/Options.cs`):
|
||||
|
||||
```csharp
|
||||
public class PrivacyOptions
|
||||
{
|
||||
public const string SectionName = "Privacy";
|
||||
public bool Enabled { get; set; } = true; // free + keyless → on by default
|
||||
public string Provider { get; set; } = "XposedOrNot"; // "XposedOrNot" | "Hibp" | "None"
|
||||
public string? HibpApiKey { get; set; } // only needed if Provider="Hibp"
|
||||
public int CacheHours { get; set; } = 24; // don't re-check more than daily
|
||||
}
|
||||
```
|
||||
|
||||
`appsettings.json` gains a `Privacy` section: `Enabled: true`, `Provider:
|
||||
"XposedOrNot"`, empty `HibpApiKey`.
|
||||
|
||||
```csharp
|
||||
// XposedOrNot needs no key; Hibp does.
|
||||
IsEnabled => Enabled && Provider switch {
|
||||
"XposedOrNot" => true,
|
||||
"Hibp" => !string.IsNullOrWhiteSpace(HibpApiKey),
|
||||
_ => false
|
||||
};
|
||||
```
|
||||
|
||||
> **Privacy note:** this sends the user's own email address to a third-party service.
|
||||
> That's the feature's purpose and it's the signed-in user's own address, but it stays
|
||||
> a single config flag away from off, and we never check anyone else's address.
|
||||
|
||||
## 3. Provider abstraction
|
||||
|
||||
```csharp
|
||||
public record BreachDto(string Name, string Title, string? Domain, int? Year,
|
||||
IReadOnlyList<string> DataClasses, string? RiskLevel,
|
||||
string? Industry, string? LogoUrl, string? Description);
|
||||
|
||||
public interface IBreachProvider
|
||||
{
|
||||
bool IsEnabled { get; }
|
||||
/// <summary>Breaches for an address; empty list if clean; throws only on hard errors.</summary>
|
||||
Task<IReadOnlyList<BreachDto>> CheckAsync(string emailAddress, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
- **`XposedOrNotBreachProvider`** (default) — `GET /v1/breach-analytics?email={url-encoded}`
|
||||
on the `"xposedornot"` named client. Map `ExposedBreaches.breaches_details[]`
|
||||
(`breach`, `xposed_data` → `DataClasses`, `xposed_date`/`year`, `industry`,
|
||||
`risk` → `RiskLevel`, `logo`, `details` → `Description`) into `BreachDto`. Treat
|
||||
`Error:"Not found"` / `404` as clean (empty). `429` → respect backoff, return
|
||||
cached/empty. No key, no `user-agent` requirement.
|
||||
- `HibpBreachProvider` — `GET https://haveibeenpwned.com/api/v3/breachedaccount/{account}?truncateResponse=false`,
|
||||
header `hibp-api-key`, descriptive `user-agent`. `404` = clean, `401` = misconfig (log,
|
||||
disable), `429` = back off. Used only when `Provider="Hibp"` + key set.
|
||||
- `NullBreachProvider` — `IsEnabled => false`, returns empty. Registered when
|
||||
`Provider="None"` or the chosen provider isn't usable (mirrors `NullAiProvider`).
|
||||
|
||||
DI selects the provider by `PrivacyOptions.Provider` (switch in `DependencyInjection`,
|
||||
same shape as the AI provider selection).
|
||||
|
||||
## 3. Service
|
||||
|
||||
```csharp
|
||||
public interface IPrivacyService
|
||||
{
|
||||
bool IsEnabled { get; }
|
||||
/// <summary>Check the signed-in user's own address. Cached per PrivacyOptions.CacheHours.</summary>
|
||||
Task<PrivacyReportDto> CheckSelfAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public record PrivacyReportDto(string Address, bool Checked, int BreachCount,
|
||||
IReadOnlyList<BreachDto> Breaches, DateTimeOffset? LastCheckedUtc);
|
||||
```
|
||||
|
||||
- Resolve the user's address from `User`/identity.
|
||||
- Cache the last result + timestamp (new `User.LastBreachCheckUtc` + a small
|
||||
`BreachCheck`/JSON column, or a dedicated table if we later check multiple addresses).
|
||||
For v1, store on `User`: `LastBreachCheckUtc`, `BreachCountCached`.
|
||||
- Respect `CacheHours`: return cached unless stale or `force` requested.
|
||||
|
||||
## 4. API (`PrivacyController : ApiControllerBase`)
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|--------|-------|---------|
|
||||
| GET | `/privacy/status` | `{ enabled }` so the UI can hide the feature when off |
|
||||
| GET | `/privacy/self` | cached report for the signed-in user |
|
||||
| POST | `/privacy/self/refresh` | force a fresh check (rate-limit aware) |
|
||||
|
||||
All scoped to `UserId`. We **only** check the authenticated user's own address in v1 —
|
||||
never arbitrary addresses (avoids turning the app into a breach-lookup tool for others).
|
||||
|
||||
## 5. Frontend
|
||||
|
||||
- **Privacy page** (`/app/privacy`), hidden from nav when `GET /privacy/status` is disabled.
|
||||
- Shows: address checked, breach count, and a card per breach (title, date, what leaked,
|
||||
verified badge), plus a "Re-check" button and "what this means / next steps" guidance.
|
||||
- Dashboard widget (optional): a small "Privacy" tile with breach count + link, also
|
||||
hidden when disabled.
|
||||
|
||||
## 6. Security & safety
|
||||
|
||||
- [ ] Only the authenticated user's own address is ever checked (no lookup of others).
|
||||
- [ ] Any API key (HIBP path) read from config/secrets, never logged, never sent to the client.
|
||||
- [ ] Feature can be fully disabled via `Privacy:Enabled=false` or `Provider="None"`
|
||||
(endpoints return `{ enabled:false }`, nav hidden).
|
||||
- [ ] Rate-limit/backoff respected (XposedOrNot 2 req/s); failures degrade to cached/empty, never crash.
|
||||
- [ ] Provider responses cached (`CacheHours`) to minimize external calls and avoid leaking usage patterns.
|
||||
- [ ] Email is URL-encoded into the request path/query; no other PII is sent.
|
||||
@@ -0,0 +1,330 @@
|
||||
# Spec: Automation Engine, Auto Clean Rules, Pinned, Mute, Age-based cleanup
|
||||
|
||||
The keystone feature. Everything else in the build-out plugs into this engine.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
- Persistent, user-defined **Auto Clean Rules**: *match conditions → action*, run
|
||||
automatically against new and existing mail.
|
||||
- **Hybrid safety**: safe actions auto-apply; destructive actions queue for approval.
|
||||
- Reusable execution path for the per-sender policies (`feature-sender-policy.md`)
|
||||
and the activity log (`feature-activity-log.md`).
|
||||
- **Pinned** emails and **Muted** threads as first-class automation exemptions.
|
||||
|
||||
## 2. Domain model
|
||||
|
||||
### 2.1 New enums (`InboxIntel.Domain/Enums/Enums.cs`)
|
||||
|
||||
```csharp
|
||||
/// <summary>What an automation rule/policy does to a matched email.
|
||||
/// Safe = applied automatically. Destructive = proposed, needs approval.</summary>
|
||||
public enum AutomationActionType
|
||||
{
|
||||
// ── Safe (auto-applied) ──
|
||||
Archive = 0, // remove INBOX (skip inbox), keep the mail
|
||||
MarkRead = 1,
|
||||
Star = 2,
|
||||
ApplyLabel = 3, // add a Gmail label (Deliver-To, Read-Later, Paused, Screener)
|
||||
SkipInbox = 4, // remove INBOX only (used by Pause/Read-Later/Screener)
|
||||
// ── Destructive (proposed, needs approval) ──
|
||||
Trash = 50, // move to Trash (reversible in Gmail for 30 days)
|
||||
KeepNewestCull= 51, // trash all-but-newest-N from a sender
|
||||
}
|
||||
|
||||
public static class AutomationActionTypeExtensions
|
||||
{
|
||||
public static bool IsDestructive(this AutomationActionType t) => (int)t >= 50;
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle of a single proposed/applied automation action.</summary>
|
||||
public enum AutomationActionStatus
|
||||
{
|
||||
Proposed = 0, // destructive, awaiting user approval
|
||||
Applied = 1, // executed against Gmail
|
||||
Rejected = 2, // user declined the proposal
|
||||
Undone = 3, // user reverted an applied action
|
||||
Failed = 4, // execution errored
|
||||
}
|
||||
|
||||
/// <summary>Where an automation action originated.</summary>
|
||||
public enum AutomationSource
|
||||
{
|
||||
Rule = 0, // an AutomationRule
|
||||
SenderPolicy = 1, // Block / Pause / Read-Later / Keep-Newest / Deliver-To
|
||||
Screener = 2,
|
||||
AgeSweep = 3, // Trash-by-Age
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 `AutomationRule` (new entity)
|
||||
|
||||
The user-facing "Auto Clean Rules". Structured match columns (no free-form JSON — keeps
|
||||
EF querying and the UI simple). All match fields are nullable = "don't care"; a rule
|
||||
matches an email when **every non-null condition** is satisfied (AND semantics).
|
||||
|
||||
```csharp
|
||||
public class AutomationRule : AuditableEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; } = true;
|
||||
/// <summary>Lower number = evaluated first. Ties broken by CreatedUtc.</summary>
|
||||
public int Priority { get; set; }
|
||||
|
||||
// ── Match conditions (all nullable = ignore) ──
|
||||
public string? SenderAddress { get; set; } // exact, lower-cased
|
||||
public string? SenderDomain { get; set; } // e.g. "github.com"
|
||||
public EmailCategory? Category { get; set; }
|
||||
public string? SubjectContains { get; set; } // case-insensitive substring
|
||||
public bool? IsUnread { get; set; }
|
||||
public bool? HasAttachment { get; set; }
|
||||
public bool? HasListUnsubscribe { get; set; }
|
||||
public long? MinSizeBytes { get; set; }
|
||||
public int? OlderThanDays { get; set; } // SentAtUtc older than N days
|
||||
|
||||
// ── Action ──
|
||||
public AutomationActionType Action { get; set; }
|
||||
/// <summary>Label name for ApplyLabel actions (created under InboxIntel/ if not user-chosen).</summary>
|
||||
public string? ActionLabelName { get; set; }
|
||||
/// <summary>N for KeepNewestCull; null otherwise.</summary>
|
||||
public int? ActionParam { get; set; }
|
||||
|
||||
/// <summary>If true, also remove INBOX when applying a label (move vs. just tag).</summary>
|
||||
public bool AlsoSkipInbox { get; set; }
|
||||
|
||||
public int TimesApplied { get; set; }
|
||||
public DateTimeOffset? LastRunUtc { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
> **Trash-by-Age** is just an `AutomationRule` with `OlderThanDays` set and
|
||||
> `Action = Trash`. **Keep-Newest** is `Action = KeepNewestCull, ActionParam = N`
|
||||
> (and is owned per-sender via `SenderPolicy`, which materializes one of these rules —
|
||||
> see `feature-sender-policy.md`). No special-case code paths.
|
||||
|
||||
### 2.3 `AutomationAction` (new entity) — queue **and** log
|
||||
|
||||
One row per (action, email) the engine decides to take. This is the approval queue
|
||||
when `Proposed`, and the activity log once `Applied`/`Rejected`/`Undone`.
|
||||
|
||||
```csharp
|
||||
public class AutomationAction : AuditableEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public AutomationSource Source { get; set; }
|
||||
public Guid? RuleId { get; set; } // AutomationRule, if Source=Rule
|
||||
public Guid? SenderPolicyId { get; set; } // if Source=SenderPolicy/Screener
|
||||
|
||||
public Guid EmailId { get; set; }
|
||||
public Email? Email { get; set; }
|
||||
public string GmailMessageId { get; set; } = string.Empty; // captured for undo
|
||||
|
||||
public AutomationActionType Action { get; set; }
|
||||
public AutomationActionStatus Status { get; set; }
|
||||
|
||||
/// <summary>Label/state captured before applying, so Undo can restore it.
|
||||
/// e.g. "had INBOX; had no InboxIntel/Paused". Serialized small JSON.</summary>
|
||||
public string? UndoStateJson { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTimeOffset? AppliedUtc { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
> **Batching note:** destructive proposals are grouped in the UI by `(RuleId/Source,
|
||||
> Action, SenderId)` so the user approves "Trash 412 emails from Groupon" as one click,
|
||||
> not 412 rows. The grouping is a query concern, not a schema one.
|
||||
|
||||
### 2.4 Exemption flags on existing entities
|
||||
|
||||
- `Email.IsPinned` (bool, default false) — **Pinned Messages**. Engine skips pinned emails entirely.
|
||||
- `MailThread.IsMuted` (bool, default false) — **Mute**. New messages in a muted thread are auto `SkipInbox` + `MarkRead` and never surface in other automation.
|
||||
|
||||
Both require a migration (`AddAutomationCore`).
|
||||
|
||||
## 3. Application layer
|
||||
|
||||
### 3.1 `IGmailService` addition
|
||||
|
||||
Screener/Read-Later/Pause/Deliver-To need to create labels on demand:
|
||||
|
||||
```csharp
|
||||
/// <summary>Returns the labelId for a label name, creating it (and any
|
||||
/// "Parent/Child" nesting) if it does not exist. Idempotent.</summary>
|
||||
Task<string> EnsureLabelAsync(Guid userId, string name, CancellationToken ct = default);
|
||||
```
|
||||
|
||||
Implement in `GmailApiService` using `Users.Labels.List` (already wired via
|
||||
`ListLabelsAsync`) then `Users.Labels.Create` when missing. Cache name→id per request scope.
|
||||
|
||||
### 3.2 New service interfaces (`IServices.cs`)
|
||||
|
||||
```csharp
|
||||
public interface IRuleService
|
||||
{
|
||||
Task<IReadOnlyList<AutomationRuleDto>> ListRulesAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<AutomationRuleDto> CreateRuleAsync(Guid userId, AutomationRuleInputDto input, CancellationToken ct = default);
|
||||
Task<AutomationRuleDto> UpdateRuleAsync(Guid userId, Guid ruleId, AutomationRuleInputDto input, CancellationToken ct = default);
|
||||
Task DeleteRuleAsync(Guid userId, Guid ruleId, CancellationToken ct = default);
|
||||
/// <summary>Dry-run: how many existing emails would this rule match right now?</summary>
|
||||
Task<RuleMatchPreviewDto> PreviewRuleAsync(Guid userId, AutomationRuleInputDto input, CancellationToken ct = default);
|
||||
Task SetEnabledAsync(Guid userId, Guid ruleId, bool enabled, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>The execution engine. Evaluates rules + policies and applies/queues actions.</summary>
|
||||
public interface IAutomationEngine
|
||||
{
|
||||
/// <summary>Evaluate all enabled rules + policies for a user against
|
||||
/// candidate emails (newly synced, or all if full=true). Safe actions apply
|
||||
/// immediately; destructive ones are written as Proposed.</summary>
|
||||
Task RunAsync(Guid userId, bool full = false, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>The hybrid approval queue + activity log.</summary>
|
||||
public interface IAutomationActionService
|
||||
{
|
||||
Task<IReadOnlyList<PendingActionGroupDto>> GetPendingAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ActivityLogEntryDto>> GetActivityAsync(Guid userId, int take = 100, CancellationToken ct = default);
|
||||
Task ApproveAsync(Guid userId, IReadOnlyList<Guid> actionIds, CancellationToken ct = default);
|
||||
Task RejectAsync(Guid userId, IReadOnlyList<Guid> actionIds, CancellationToken ct = default);
|
||||
Task UndoAsync(Guid userId, IReadOnlyList<Guid> actionIds, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 DTOs (`AutomationDtos.cs`, new file)
|
||||
|
||||
```csharp
|
||||
public record AutomationRuleInputDto(
|
||||
string Name, bool Enabled, int Priority,
|
||||
string? SenderAddress, string? SenderDomain, EmailCategory? Category,
|
||||
string? SubjectContains, bool? IsUnread, bool? HasAttachment,
|
||||
bool? HasListUnsubscribe, long? MinSizeBytes, int? OlderThanDays,
|
||||
AutomationActionType Action, string? ActionLabelName, int? ActionParam, bool AlsoSkipInbox);
|
||||
|
||||
public record AutomationRuleDto( /* input fields + */ Guid Id, int TimesApplied, DateTimeOffset? LastRunUtc);
|
||||
|
||||
public record RuleMatchPreviewDto(int MatchCount, IReadOnlyList<EmailSummaryDto> Sample);
|
||||
|
||||
public record PendingActionGroupDto(
|
||||
string GroupKey, AutomationActionType Action, string Description,
|
||||
int Count, IReadOnlyList<Guid> ActionIds, IReadOnlyList<EmailSummaryDto> Sample);
|
||||
|
||||
public record ActivityLogEntryDto(
|
||||
Guid Id, AutomationSource Source, string Description, AutomationActionType Action,
|
||||
AutomationActionStatus Status, int Count, DateTimeOffset When, bool CanUndo);
|
||||
```
|
||||
|
||||
## 4. Engine semantics (`AutomationEngine`)
|
||||
|
||||
Pseudo-flow of `RunAsync(userId, full)`:
|
||||
|
||||
```
|
||||
1. Load enabled rules (ordered by Priority, CreatedUtc) and sender policies.
|
||||
2. Determine candidate emails:
|
||||
full == true → all non-trashed emails for the user
|
||||
full == false → emails added/updated since LastAutomationRunUtc (SyncState)
|
||||
3. Pre-load the whitelist (SenderPolicy where Kind=Allow) and pinned email ids.
|
||||
4. For each candidate email:
|
||||
skip if email.IsPinned
|
||||
skip if sender is whitelisted
|
||||
skip if thread.IsMuted (handled by its own SkipInbox+MarkRead pass)
|
||||
for each rule in priority order:
|
||||
if Matches(rule, email):
|
||||
plan = (rule.Action, labelName, param)
|
||||
if plan.Action.IsDestructive():
|
||||
upsert AutomationAction(Proposed) // no Gmail call
|
||||
else:
|
||||
apply via Gmail BatchModify (batched per action+label)
|
||||
write AutomationAction(Applied)
|
||||
break // first matching rule wins (priority); configurable later
|
||||
5. Run age-based + keep-newest evaluation (see §5).
|
||||
6. Flush batched safe actions to Gmail in BatchModify groups (≤1000 ids/call).
|
||||
7. Update SyncState.LastAutomationRunUtc.
|
||||
```
|
||||
|
||||
**Matching** is a pure function `bool Matches(AutomationRule, Email, Sender)` —
|
||||
unit-testable, no I/O. Each non-null condition must hold.
|
||||
|
||||
**Batching:** collect `(addLabelIds, removeLabelIds)` per email, group identical
|
||||
label-sets, and issue one `BatchModifyAsync` per group. Trash proposals never call
|
||||
Gmail in the engine — only on approval.
|
||||
|
||||
## 5. Age-based & Keep-Newest
|
||||
|
||||
- **Trash-by-Age** (`OlderThanDays` + `Trash`): matched in the normal candidate loop,
|
||||
but because age changes over time independent of new mail, it must also run in a
|
||||
**periodic full sweep**. The `AutomationWorker` (next section) calls `RunAsync(full:true)`
|
||||
on a daily cadence so age rules catch up.
|
||||
- **Keep-Newest** (`KeepNewestCull`, param N): evaluated per sender — order that
|
||||
sender's non-pinned mail by `SentAtUtc desc`, skip the newest N, propose `Trash` for
|
||||
the rest. Runs in the same daily full sweep.
|
||||
|
||||
Both produce `Proposed` actions (destructive) → approval queue.
|
||||
|
||||
## 6. Background worker (`AutomationWorker`)
|
||||
|
||||
New `BackgroundService` mirroring `GmailSyncWorker`/`DigestWorker`:
|
||||
|
||||
- Hourly tick.
|
||||
- **After each incremental sync** the engine should also run on just-synced mail.
|
||||
Cleanest hook: have `SyncService.RunIncrementalSyncAsync` (and the manual sync path)
|
||||
call `IAutomationEngine.RunAsync(userId, full:false)` at the end, inside the same
|
||||
scope. This gives near-real-time automation without a separate schedule.
|
||||
- **Daily full sweep** at a configured hour (`AutomationOptions.SweepHourUtc`, default 3)
|
||||
→ `RunAsync(userId, full:true)` for age/keep-newest catch-up.
|
||||
- Per-user try/catch, logs and continues. Early-out if the user has no enabled rules
|
||||
or policies.
|
||||
|
||||
`AutomationOptions` (new, `Configuration/Options.cs`): `Enabled` (default true),
|
||||
`SweepHourUtc` (3), `MaxAutoActionsPerRun` (safety cap, default 5000).
|
||||
|
||||
## 7. API (`AutomationController : ApiControllerBase`)
|
||||
|
||||
All actions scoped to `UserId`.
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|--------|-------|---------|
|
||||
| GET | `/automation/rules` | list rules |
|
||||
| POST | `/automation/rules` | create |
|
||||
| PUT | `/automation/rules/{id}` | update |
|
||||
| DELETE | `/automation/rules/{id}` | delete |
|
||||
| POST | `/automation/rules/preview` | dry-run match count + sample |
|
||||
| PUT | `/automation/rules/{id}/enabled` | toggle |
|
||||
| GET | `/automation/pending` | grouped approval queue |
|
||||
| POST | `/automation/pending/approve` | `{ actionIds[] }` → execute + log |
|
||||
| POST | `/automation/pending/reject` | `{ actionIds[] }` |
|
||||
| GET | `/automation/activity?take=100` | activity log |
|
||||
| POST | `/automation/activity/undo` | `{ actionIds[] }` → revert |
|
||||
| POST | `/email/{id}/pin` · `/unpin` | Pinned Messages |
|
||||
| POST | `/thread/{id}/mute` · `/unmute` | Mute |
|
||||
|
||||
## 8. Frontend (built on the new design system — see `ui-overhaul.md`)
|
||||
|
||||
- **Rules page** (`/app/rules`): table of rules with enable toggles; a rule editor
|
||||
drawer (Sheet) with condition builder + action picker + live "matches N emails"
|
||||
preview; priority drag-reorder.
|
||||
- **Review queue** (`/app/review` or a badge in the topbar): grouped pending
|
||||
destructive actions, Approve/Reject per group, "Approve all".
|
||||
- **Pin** affordance on email rows/detail (📌). **Mute** affordance on thread views.
|
||||
- Pending count surfaces as a badge in the sidebar/topbar.
|
||||
|
||||
## 9. Security & safety checklist
|
||||
|
||||
- [ ] Every endpoint filters by `UserId`; rule/action ownership verified before mutate.
|
||||
- [ ] Destructive actions can ONLY be executed via `ApproveAsync`, never by the engine.
|
||||
- [ ] Hard delete is never an `AutomationActionType` — not automatable.
|
||||
- [ ] Pinned/whitelisted exemptions enforced in `Matches`/candidate selection, with tests.
|
||||
- [ ] `MaxAutoActionsPerRun` cap prevents a misconfigured rule from mass-acting; overflow logged.
|
||||
- [ ] Undo restores prior label state from `UndoStateJson`.
|
||||
|
||||
## 10. Test plan
|
||||
|
||||
- Unit: `Matches()` truth table across every condition + AND combinations.
|
||||
- Unit: destructive vs safe routing (proposed vs applied).
|
||||
- Unit: Keep-Newest ordering & pinned exemption.
|
||||
- Integration: engine run with a fake `IGmailService` asserts BatchModify groups + queue rows.
|
||||
- Integration: approve → Gmail trash called + status Applied; undo → labels restored.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Spec: Sender Policy — Block, Whitelist, Screener, Pause, Read-Later, Keep-Newest, Deliver-To
|
||||
|
||||
Per-sender ongoing behaviors. These are toggles the user sets from the Senders UI (or
|
||||
the Screener queue), distinct from the condition-based `AutomationRule`s. They share the
|
||||
same execution engine and `AutomationAction` queue/log (`feature-rules-engine.md`).
|
||||
|
||||
## 1. Domain model
|
||||
|
||||
### 1.1 `SenderPolicy` (new entity)
|
||||
|
||||
One row per sender that has any non-default policy. Created lazily.
|
||||
|
||||
```csharp
|
||||
public class SenderPolicy : AuditableEntity
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid UserId { get; set; }
|
||||
public Guid SenderId { get; set; }
|
||||
public Sender? Sender { get; set; }
|
||||
|
||||
public SenderDisposition Disposition { get; set; } = SenderDisposition.None;
|
||||
|
||||
// Independent toggles (a sender can be Read-Later AND Keep-Newest, etc.)
|
||||
public bool ReadLater { get; set; } // route new mail to InboxIntel/Read Later, skip inbox
|
||||
public bool Paused { get; set; } // hold new mail in InboxIntel/Paused, skip inbox
|
||||
public int? KeepNewestCount { get; set; } // trash all-but-newest-N (destructive → proposed)
|
||||
public int? TrashOlderThanDays { get; set; } // per-sender trash-by-age (destructive → proposed)
|
||||
public string? DeliverToLabel { get; set; } // auto-apply this label to new mail
|
||||
public bool DeliverToSkipInbox { get; set; } // move vs. tag for Deliver-To
|
||||
|
||||
public DateTimeOffset? UpdatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public enum SenderDisposition
|
||||
{
|
||||
None = 0,
|
||||
Allow = 1, // Whitelist — exempt from ALL automation, always reaches inbox
|
||||
Block = 2, // auto-trash new mail (destructive → proposed)
|
||||
Screening= 3, // unknown sender quarantined pending approve/block
|
||||
}
|
||||
```
|
||||
|
||||
> `Allow` (Whitelist) is checked first in the engine and short-circuits every other
|
||||
> rule/policy for that sender. `Block` proposes Trash on each new message. `Screening`
|
||||
> applies the `InboxIntel/Screener` label + skip-inbox and surfaces in the Screener queue.
|
||||
|
||||
### 1.2 Managed labels (created via `EnsureLabelAsync`)
|
||||
|
||||
| Feature | Label | On apply |
|
||||
|---------|-------|----------|
|
||||
| Screener | `InboxIntel/Screener` | + skip inbox |
|
||||
| Pause | `InboxIntel/Paused` | + skip inbox |
|
||||
| Read-Later | `InboxIntel/Read Later` | + skip inbox |
|
||||
| Deliver-To | user-chosen (any Gmail label) | optional skip inbox |
|
||||
|
||||
Whitelist/Block need no label (exempt / trash).
|
||||
|
||||
## 2. Screener semantics
|
||||
|
||||
The Screener catches mail from **first-seen senders**.
|
||||
|
||||
- A sender is "known" if the user has ever received mail from them **before** the
|
||||
screener was enabled, or has explicitly Allowed/Blocked them. Establish a baseline
|
||||
`ScreenerEnabledUtc` on the `User` when the user turns Screener on, so existing
|
||||
contacts aren't all quarantined retroactively.
|
||||
- During automation, a candidate email from a sender with **no prior history before
|
||||
`ScreenerEnabledUtc`** and **no disposition** → create `SenderPolicy { Disposition =
|
||||
Screening }`, apply `InboxIntel/Screener` + skip inbox, log `AutomationAction(Source=Screener,
|
||||
Applied)` (this is a *safe* action — just labeling/skip-inbox, nothing destroyed).
|
||||
- **Screener queue UI**: lists screening senders with a sample subject + count.
|
||||
- **Approve** → `Disposition = Allow`; remove `InboxIntel/Screener`, restore to inbox
|
||||
for held mail; future mail flows normally.
|
||||
- **Block** → `Disposition = Block`; propose Trash for held mail; future mail auto-proposed for trash.
|
||||
- Screener is **opt-in** (a `User.ScreenerEnabled` flag, default false) because it
|
||||
actively reroutes mail.
|
||||
|
||||
`User` additions (migration): `ScreenerEnabled` (bool), `ScreenerEnabledUtc` (DateTimeOffset?).
|
||||
|
||||
## 3. Pause vs Read-Later vs Deliver-To
|
||||
|
||||
All three are **safe** (label + optional skip-inbox), so they auto-apply:
|
||||
|
||||
- **Pause**: temporarily stop a sender cluttering the inbox without unsubscribing. New
|
||||
mail → `InboxIntel/Paused` + skip inbox. **Resume** removes the policy and (optionally)
|
||||
re-inboxes held mail.
|
||||
- **Read-Later**: newsletters you want to read on your own time → `InboxIntel/Read Later`
|
||||
+ skip inbox. A "Read Later" view in-app lists them.
|
||||
- **Deliver-To**: auto-file a sender's mail under a chosen label (e.g. "Receipts"),
|
||||
optionally skipping the inbox.
|
||||
|
||||
## 4. Block & Keep-Newest & per-sender Trash-by-Age
|
||||
|
||||
Destructive → **proposed**, surfaced in the Review queue:
|
||||
|
||||
- **Block**: each new message from a blocked sender → propose Trash. (We never
|
||||
hard-delete; Gmail Trash auto-purges after 30 days.)
|
||||
- **Keep-Newest** / **per-sender Trash-by-Age**: evaluated in the daily full sweep
|
||||
(`feature-rules-engine.md` §5), proposing Trash for the cull set.
|
||||
|
||||
## 5. Application layer
|
||||
|
||||
```csharp
|
||||
public interface ISenderPolicyService
|
||||
{
|
||||
Task<SenderPolicyDto> GetAsync(Guid userId, Guid senderId, CancellationToken ct = default);
|
||||
Task<SenderPolicyDto> SetAsync(Guid userId, Guid senderId, SenderPolicyInputDto input, CancellationToken ct = default);
|
||||
Task ClearAsync(Guid userId, Guid senderId, CancellationToken ct = default);
|
||||
|
||||
// Screener
|
||||
Task<IReadOnlyList<ScreenerEntryDto>> GetScreenerQueueAsync(Guid userId, CancellationToken ct = default);
|
||||
Task ApproveSenderAsync(Guid userId, Guid senderId, CancellationToken ct = default); // → Allow
|
||||
Task BlockSenderAsync(Guid userId, Guid senderId, CancellationToken ct = default); // → Block
|
||||
Task SetScreenerEnabledAsync(Guid userId, bool enabled, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
The engine reads `SenderPolicy` rows alongside `AutomationRule`s in `RunAsync`. Policy
|
||||
evaluation order: **Allow (exempt) → Block → Pause → Read-Later → Deliver-To →
|
||||
Keep-Newest/Trash-by-Age**. A whitelisted sender exits immediately.
|
||||
|
||||
## 6. API (`SenderPolicyController` or extend an existing senders controller)
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|--------|-------|---------|
|
||||
| GET | `/senders/{id}/policy` | current policy |
|
||||
| PUT | `/senders/{id}/policy` | set disposition/toggles |
|
||||
| DELETE | `/senders/{id}/policy` | clear |
|
||||
| GET | `/screener` | screener queue |
|
||||
| POST | `/screener/{senderId}/approve` | whitelist |
|
||||
| POST | `/screener/{senderId}/block` | block + propose trash |
|
||||
| PUT | `/screener/enabled` | enable/disable screener |
|
||||
|
||||
## 7. Frontend
|
||||
|
||||
- **Senders page**: each sender row gets a policy menu (DropdownMenu): Whitelist, Block,
|
||||
Pause, Read-Later, Deliver-To→(label picker), Keep-Newest→(N), Trash-by-Age→(days).
|
||||
Active policies shown as small badges on the row.
|
||||
- **Screener page** (`/app/screener`): queue of screening senders, Approve/Block per row,
|
||||
bulk approve/block, and a master enable toggle with an explainer.
|
||||
- **Read Later view** (`/app/read-later`): mail tagged `InboxIntel/Read Later`.
|
||||
|
||||
## 8. Security & safety
|
||||
|
||||
- [ ] Policy rows verified to belong to `UserId` before any read/write.
|
||||
- [ ] Block/Keep-Newest/Trash-by-Age only ever **propose** (hybrid rule) — never auto-trash.
|
||||
- [ ] Whitelist exemption enforced before any other policy/rule, with a test.
|
||||
- [ ] Screener baseline (`ScreenerEnabledUtc`) prevents retroactive mass-quarantine.
|
||||
- [ ] Resume/Approve restores inbox state from `AutomationAction.UndoStateJson`.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Spec: UI Overhaul — Stripe/Notion aesthetic, Tailwind + shadcn-style, light+dark
|
||||
|
||||
A full visual rebuild: clean, airy, modern, light-first with a polished dark mode.
|
||||
Rolled out **incrementally** — design system first, then page-by-page — so the app
|
||||
keeps working throughout.
|
||||
|
||||
## 1. Stack
|
||||
|
||||
Add to `frontend`:
|
||||
|
||||
- **tailwindcss** (+ `postcss`, `autoprefixer`) — utility styling.
|
||||
- **Radix UI primitives** (`@radix-ui/react-*`: dialog, dropdown-menu, tabs, tooltip,
|
||||
switch, popover, toast, separator, scroll-area) — accessible behavior.
|
||||
- **class-variance-authority** + **tailwind-merge** + **clsx** — the shadcn component pattern.
|
||||
- **lucide-react** — icon set (clean, consistent; replaces ad-hoc emoji where it helps).
|
||||
|
||||
Config:
|
||||
- `tailwind.config.js` — content globs over `index.html` + `src/**/*.{js,jsx}`; theme
|
||||
extends map to CSS variables (below); `darkMode: 'class'`.
|
||||
- `postcss.config.js`. A `src/index.css` with `@tailwind base/components/utilities` +
|
||||
the token `:root` / `.dark` blocks. Keep the old `styles.css` importing until a page is
|
||||
migrated, then drop per-page.
|
||||
|
||||
## 2. Design tokens (CSS variables, HSL)
|
||||
|
||||
Defined once in `src/index.css`; Tailwind theme references them so `bg-background`,
|
||||
`text-foreground`, `bg-primary`, etc. just work and flip with `.dark`.
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Neutrals — warm-tinted slate (Notion-ish paper) */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222 22% 12%;
|
||||
--card: 0 0% 100%;
|
||||
--muted: 220 16% 96%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--border: 220 16% 90%;
|
||||
--input: 220 16% 90%;
|
||||
--ring: 245 75% 60%;
|
||||
|
||||
/* Brand accent — indigo/iris (modern SaaS, Stripe-blurple cousin) */
|
||||
--primary: 245 75% 59%; /* #5b5bf0-ish */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Semantic */
|
||||
--success: 152 56% 40%;
|
||||
--warning: 38 92% 50%;
|
||||
--danger: 0 72% 51%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
|
||||
--radius: 0.625rem; /* soft, modern corners */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 32% 9%; /* deep slate, not pure black */
|
||||
--foreground: 220 18% 92%;
|
||||
--card: 224 28% 12%;
|
||||
--muted: 223 22% 17%;
|
||||
--muted-foreground: 220 12% 64%;
|
||||
--border: 223 20% 20%;
|
||||
--input: 223 20% 22%;
|
||||
--ring: 245 80% 66%;
|
||||
--primary: 245 80% 67%;
|
||||
--primary-foreground: 224 32% 9%;
|
||||
--success: 152 50% 50%;
|
||||
--warning: 38 92% 58%;
|
||||
--danger: 0 70% 60%;
|
||||
}
|
||||
```
|
||||
|
||||
> **Accent is swappable by design.** The accent lives in exactly one token (`--primary`,
|
||||
> plus its dark variant). Changing the brand color = editing those two lines. This also
|
||||
> makes a **future user-facing accent picker** cheap: store a chosen hue on the user (or
|
||||
> in `localStorage`) and write `--primary`/`--ring` at runtime. Decision (2026-06-30):
|
||||
> ship with **Indigo `#5b5bf0`**; leave the picker as a documented future enhancement.
|
||||
|
||||
### Proposed palette (for sign-off)
|
||||
|
||||
| Token | Light | Dark | Use |
|
||||
|-------|-------|------|-----|
|
||||
| Primary (accent) | **Indigo `#5b5bf0`** | `#7c7cf5` | buttons, links, active nav, focus ring |
|
||||
| Background | `#ffffff` | `#11151f` | app canvas |
|
||||
| Card/surface | `#ffffff` | `#161b27` | panels, cards |
|
||||
| Muted surface | `#f3f5f9` | `#1f2533` | subtle fills, hover |
|
||||
| Border | `#e3e8ef` | `#2b3242` | hairlines |
|
||||
| Text | `#191e2b` | `#e7eaf2` | body |
|
||||
| Muted text | `#6b7280` | `#9aa3b2` | secondary |
|
||||
| Success | `#2f9e6b` | `#3dbd86` | healthy, succeeded |
|
||||
| Warning | `#f5a623` | `#f7b84b` | caution, pending |
|
||||
| Danger | `#e23b3b` | `#ef5a5a` | destructive, failed |
|
||||
|
||||
> **Alternatives if indigo isn't your taste** (pick one and I'll swap the single token):
|
||||
> Emerald `#10b981` (calm, "clean"), Violet `#7c3aed` (premium), Teal `#0d9488` (fresh),
|
||||
> Blue `#2563eb` (classic/trustworthy). Logo stays as-is; accent just needs to sit well beside it.
|
||||
|
||||
## 3. Component library (`src/components/ui/`)
|
||||
|
||||
Hand-built shadcn-style primitives, each a thin `cva` wrapper over Tailwind + (where
|
||||
interactive) a Radix primitive:
|
||||
|
||||
`button`, `card`, `input`, `textarea`, `select`, `checkbox`, `switch`, `badge`,
|
||||
`dialog`, `sheet` (side drawer), `dropdown-menu`, `tabs`, `tooltip`, `toast` (+ a
|
||||
`useToast` hook to replace ad-hoc toast state), `table`, `skeleton`, `separator`,
|
||||
`avatar`, `empty-state`.
|
||||
|
||||
Plus app-level shells: `PageHeader`, `Sidebar`, `Topbar`, `StatCard`,
|
||||
`ThemeToggle` (writes `.dark` on `<html>`, persists to `localStorage`).
|
||||
|
||||
## 4. Layout language
|
||||
|
||||
- **Sidebar**: 248px, `bg-card`, hairline border, grouped nav with section labels
|
||||
(Overview · Cleanup · Automation · Account). Lucide icons. Active item = soft primary
|
||||
tint pill. Collapsible to icon-rail on narrow widths.
|
||||
- **Topbar**: page title + breadcrumbs left; sync status, theme toggle, digest toggle,
|
||||
pending-review badge, "Sync now", avatar right. Sticky, subtle bottom border.
|
||||
- **Content**: max-width container, generous padding (`p-6`/`p-8`), cards with
|
||||
`rounded-[--radius]`, `border`, soft shadow (`shadow-sm`), 16–24px gaps.
|
||||
- **Density**: comfortable default; tables get a compact variant for big lists.
|
||||
- **Motion**: 150–200ms ease transitions on hover/expand; Radix-driven enter/exit on
|
||||
dialogs/sheets/toasts. Respect `prefers-reduced-motion`.
|
||||
|
||||
## 5. Page-by-page migration map
|
||||
|
||||
| Order | Page | Notes |
|
||||
|-------|------|-------|
|
||||
| 0 | **Tooling + tokens + primitives** | no visual swap yet; build the system |
|
||||
| 1 | **App shell** (`Layout.jsx`) | sidebar + topbar + theme toggle — biggest immediate lift |
|
||||
| 2 | **Dashboard** | StatCards, chart cards restyled (keep chart.js, theme its colors via tokens) |
|
||||
| 3 | **Senders** | list/detail split, new email rows, policy dropdown (ties to sender-policy spec) |
|
||||
| 4 | **Unsubscribe** | table → new `table` primitive, confidence as colored `badge`/meter |
|
||||
| 5 | **Search / Folders / Cleanup** | shared list components, filter bar, bulk toolbar restyle |
|
||||
| 6 | **New feature pages** | Rules, Review queue, Screener, Activity, Privacy, Read-Later — built native |
|
||||
| 7 | **Landing** | polish to match the new system |
|
||||
| 8 | **Retire `styles.css`** | delete once nothing imports it |
|
||||
|
||||
Each increment: convert the page, verify `npm run build`, screenshot/sanity-check, commit.
|
||||
|
||||
## 6. Accessibility & quality bar
|
||||
|
||||
- Radix primitives give focus management, ESC/overlay behavior, ARIA for free — don't
|
||||
hand-roll dialogs/menus.
|
||||
- Visible focus ring (`--ring`) on all interactive elements.
|
||||
- Color is never the only signal (icons/labels alongside semantic colors).
|
||||
- Contrast ≥ WCAG AA in both themes for text and primary buttons.
|
||||
- Keyboard shortcuts (already present) preserved and surfaced in a `?` cheat-sheet dialog.
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- No logo redesign (keeping current `Logo.jsx`).
|
||||
- No mobile-dedicated layouts beyond responsive degradation (desktop-first decision).
|
||||
- No new charting library (theme the existing chart.js).
|
||||
@@ -5,6 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>InboxIntel — Gmail analytics & cleanup</title>
|
||||
<!-- Theme applied before first paint; external file so CSP can use script-src 'self'. -->
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,6 +4,19 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# AUDIT M-2: security headers on the SPA. script-src 'self' works because the theme
|
||||
# bootstrap lives in /theme-init.js (no inline scripts); style-src needs 'unsafe-inline'
|
||||
# for React/Chart.js/grid-layout inline style attributes (low risk with script-src locked).
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
|
||||
# Compress the SPA bundle (AUDIT perf note: ~680 KB JS).
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# SPA fallback.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Generated
+2568
-1094
File diff suppressed because it is too large
Load Diff
+23
-3
@@ -9,16 +9,36 @@
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@radix-ui/react-avatar": "^1.2.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.6",
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.18",
|
||||
"@radix-ui/react-scroll-area": "^1.2.13",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-switch": "^1.3.2",
|
||||
"@radix-ui/react-tabs": "^1.1.16",
|
||||
"@radix-ui/react-toast": "^1.2.18",
|
||||
"@radix-ui/react-tooltip": "^1.2.11",
|
||||
"axios": "^1.7.2",
|
||||
"chart.js": "^4.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.22.0",
|
||||
"react": "^18.3.1",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-router-dom": "^6.24.0"
|
||||
"react-router-dom": "^6.24.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.3.1"
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Applied before first paint to avoid a flash of the wrong theme. Lives in a file (not
|
||||
// inline) so the SPA can ship a CSP with script-src 'self' (AUDIT M-2).
|
||||
(function () {
|
||||
try {
|
||||
// Dark-first: default new users to dark unless they've chosen light.
|
||||
var t = localStorage.getItem('ii:theme') || 'dark';
|
||||
if (t === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -51,6 +51,21 @@ export const CleanupApi = {
|
||||
execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data)
|
||||
};
|
||||
|
||||
// Bulk actions over an explicit set of email IDs (selection-driven, always confirmed —
|
||||
// the user already opted in by selecting rows and clicking the action).
|
||||
// Numeric values must match CleanupActionType in Domain/Enums/Enums.cs (no string
|
||||
// enum converter is configured on the API, so plain numbers are required here).
|
||||
const CLEANUP_ACTION = { Archive: 0, Trash: 1, MarkRead: 5, MarkUnread: 6, Star: 7, Unstar: 8 };
|
||||
|
||||
export const BulkApi = {
|
||||
markRead: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkRead, emailIds: ids, confirmed: true }),
|
||||
markUnread: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.MarkUnread, emailIds: ids, confirmed: true }),
|
||||
star: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Star, emailIds: ids, confirmed: true }),
|
||||
unstar: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Unstar, emailIds: ids, confirmed: true }),
|
||||
archive: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Archive, emailIds: ids, confirmed: true }),
|
||||
trash: (ids) => CleanupApi.execute({ action: CLEANUP_ACTION.Trash, emailIds: ids, confirmed: true }),
|
||||
};
|
||||
|
||||
export const UnsubscribeApi = {
|
||||
detect: () => api.post('/unsubscribe/detect'),
|
||||
safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data),
|
||||
@@ -62,8 +77,82 @@ export const LayoutApi = {
|
||||
save: (layout) => api.put('/widgetlayout', layout)
|
||||
};
|
||||
|
||||
export const SearchApi = {
|
||||
folder: (slug, page = 1, pageSize = 50) =>
|
||||
api.post('/search', folderToRequest(slug, page, pageSize)).then((r) => r.data),
|
||||
query: (q, page = 1, pageSize = 50) =>
|
||||
api.get('/search', { params: { q, page, pageSize } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
function folderToRequest(slug, page, pageSize) {
|
||||
const base = { page, pageSize };
|
||||
switch (slug) {
|
||||
// ── Mailbox ──
|
||||
case 'inbox': return { ...base, isInInbox: true, isTrashed: false };
|
||||
case 'allmail': return { ...base };
|
||||
case 'unread': return { ...base, isUnread: true };
|
||||
case 'starred': return { ...base, isStarred: true };
|
||||
case 'sent': return { ...base, gmailLabel: 'SENT' };
|
||||
case 'drafts': return { ...base, gmailLabel: 'DRAFT' };
|
||||
case 'archive': return { ...base, isInInbox: false, isTrashed: false };
|
||||
case 'spam': return { ...base, gmailLabel: 'SPAM' };
|
||||
case 'trash': return { ...base, isTrashed: true };
|
||||
// ── Special filters ──
|
||||
case 'large': return { ...base, minSizeBytes: 5_000_000 };
|
||||
case 'old': {
|
||||
const d = new Date(); d.setFullYear(d.getFullYear() - 1);
|
||||
return { ...base, to: d.toISOString().slice(0, 10) };
|
||||
}
|
||||
// ── Smart folders ──
|
||||
case 'automated': return { ...base, category: 'Notification' };
|
||||
case 'finance': return { ...base, category: 'Finance' };
|
||||
case 'social': return { ...base, category: 'Social' };
|
||||
case 'shopping': return { ...base, category: 'Shopping' };
|
||||
case 'noreply': return { ...base, query: 'from:noreply' };
|
||||
case 'gaming': return { ...base, category: 'Gaming' };
|
||||
case 'sales': return { ...base, category: 'SeasonalSales' };
|
||||
case 'ridesharing': return { ...base, category: 'RideSharing' };
|
||||
case 'food': return { ...base, category: 'FoodDelivery' };
|
||||
case 'wellness': return { ...base, category: 'Wellness' };
|
||||
// New categories
|
||||
case 'travel': return { ...base, category: 'Travel' };
|
||||
case 'subscriptions': return { ...base, category: 'Subscriptions' };
|
||||
case 'parcels': return { ...base, category: 'Parcels' };
|
||||
case 'recruitment': return { ...base, category: 'Recruitment' };
|
||||
case 'events': return { ...base, category: 'Events' };
|
||||
case 'security': return { ...base, category: 'SecurityAlerts' };
|
||||
case 'healthcare': return { ...base, category: 'Healthcare' };
|
||||
case 'education': return { ...base, category: 'Education' };
|
||||
case 'news': return { ...base, category: 'NewsMedia' };
|
||||
case 'property': return { ...base, category: 'PropertyUtilities' };
|
||||
case 'charity': return { ...base, category: 'Charity' };
|
||||
case 'government': return { ...base, category: 'Government' };
|
||||
case 'crypto': return { ...base, category: 'CryptoInvesting' };
|
||||
case 'family': return { ...base, category: 'FamilySchool' };
|
||||
default: return { ...base };
|
||||
}
|
||||
}
|
||||
|
||||
export const EmailApi = {
|
||||
get: (id) => api.get(`/email/${id}`).then((r) => r.data),
|
||||
summary: (id) => api.get(`/email/${id}/summary`).then((r) => r.data),
|
||||
markRead: (id) => api.post(`/email/${id}/read`),
|
||||
markUnread: (id) => api.post(`/email/${id}/unread`),
|
||||
star: (id) => api.post(`/email/${id}/star`),
|
||||
unstar: (id) => api.post(`/email/${id}/unstar`),
|
||||
trash: (id) => api.post(`/email/${id}/trash`),
|
||||
untrash: (id) => api.post(`/email/${id}/untrash`),
|
||||
unsubscribe:(id) => api.post(`/email/${id}/unsubscribe`).then((r) => r.data),
|
||||
};
|
||||
|
||||
export const ExportApi = {
|
||||
reportUrl: (format) => `/api/v1/export/report?format=${format}`
|
||||
};
|
||||
|
||||
export const SettingsApi = {
|
||||
getDigest: () => api.get('/settings/digest').then((r) => r.data),
|
||||
setDigest: (enabled) => api.put('/settings/digest', enabled).then((r) => r.data),
|
||||
sendDigestNow: () => api.post('/settings/digest/send-now'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
|
||||
import { BulkApi } from '../api/client.js';
|
||||
import {
|
||||
Button, useToast,
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
|
||||
} from './ui';
|
||||
|
||||
/**
|
||||
* Toolbar shown above an email list when rows are selected.
|
||||
*
|
||||
* Safety (Phase 4 / UX-Critical): the destructive Trash action now requires an
|
||||
* explicit confirmation dialog, every action surfaces success/partial-failure via
|
||||
* a toast, and rows are only removed from the list when the server confirms the
|
||||
* whole batch succeeded (no more optimistic removal that hides failures).
|
||||
* `onDone(action, ids)` is called only on full success so the caller can prune state.
|
||||
*/
|
||||
export default function BulkToolbar({ selectedIds, onDone, onClear }) {
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmTrash, setConfirmTrash] = useState(false);
|
||||
const count = selectedIds.length;
|
||||
if (count === 0) return null;
|
||||
|
||||
const apply = async (fn, action, label) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn(selectedIds);
|
||||
// CleanupResultDto: { succeededCount, failedCount, errors }
|
||||
const ok = res?.succeededCount ?? count;
|
||||
const failed = res?.failedCount ?? 0;
|
||||
if (failed > 0) {
|
||||
toast({
|
||||
variant: 'warning',
|
||||
title: `${label}: ${ok} done, ${failed} failed`,
|
||||
description: 'Some items could not be updated — the list was left unchanged so you can retry.',
|
||||
});
|
||||
} else {
|
||||
toast({ variant: 'success', title: `${label} ${ok} email${ok === 1 ? '' : 's'}` });
|
||||
onDone(action, selectedIds);
|
||||
onClear();
|
||||
}
|
||||
} catch {
|
||||
toast({ variant: 'danger', title: `Couldn't ${label.toLowerCase()} ${count} email${count === 1 ? '' : 's'}`, description: 'Please try again.' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirmTrash(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
|
||||
<span className="text-sm font-medium">{count} selected</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
|
||||
<MailOpen /> Read
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
|
||||
<Mail /> Unread
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
|
||||
<Star /> Star
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
|
||||
<Archive /> Archive
|
||||
</Button>
|
||||
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
|
||||
<Trash2 /> Trash
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
|
||||
|
||||
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move {count} email{count === 1 ? '' : 's'} to Trash?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This moves the selected mail to your Gmail Trash, where it stays recoverable
|
||||
for 30 days before Gmail permanently deletes it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm" disabled={busy}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="danger" size="sm" disabled={busy} onClick={() => apply(BulkApi.trash, 'trash', 'Trashed')}>
|
||||
{busy ? 'Moving…' : 'Move to Trash'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SettingsApi } from '../api/client.js';
|
||||
|
||||
export default function DigestToggle() {
|
||||
const [enabled, setEnabled] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
SettingsApi.getDigest().then((d) => setEnabled(d.enabled)).catch(() => setEnabled(false));
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
const next = !enabled;
|
||||
setEnabled(next);
|
||||
try { await SettingsApi.setDigest(next); } catch { setEnabled(!next); }
|
||||
};
|
||||
|
||||
if (enabled === null) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="ghost digest-toggle"
|
||||
title={enabled ? 'Weekly digest emails: on' : 'Weekly digest emails: off'}
|
||||
onClick={toggle}
|
||||
>
|
||||
{enabled ? '🔔' : '🔕'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { EmailApi } from '../api/client.js';
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffDays = (now - d) / 86400000;
|
||||
if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const fmtSize = (b) => {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||
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 }) {
|
||||
const [email, setEmail] = useState(initial);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
const act = (fn, patch) => async (e) => {
|
||||
e.stopPropagation();
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await fn(email.id);
|
||||
setEmail((prev) => ({ ...prev, ...patch }));
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsub = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try {
|
||||
const res = await EmailApi.unsubscribe(email.id);
|
||||
if (res.method === 'mailto') {
|
||||
window.location.href = res.target;
|
||||
} else {
|
||||
setEmail((prev) => ({ ...prev, _unsubDone: true }));
|
||||
}
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrash = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try {
|
||||
await EmailApi.trash(email.id);
|
||||
onRemove?.(email.id);
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openInGmail = () => window.open(
|
||||
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
|
||||
onClick={openInGmail}
|
||||
title="Open in Gmail"
|
||||
>
|
||||
{onToggleSelect && (
|
||||
<td className="el-select" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
|
||||
</td>
|
||||
)}
|
||||
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||||
<td className="el-sender" title={email.senderAddress}>
|
||||
{email.senderDisplayName || email.senderAddress}
|
||||
</td>
|
||||
<td className="el-subject">
|
||||
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||||
{email.matchHighlight
|
||||
? <span className="el-snippet"> — {renderHighlight(email.matchHighlight)}</span>
|
||||
: email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||||
</td>
|
||||
<td className="el-meta">
|
||||
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||||
{email.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(email.sizeEstimateBytes)}</span>}
|
||||
</td>
|
||||
<td className="el-date">{fmtDate(email.sentAtUtc)}</td>
|
||||
<td className="el-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="action-btn"
|
||||
title={email.isUnread ? 'Mark as read' : 'Mark as unread'}
|
||||
onClick={email.isUnread
|
||||
? act(EmailApi.markRead, { isUnread: false })
|
||||
: act(EmailApi.markUnread, { isUnread: true })}
|
||||
>{email.isUnread ? '✓' : '●'}</button>
|
||||
<button
|
||||
className={`action-btn${email.isStarred ? ' action-btn--active' : ''}`}
|
||||
title={email.isStarred ? 'Unstar' : 'Star'}
|
||||
onClick={email.isStarred
|
||||
? act(EmailApi.unstar, { isStarred: false })
|
||||
: act(EmailApi.star, { isStarred: true })}
|
||||
>⭐</button>
|
||||
{email.hasListUnsubscribe && (
|
||||
<button
|
||||
className={`action-btn action-btn--unsub${email._unsubDone ? ' action-btn--done' : ''}`}
|
||||
title={email._unsubDone ? 'Unsubscribed' : 'Unsubscribe'}
|
||||
onClick={handleUnsub}
|
||||
disabled={email._unsubDone}
|
||||
>{email._unsubDone ? '✓' : '✉✕'}</button>
|
||||
)}
|
||||
<button
|
||||
className="action-btn action-btn--danger"
|
||||
title="Move to trash"
|
||||
onClick={handleTrash}
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
+283
-127
@@ -1,8 +1,22 @@
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
LayoutDashboard, Users, Sparkles, MailX, Search, RefreshCw, LogOut,
|
||||
ChevronLeft, ChevronRight, ChevronDown, X, Plus, Wand2, ShieldCheck,
|
||||
ScrollText, ListChecks,
|
||||
} from 'lucide-react';
|
||||
import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
|
||||
import Logo from './Logo.jsx';
|
||||
import DevBanner from './DevBanner.jsx';
|
||||
import SyncStatus from './SyncStatus.jsx';
|
||||
import DigestToggle from './DigestToggle.jsx';
|
||||
import useSavedSearches from '../hooks/useSavedSearches.js';
|
||||
import { cn } from '../lib/utils.js';
|
||||
import {
|
||||
Button, Input, Separator, ThemeToggle, Tooltip, TooltipContent, TooltipTrigger,
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator,
|
||||
} from './ui';
|
||||
|
||||
// ── Folder definitions ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -20,9 +34,8 @@ const MAILBOX = [
|
||||
{ slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null },
|
||||
];
|
||||
|
||||
// Special items always available as favorites (not smart folders)
|
||||
const FAV_SPECIALS = {
|
||||
inbox: { slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox', type: 'mailbox' },
|
||||
inbox: { slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox', type: 'mailbox' },
|
||||
unread: { slug: 'unread', icon: '🔵', label: 'Unread Mail', countKey: 'unread', type: 'filter' },
|
||||
large: { slug: 'large', icon: '📎', label: 'Large Mail', countKey: 'large', type: 'filter' },
|
||||
old: { slug: 'old', icon: '🕰️', label: 'Old Mail', countKey: 'old', type: 'filter' },
|
||||
@@ -32,16 +45,40 @@ const FAV_SPECIALS = {
|
||||
const DEFAULT_FAV_SLUGS = ['inbox', 'unread', 'large', 'old', 'readlater'];
|
||||
|
||||
const SMART_FOLDERS = [
|
||||
{ slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' },
|
||||
{ slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' },
|
||||
{ slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' },
|
||||
{ slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' },
|
||||
{ slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' },
|
||||
{ slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' },
|
||||
{ slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' },
|
||||
{ slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' },
|
||||
{ slug: 'social', icon: '📱', label: 'Social Notifications',countKey: 'social' },
|
||||
{ slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' },
|
||||
{ slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' },
|
||||
{ slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' },
|
||||
{ slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' },
|
||||
{ slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' },
|
||||
{ slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' },
|
||||
{ slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' },
|
||||
{ slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' },
|
||||
{ slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' },
|
||||
{ slug: 'social', icon: '📱', label: 'Social Notifications', countKey: 'social' },
|
||||
{ slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' },
|
||||
{ slug: 'travel', icon: '✈️', label: 'Travel', countKey: 'travel' },
|
||||
{ slug: 'subscriptions',icon: '🔄', label: 'Subscriptions & SaaS', countKey: 'subscriptions' },
|
||||
{ slug: 'parcels', icon: '📦', label: 'Parcels & Delivery', countKey: 'parcels' },
|
||||
{ slug: 'recruitment', icon: '💼', label: 'Recruitment & Jobs', countKey: 'recruitment' },
|
||||
{ slug: 'events', icon: '🎟️', label: 'Events & Tickets', countKey: 'events' },
|
||||
{ slug: 'security', icon: '🔐', label: 'Security & Alerts', countKey: 'security' },
|
||||
{ slug: 'healthcare', icon: '🏥', label: 'Healthcare', countKey: 'healthcare' },
|
||||
{ slug: 'education', icon: '🎓', label: 'Education', countKey: 'education' },
|
||||
{ slug: 'news', icon: '📰', label: 'News & Media', countKey: 'news' },
|
||||
{ slug: 'property', icon: '🏠', label: 'Property & Utilities', countKey: 'property' },
|
||||
{ slug: 'charity', icon: '❤️', label: 'Charities', countKey: 'charity' },
|
||||
{ slug: 'government', icon: '🏛️', label: 'Government', countKey: 'government' },
|
||||
{ slug: 'crypto', icon: '📈', label: 'Crypto & Investing', countKey: 'crypto' },
|
||||
{ slug: 'family', icon: '👨👩👧', label: 'Family & School', countKey: 'family' },
|
||||
];
|
||||
|
||||
// Primary app navigation (top of the sidebar). Automation pages are placeholders
|
||||
// until their feature work lands (see docs/specs); links are kept here so the IA
|
||||
// is visible, and they no-op gracefully to routes added later.
|
||||
const PRIMARY_NAV = [
|
||||
{ to: '/app', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||
{ to: '/app/senders', label: 'Senders', icon: Users },
|
||||
{ to: '/app/cleanup', label: 'Cleanup', icon: Sparkles },
|
||||
{ to: '/app/unsubscribe', label: 'Unsubscribe', icon: MailX },
|
||||
];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -62,75 +99,98 @@ const loadFavs = () => {
|
||||
const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v));
|
||||
|
||||
const loadSections = () => {
|
||||
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true }; }
|
||||
catch { return { fav: true, mailbox: true, smart: true }; }
|
||||
try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true, saved: true }; }
|
||||
catch { return { fav: true, mailbox: true, smart: true, saved: true }; }
|
||||
};
|
||||
const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v));
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
// ── Sidebar sub-components ──────────────────────────────────────────────────────
|
||||
|
||||
function SectionHead({ label, open, onToggle, collapsed }) {
|
||||
if (collapsed) return <Separator className="my-2" />;
|
||||
return (
|
||||
<button className="section-head" onClick={onToggle} title={label}>
|
||||
{!collapsed && <span className="section-title">{label}</span>}
|
||||
<span className={`section-arrow${open ? '' : ' section-arrow--closed'}`}>›</span>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-3 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80 transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !open && '-rotate-90')} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderLink({ item, active, count, collapsed, extra }) {
|
||||
return (
|
||||
function FolderRow({ item, active, count, collapsed, extra }) {
|
||||
const content = (
|
||||
<Link
|
||||
to={`/app/folder/${item.slug}`}
|
||||
className={`folder-item${active ? ' folder-item--active' : ''}`}
|
||||
title={item.label}
|
||||
className={cn(
|
||||
'group flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||
collapsed && 'justify-center px-0',
|
||||
active ? 'bg-primary/10 font-medium text-primary' : 'text-foreground/80 hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<span className="folder-icon">{item.icon}</span>
|
||||
<span className="text-base leading-none">{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="folder-label">{item.label}</span>
|
||||
<span className="folder-spacer" />
|
||||
{count && <span className="folder-badge">{count}</span>}
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{count && <span className="text-xs tabular-nums text-muted-foreground">{count}</span>}
|
||||
{extra}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// ── Main Layout ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Layout() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams.get('q') ?? '');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => {
|
||||
try { return localStorage.getItem(LS_OPEN) !== 'false'; } catch { return true; }
|
||||
});
|
||||
const [sections, setSections] = useState(loadSections);
|
||||
const [favSlugs, setFavSlugs] = useState(loadFavs);
|
||||
const [counts, setCounts] = useState(null);
|
||||
const { searches: savedSearches, remove: removeSavedSearch } = useSavedSearches();
|
||||
|
||||
const loc = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchInputRef = useRef(null);
|
||||
const collapsed = !sidebarOpen;
|
||||
|
||||
useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []);
|
||||
useEffect(() => { AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {}); }, []);
|
||||
|
||||
// "/" focuses the search bar from anywhere, unless already typing in a field.
|
||||
useEffect(() => {
|
||||
AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {});
|
||||
const handler = (e) => {
|
||||
if (e.key !== '/') return;
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarOpen((o) => {
|
||||
localStorage.setItem(LS_OPEN, String(!o));
|
||||
return !o;
|
||||
});
|
||||
setSidebarOpen((o) => { localStorage.setItem(LS_OPEN, String(!o)); return !o; });
|
||||
};
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setSections((s) => {
|
||||
const next = { ...s, [key]: !s[key] };
|
||||
saveSections(next);
|
||||
return next;
|
||||
});
|
||||
setSections((s) => { const next = { ...s, [key]: !s[key] }; saveSections(next); return next; });
|
||||
};
|
||||
|
||||
const toggleFav = useCallback((slug) => {
|
||||
@@ -141,14 +201,6 @@ export default function Layout() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const nav = [
|
||||
{ to: '/app', label: 'Dashboard', end: true },
|
||||
{ to: '/app/senders', label: 'Senders' },
|
||||
{ to: '/app/cleanup', label: 'Cleanup' },
|
||||
{ to: '/app/unsubscribe', label: 'Unsubscribe' }
|
||||
];
|
||||
|
||||
const isNavActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to));
|
||||
const activeSlug = loc.pathname.startsWith('/app/folder/')
|
||||
? loc.pathname.split('/app/folder/')[1]
|
||||
: null;
|
||||
@@ -158,130 +210,234 @@ export default function Layout() {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const submitSearch = (e) => {
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
if (q) navigate(`/app/search?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
const startSync = async () => {
|
||||
try { await SyncApi.incremental(); } catch { /* ignore */ }
|
||||
if (loc.pathname !== '/app') navigate('/app');
|
||||
window.dispatchEvent(new Event('inboxintel:sync-started'));
|
||||
};
|
||||
|
||||
// Count getters
|
||||
const mailboxCount = (item) => fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
||||
const smartCount = (item) => fmtCount(counts?.smartFolders?.[item.slug]);
|
||||
|
||||
// Build favorites list: specials first (in default order), then pinned smart folders
|
||||
const favItems = favSlugs.map((slug) => {
|
||||
if (FAV_SPECIALS[slug]) return { ...FAV_SPECIALS[slug], source: 'special' };
|
||||
const sf = SMART_FOLDERS.find((f) => f.slug === slug);
|
||||
return sf ? { ...sf, source: 'smart' } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
const favCount = (item) => {
|
||||
if (item.source === 'smart') return smartCount(item);
|
||||
return fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
||||
};
|
||||
const favCount = (item) =>
|
||||
item.source === 'smart' ? smartCount(item) : fmtCount(item.countKey ? counts?.[item.countKey] : null);
|
||||
|
||||
// Smart folders sorted by count desc
|
||||
const sortedSmart = [...SMART_FOLDERS].sort((a, b) => {
|
||||
const ca = counts?.smartFolders?.[a.slug] ?? 0;
|
||||
const cb = counts?.smartFolders?.[b.slug] ?? 0;
|
||||
return cb - ca;
|
||||
});
|
||||
const sortedSmart = [...SMART_FOLDERS]
|
||||
.filter((f) => !counts || (counts.smartFolders?.[f.slug] ?? 0) > 0)
|
||||
.sort((a, b) => (counts?.smartFolders?.[b.slug] ?? 0) - (counts?.smartFolders?.[a.slug] ?? 0));
|
||||
|
||||
const isPinnedSmart = (slug) => favSlugs.includes(slug);
|
||||
const userInitial = (user?.email?.[0] || '?').toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<DevBanner />
|
||||
<header className="topbar">
|
||||
<Link to="/app" className="brand-link"><Logo size={28} withWordmark /></Link>
|
||||
<nav>
|
||||
{nav.map((n) => (
|
||||
<Link key={n.to} to={n.to} className={isNavActive(n) ? 'active' : ''}>{n.label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="spacer" />
|
||||
<button onClick={startSync}>Sync now</button>
|
||||
<span className="user">{user?.email}</span>
|
||||
<button className="ghost" onClick={logout}>Log out</button>
|
||||
|
||||
{/* ── Topbar ── */}
|
||||
<header className="z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/80 px-4 backdrop-blur">
|
||||
<Link to="/app" className="flex items-center gap-2 shrink-0">
|
||||
<Logo size={26} withWordmark />
|
||||
</Link>
|
||||
|
||||
<form className="relative ml-2 hidden flex-1 md:block" onSubmit={submitSearch}>
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
placeholder="Search your inbox…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
aria-label="Search emails"
|
||||
className="max-w-xl pl-9"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-1.5 md:flex-initial">
|
||||
<SyncStatus />
|
||||
<DigestToggle />
|
||||
<ThemeToggle />
|
||||
<Button onClick={startSync} size="sm" className="gap-1.5">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sync now</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="ml-1 flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{userInitial}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuLabel className="truncate normal-case">{user?.email || 'Signed in'}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={logout} className="text-danger focus:bg-danger/10">
|
||||
<LogOut className="h-4 w-4" /> Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
<aside className={`sidebar${sidebarOpen ? '' : ' sidebar--collapsed'}`}>
|
||||
<div className="sidebar-header">
|
||||
{sidebarOpen && <span className="sidebar-brand">Folders</span>}
|
||||
<button className="sidebar-toggle ghost" onClick={toggleSidebar} title={sidebarOpen ? 'Collapse' : 'Expand'}>
|
||||
{sidebarOpen ? '‹' : '›'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── Sidebar ── */}
|
||||
<aside
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col overflow-y-auto border-r border-border bg-card transition-[width] duration-200',
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
{PRIMARY_NAV.map((n) => {
|
||||
const Icon = n.icon;
|
||||
const link = (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
collapsed && 'justify-center px-0',
|
||||
isActive ? 'bg-primary/10 text-primary' : 'text-foreground/80 hover:bg-muted'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{!collapsed && <span>{n.label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
return collapsed ? (
|
||||
<Tooltip key={n.to}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{n.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : link;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* ── Favorites ── */}
|
||||
<SectionHead label="Favorites" open={sections.fav} onToggle={() => toggleSection('fav')} collapsed={!sidebarOpen} />
|
||||
{sections.fav && (
|
||||
<ul className="folder-list">
|
||||
{favItems.map((item) => (
|
||||
<li key={item.slug}>
|
||||
<FolderLink
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 p-2">
|
||||
{/* Favorites */}
|
||||
<SectionHead label="Favorites" open={sections.fav} onToggle={() => toggleSection('fav')} collapsed={collapsed} />
|
||||
{(sections.fav || collapsed) && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{favItems.map((item) => (
|
||||
<FolderRow
|
||||
key={item.slug}
|
||||
item={item}
|
||||
active={activeSlug === item.slug}
|
||||
count={favCount(item)}
|
||||
collapsed={!sidebarOpen}
|
||||
extra={item.source === 'smart' && sidebarOpen && (
|
||||
collapsed={collapsed}
|
||||
extra={item.source === 'smart' && (
|
||||
<button
|
||||
className="fav-pin fav-pin--remove"
|
||||
className="text-muted-foreground opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
|
||||
title="Remove from Favorites"
|
||||
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
|
||||
>✕</button>
|
||||
><X className="h-3.5 w-3.5" /></button>
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Mailbox ── */}
|
||||
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={!sidebarOpen} />
|
||||
{sections.mailbox && (
|
||||
<ul className="folder-list">
|
||||
{MAILBOX.map((item) => (
|
||||
<li key={item.slug}>
|
||||
<FolderLink
|
||||
item={item}
|
||||
active={activeSlug === item.slug}
|
||||
count={mailboxCount(item)}
|
||||
collapsed={!sidebarOpen}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Saved Searches */}
|
||||
{savedSearches.length > 0 && (
|
||||
<>
|
||||
<SectionHead label="Saved Searches" open={sections.saved} onToggle={() => toggleSection('saved')} collapsed={collapsed} />
|
||||
{!collapsed && sections.saved && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{savedSearches.map((s) => (
|
||||
<div key={s.id} className="group flex items-center">
|
||||
<Link
|
||||
to={`/app/search?q=${encodeURIComponent(s.query)}`}
|
||||
title={s.query}
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-2.5 rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||
loc.pathname === '/app/search' && searchParams.get('q') === s.query
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-foreground/80 hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{s.label}</span>
|
||||
</Link>
|
||||
<button
|
||||
className="px-1.5 text-muted-foreground opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
|
||||
title="Remove saved search"
|
||||
onClick={(e) => { e.preventDefault(); removeSavedSearch(s.id); }}
|
||||
><X className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Smart Folders ── */}
|
||||
<SectionHead label="Smart Folders" open={sections.smart} onToggle={() => toggleSection('smart')} collapsed={!sidebarOpen} />
|
||||
{sections.smart && (
|
||||
<ul className="folder-list">
|
||||
{sortedSmart.map((item) => (
|
||||
<li key={item.slug}>
|
||||
<FolderLink
|
||||
{/* Mailbox */}
|
||||
<SectionHead label="Mailbox" open={sections.mailbox} onToggle={() => toggleSection('mailbox')} collapsed={collapsed} />
|
||||
{(sections.mailbox || collapsed) && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{MAILBOX.map((item) => (
|
||||
<FolderRow key={item.slug} item={item} active={activeSlug === item.slug} count={mailboxCount(item)} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Folders */}
|
||||
<SectionHead label="Smart Folders" open={sections.smart} onToggle={() => toggleSection('smart')} collapsed={collapsed} />
|
||||
{(sections.smart || collapsed) && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{sortedSmart.map((item) => (
|
||||
<FolderRow
|
||||
key={item.slug}
|
||||
item={item}
|
||||
active={activeSlug === item.slug}
|
||||
count={smartCount(item)}
|
||||
collapsed={!sidebarOpen}
|
||||
extra={sidebarOpen && (
|
||||
collapsed={collapsed}
|
||||
extra={
|
||||
<button
|
||||
className={`fav-pin${isPinnedSmart(item.slug) ? ' fav-pin--remove' : ''}`}
|
||||
className={cn(
|
||||
'text-muted-foreground transition-opacity hover:text-primary',
|
||||
isPinnedSmart(item.slug) ? 'opacity-0 hover:text-danger group-hover:opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
title={isPinnedSmart(item.slug) ? 'Remove from Favorites' : 'Add to Favorites'}
|
||||
onClick={(e) => { e.preventDefault(); toggleFav(item.slug); }}
|
||||
>{isPinnedSmart(item.slug) ? '✕' : '⊕'}</button>
|
||||
)}
|
||||
>{isPinnedSmart(item.slug) ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}</button>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="border-t border-border p-2">
|
||||
<Button variant="ghost" size={collapsed ? 'icon' : 'sm'} onClick={toggleSidebar} className={cn('w-full', collapsed && 'w-auto')}>
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <><ChevronLeft className="h-4 w-4" /> Collapse</>}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<Outlet />
|
||||
{/* ── Main content ── */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-7xl p-6 lg:p-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SyncApi } from '../api/client.js';
|
||||
|
||||
const POLL_INTERVAL_IDLE = 60_000; // 1 min when not syncing
|
||||
const POLL_INTERVAL_ACTIVE = 2_000; // 2 sec while running
|
||||
|
||||
function fmtAge(iso) {
|
||||
if (!iso) return null;
|
||||
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function SyncStatus() {
|
||||
const [progress, setProgress] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
|
||||
const fetch = () => {
|
||||
SyncApi.status().then((p) => {
|
||||
setProgress(p);
|
||||
const next = p.isRunning ? POLL_INTERVAL_ACTIVE : POLL_INTERVAL_IDLE;
|
||||
timer = setTimeout(fetch, next);
|
||||
}).catch(() => {
|
||||
timer = setTimeout(fetch, POLL_INTERVAL_IDLE);
|
||||
});
|
||||
};
|
||||
|
||||
fetch();
|
||||
|
||||
// Also refresh when a sync is manually kicked off
|
||||
const handler = () => { clearTimeout(timer); fetch(); };
|
||||
window.addEventListener('inboxintel:sync-started', handler);
|
||||
|
||||
return () => { clearTimeout(timer); window.removeEventListener('inboxintel:sync-started', handler); };
|
||||
}, []);
|
||||
|
||||
if (!progress) return null;
|
||||
|
||||
if (progress.isRunning) {
|
||||
const pct = progress.total > 0
|
||||
? Math.round((progress.processed / progress.total) * 100)
|
||||
: null;
|
||||
return (
|
||||
<div className="sync-status sync-status--running" title="Sync in progress">
|
||||
<span className="sync-spinner" />
|
||||
<span className="sync-label">
|
||||
Syncing{pct !== null ? ` ${pct}%` : '…'}
|
||||
</span>
|
||||
{progress.total > 0 && (
|
||||
<span className="sync-bar-wrap">
|
||||
<span className="sync-bar" style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const age = fmtAge(progress.lastSuccessfulSyncUtc);
|
||||
return (
|
||||
<div
|
||||
className={`sync-status${progress.lastError ? ' sync-status--error' : ''}`}
|
||||
title={progress.lastError ?? (age ? `Last synced ${age}` : 'Never synced')}
|
||||
>
|
||||
{progress.lastError
|
||||
? <span className="sync-label sync-label--err">⚠ Sync error</span>
|
||||
: <span className="sync-label">{age ? `Synced ${age}` : 'Never synced'}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-muted text-muted-foreground',
|
||||
primary: 'border-transparent bg-primary/10 text-primary',
|
||||
success: 'border-transparent bg-success/10 text-success',
|
||||
warning: 'border-transparent bg-warning/15 text-warning',
|
||||
danger: 'border-transparent bg-danger/10 text-danger',
|
||||
outline: 'border-border text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
|
||||
secondary: 'bg-muted text-foreground hover:bg-muted/70',
|
||||
outline: 'border border-border bg-card hover:bg-muted text-foreground',
|
||||
ghost: 'hover:bg-muted text-foreground',
|
||||
danger: 'bg-danger text-danger-foreground hover:bg-danger/90 shadow-sm',
|
||||
'danger-outline': 'border border-danger/40 text-danger hover:bg-danger/10',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4',
|
||||
lg: 'h-10 px-6',
|
||||
icon: 'h-9 w-9',
|
||||
'icon-sm': 'h-8 w-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'primary', size: 'md' },
|
||||
}
|
||||
);
|
||||
|
||||
const Button = forwardRef(function Button({ className, variant, size, ...props }, ref) {
|
||||
return <button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Card = forwardRef(function Card({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border border-border bg-card text-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const CardHeader = forwardRef(function CardHeader({ className, ...props }, ref) {
|
||||
return <div ref={ref} className={cn('flex flex-col gap-1 p-5', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardTitle = forwardRef(function CardTitle({ className, ...props }, ref) {
|
||||
return <h3 ref={ref} className={cn('text-base font-semibold leading-none tracking-tight', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardDescription = forwardRef(function CardDescription({ className, ...props }, ref) {
|
||||
return <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardContent = forwardRef(function CardContent({ className, ...props }, ref) {
|
||||
return <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />;
|
||||
});
|
||||
|
||||
const CardFooter = forwardRef(function CardFooter({ className, ...props }, ref) {
|
||||
return <div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = forwardRef(function DialogOverlay({ className, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm animate-fade-in', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const DialogContent = forwardRef(function DialogContent({ className, children, ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-md animate-slide-up',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
function DialogHeader({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col gap-1.5 text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
|
||||
}
|
||||
|
||||
const DialogTitle = forwardRef(function DialogTitle({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />;
|
||||
});
|
||||
|
||||
const DialogDescription = forwardRef(function DialogDescription({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
});
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const contentClasses =
|
||||
'z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-foreground shadow-md animate-slide-up';
|
||||
|
||||
const DropdownMenuContent = forwardRef(function DropdownMenuContent(
|
||||
{ className, sideOffset = 4, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(contentClasses, className)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
const itemClasses =
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4';
|
||||
|
||||
const DropdownMenuItem = forwardRef(function DropdownMenuItem({ className, inset, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(itemClasses, inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuCheckboxItem = forwardRef(function DropdownMenuCheckboxItem(
|
||||
{ className, children, checked, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
className={cn(itemClasses, 'pl-8', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-4 w-4 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuSubTrigger = forwardRef(function DropdownMenuSubTrigger(
|
||||
{ className, children, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(itemClasses, 'data-[state=open]:bg-muted', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuSubContent = forwardRef(function DropdownMenuSubContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent ref={ref} className={cn(contentClasses, className)} {...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuLabel = forwardRef(function DropdownMenuLabel({ className, inset, ...props }, ref) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-muted-foreground', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const DropdownMenuSeparator = forwardRef(function DropdownMenuSeparator({ className, ...props }, ref) {
|
||||
return <DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />;
|
||||
});
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// Barrel export for the UI primitive set. Import from '../components/ui'.
|
||||
export { Button, buttonVariants } from './button.jsx';
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx';
|
||||
export { Badge, badgeVariants } from './badge.jsx';
|
||||
export { Input, Textarea } from './input.jsx';
|
||||
export { Switch } from './switch.jsx';
|
||||
export { Separator } from './separator.jsx';
|
||||
export { Skeleton } from './skeleton.jsx';
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './tooltip.jsx';
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
} from './dropdown-menu.jsx';
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog.jsx';
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from './sheet.jsx';
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs.jsx';
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './table.jsx';
|
||||
export { ToastProvider, useToast } from './toast.jsx';
|
||||
export { default as ThemeToggle } from './theme-toggle.jsx';
|
||||
export { PageHeader, StatCard, EmptyState } from './misc.jsx';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Input = forwardRef(function Input({ className, type = 'text', ...props }, ref) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm transition-colors',
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const Textarea = forwardRef(function Textarea({ className, ...props }, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex min-h-[72px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm shadow-sm transition-colors',
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Input, Textarea };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
/** Standard page title + optional description + right-aligned actions. */
|
||||
export function PageHeader({ title, description, actions, className }) {
|
||||
return (
|
||||
<div className={cn('mb-6 flex flex-wrap items-start justify-between gap-4', className)}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard metric tile. */
|
||||
export function StatCard({ label, value, hint, icon: Icon, tone = 'primary', className }) {
|
||||
const toneClass =
|
||||
tone === 'success'
|
||||
? 'text-success'
|
||||
: tone === 'warning'
|
||||
? 'text-warning'
|
||||
: tone === 'danger'
|
||||
? 'text-danger'
|
||||
: 'text-primary';
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-border bg-card p-5 shadow-sm', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{label}</span>
|
||||
{Icon && <Icon className={cn('h-4 w-4', toneClass)} />}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight">{value}</div>
|
||||
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty / zero-state placeholder for lists and tables. */
|
||||
export function EmptyState({ icon: Icon, title, description, action, className }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-10 text-center', className)}>
|
||||
{Icon && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Separator = forwardRef(function Separator(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
// A side drawer built on Radix Dialog. Used for the rule editor, filters, etc.
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
|
||||
const sideClasses = {
|
||||
right: 'inset-y-0 right-0 h-full w-full max-w-md border-l animate-slide-in-right',
|
||||
left: 'inset-y-0 left-0 h-full w-full max-w-md border-r',
|
||||
};
|
||||
|
||||
const SheetContent = forwardRef(function SheetContent({ className, children, side = 'right', ...props }, ref) {
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm animate-fade-in" />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-card p-6 shadow-md border-border',
|
||||
sideClasses[side],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
function SheetHeader({ className, ...props }) {
|
||||
return <div className={cn('flex flex-col gap-1.5', className)} {...props} />;
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }) {
|
||||
return <div className={cn('mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />;
|
||||
}
|
||||
|
||||
const SheetTitle = forwardRef(function SheetTitle({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />;
|
||||
});
|
||||
|
||||
const SheetDescription = forwardRef(function SheetDescription({ className, ...props }, ref) {
|
||||
return <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
function Skeleton({ className, ...props }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Switch = forwardRef(function Switch({ className, ...props }, ref) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Table = forwardRef(function Table({ className, ...props }, ref) {
|
||||
return (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TableHeader = forwardRef(function TableHeader({ className, ...props }, ref) {
|
||||
return <thead ref={ref} className={cn('[&_tr]:border-b [&_tr]:border-border', className)} {...props} />;
|
||||
});
|
||||
|
||||
const TableBody = forwardRef(function TableBody({ className, ...props }, ref) {
|
||||
return <tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
|
||||
});
|
||||
|
||||
const TableRow = forwardRef(function TableRow({ className, ...props }, ref) {
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TableHead = forwardRef(function TableHead({ className, ...props }, ref) {
|
||||
return (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn('h-10 px-3 text-left align-middle text-xs font-semibold uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TableCell = forwardRef(function TableCell({ className, ...props }, ref) {
|
||||
return <td ref={ref} className={cn('px-3 py-2.5 align-middle', className)} {...props} />;
|
||||
});
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = forwardRef(function TabsList({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('inline-flex h-9 items-center justify-center gap-1 rounded-lg bg-muted p-1 text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabsTrigger = forwardRef(function TabsTrigger({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabsContent = forwardRef(function TabsContent({ className, ...props }, ref) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import useTheme from '../../hooks/useTheme.js';
|
||||
import { Button } from './button.jsx';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip.jsx';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle theme">
|
||||
{isDark ? <Sun className="h-[18px] w-[18px]" /> : <Moon className="h-[18px] w-[18px]" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isDark ? 'Switch to light' : 'Switch to dark'}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import { X, CheckCircle2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
let idSeq = 0;
|
||||
|
||||
const ICONS = {
|
||||
success: CheckCircle2,
|
||||
danger: AlertTriangle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap the app once. Exposes useToast().toast({ title, description, variant }).
|
||||
* Replaces the ad-hoc per-page toast state scattered across the old pages.
|
||||
*/
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const dismiss = useCallback((id) => setToasts((t) => t.filter((x) => x.id !== id)), []);
|
||||
|
||||
const toast = useCallback(({ title, description, variant = 'info', duration = 4000 }) => {
|
||||
const id = ++idSeq;
|
||||
setToasts((t) => [...t, { id, title, description, variant, duration }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||
<ToastPrimitive.Provider swipeDirection="right" duration={4000}>
|
||||
{children}
|
||||
{toasts.map(({ id, title, description, variant, duration }) => {
|
||||
const Icon = ICONS[variant] || Info;
|
||||
const tone =
|
||||
variant === 'success'
|
||||
? 'text-success'
|
||||
: variant === 'danger'
|
||||
? 'text-danger'
|
||||
: variant === 'warning'
|
||||
? 'text-warning'
|
||||
: 'text-primary';
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
key={id}
|
||||
duration={duration}
|
||||
onOpenChange={(open) => !open && dismiss(id)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border bg-card p-4 shadow-md',
|
||||
'animate-slide-up data-[state=closed]:animate-fade-in'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', tone)} />
|
||||
<div className="flex-1">
|
||||
{title && <ToastPrimitive.Title className="text-sm font-semibold">{title}</ToastPrimitive.Title>}
|
||||
{description && (
|
||||
<ToastPrimitive.Description className="mt-0.5 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</ToastPrimitive.Description>
|
||||
)}
|
||||
</div>
|
||||
<ToastPrimitive.Close className="text-muted-foreground transition-colors hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitive.Close>
|
||||
</ToastPrimitive.Root>
|
||||
);
|
||||
})}
|
||||
<ToastPrimitive.Viewport className="fixed bottom-0 right-0 z-[100] flex w-full max-w-sm flex-col gap-2 p-4 outline-none" />
|
||||
</ToastPrimitive.Provider>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within <ToastProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { forwardRef } from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/utils.js';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = forwardRef(function TooltipContent(
|
||||
{ className, sideOffset = 6, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-md',
|
||||
'animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
||||
import { AnalyticsApi } from '../api/client.js';
|
||||
import {
|
||||
@@ -15,9 +16,10 @@ const fmtBytes = (b) => {
|
||||
return `${n.toFixed(1)} ${u[i]}`;
|
||||
};
|
||||
|
||||
export function StatCard({ label, value }) {
|
||||
export function StatCard({ label, value, to }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="widget stat">
|
||||
<div className={`widget stat${to ? ' widget--link' : ''}`} onClick={to ? () => navigate(to) : undefined} title={to ? `View ${label}` : undefined}>
|
||||
<div className="stat-value">{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
</div>
|
||||
@@ -37,6 +39,7 @@ export function HealthWidget({ health }) {
|
||||
}
|
||||
|
||||
export function TopSendersWidget({ senders }) {
|
||||
const navigate = useNavigate();
|
||||
if (!senders) return <Empty />;
|
||||
return (
|
||||
<div className="widget">
|
||||
@@ -44,7 +47,12 @@ export function TopSendersWidget({ senders }) {
|
||||
<table className="mini">
|
||||
<tbody>
|
||||
{senders.map((s) => (
|
||||
<tr key={s.senderId}>
|
||||
<tr
|
||||
key={s.senderId}
|
||||
className="mini-row--link"
|
||||
onClick={() => navigate(`/app/search?q=${encodeURIComponent(`from:${s.address}`)}`)}
|
||||
title={`Search emails from ${s.address}`}
|
||||
>
|
||||
<td title={s.address}>{s.displayName || s.address}</td>
|
||||
<td className="num">{s.emailCount}</td>
|
||||
</tr>
|
||||
@@ -95,8 +103,41 @@ export function HeatmapWidget({ heatmap }) {
|
||||
|
||||
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
// Maps EmailCategory enum name → folder slug or search query
|
||||
const CAT_SLUG = {
|
||||
Finance: '/app/folder/finance',
|
||||
Social: '/app/folder/social',
|
||||
Notification: '/app/folder/automated',
|
||||
Promotional: '/app/folder/shopping',
|
||||
Shopping: '/app/folder/shopping',
|
||||
Gaming: '/app/folder/gaming',
|
||||
RideSharing: '/app/folder/ridesharing',
|
||||
FoodDelivery: '/app/folder/food',
|
||||
SeasonalSales: '/app/folder/sales',
|
||||
Wellness: '/app/folder/wellness',
|
||||
Travel: '/app/folder/travel',
|
||||
Subscriptions: '/app/folder/subscriptions',
|
||||
Parcels: '/app/folder/parcels',
|
||||
Recruitment: '/app/folder/recruitment',
|
||||
Events: '/app/folder/events',
|
||||
SecurityAlerts: '/app/folder/security',
|
||||
Healthcare: '/app/folder/healthcare',
|
||||
Education: '/app/folder/education',
|
||||
NewsMedia: '/app/folder/news',
|
||||
PropertyUtilities:'/app/folder/property',
|
||||
Charity: '/app/folder/charity',
|
||||
Government: '/app/folder/government',
|
||||
CryptoInvesting: '/app/folder/crypto',
|
||||
FamilySchool: '/app/folder/family',
|
||||
Spam: '/app/folder/spam',
|
||||
Newsletter: '/app/search?q=newsletter',
|
||||
Personal: '/app/search?q=is:unread',
|
||||
Unknown: '/app/folder/allmail',
|
||||
};
|
||||
|
||||
export function CategoryHeatmapWidget() {
|
||||
const [cells, setCells] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
||||
|
||||
if (!cells) return <div className="widget"><div className="muted">Loading…</div></div>;
|
||||
@@ -115,15 +156,22 @@ export function CategoryHeatmapWidget() {
|
||||
<span className="chm-label" />
|
||||
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
|
||||
</div>
|
||||
{categories.map((cat) => (
|
||||
<div className="chm-row" key={cat}>
|
||||
<span className="chm-label" title={cat}>{cat}</span>
|
||||
{DOW.map((_, dow) => {
|
||||
const v = grid[`${cat}-${dow}`] || 0;
|
||||
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]} — ${v}`}>{v || ''}</span>;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{categories.map((cat) => {
|
||||
const dest = CAT_SLUG[cat];
|
||||
return (
|
||||
<div className="chm-row" key={cat}>
|
||||
<span
|
||||
className={`chm-label${dest ? ' chm-label--link' : ''}`}
|
||||
title={dest ? `View ${cat} emails` : cat}
|
||||
onClick={dest ? () => navigate(dest) : undefined}
|
||||
>{cat}</span>
|
||||
{DOW.map((_, dow) => {
|
||||
const v = grid[`${cat}-${dow}`] || 0;
|
||||
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]} — ${v}`}>{v || ''}</span>;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BulkApi } from '../api/client.js';
|
||||
|
||||
/// <summary>
|
||||
/// j/k move focus down/up the list, e archives the focused email, # (shift+3)
|
||||
/// trashes it. Ignored while an input/textarea/select has focus, or while
|
||||
/// the "/" search shortcut is active, so typing is never hijacked.
|
||||
/// `onRemoved(id)` lets the caller drop the row from local state after a
|
||||
/// successful archive/trash.
|
||||
/// </summary>
|
||||
export default function useListKeyboardNav(emails, onRemoved) {
|
||||
const [focusedId, setFocusedId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (e) => {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
if (!emails.length) return;
|
||||
|
||||
const idx = emails.findIndex((x) => x.id === focusedId);
|
||||
|
||||
if (e.key === 'j') {
|
||||
e.preventDefault();
|
||||
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
|
||||
setFocusedId(emails[next].id);
|
||||
} else if (e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
|
||||
setFocusedId(emails[prev].id);
|
||||
} else if (e.key === 'e' && idx >= 0) {
|
||||
e.preventDefault();
|
||||
const id = emails[idx].id;
|
||||
await BulkApi.archive([id]);
|
||||
onRemoved(id);
|
||||
} else if (e.key === '#' && idx >= 0) {
|
||||
e.preventDefault();
|
||||
const id = emails[idx].id;
|
||||
await BulkApi.trash([id]);
|
||||
onRemoved(id);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [emails, focusedId, onRemoved]);
|
||||
|
||||
return focusedId;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const LS_KEY = 'ii:saved-searches';
|
||||
|
||||
const load = () => {
|
||||
try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? []; }
|
||||
catch { return []; }
|
||||
};
|
||||
const save = (v) => localStorage.setItem(LS_KEY, JSON.stringify(v));
|
||||
|
||||
export default function useSavedSearches() {
|
||||
const [searches, setSearches] = useState(load);
|
||||
|
||||
const add = useCallback((label, query) => {
|
||||
setSearches((prev) => {
|
||||
const next = [...prev.filter((s) => s.query !== query), { id: crypto.randomUUID(), label, query }];
|
||||
save(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id) => {
|
||||
setSearches((prev) => {
|
||||
const next = prev.filter((s) => s.id !== id);
|
||||
save(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { searches, add, remove };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
/// <summary>Tracks a set of selected row IDs for bulk actions. Resets on `resetKey` change.</summary>
|
||||
export default function useSelection() {
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
|
||||
const toggle = useCallback((id) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => setSelected(new Set()), []);
|
||||
|
||||
const removeIds = useCallback((ids) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { selected, toggle, clear, removeIds, selectedIds: [...selected] };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const LS_KEY = 'ii:theme';
|
||||
|
||||
function getInitial() {
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
// Light-first product, but honor an explicit OS dark preference on first run.
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
/**
|
||||
* Light/dark theme, persisted to localStorage and reflected as `.dark` on <html>.
|
||||
* Apply as early as possible to avoid a flash (see the inline script in index.html).
|
||||
*/
|
||||
export default function useTheme() {
|
||||
const [theme, setTheme] = useState(getInitial);
|
||||
|
||||
useEffect(() => {
|
||||
apply(theme);
|
||||
localStorage.setItem(LS_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = useCallback(() => setTheme((t) => (t === 'dark' ? 'light' : 'dark')), []);
|
||||
|
||||
return { theme, setTheme, toggle };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
Design tokens. The ONLY place colors are defined. Change --primary / --ring
|
||||
to re-brand the whole app; a future user-facing accent picker can write these
|
||||
two variables at runtime. See docs/specs/ui-overhaul.md.
|
||||
*/
|
||||
@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 {
|
||||
/* Light — genuine white, warm off-white surfaces */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 30 10% 12%;
|
||||
--card: 40 33% 99%;
|
||||
--muted: 40 24% 96%;
|
||||
--muted-foreground: 35 8% 42%;
|
||||
--border: 38 18% 89%;
|
||||
--input: 38 18% 89%;
|
||||
--ring: 110 62% 38%;
|
||||
|
||||
/* Brand accent — green (from #3ba31f), darkened for AA white-on-green */
|
||||
--primary: 110 62% 33%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Semantic */
|
||||
--success: 145 55% 38%;
|
||||
--warning: 38 92% 45%;
|
||||
--danger: 4 74% 50%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
|
||||
--radius: 0.5rem; /* lg 8px · md 6px · sm 4px */
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark (default) — warm charcoal layers, NOT pure black */
|
||||
--background: 30 7% 10%;
|
||||
--foreground: 40 22% 93%;
|
||||
--card: 30 7% 13%;
|
||||
--muted: 30 6% 17%;
|
||||
--muted-foreground: 35 9% 64%;
|
||||
--border: 30 7% 22%;
|
||||
--input: 30 7% 24%;
|
||||
--ring: 110 50% 46%;
|
||||
|
||||
/* Brand accent — luminous green for dark surfaces */
|
||||
--primary: 110 52% 42%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--success: 145 50% 48%;
|
||||
--warning: 38 90% 56%;
|
||||
--danger: 4 72% 58%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
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 {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
/* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */
|
||||
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. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border));
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merge conditional class names, de-duplicating conflicting Tailwind classes. */
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
+43
-19
@@ -1,31 +1,55 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Landing from './pages/Landing.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import Senders from './pages/Senders.jsx';
|
||||
import Cleanup from './pages/Cleanup.jsx';
|
||||
import Unsubscribe from './pages/Unsubscribe.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import { ToastProvider, TooltipProvider } from './components/ui';
|
||||
import '@fontsource-variable/inter';
|
||||
import './index.css';
|
||||
import './styles.css';
|
||||
|
||||
// Route-level code splitting: each page loads its own chunk on first visit, so the
|
||||
// initial bundle no longer carries Chart.js / grid-layout / every page at once.
|
||||
// Landing + Layout stay eager (they're the first paint).
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));
|
||||
const Senders = lazy(() => import('./pages/Senders.jsx'));
|
||||
const Cleanup = lazy(() => import('./pages/Cleanup.jsx'));
|
||||
const Unsubscribe = lazy(() => import('./pages/Unsubscribe.jsx'));
|
||||
const FolderView = lazy(() => import('./pages/FolderView.jsx'));
|
||||
const SearchResults = lazy(() => import('./pages/SearchResults.jsx'));
|
||||
const DesignSystem = lazy(() => import('./pages/DesignSystem.jsx'));
|
||||
|
||||
// Minimal, theme-correct route fallback (skeleton-style, per the design system).
|
||||
const RouteFallback = () => (
|
||||
<div className="p-6 text-sm text-muted-foreground" aria-busy="true">Loading…</div>
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public landing page */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
<ToastProvider>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
{/* Public landing page */}
|
||||
<Route path="/" element={<Landing />} />
|
||||
|
||||
{/* Authenticated app */}
|
||||
<Route path="/app" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="senders" element={<Senders />} />
|
||||
<Route path="cleanup" element={<Cleanup />} />
|
||||
<Route path="unsubscribe" element={<Unsubscribe />} />
|
||||
</Route>
|
||||
{/* Authenticated app */}
|
||||
<Route path="/app" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="senders" element={<Senders />} />
|
||||
<Route path="cleanup" element={<Cleanup />} />
|
||||
<Route path="unsubscribe" element={<Unsubscribe />} />
|
||||
<Route path="folder/:slug" element={<FolderView />} />
|
||||
<Route path="search" element={<SearchResults />} />
|
||||
<Route path="design" element={<DesignSystem />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -89,8 +89,8 @@ export default function Dashboard() {
|
||||
const render = (key) => {
|
||||
switch (key) {
|
||||
case 'inbox-health': return <HealthWidget health={data?.health} />;
|
||||
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} />;
|
||||
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} />;
|
||||
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
|
||||
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
|
||||
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
|
||||
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
|
||||
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SearchApi } from '../api/client.js';
|
||||
import EmailRow from '../components/EmailRow.jsx';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
|
||||
const FOLDER_META = {
|
||||
inbox: { icon: '📥', label: 'Inbox' },
|
||||
allmail: { icon: '📬', label: 'All Mail' },
|
||||
unread: { icon: '🔵', label: 'Unread' },
|
||||
starred: { icon: '⭐', label: 'Starred' },
|
||||
sent: { icon: '📤', label: 'Sent' },
|
||||
drafts: { icon: '✏️', label: 'Drafts' },
|
||||
archive: { icon: '📦', label: 'Archive' },
|
||||
spam: { icon: '🚫', label: 'Spam' },
|
||||
trash: { icon: '🗑️', label: 'Trash' },
|
||||
unlabeled: { icon: '🏷️', label: 'Unlabeled' },
|
||||
pinned: { icon: '📌', label: 'Pinned' },
|
||||
readlater: { icon: '🔖', label: 'Read Later' },
|
||||
large: { icon: '📎', label: 'Large Mail' },
|
||||
old: { icon: '🕰️', label: 'Old Mail' },
|
||||
automated: { icon: '🤖', label: 'Automated' },
|
||||
noreply: { icon: '🔇', label: 'No-Reply' },
|
||||
shopping: { icon: '🛍️', label: 'Online Shopping' },
|
||||
gaming: { icon: '🎮', label: 'Gaming' },
|
||||
finance: { icon: '💳', label: 'Finance & Insurance' },
|
||||
sales: { icon: '🏷️', label: 'Seasonal Sales' },
|
||||
ridesharing:{ icon: '🚗', label: 'Ride Sharing' },
|
||||
food: { icon: '🍕', label: 'Food Delivery' },
|
||||
social: { icon: '📱', label: 'Social Notifications' },
|
||||
wellness: { icon: '🏃', label: 'Wellness & Sport' },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
||||
export default function FolderView() {
|
||||
const { slug } = useParams();
|
||||
const meta = FOLDER_META[slug] ?? { icon: '📁', label: slug };
|
||||
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
|
||||
// Reset when the folder changes
|
||||
useEffect(() => {
|
||||
setEmails([]);
|
||||
setTotalCount(null);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
clear();
|
||||
}, [slug, clear]);
|
||||
|
||||
// Fetch a page and append results
|
||||
const fetchPage = useCallback((p) => {
|
||||
setLoading(true);
|
||||
SearchApi.folder(slug, p, PAGE_SIZE)
|
||||
.then((r) => {
|
||||
setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
|
||||
setTotalCount(r.totalCount);
|
||||
setHasMore(p < r.totalPages);
|
||||
setPage(p);
|
||||
})
|
||||
.catch(() => setError('Failed to load emails.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { fetchPage(1); }, [fetchPage]);
|
||||
|
||||
// Sentinel div observed to trigger next page
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||
fetchPage(page + 1);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loading, page, fetchPage]);
|
||||
|
||||
return (
|
||||
<div className="folder-view">
|
||||
<div className="folder-view-head">
|
||||
<h2 className="folder-view-title">{meta.icon} {meta.label}</h2>
|
||||
{totalCount !== null && (
|
||||
<span className="folder-view-count">{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="fv-error">{error}</div>}
|
||||
|
||||
<BulkToolbar
|
||||
selectedIds={selectedIds}
|
||||
onClear={clear}
|
||||
onDone={(action, ids) => {
|
||||
if (action === 'trash' || action === 'archive') {
|
||||
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
|
||||
removeIds(ids);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{!loading && !error && emails.length === 0 && (
|
||||
<div className="fv-empty">No emails in this folder.</div>
|
||||
)}
|
||||
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Sentinel — triggers next page load when scrolled into view */}
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { SearchApi } from '../api/client.js';
|
||||
import EmailRow from '../components/EmailRow.jsx';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
import useSavedSearches from '../hooks/useSavedSearches.js';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
|
||||
export default function SearchResults() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const q = searchParams.get('q') ?? '';
|
||||
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
const { searches: savedSearches, add: addSavedSearch } = useSavedSearches();
|
||||
const isSaved = savedSearches.some((s) => s.query === q);
|
||||
|
||||
const handleSave = () => {
|
||||
const label = window.prompt('Name this saved search:', q);
|
||||
if (label && label.trim()) addSavedSearch(label.trim(), q);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEmails([]);
|
||||
setTotalCount(null);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setError(null);
|
||||
clear();
|
||||
}, [q, clear]);
|
||||
|
||||
const fetchPage = useCallback((p) => {
|
||||
if (!q.trim()) return;
|
||||
setLoading(true);
|
||||
SearchApi.query(q, p, PAGE_SIZE)
|
||||
.then((r) => {
|
||||
setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
|
||||
setTotalCount(r.totalCount);
|
||||
setHasMore(p < r.totalPages);
|
||||
setPage(p);
|
||||
})
|
||||
.catch(() => setError('Search failed. Please try again.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [q]);
|
||||
|
||||
useEffect(() => { fetchPage(1); }, [fetchPage]);
|
||||
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1);
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loading, page, fetchPage]);
|
||||
|
||||
return (
|
||||
<div className="folder-view">
|
||||
<div className="folder-view-head saved-search-row">
|
||||
<h2 className="folder-view-title">🔍 "{q}"</h2>
|
||||
{totalCount !== null && (
|
||||
<span className="folder-view-count">{totalCount.toLocaleString()} result{totalCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{q.trim() && (
|
||||
<button className="saved-search-save-btn" onClick={handleSave} disabled={isSaved}>
|
||||
{isSaved ? '★ Saved' : '☆ Save search'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
|
||||
{error && <div className="fv-error">{error}</div>}
|
||||
{!loading && !error && q.trim() && emails.length === 0 && !hasMore && (
|
||||
<div className="fv-empty">No results for "{q}".</div>
|
||||
)}
|
||||
|
||||
<BulkToolbar
|
||||
selectedIds={selectedIds}
|
||||
onClear={clear}
|
||||
onDone={(action, ids) => {
|
||||
if (action === 'trash' || action === 'archive') {
|
||||
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
|
||||
removeIds(ids);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} className="fv-sentinel" />
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} results —</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+345
-58
@@ -1,69 +1,356 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AnalyticsApi } from '../api/client.js';
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { AnalyticsApi, SearchApi, EmailApi } from '../api/client.js';
|
||||
import BulkToolbar from '../components/BulkToolbar.jsx';
|
||||
import useSelection from '../hooks/useSelection.js';
|
||||
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
|
||||
|
||||
// Volume tiers, highest first.
|
||||
const TIERS = [
|
||||
{ key: '1000+', label: '1000+ emails', min: 1000, max: Infinity, accent: '#eb5757' },
|
||||
{ key: '500-1000', label: '500 – 1000', min: 500, max: 999, accent: '#f2994a' },
|
||||
{ key: '250-500', label: '250 – 500', min: 250, max: 499, accent: '#f2c94c' },
|
||||
{ key: '100-250', label: '100 – 250', min: 100, max: 249, accent: '#56ccf2' },
|
||||
{ key: '<100', label: 'Under 100', min: 0, max: 99, accent: '#6fcf97' }
|
||||
];
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PER_TIER = 60; // cap visible blocks per tier to keep it light
|
||||
const fmtDate = (iso) => {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffDays = (now - d) / 86400000;
|
||||
if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
export default function Senders() {
|
||||
const [senders, setSenders] = useState(null);
|
||||
const fmtSize = (b) => {
|
||||
if (!b) return '';
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
useEffect(() => { AnalyticsApi.allSenders().then(setSenders).catch(() => setSenders([])); }, []);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const buckets = useMemo(() => {
|
||||
const map = Object.fromEntries(TIERS.map((t) => [t.key, []]));
|
||||
(senders || []).forEach((s) => {
|
||||
const tier = TIERS.find((t) => s.emailCount >= t.min && s.emailCount <= t.max);
|
||||
if (tier) map[tier.key].push(s);
|
||||
});
|
||||
Object.values(map).forEach((arr) => arr.sort((a, b) => b.emailCount - a.emailCount));
|
||||
return map;
|
||||
}, [senders]);
|
||||
// ── SenderList (left panel) ───────────────────────────────────────────────────
|
||||
|
||||
if (!senders) return <div className="page"><h2>Senders by Volume</h2><p className="muted">Loading…</p></div>;
|
||||
function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
||||
const filtered = senders.filter((s) => {
|
||||
const q = search.toLowerCase();
|
||||
return !q || (s.address + ' ' + (s.displayName ?? '')).toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page senders-page">
|
||||
<h2>Senders by Volume</h2>
|
||||
<p className="muted">Who fills your inbox, grouped by how many emails they've sent you.</p>
|
||||
|
||||
{TIERS.map((tier) => {
|
||||
const list = buckets[tier.key];
|
||||
if (!list.length) return null;
|
||||
const shown = list.slice(0, PER_TIER);
|
||||
return (
|
||||
<section className="tier" key={tier.key}>
|
||||
<div className="tier-head">
|
||||
<span className="tier-dot" style={{ background: tier.accent }} />
|
||||
<h3>{tier.label}</h3>
|
||||
<span className="tier-count">{list.length} sender{list.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="sender-blocks">
|
||||
{shown.map((s) => (
|
||||
<div className="sender-block" key={s.senderId} style={{ borderTopColor: tier.accent }}>
|
||||
<div className="sb-name" title={s.address}>{s.displayName || s.address}</div>
|
||||
<div className="sb-domain">{s.domain}</div>
|
||||
<div className="sb-stats">
|
||||
<span className="sb-count">{s.emailCount.toLocaleString()}</span>
|
||||
{s.unreadCount > 0 && <span className="sb-unread">{s.unreadCount} unread</span>}
|
||||
{s.hasUnsubscribe && <span className="sb-unsub">unsub</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{list.length > PER_TIER && <div className="muted tier-more">+ {list.length - PER_TIER} more</div>}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{senders.length === 0 && <p className="muted">No senders yet — run a sync first.</p>}
|
||||
<div className="sl-panel">
|
||||
<div className="sl-search-wrap">
|
||||
<input
|
||||
className="sl-search"
|
||||
type="search"
|
||||
placeholder="Filter senders…"
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
aria-label="Filter senders"
|
||||
/>
|
||||
</div>
|
||||
<div className="sl-count">{filtered.length.toLocaleString()} sender{filtered.length !== 1 ? 's' : ''}</div>
|
||||
<ul className="sl-list">
|
||||
{filtered.map((s) => (
|
||||
<li key={s.senderId}>
|
||||
<button
|
||||
className={`sl-item${selectedId === s.senderId ? ' sl-item--active' : ''}`}
|
||||
onClick={() => onSelect(s)}
|
||||
>
|
||||
<div className="sl-name">{s.displayName || s.address}</div>
|
||||
<div className="sl-addr">{s.displayName ? s.address : s.domain}</div>
|
||||
<div className="sl-meta">
|
||||
<span className="sl-email-count">{s.emailCount.toLocaleString()}</span>
|
||||
{s.unreadCount > 0 && <span className="sl-unread">{s.unreadCount} unread</span>}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmailDetail (full single-email view) ──────────────────────────────────────
|
||||
|
||||
function EmailDetail({ email: summary, onBack }) {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [aiSummary, setAiSummary] = useState(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDetail(null);
|
||||
setLoading(true);
|
||||
setAiSummary(null);
|
||||
EmailApi.get(summary.id)
|
||||
.then(setDetail)
|
||||
.catch(() => setDetail(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [summary.id]);
|
||||
|
||||
const email = detail ?? summary;
|
||||
|
||||
const fetchAiSummary = () => {
|
||||
setAiLoading(true);
|
||||
EmailApi.summary(summary.id)
|
||||
.then((r) => setAiSummary(r.summary))
|
||||
.catch(() => setAiSummary(null))
|
||||
.finally(() => setAiLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sd-detail">
|
||||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||
<div className="sd-detail-header">
|
||||
<div className="sd-detail-subject">{email.subject || '(no subject)'}</div>
|
||||
<div className="sd-detail-meta">
|
||||
<span>{email.senderDisplayName || email.senderAddress}</span>
|
||||
<span className="sd-detail-sep">·</span>
|
||||
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
|
||||
{email.sizeEstimateBytes > 0 && (
|
||||
<>
|
||||
<span className="sd-detail-sep">·</span>
|
||||
<span>{fmtSize(email.sizeEstimateBytes)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className="sd-ai-summary">
|
||||
{aiSummary != null ? (
|
||||
<div className="sd-ai-summary-text">✨ {aiSummary}</div>
|
||||
) : (
|
||||
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
|
||||
{aiLoading ? 'Summarising…' : '✨ AI summary'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="sd-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
<div className="sd-body">{detail.bodyText}</div>
|
||||
)}
|
||||
|
||||
{!loading && !detail?.bodyText && email.snippet && (
|
||||
<div className="sd-snippet">{email.snippet}</div>
|
||||
)}
|
||||
|
||||
<div className="sd-detail-actions">
|
||||
<button
|
||||
className="btn-sm"
|
||||
onClick={() => window.open(
|
||||
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
|
||||
'_blank', 'noopener,noreferrer'
|
||||
)}
|
||||
>Open in Gmail ↗</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmailListRow (row inside sender email list) ───────────────────────────────
|
||||
|
||||
function EmailListRow({ email: initial, onRemove, onOpen, selected, onToggleSelect, focused }) {
|
||||
const [email, setEmail] = useState(initial);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
const act = (fn, patch) => async (e) => {
|
||||
e.stopPropagation();
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try { await fn(email.id); setEmail((p) => ({ ...p, ...patch })); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
const handleTrash = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (acting) return;
|
||||
setActing(true);
|
||||
try { await EmailApi.trash(email.id); onRemove?.(email.id); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
|
||||
onClick={() => onOpen(email)}
|
||||
title="View email"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td className="el-select" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
|
||||
</td>
|
||||
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
|
||||
<td className="el-subject">
|
||||
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
|
||||
{email.snippet && <span className="el-snippet"> — {email.snippet}</span>}
|
||||
</td>
|
||||
<td className="el-meta">
|
||||
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
|
||||
{email.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(email.sizeEstimateBytes)}</span>}
|
||||
</td>
|
||||
<td className="el-date">{fmtDate(email.sentAtUtc)}</td>
|
||||
<td className="el-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="action-btn"
|
||||
title={email.isUnread ? 'Mark as read' : 'Mark as unread'}
|
||||
onClick={email.isUnread
|
||||
? act(EmailApi.markRead, { isUnread: false })
|
||||
: act(EmailApi.markUnread, { isUnread: true })}
|
||||
>{email.isUnread ? '✓' : '●'}</button>
|
||||
<button
|
||||
className={`action-btn${email.isStarred ? ' action-btn--active' : ''}`}
|
||||
title={email.isStarred ? 'Unstar' : 'Star'}
|
||||
onClick={email.isStarred
|
||||
? act(EmailApi.unstar, { isStarred: false })
|
||||
: act(EmailApi.star, { isStarred: true })}
|
||||
>⭐</button>
|
||||
<button
|
||||
className="action-btn action-btn--danger"
|
||||
title="Move to trash"
|
||||
onClick={handleTrash}
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SenderEmails (right panel) ────────────────────────────────────────────────
|
||||
|
||||
function SenderEmails({ sender }) {
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [openEmail, setOpenEmail] = useState(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
|
||||
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
|
||||
|
||||
// Reset when sender changes
|
||||
useEffect(() => {
|
||||
setEmails([]);
|
||||
setTotalCount(null);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setOpenEmail(null);
|
||||
clear();
|
||||
}, [sender.senderId, clear]);
|
||||
|
||||
const fetchPage = useCallback((p) => {
|
||||
setLoading(true);
|
||||
SearchApi.query(`from:${sender.address}`, p, PAGE_SIZE)
|
||||
.then((r) => {
|
||||
setEmails((prev) => p === 1 ? r.items : [...prev, ...r.items]);
|
||||
setTotalCount(r.totalCount);
|
||||
setHasMore(p < r.totalPages);
|
||||
setPage(p);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [sender]);
|
||||
|
||||
useEffect(() => { fetchPage(1); }, [fetchPage]);
|
||||
|
||||
// Infinite scroll sentinel
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => { if (entries[0].isIntersecting && hasMore && !loading) fetchPage(page + 1); },
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [hasMore, loading, page, fetchPage]);
|
||||
|
||||
if (openEmail) {
|
||||
return <EmailDetail email={openEmail} onBack={() => setOpenEmail(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sd-panel">
|
||||
<div className="sd-panel-header">
|
||||
<div className="sd-panel-name">{sender.displayName || sender.address}</div>
|
||||
<div className="sd-panel-addr">{sender.displayName ? sender.address : ''}</div>
|
||||
{totalCount !== null && (
|
||||
<div className="sd-panel-count">{totalCount.toLocaleString()} email{totalCount !== 1 ? 's' : ''}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BulkToolbar
|
||||
selectedIds={selectedIds}
|
||||
onClear={clear}
|
||||
onDone={(action, ids) => {
|
||||
if (action === 'trash' || action === 'archive') {
|
||||
setEmails((prev) => prev.filter((x) => !ids.includes(x.id)));
|
||||
removeIds(ids);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{emails.length > 0 && (
|
||||
<table className="email-list">
|
||||
<tbody>
|
||||
{emails.map((e) => (
|
||||
<EmailListRow
|
||||
key={e.id}
|
||||
email={e}
|
||||
selected={selected.has(e.id)}
|
||||
onToggleSelect={toggle}
|
||||
focused={focusedId === e.id}
|
||||
onOpen={setOpenEmail}
|
||||
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div ref={sentinelRef} />
|
||||
{loading && <div className="fv-loading-more">Loading…</div>}
|
||||
{!loading && !hasMore && emails.length > 0 && (
|
||||
<div className="fv-end">— {emails.length.toLocaleString()} emails —</div>
|
||||
)}
|
||||
{!loading && emails.length === 0 && (
|
||||
<div className="fv-empty">No emails found from this sender.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Senders() {
|
||||
const [senders, setSenders] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
AnalyticsApi.allSenders().then((list) => {
|
||||
setSenders(list);
|
||||
if (list.length > 0) setSelected(list[0]);
|
||||
}).catch(() => setSenders([]));
|
||||
}, []);
|
||||
|
||||
if (!senders) return <div className="page"><h2>Senders</h2><p className="muted">Loading…</p></div>;
|
||||
|
||||
if (senders.length === 0) return (
|
||||
<div className="page">
|
||||
<h2>Senders</h2>
|
||||
<p className="muted">No senders yet — run a sync first.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="senders-master-detail">
|
||||
<SenderList
|
||||
senders={senders}
|
||||
selectedId={selected?.senderId}
|
||||
onSelect={setSelected}
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
/>
|
||||
{selected && <SenderEmails key={selected.senderId} sender={selected} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,127 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { UnsubscribeApi } from '../api/client.js';
|
||||
|
||||
const METHOD = ['None', 'HTTP link', 'mailto', 'One-click'];
|
||||
const METHOD = ['—', 'HTTP link', 'mailto', 'One-click'];
|
||||
const STATUS = ['Detected', 'Queued', 'In progress', 'Succeeded', 'Failed', 'Skipped'];
|
||||
const STATUS_CLASS = ['detected', 'queued', 'progress', 'ok', 'fail', 'skip'];
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'pending', label: 'Pending', statuses: [0, 1, 2] },
|
||||
{ key: 'succeeded', label: 'Succeeded', statuses: [3] },
|
||||
{ key: 'failed', label: 'Failed', statuses: [4] },
|
||||
];
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filter, setFilter] = useState('pending');
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const load = async () => setItems(await UnsubscribeApi.safeList());
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const detect = async () => { setBusy(true); try { await UnsubscribeApi.detect(); await load(); } finally { setBusy(false); } };
|
||||
const visible = useMemo(() => {
|
||||
const f = FILTERS.find((f) => f.key === filter);
|
||||
const list = !f?.statuses ? items : items.filter((i) => f.statuses.includes(i.status));
|
||||
return [...list].sort((a, b) => b.confidence - a.confidence || b.emailCount - a.emailCount);
|
||||
}, [items, filter]);
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
setSelected((prev) => {
|
||||
const next = { ...prev };
|
||||
visible.forEach((it) => { next[it.id] = !allVisibleSelected; });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const detect = async () => {
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.detect(); await load(); setToast('Scan complete.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const process = async () => {
|
||||
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||
if (!ids.length) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)?`)) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)? This cannot be undone for one-click unsubscribes.`)) return;
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.process({ itemIds: ids, confirmed: true }); await load(); setSelected({}); }
|
||||
finally { setBusy(false); }
|
||||
try {
|
||||
const result = await UnsubscribeApi.process({ itemIds: ids, confirmed: true });
|
||||
await load();
|
||||
setSelected({});
|
||||
setToast(`${result.succeededCount} succeeded, ${result.failedCount} failed.`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page unsub-page">
|
||||
<h2>Unsubscribe Manager</h2>
|
||||
<p className="muted">Review senders you can unsubscribe from, queue them up, and track results.</p>
|
||||
|
||||
<div className="form-row">
|
||||
<button onClick={detect} disabled={busy}>Re-scan for subscriptions</button>
|
||||
<button onClick={process} disabled={busy} className="danger">Unsubscribe selected</button>
|
||||
<button onClick={detect} disabled={busy}>{busy ? 'Scanning…' : 'Re-scan for subscriptions'}</button>
|
||||
<button onClick={process} disabled={busy || selectedCount === 0} className="danger">
|
||||
Unsubscribe selected{selectedCount > 0 ? ` (${selectedCount})` : ''}
|
||||
</button>
|
||||
{toast && <span className="unsub-toast">{toast}</span>}
|
||||
</div>
|
||||
<table className="grid">
|
||||
<thead><tr><th></th><th>Sender</th><th>Domain</th><th>Method</th><th>Emails</th><th>Status</th></tr></thead>
|
||||
|
||||
<div className="unsub-tabs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`unsub-tab${filter === f.key ? ' unsub-tab--active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
<span className="unsub-tab-count">
|
||||
{f.statuses ? items.filter((i) => f.statuses.includes(i.status)).length : items.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="grid unsub-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAll} disabled={visible.length === 0} /></th>
|
||||
<th>Sender</th>
|
||||
<th>Domain</th>
|
||||
<th>Method</th>
|
||||
<th className="num">Emails</th>
|
||||
<th>Confidence</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
{visible.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td><input type="checkbox" checked={!!selected[it.id]} onChange={(e) => setSelected({ ...selected, [it.id]: e.target.checked })} /></td>
|
||||
<td>{it.senderAddress}</td>
|
||||
<td>{it.domain}</td>
|
||||
<td className="muted">{it.domain}</td>
|
||||
<td>{METHOD[it.method]}</td>
|
||||
<td className="num">{it.emailCount}</td>
|
||||
<td>{STATUS[it.status]}</td>
|
||||
<td className="num">{it.emailCount.toLocaleString()}</td>
|
||||
<td>
|
||||
<span className={`unsub-confidence unsub-confidence--${it.confidence >= 0.7 ? 'high' : it.confidence >= 0.4 ? 'mid' : 'low'}`}>
|
||||
{Math.round(it.confidence * 100)}%
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`unsub-badge unsub-badge--${STATUS_CLASS[it.status]}`}>{STATUS[it.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan="6" className="muted">Nothing detected yet. Run a scan.</td></tr>}
|
||||
{!visible.length && (
|
||||
<tr><td colSpan="7" className="muted">
|
||||
{items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'}
|
||||
</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+247
-38
@@ -1,22 +1,38 @@
|
||||
:root {
|
||||
--bg: #0f1420;
|
||||
--panel: #1a2030;
|
||||
--panel-2: #222a3d;
|
||||
--text: #e6e9f0;
|
||||
--muted: #8b93a7;
|
||||
--accent: #4f8cff;
|
||||
--danger: #eb5757;
|
||||
--ok: #6fcf97;
|
||||
/* v2 brand — warm charcoal + green (aligns with the token system in index.css).
|
||||
Legacy classes still read these; the v2 shell/components use the token system. */
|
||||
--bg: #1a1917;
|
||||
--panel: #211f1d;
|
||||
--panel-2: #2a2724;
|
||||
--text: #f2efe9;
|
||||
--muted: #a8a29a;
|
||||
--accent: #46ad27;
|
||||
--danger: #d95a4a;
|
||||
--ok: #5bbf4a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
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; }
|
||||
.topbar nav a { color: var(--muted); text-decoration: none; margin-right: 14px; }
|
||||
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* ── Search bar ── */
|
||||
.search-form { display: flex; align-items: center; gap: 0; flex: 1; max-width: 420px; }
|
||||
.search-input {
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
.search-input:focus { outline: none; border-color: var(--accent); }
|
||||
.search-btn {
|
||||
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;
|
||||
}
|
||||
.search-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
.user { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ── App body (sidebar + main) ── */
|
||||
@@ -24,7 +40,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
|
||||
|
||||
.sidebar {
|
||||
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;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
@@ -33,7 +49,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
|
||||
.sidebar-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 8px 10px;
|
||||
border-bottom: 1px solid #2c3550;
|
||||
border-bottom: 1px solid #332f2b;
|
||||
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; }
|
||||
@@ -43,7 +59,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
|
||||
/* Section headings */
|
||||
.section-head {
|
||||
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;
|
||||
color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
|
||||
}
|
||||
@@ -73,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;
|
||||
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 */
|
||||
.fav-pin {
|
||||
@@ -94,9 +110,15 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
.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); }
|
||||
|
||||
.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 h3 { margin: 0 0 10px; font-size: 14px; cursor: move; }
|
||||
.widget--link { cursor: pointer; }
|
||||
.widget--link:hover { border-color: var(--accent); }
|
||||
.mini-row--link { cursor: pointer; }
|
||||
.mini-row--link:hover td { color: var(--accent); }
|
||||
.chm-label--link { cursor: pointer; text-decoration: underline dotted; }
|
||||
.chm-label--link:hover { color: var(--accent); }
|
||||
.widget canvas { flex: 1; min-height: 0; }
|
||||
|
||||
.stat { align-items: flex-start; justify-content: center; }
|
||||
@@ -111,7 +133,7 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
table.mini, table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
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; }
|
||||
.muted { color: var(--muted); font-size: 12px; }
|
||||
|
||||
@@ -140,28 +162,93 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder { background: var(--accent); opacity: 0.25; border-radius: 10px; }
|
||||
|
||||
/* Senders by volume */
|
||||
.senders-page { max-width: 1100px; }
|
||||
.tier { margin-top: 22px; }
|
||||
.tier-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.tier-head h3 { margin: 0; font-size: 16px; }
|
||||
.tier-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||
.tier-count { color: var(--muted); font-size: 12px; }
|
||||
.sender-blocks { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||
.sender-block { background: var(--panel); border: 1px solid #2c3550; border-top: 3px solid var(--accent); border-radius: 8px; padding: 10px 12px; }
|
||||
.sb-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sb-domain { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; }
|
||||
.sb-stats { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.sb-count { font-size: 20px; font-weight: 700; }
|
||||
.sb-unread { font-size: 11px; color: var(--accent); }
|
||||
.sb-unsub { font-size: 10px; color: var(--muted); border: 1px solid #36405c; border-radius: 4px; padding: 0 4px; }
|
||||
.tier-more { margin-top: 8px; }
|
||||
/* ── Senders master-detail ─────────────────────────────────────────────── */
|
||||
.senders-master-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: calc(100vh - 56px); /* fill below topbar */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left panel — sender list */
|
||||
.sl-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #332f2b;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sl-search-wrap { padding: 10px 12px 6px; }
|
||||
.sl-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid #332f2b;
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sl-count { padding: 0 14px 6px; font-size: 11px; color: var(--muted); }
|
||||
.sl-list { flex: 1; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
|
||||
.sl-item {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: none; border: none; cursor: pointer;
|
||||
padding: 10px 14px; border-bottom: 1px solid #26231f;
|
||||
color: var(--text);
|
||||
}
|
||||
.sl-item:hover { background: var(--panel-2); }
|
||||
.sl-item--active { background: var(--panel-2); border-left: 3px solid var(--accent); padding-left: 11px; }
|
||||
.sl-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sl-addr { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 3px; }
|
||||
.sl-meta { display: flex; align-items: baseline; gap: 8px; }
|
||||
.sl-email-count { font-size: 13px; font-weight: 700; color: var(--accent); }
|
||||
.sl-unread { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* Right panel — email list for selected sender */
|
||||
.sd-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.sd-panel-header {
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid #332f2b;
|
||||
background: var(--panel);
|
||||
position: sticky; top: 0; z-index: 1;
|
||||
}
|
||||
.sd-panel-name { font-size: 16px; font-weight: 700; }
|
||||
.sd-panel-addr { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||
.sd-panel-count { font-size: 12px; color: var(--muted); }
|
||||
|
||||
/* Single email detail view */
|
||||
.sd-detail { padding: 20px; max-width: 760px; }
|
||||
.sd-back {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: none; border: 1px solid #332f2b; border-radius: 6px;
|
||||
color: var(--text); font-size: 13px; cursor: pointer;
|
||||
padding: 5px 12px; margin-bottom: 18px;
|
||||
}
|
||||
.sd-back:hover { background: var(--panel-2); }
|
||||
.sd-detail-header { margin-bottom: 14px; }
|
||||
.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-sep { opacity: 0.4; }
|
||||
.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-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; }
|
||||
.btn-sm {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
||||
padding: 7px 14px; font-size: 13px; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.btn-sm:hover { opacity: 0.85; }
|
||||
|
||||
.page { max-width: 900px; }
|
||||
.form-row { display: flex; gap: 10px; margin: 14px 0; }
|
||||
.form-row input { flex: 1; }
|
||||
input, select { background: var(--panel-2); border: 1px solid #2c3550; color: var(--text); border-radius: 6px; padding: 8px; }
|
||||
.card { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; padding: 14px; margin-top: 12px; }
|
||||
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 #332f2b; border-radius: 10px; padding: 14px; margin-top: 12px; }
|
||||
.card.success { border-color: var(--ok); }
|
||||
.card ul { font-size: 13px; color: var(--muted); }
|
||||
|
||||
@@ -174,14 +261,14 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
||||
|
||||
/* ── Shared buttons ── */
|
||||
.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); }
|
||||
.cta {
|
||||
background: var(--accent); color: #fff; border: none; border-radius: 8px;
|
||||
padding: 12px 22px; font-size: 16px; font-weight: 700; cursor: pointer;
|
||||
text-decoration: none; display: inline-block;
|
||||
}
|
||||
.cta:hover { background: #5d97ff; }
|
||||
.cta:hover { background: #57c231; }
|
||||
|
||||
/* ── Landing page ── */
|
||||
.landing { max-width: 1080px; margin: 0 auto; padding: 0 20px 60px; }
|
||||
@@ -192,13 +279,56 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
||||
.hero-cta { margin: 8px 0; }
|
||||
.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; }
|
||||
.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 h3 { margin: 10px 0 6px; font-size: 17px; }
|
||||
.feature p { color: var(--muted); font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.closing { text-align: center; margin: 56px 0 20px; }
|
||||
.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 { max-width: 960px; }
|
||||
.folder-view-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.folder-view-title { margin: 0; font-size: 20px; font-weight: 700; }
|
||||
.folder-view-count { color: var(--muted); font-size: 13px; }
|
||||
.fv-loading, .fv-empty { color: var(--muted); font-size: 14px; padding: 32px 0; }
|
||||
.fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; }
|
||||
|
||||
.email-list { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.email-row { border-bottom: 1px solid #332f2b; cursor: pointer; }
|
||||
.email-row:hover { background: var(--panel); }
|
||||
.email-row--unread .el-sender,
|
||||
.email-row--unread .el-subj-text { font-weight: 700; color: var(--text); }
|
||||
|
||||
.el-unread { width: 14px; padding: 10px 4px 10px 0; }
|
||||
.unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.el-sender { width: 180px; padding: 10px 12px 10px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
||||
.el-subject { padding: 10px 8px; overflow: hidden; }
|
||||
.el-subj-text { color: var(--text); }
|
||||
.el-snippet { color: var(--muted); }
|
||||
.el-meta { width: 80px; padding: 10px 8px; text-align: right; white-space: nowrap; }
|
||||
.el-attach { margin-right: 4px; font-size: 12px; }
|
||||
.el-size { font-size: 11px; color: var(--muted); }
|
||||
.el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; }
|
||||
|
||||
.el-actions { width: 80px; padding: 0 6px; text-align: right; white-space: nowrap; }
|
||||
.action-btn {
|
||||
background: none; border: none; padding: 3px 4px; cursor: pointer;
|
||||
font-size: 13px; opacity: 0; transition: opacity 0.1s, color 0.1s;
|
||||
border-radius: 4px; color: var(--muted);
|
||||
}
|
||||
.action-btn:hover { background: var(--panel-2); opacity: 1 !important; }
|
||||
.action-btn--active { opacity: 1 !important; }
|
||||
.action-btn--danger:hover { color: var(--danger); }
|
||||
.email-row:hover .action-btn { opacity: 0.6; }
|
||||
.email-row--acting { opacity: 0.6; pointer-events: none; }
|
||||
.action-btn--unsub { font-size: 11px; }
|
||||
.action-btn--done { opacity: 1 !important; color: var(--ok); }
|
||||
|
||||
.fv-sentinel { height: 1px; }
|
||||
.fv-loading-more { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||
.fv-end { color: var(--muted); font-size: 12px; padding: 20px 0 8px; text-align: center; }
|
||||
|
||||
/* ── Sync splash ── */
|
||||
.splash {
|
||||
@@ -209,7 +339,86 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
||||
.splash-card { text-align: center; max-width: 440px; padding: 32px; }
|
||||
.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-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; }
|
||||
@keyframes indet { 0% { margin-left: -40%; } 100% { margin-left: 100%; } }
|
||||
.progress-label { color: var(--muted); font-size: 13px; }
|
||||
|
||||
/* ── Sync status (topbar) ──────────────────────────────────────────────── */
|
||||
.sync-status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); margin-right: 4px; }
|
||||
.sync-status--error { color: var(--danger); }
|
||||
.sync-label--err { color: var(--danger); }
|
||||
.sync-spinner {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
border: 2px solid #3d3833; border-top-color: var(--accent);
|
||||
animation: sync-spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes sync-spin { to { transform: rotate(360deg); } }
|
||||
.sync-bar-wrap { width: 50px; height: 4px; background: var(--panel-2); border-radius: 3px; overflow: hidden; }
|
||||
.sync-bar { display: block; height: 100%; background: var(--accent); transition: width 0.3s ease; }
|
||||
|
||||
/* ── Email detail body (Senders page) ──────────────────────────────────── */
|
||||
.sd-body-loading { padding: 8px 0 18px; }
|
||||
.sd-body {
|
||||
background: var(--panel); border: 1px solid #332f2b; border-radius: 8px;
|
||||
padding: 16px 18px; font-size: 13px; line-height: 1.7; color: var(--text);
|
||||
white-space: pre-wrap; word-break: break-word; margin-bottom: 18px;
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Unsubscribe queue ──────────────────────────────────────────────────── */
|
||||
.unsub-page { max-width: 1000px; }
|
||||
.unsub-toast { color: var(--ok); font-size: 13px; margin-left: 6px; }
|
||||
.unsub-tabs { display: flex; gap: 6px; margin: 16px 0 10px; }
|
||||
.unsub-tab {
|
||||
background: var(--panel); border: 1px solid #332f2b; color: var(--muted);
|
||||
border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.unsub-tab:hover { color: var(--text); }
|
||||
.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-grid { width: 100%; }
|
||||
.unsub-badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; border: 1px solid #3d3833; }
|
||||
.unsub-badge--detected { color: var(--muted); }
|
||||
.unsub-badge--queued { color: #f2c94c; border-color: #f2c94c66; }
|
||||
.unsub-badge--progress { color: var(--accent); border-color: var(--accent); }
|
||||
.unsub-badge--ok { color: var(--ok); border-color: var(--ok); }
|
||||
.unsub-badge--fail { color: var(--danger); border-color: var(--danger); }
|
||||
.unsub-badge--skip { color: var(--muted); }
|
||||
.unsub-confidence { font-size: 11px; font-weight: 600; }
|
||||
.unsub-confidence--high { color: var(--ok); }
|
||||
.unsub-confidence--mid { color: #f2c94c; }
|
||||
.unsub-confidence--low { color: var(--danger); }
|
||||
|
||||
/* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */
|
||||
.bulk-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--panel-2); border: 1px solid #332f2b; border-radius: 8px;
|
||||
padding: 8px 14px; margin-bottom: 10px; font-size: 13px;
|
||||
}
|
||||
.bulk-toolbar .muted { font-size: 12px; }
|
||||
.bulk-toolbar-spacer { flex: 1; }
|
||||
.bulk-btn {
|
||||
background: var(--panel); border: 1px solid #332f2b; color: var(--text);
|
||||
border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.bulk-btn:hover { border-color: var(--accent); }
|
||||
.bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||
.el-select { width: 28px; text-align: center; }
|
||||
|
||||
/* ── Keyboard shortcut help ──────────────────────────────────────────────── */
|
||||
.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 #3d3833; border-radius: 3px; padding: 0 4px; font-family: inherit; }
|
||||
|
||||
/* ── Saved searches ─────────────────────────────────────────────────────── */
|
||||
.saved-search-row { display: flex; align-items: center; gap: 8px; }
|
||||
.saved-search-save-btn {
|
||||
background: none; border: 1px solid #332f2b; color: var(--muted);
|
||||
border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); }
|
||||
|
||||
.email-row--focused { outline: 1px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.digest-toggle { font-size: 16px; padding: 4px 8px; }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// All colors reference CSS variables (defined in src/index.css) so the
|
||||
// whole palette — including the accent — is swappable in one place and
|
||||
// flips automatically between light and `.dark`.
|
||||
background: 'hsl(var(--background) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--foreground) / <alpha-value>)',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
|
||||
},
|
||||
border: 'hsl(var(--border) / <alpha-value>)',
|
||||
input: 'hsl(var(--input) / <alpha-value>)',
|
||||
ring: 'hsl(var(--ring) / <alpha-value>)',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
|
||||
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
|
||||
},
|
||||
success: 'hsl(var(--success) / <alpha-value>)',
|
||||
warning: 'hsl(var(--warning) / <alpha-value>)',
|
||||
danger: {
|
||||
DEFAULT: 'hsl(var(--danger) / <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: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
boxShadow: {
|
||||
sm: '0 1px 2px 0 hsl(var(--foreground) / 0.05)',
|
||||
DEFAULT: '0 1px 3px 0 hsl(var(--foreground) / 0.08), 0 1px 2px -1px hsl(var(--foreground) / 0.06)',
|
||||
md: '0 4px 12px -2px hsl(var(--foreground) / 0.10)',
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': { from: { opacity: 0 }, to: { opacity: 1 } },
|
||||
'slide-up': {
|
||||
from: { opacity: 0, transform: 'translateY(6px)' },
|
||||
to: { opacity: 1, transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-in-right': {
|
||||
from: { transform: 'translateX(100%)' },
|
||||
to: { transform: 'translateX(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 150ms ease-out',
|
||||
'slide-up': 'slide-up 180ms ease-out',
|
||||
'slide-in-right': 'slide-in-right 220ms ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"timezone": "Europe/Berlin",
|
||||
"schedule": ["before 6am on monday"],
|
||||
"labels": ["dependencies"],
|
||||
"prConcurrentLimit": 5,
|
||||
"commitMessagePrefix": "chore(deps):",
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group safe minor+patch updates into one weekly PR per ecosystem",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "{{manager}} minor & patch"
|
||||
},
|
||||
{
|
||||
"description": "Major updates stay individual PRs for careful review",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"dependencyDashboardApproval": true
|
||||
}
|
||||
],
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"labels": ["security"],
|
||||
"schedule": ["at any time"]
|
||||
},
|
||||
"ignorePaths": ["**/node_modules/**", "**/bin/**", "**/obj/**"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user