Compare commits

...

39 Commits

Author SHA1 Message Date
cesnimda 2d1ca9d0d6 feat(ui): email dashboard UX refactor — split-view, row polish, dashboard (PHASES 1/3/4)
CI / backend (pull_request) Successful in 2m26s
CI / frontend (pull_request) Successful in 31s
CI / format (pull_request) Successful in 1m25s
CI / db-tests (pull_request) Successful in 1m27s
Security / secrets (pull_request) Successful in 7s
Security / dependencies (pull_request) Successful in 1m18s
Security / sast (pull_request) Successful in 1m1s
Batch of 4 parallel units, rebased onto current develop and integrated:
- PHASE 1 split-view: clicking an email opens an in-place, collapsible + resizable
  reading pane (shared EmailDetail extracted from Senders) instead of a new tab;
  list stays interactive, selection preserved, mobile stacks (<768px); list gains
  Skeleton loading + EmptyState. (FolderView, SearchResults, +EmailDetail, +split.css)
- PHASE 3 row polish: EmailRow gains onOpen (Gmail fallback kept), a new accessible
  Checkbox primitive (ui/checkbox.jsx), 44px rows, clearer hierarchy + hover; keeps
  the search why-matched highlight rendering. (EmailRow, ui/checkbox, styles.css)
- PHASE 4 bulk+keys: BulkToolbar hierarchy/responsive/clear-selection; keyboard nav
  adds ArrowUp/Down + u=unread, hardened input guard. (BulkToolbar, useListKeyboardNav)
- PHASE 3/4 dashboard: remove dead HeatmapWidget, replace CategoryHeatmap with a
  clickable Emails-by-Category bar (a11y: rank by text+count, not colour); Skeleton +
  EmptyState on widgets. (widgets, Dashboard, client.js, styles.css)

Each unit was self-code-reviewed and built green; integrated build passes. Supersedes
PRs #40-43 (their branches had a stale 38-commit base).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:59:54 +02:00
cesnimda 04ff72097b fix(auth): login 500 — DataProtection key ring on root-owned volume (#37)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 49s
CI / db-tests (push) Successful in 52s
Deploy Staging / deploy (push) Successful in 24s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 52s
CI / db-tests (pull_request) Successful in 53s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 59s
Security / sast (push) Successful in 37s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 53s
Security / sast (pull_request) Successful in 34s
2026-07-04 16:52:56 +02:00
cesnimda eccf66eaed fix(categories): correct all 7 smart-folder bugs at source (PHASE 2) (#38)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 1m8s
CI / db-tests (push) Successful in 49s
Deploy Staging / deploy (push) Successful in 24s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 11s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 52s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 58s
Security / sast (push) Successful in 33s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 58s
Security / sast (pull_request) Successful in 37s
2026-07-04 16:45:10 +02:00
cesnimda be970ff621 chore: strip UTF-8 BOM from migration files (unblock format gate) (#39)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 14s
CI / format (push) Successful in 49s
CI / db-tests (push) Successful in 57s
Deploy Staging / deploy (push) Successful in 30s
CI / backend (pull_request) Successful in 58s
CI / frontend (pull_request) Successful in 14s
CI / format (pull_request) Successful in 50s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / sast (push) Successful in 32s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 58s
Security / sast (pull_request) Successful in 35s
2026-07-04 16:40:05 +02:00
cesnimda 3333e19452 perf(search): keyset pagination (browse path) (#36)
CI / backend (push) Successful in 54s
CI / frontend (push) Successful in 12s
CI / format (push) Failing after 51s
CI / db-tests (push) Successful in 57s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 59s
Security / sast (push) Successful in 40s
CI / backend (pull_request) Successful in 55s
CI / frontend (pull_request) Successful in 15s
CI / format (pull_request) Failing after 54s
CI / db-tests (pull_request) Successful in 52s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 56s
Security / sast (pull_request) Successful in 37s
2026-07-04 16:01:39 +02:00
cesnimda 1e8feeef71 ci(security): Semgrep SAST (#32)
CI / backend (push) Successful in 1m1s
CI / frontend (push) Successful in 16s
CI / format (push) Successful in 51s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 20s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m6s
Security / sast (push) Successful in 41s
CI / backend (pull_request) Successful in 56s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 57s
Security / sast (pull_request) Successful in 38s
2026-07-04 16:01:34 +02:00
cesnimda 074d39d9a2 feat(platform): feature flags + user settings foundation (#35)
CI / backend (push) Successful in 1m4s
CI / frontend (push) Successful in 18s
CI / format (push) Successful in 1m1s
CI / db-tests (push) Successful in 1m4s
Deploy Staging / deploy (push) Successful in 37s
CI / backend (pull_request) Successful in 1m7s
CI / frontend (pull_request) Successful in 18s
CI / format (pull_request) Successful in 59s
CI / db-tests (pull_request) Successful in 1m5s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m4s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m4s
2026-07-02 18:04:14 +02:00
cesnimda b7d4b87d75 feat(search): hybrid semantic search (RRF) (#34)
CI / backend (push) Successful in 59s
CI / frontend (push) Successful in 15s
CI / format (push) Successful in 56s
CI / db-tests (push) Successful in 1m4s
Deploy Staging / deploy (push) Successful in 30s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m4s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 31s
CI / format (pull_request) Successful in 1m4s
CI / db-tests (pull_request) Successful in 1m5s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m5s
2026-07-02 17:36:27 +02:00
cesnimda fb6b89b6dc feat(ops): OpenTelemetry + observability profile (#33)
CI / backend (push) Successful in 1m12s
CI / frontend (push) Successful in 19s
CI / format (push) Successful in 1m3s
CI / db-tests (push) Successful in 1m9s
Deploy Staging / deploy (push) Successful in 48s
CI / backend (pull_request) Successful in 1m1s
CI / frontend (pull_request) Successful in 16s
CI / format (pull_request) Successful in 55s
CI / db-tests (pull_request) Successful in 59s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m3s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m1s
2026-07-02 17:32:35 +02:00
cesnimda 402628a3a7 refactor(db): named tenant query filters (#31)
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 50s
CI / db-tests (push) Successful in 52s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m18s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 23s
CI / format (pull_request) Successful in 1m2s
CI / db-tests (pull_request) Successful in 1m11s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m10s
2026-07-02 17:24:21 +02:00
cesnimda 5191dd010f feat(ai): semantic-search backfill + Ollama profile (#30)
CI / backend (push) Successful in 55s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 42s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
2026-07-02 16:59:30 +02:00
cesnimda 7ec4f1ddb8 perf(ui): route-level code splitting (#29)
CI / backend (push) Successful in 54s
CI / frontend (push) Successful in 13s
CI / format (push) Successful in 52s
CI / db-tests (push) Successful in 56s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 52s
Deploy Staging / deploy (push) Successful in 26s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m0s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m2s
2026-07-02 16:53:31 +02:00
cesnimda a3d8654198 feat!: migrate to .NET 10 LTS (#28)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 51s
Deploy Staging / deploy (push) Successful in 26s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 46s
CI / db-tests (pull_request) Successful in 50s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 57s
2026-07-02 16:53:24 +02:00
cesnimda 86ecf03963 feat(ci): Renovate dependency automation (#27)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 47s
CI / db-tests (push) Successful in 49s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 15s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 50s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 52s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 53s
2026-07-02 16:28:02 +02:00
cesnimda dc9d5fc301 feat(ops): nightly DB backups with rotation (#26)
CI / backend (push) Successful in 1m0s
CI / frontend (push) Successful in 14s
CI / format (push) Successful in 52s
CI / db-tests (push) Successful in 1m10s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 50s
CI / db-tests (pull_request) Successful in 51s
Deploy Staging / deploy (push) Successful in 17s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 56s
2026-07-02 16:27:57 +02:00
cesnimda 59522a6e96 docs: Phase 3 recommendations (#25)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 46s
CI / db-tests (push) Successful in 50s
Deploy Staging / deploy (push) Successful in 16s
CI / backend (pull_request) Successful in 48s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 45s
CI / db-tests (pull_request) Successful in 53s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 52s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
2026-07-02 16:10:12 +02:00
cesnimda b7f3a42812 docs(audit): Phase 2 resolution status (#24)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 47s
CI / db-tests (push) Successful in 48s
CI / backend (pull_request) Successful in 50s
CI / frontend (pull_request) Successful in 10s
CI / format (pull_request) Successful in 47s
CI / db-tests (pull_request) Successful in 49s
Deploy Staging / deploy (push) Successful in 15s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 52s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 52s
2026-07-02 10:29:04 +02:00
cesnimda 7dbe1469d7 ci: audit batch D — live-db tests + format gate (#23)
CI / backend (push) Successful in 48s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 47s
CI / db-tests (push) Successful in 49s
CI / backend (pull_request) Successful in 48s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 50s
CI / db-tests (pull_request) Successful in 50s
Deploy Staging / deploy (push) Successful in 15s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 50s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
2026-07-02 10:13:12 +02:00
cesnimda 2b19bddf7b feat(security): audit batch C — data posture, retention, DP keys (#22)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 11s
Deploy Staging / deploy (push) Successful in 24s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 52s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 10s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s
2026-07-02 10:13:07 +02:00
cesnimda b905c93884 fix(security): audit batch B — SPA CSP + vite upgrade (#21)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 45s
CI / backend (pull_request) Successful in 56s
CI / frontend (pull_request) Successful in 11s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 54s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
2026-07-02 03:24:57 +02:00
cesnimda c5c33f7023 fix(security): audit batch A — validation, rate limiting, sessions (#20)
CI / backend (push) Successful in 53s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 28s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 56s
2026-07-02 03:22:17 +02:00
cesnimda 2056548702 feat(ai): pgvector embedding infrastructure (#19)
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 46s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 56s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s
2026-07-02 03:00:06 +02:00
cesnimda d78fe601ff feat(ai): IEmbeddingProvider foundation (#18)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 26s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 16s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 53s
2026-07-02 02:05:57 +02:00
cesnimda 06b050a5ed perf(search): trigram indexes for sender/domain filters (#17)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 56s
2026-07-02 01:55:59 +02:00
cesnimda c0c1777d3f feat(search): fuzzy/typo tolerance (pg_trgm) (#16)
CI / backend (push) Successful in 48s
CI / frontend (push) Successful in 13s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 53s
2026-07-01 22:45:10 +02:00
cesnimda 9bbab5d32a feat(search): why this matched highlights (#15)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 47s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s
2026-07-01 22:22:38 +02:00
cesnimda fcf290a83b feat(search): weight subject above body in FTS vector (#14)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 23s
Deploy Staging / deploy (push) Successful in 38s
CI / backend (pull_request) Successful in 1m17s
CI / frontend (pull_request) Successful in 22s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m2s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m2s
2026-07-01 21:29:38 +02:00
cesnimda a9be48daee feat(search): relevance ranking + websearch_to_tsquery (#13)
CI / backend (push) Successful in 1m13s
CI / frontend (push) Successful in 27s
Deploy Staging / deploy (push) Successful in 44s
CI / backend (pull_request) Successful in 1m12s
CI / frontend (pull_request) Successful in 21s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m3s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m3s
2026-07-01 21:08:53 +02:00
cesnimda dfd8579899 feat(ui): discoverable-first search placeholder (#12)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Successful in 32s
CI / backend (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m16s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m12s
2026-07-01 20:35:13 +02:00
cesnimda 4d6f10c707 feat(ui): reduced-motion + focus rings (#11)
CI / backend (push) Successful in 1m7s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Successful in 30s
CI / backend (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 22s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m4s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m11s
2026-07-01 20:23:42 +02:00
cesnimda dde23c980c feat(ui): Inter + design-system preview page (#10)
CI / backend (push) Successful in 1m11s
CI / frontend (push) Successful in 28s
Deploy Staging / deploy (push) Successful in 1m9s
CI / backend (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 23s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m6s
2026-07-01 20:14:51 +02:00
cesnimda e482d60b69 feat(ui): design-system v2 tokens (#9)
CI / backend (push) Successful in 53s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 24s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m22s
CI / backend (pull_request) Successful in 1m1s
CI / frontend (pull_request) Successful in 18s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m6s
2026-07-01 20:01:43 +02:00
cesnimda 4ce2df0a2b docs: discovery blueprint + multi-provider design (#8)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
2026-07-01 19:59:53 +02:00
cesnimda 9ee5d757f5 docs(changelog): note health gate + verified end-to-end pipeline (#7)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 16s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 14s
Deploy Staging / deploy (push) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 53s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 53s
Co-authored-by: cesnimda <connor.babbington@cesnimda.co.uk>
Reviewed-on: #7
2026-07-01 17:20:34 +02:00
cesnimda 045be2bb81 Add post-deploy health gate to staging (#5)
CI / backend (push) Successful in 1m0s
CI / frontend (push) Successful in 19s
Deploy Staging / deploy (push) Successful in 19s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m1s
CI / backend (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 19s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m1s
2026-07-01 16:08:27 +02:00
cesnimda 11e6768d17 Fix staging deploy: ASCII-only echo (#4)
Deploy Staging / deploy (push) Successful in 21s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m3s
CI / backend (push) Successful in 1m0s
CI / frontend (push) Successful in 18s
2026-07-01 13:16:31 +02:00
cesnimda d9a07a01b2 Fix staging auto-deploy: env write + port isolation (#3)
CI / backend (push) Successful in 1m5s
CI / frontend (push) Successful in 22s
Deploy Staging / deploy (push) Failing after 24s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m4s
2026-07-01 12:50:12 +02:00
cesnimda 8727be9e94 Automated staging deploy: build env from secrets (#2)
CI / backend (push) Successful in 1m18s
CI / frontend (push) Successful in 27s
Deploy Staging / deploy (push) Failing after 1m5s
Security / secrets (push) Successful in 8s
Security / dependencies (push) Successful in 1m8s
2026-07-01 12:35:58 +02:00
cesnimda ae8e6b672e Git workflow, environments & CI/CD pipeline (#1)
CI / backend (push) Successful in 1m14s
CI / frontend (push) Successful in 28s
Security / secrets (push) Successful in 6s
Security / dependencies (push) Successful in 1m14s
Deploy Staging / deploy (push) Failing after 43s
2026-07-01 11:44:34 +02:00
137 changed files with 11886 additions and 1543 deletions
+13
View File
@@ -0,0 +1,13 @@
# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with
# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git
# stores LF, and the pre-commit/CI format gates flip-flop forever.
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.cs]
indent_style = space
indent_size = 4
+3
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
# Copy to deploy/.env.staging and fill in. Used by:
# docker compose -p inboxintel-staging --env-file deploy/.env.staging \
# -f docker-compose.yml -f docker-compose.staging.yml up
# (or ./deploy/up.ps1 -Staging). Kept separate from deploy/.env (production) so
# staging can never touch prod credentials, DB, or Google project.
POSTGRES_PASSWORD=change-me-staging
# Use a SEPARATE Google OAuth client for staging with redirect URI:
# http://localhost:18081/signin-google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AI_MODE=Disabled
FRONTEND_ORIGIN=http://localhost:18081
# Staging caps the initial mailbox sync so rehearsals are fast.
MAX_MESSAGES=2000
+44 -2
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore InboxIntel.sln
- name: Build
@@ -31,8 +31,50 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- name: Install
run: npm ci
- name: Build
run: npm run build
# AUDIT L-10: the pre-commit hook enforces formatting locally, but --no-verify or web edits
# can bypass it — this makes the same check a server-side gate.
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: dotnet format (verify only)
run: dotnet format InboxIntel.sln --verify-no-changes
# AUDIT M-7: the search paths the InMemory provider can't translate (FTS ranking,
# ts_headline, pg_trgm, pgvector) previously had only manual verification. This job runs
# the Category=LiveDb tests against a real pgvector Postgres service container.
db-tests:
runs-on: ubuntu-latest
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
env:
LIVEDB_CONNECTION: "Host=postgres;Port=5432;Database=test;Username=test;Password=test"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Wait for Postgres
run: |
for i in $(seq 1 30); do
(echo > /dev/tcp/postgres/5432) 2>/dev/null && exit 0
sleep 1
done
echo "Postgres service did not become reachable" >&2
exit 1
- name: Live-DB tests
run: dotnet test InboxIntel.sln --filter "Category=LiveDb" --verbosity normal
+45
View File
@@ -0,0 +1,45 @@
name: Deploy Production
# Production promotion. The APPROVAL GATE is the git tag: production only ever
# deploys a tagged release cut on main (see docs/WORKFLOW.md §5). Cutting the tag
# is the deliberate, auditable "approve to go live" action — and the tag doubles
# as the rollback target. workflow_dispatch adds a manual "Run workflow" button
# for re-deploys/rollbacks.
#
# STATUS: inactive until (a) the Linux production server exists and (b) a
# self-hosted Gitea runner is registered on it with labels [self-hosted, production].
# Tag pushes before then will queue harmlessly. This file is the wiring, ready to
# switch on — review the deploy step for your server before first use.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
ref:
description: 'Tag or commit to deploy (e.g. v1.2.0)'
required: true
jobs:
deploy:
runs-on: [self-hosted, production]
steps:
- uses: actions/checkout@v4
with:
# Deploy the exact tag that triggered the run (immutable), or the
# ref given to a manual dispatch.
ref: ${{ github.event.inputs.ref || github.ref_name }}
fetch-depth: 0
- name: Deploy release to production
run: |
echo "Deploying ${{ github.event.inputs.ref || github.ref_name }} to production"
# deploy/.env lives on the server (never in git). up.sh validates it,
# builds the Linux images, applies EF migrations on boot, and starts
# the stack behind the nginx reverse proxy.
./deploy/up.sh --proxy
- name: Smoke check
run: |
sleep 5
curl -fsS http://localhost/ >/dev/null && echo "Prod responding on :80" || \
{ echo "Smoke check failed"; exit 1; }
+70
View File
@@ -0,0 +1,70 @@
name: Deploy Staging
# Continuous deployment to the LOCAL staging stack. Fires when develop advances
# (i.e. after a PR is merged into develop), rebuilding and restarting the isolated
# staging stack on this machine.
#
# Runs on the self-hosted host-mode runner (labels: self-hosted, windows) so it can
# reach the host's Docker and publish to localhost:18081. Because the runner does a
# fresh checkout that (correctly) does NOT contain the git-ignored deploy/.env.staging,
# the staging secrets are supplied as Gitea Actions secrets and the env file is
# regenerated here at deploy time.
on:
push:
branches: [develop]
workflow_dispatch: {} # also allow a manual "Run workflow" from the Gitea UI
jobs:
deploy:
runs-on: [self-hosted, windows]
steps:
- uses: actions/checkout@v4
- name: Write staging env from secrets
shell: powershell
# Per-line writes (not a here-string) so the step can't be broken by how the
# runner indents/wraps the script. Gitea substitutes ${{ secrets.* }} first;
# ascii = no BOM, which docker compose's env parser needs.
run: |
Set-Content deploy/.env.staging "POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}" -Encoding ascii
Add-Content deploy/.env.staging "GOOGLE_CLIENT_ID=${{ secrets.STAGING_GOOGLE_CLIENT_ID }}" -Encoding ascii
Add-Content deploy/.env.staging "GOOGLE_CLIENT_SECRET=${{ secrets.STAGING_GOOGLE_CLIENT_SECRET }}" -Encoding ascii
Add-Content deploy/.env.staging "AI_MODE=Disabled" -Encoding ascii
Add-Content deploy/.env.staging "FRONTEND_ORIGIN=http://localhost:18081" -Encoding ascii
Add-Content deploy/.env.staging "MAX_MESSAGES=2000" -Encoding ascii
Write-Host "wrote $((Get-Content deploy/.env.staging).Count) env lines"
- name: Redeploy staging stack
shell: powershell
run: |
docker compose -p inboxintel-staging `
--env-file deploy/.env.staging `
-f docker-compose.yml -f docker-compose.staging.yml `
up -d --build
docker compose -p inboxintel-staging ps
- name: Verify staging health
shell: powershell
# 'up -d' returns as soon as containers START, so a container that crashes
# on boot (e.g. bad DB password) would still report success. Poll the actual
# endpoints and fail the job if either isn't serving, dumping api logs so the
# cause is visible in the run. ASCII only (Windows PowerShell reads .ps1 as ANSI).
run: |
$ok = $false
foreach ($i in 1..20) {
Start-Sleep -Seconds 3
try { Invoke-WebRequest "http://localhost:18081/" -UseBasicParsing -TimeoutSec 5 | Out-Null; $fe = 200 }
catch { $fe = 0 }
# A 401 from the api means it is serving (auth enforced); Invoke-WebRequest
# throws on non-2xx, so read the status code off the exception.
try { Invoke-WebRequest "http://localhost:18080/api/v1/auth/me" -UseBasicParsing -TimeoutSec 5 | Out-Null; $api = 200 }
catch { $api = $_.Exception.Response.StatusCode.value__; if (-not $api) { $api = 0 } }
Write-Host "attempt $i - frontend=$fe api=$api"
if ($fe -eq 200 -and $api -gt 0) { $ok = $true; break }
}
if (-not $ok) {
Write-Host "Staging health check FAILED. Last 40 api log lines:"
docker logs inboxintel-staging-api-1 --tail 40
exit 1
}
Write-Host "Staging healthy - Frontend http://localhost:18081 API http://localhost:18080"
+35
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
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
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
# don't look for. Advisory at first (not a required check); promote once tuned.
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The runner image lacks pip, and a semgrep job-container lacks the node that
# actions/checkout needs — so install pip via apt on the standard image.
- name: Install semgrep
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
pipx install semgrep
- name: Semgrep scan
run: |
export PATH="$HOME/.local/bin:$PATH"
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
+7
View File
@@ -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/
@@ -66,3 +70,6 @@ vendor/
coverage/
.cache/
tmp/
# agent worktrees (never commit)
.claude/worktrees/
+267
View File
@@ -0,0 +1,267 @@
# InboxIntel — Full Application Audit Report
**Date:** 2026-07-02 · **Scope:** entire repository (backend `src/`, `frontend/`, migrations,
CI/CD `.gitea/workflows/`, `docker-compose*`, `deploy/`, git history) · **Phase:** 1 (read-only)
**Stack:** .NET 8 / ASP.NET Core (Clean Architecture) · EF Core + PostgreSQL (pgvector image)
· React 18 + Vite SPA · Google OAuth2 (cookie session) · Gitea Actions CI/CD · self-hosted
Docker Compose (single-operator deployment).
## Executive summary
This codebase is **markedly more hardened than typical** — previous hardening phases (V-01…V-12,
visible in code comments) already addressed the classic killers: IDOR (EF global query filters),
SSRF (a genuinely thorough `SafeHttpGuard` with DNS-rebinding defence), token encryption at rest,
non-root containers, stack-trace suppression, forwarded-header trust, secret hygiene (nothing in
git history), and confirmed-destructive-actions. **No Critical findings.** The most significant
issues are: **registered input validators that never execute (H-1)**, **no rate limiting (H-2)**,
and a **data-at-rest posture** for email bodies that should be a deliberate, documented decision
(H-3). CI has real security gates (gitleaks + dependency audit) already.
Severity counts: **Critical 0 · High 3 · Medium 6 · Low 7**
---
## 1. Security
### H-1 · FluentValidation validators are registered but NEVER executed
- **File:** `src/InboxIntel.Application/DependencyInjection.cs:11` (registration) vs
`src/InboxIntel.Api/Program.cs` (no `AddFluentValidationAutoValidation()` anywhere; no
controller injects `IValidator<>`).
- **Why it's a problem:** `Validators.cs` defines real safety rules — `CleanupRequestValidator`
requires `Confirmed=true` for Trash/HardDelete, `SearchRequestValidator` bounds paging, etc. —
but FluentValidation 11.x requires explicit auto-validation enablement, which is absent. The
rules are dead code; requests reach services unvalidated. *Mitigating factor:* the services
re-check the most dangerous rules (`CleanupService.cs:48` re-enforces `Confirmed`), and the
controller clamps paging — so this is defence-in-depth loss, not an open hole. But rules that
exist **only** in the validators (e.g. `From <= To`, LabelId-required) are silently unenforced.
- **Fix:** add `services.AddFluentValidationAutoValidation()` (package already referenced) in
`Program.cs`; add a regression test posting an invalid DTO and asserting 400.
- **Blast radius:** isolated (1 line + tests). Verify no existing client sends payloads that
would now 400.
### H-2 · No rate limiting on any endpoint
- **File:** `src/InboxIntel.Api/Program.cs` (no `AddRateLimiter`/middleware anywhere).
- **Why:** login (`/auth/login` → OAuth), search (now with FTS + trigram + future AI), export
(PDF generation), unsubscribe (server-side outbound HTTP), and AI endpoints are all
unthrottled. A single authenticated user (or an unauthenticated client hammering
`/auth/login`/`/appinfo`) can exhaust CPU/DB/outbound quota. For the future multi-user platform
(see `docs/discovery/multi-provider/`), this is a prerequisite.
- **Fix:** ASP.NET Core's built-in `RateLimiter` — a global fixed-window per-IP policy + stricter
policies on `auth`, `export`, `unsubscribe`, `ai`. Return 429 with Retry-After.
- **Blast radius:** isolated (Program.cs + policy constants + tests).
### Verified-good (no finding)
- **Authorization / IDOR:** `[Authorize]` on `ApiControllerBase`; only `AppInfo` + `Auth.login`
are `[AllowAnonymous]` (correct). Global query filters on every tenant-scoped entity enforce
per-user isolation even if a query forgets its `Where` — covered by `TenantIsolationTests`.
- **Injection:** all data access via EF parameterisation; no raw SQL string concatenation in app
code; FTS uses `websearch_to_tsquery` parameters. Frontend has **zero**
`dangerouslySetInnerHTML`/`innerHTML`/`eval`; the search-highlight feature deliberately uses
non-HTML sentinels rendered as escaped React elements (XSS-safe by construction).
- **SSRF:** `SafeHttpGuard` validates scheme, resolves DNS and checks **every** address against
loopback/private/link-local/CGNAT/metadata/multicast (v4+v6, v4-mapped), fails closed on
unknown families, and is paired with `AllowAutoRedirect=false` (`DependencyInjection.cs:65`).
This is better than most production code.
- **Secrets:** none hardcoded (empty placeholders in `appsettings.json`); no `.env` ever
committed (git history checked); real secrets live in git-ignored `deploy/.env*` and Gitea
Actions secrets; pre-commit hook + CI gitleaks scan both guard regressions.
- **Session cookies:** HttpOnly, SameSite=Lax, Secure-always outside dev, 401-not-redirect for
XHR. Forwarded headers only trusted from configured proxy CIDRs (V-08).
- **Headers:** nosniff, X-Frame-Options DENY, Referrer-Policy, COOP on API; HSTS in prod.
### M-1 · No absolute session lifetime (sliding-only)
- **File:** `Program.cs:58-59``ExpireTimeSpan = 7d` with `SlidingExpiration = true`.
- **Why:** a session that's touched at least weekly renews forever; a stolen cookie never
expires as long as the attacker uses it. No server-side revocation list exists either
(cookie is self-contained).
- **Fix:** add an absolute cap via an `issued-at` claim checked in `OnValidatePrincipal`
(e.g. re-auth after 30 days), and/or a session-stamp validated against the DB to enable
revocation. (The multi-provider design doc 02/06 already specs DB-backed sessions — this
aligns.)
- **Blast radius:** isolated.
### M-2 · No Content-Security-Policy on the SPA or API
- **File:** `Program.cs:156-167` (comment says CSP "report-only for now" but none is actually
set); `frontend/nginx.conf` serves the SPA without security headers.
- **Why:** CSP is the main mitigation layer against any future XSS slip; currently absent.
- **Fix:** add CSP (default-src 'self'; connect-src API origin; etc.) + nosniff/XFO to the SPA
nginx config; optionally a report-only CSP on the API.
- **Blast radius:** isolated (nginx conf + one middleware line), needs SPA smoke-test (Chart.js
inline styles etc.).
### L-1 · CSRF: no antiforgery tokens (accepted-risk, documented here)
- SameSite=Lax cookies + strict CORS allowlist + JSON-only POST bodies make classic CSRF
impractical in modern browsers. Acceptable for now; revisit if cookie SameSite is ever
relaxed or non-JSON form endpoints are added.
### L-2 · `AllowedHosts: "*"` (`appsettings.json`)
- Host-header filtering disabled; low risk behind the proxy but set it to the real hostnames
at production deployment.
---
## 2. Data protection (top priority)
**Inventory of sensitive data:**
| Data | Where | At-rest protection | Verdict |
|---|---|---|---|
| Google OAuth **refresh/access tokens** | `users.EncryptedRefreshToken` | **Encrypted** (Data Protection API, keys on `/keys` volume) | ✅ good |
| **Passwords** | — | **None stored** (OAuth-only login; no password column exists) | ✅ best-possible |
| **Email bodies / subjects / snippets** | `emails.BodyText` etc. | **Plaintext** in Postgres | ⚠️ **H-3** below |
| Sender names/addresses (PII) | `senders`, `emails` | Plaintext | part of H-3 |
| User email + display name | `users` | Plaintext | part of H-3 |
| DB password | `deploy/.env` (git-ignored) + Actions secret | not in repo | ✅ |
| Data Protection keys | `/keys` Docker volume | filesystem | M-3 below |
| IPs / payment / health data | — | not collected | ✅ n/a |
### H-3 · Email content stored in plaintext at rest, with unbounded retention
- **File:** `src/InboxIntel.Domain/Entities/Email.cs` (`BodyText`, `Snippet`, `Subject`);
Postgres `pgdata` volume; also flows into PDF/CSV/JSON **exports** and (when enabled) to the
local Ollama process.
- **Why:** the entire product is a copy of the user's mailbox. On this self-hosted,
single-operator deployment the DB lives on the operator's own disk — a defensible posture —
but: (a) anyone with disk/volume/backup access reads all mail; (b) there is **no retention or
purge policy** (mail persists even after unsubscribe/cleanup in Gmail; "remove account"
flows don't exist yet); (c) the planned **multi-user platform** (docs/discovery/multi-provider)
makes plaintext-bodies-readable-by-host-admin a real privacy issue (its own security doc
promises "admins never read users' mail" — the DB must back that up).
- **Fix (phased):** 1) *Document* the current posture in README/threat model (deliberate,
local-first). 2) Enable **pgcrypto/field-level encryption or full-disk/volume encryption**
before any multi-user deployment. 3) Add a **data-retention setting** + purge job and an
account-deletion path (GDPR-style erasure). 4) Ensure DB **backups** inherit the same
protection.
- **Blast radius:** documentation = trivial; field-level encryption = **large** (touches search
— FTS can't index encrypted columns; would need architectural decision). Recommend
volume-level encryption + retention/deletion first.
### M-3 · Data Protection keys stored unencrypted on the `/keys` volume
- **File:** `Program.cs:26-28``PersistKeysToFileSystem` without `ProtectKeysWith*`.
- **Why:** whoever reads the volume can decrypt all stored refresh tokens. Same-disk-as-DB
caveat applies, but keys and ciphertext living side-by-side weakens the encryption's value.
- **Fix:** `ProtectKeysWithCertificate(...)` (cert from env/secret), or OS-level DPAPI on
Windows hosts; at minimum document the volume-permissions requirement.
- **Blast radius:** isolated, but requires a key-migration step for existing tokens.
### M-4 · Default DB credentials in `appsettings.json`
- **File:** `appsettings.json:3``Password=inboxintel` as the fallback connection string.
- **Why:** if a deployment forgets the env override, the app happily connects with a guessable
password (compose enforces `POSTGRES_PASSWORD` but a non-compose deployment might not).
- **Fix:** empty the default and fail fast at startup with a clear message when unset.
- **Blast radius:** isolated (plus updating dev docs to use user-secrets).
### Logging & transit — verified
- **Logs:** no token/password logging found; the one PII-ish log is `SmtpEmailSender.cs:28`
(recipient address + subject at Info when SMTP is unconfigured) — acceptable, downgrade to
Debug if desired (L-3). Serilog request logging does not include query strings or bodies.
- **Transit:** TLS terminates at nginx (HSTS enabled in prod); API+DB bound to loopback/compose
network only. **DB connection itself is non-TLS** — fine while Postgres is co-located on the
compose network; **must add `SSL Mode=Require` if the DB ever moves to another host** (L-4).
---
## 3. Dependencies & supply chain
- **`dotnet list package --vulnerable --include-transitive`:** **clean** (all projects) — the
earlier round of CVE pins (System.Text.Json 8.0.6 etc.) is holding, and CI re-checks on every PR.
- **`npm audit` (production deps):** **0 vulnerabilities**.
- **M-5 · `npm audit` (dev deps):** vite 5.x depends on a vulnerable **esbuild** (1 moderate,
1 high, dev-server-only vectors). Not shipped to users and CI already scopes to prod deps —
but the fix is a routine **vite 5→6/7 upgrade**. Effort S.
- **Duplication (L-5):** both `MailKit` (Infrastructure) and the frontend carry sizeable deps;
`react-grid-layout` exists solely for the draggable dashboard the redesign plans to retire —
candidate for removal with the Analytics migration. No abandoned packages spotted.
- CI supply-chain posture: gitleaks (full history) + NuGet/npm audits are **required checks**
good. No SAST/CodeQL (L-6, nice-to-have).
## 4. Error handling & reliability
- **Good:** global `UseExceptionHandler` + RFC7807 ProblemDetails (no stack traces to clients);
Polly retry/backoff on Gmail sync; idempotent sync upserts; AI calls wrapped in try/catch with
non-AI fallbacks ("sync must never fail because the AI provider is down").
- **M-6 · Known EF model warning:** `Email` has a global query filter but is the required end
of the `Email↔EmailLabel` relationship — logged on every boot; can yield surprising results
when filtered parents are excluded. Fix: matching filter on `EmailLabel` (one line) + test.
(Long-noted in logs; this is the nudge to actually do it.)
- **L-7 · Multi-step writes without explicit transactions:** e.g. `GoogleAuthEvents` upsert and
sync batches rely on EF's single-SaveChanges transactionality — mostly fine; the sync
cursor-advance + batch-upsert pairing is the one place an explicit transaction would guard a
mid-batch crash (currently self-heals via idempotent re-sync — acceptable).
## 5. Performance
- **Fixed this cycle (verified live):** relevance-ranked FTS with weighted tsvector + GIN;
trigram GIN indexes for the previously non-sargable sender/domain `.Contains()`; HNSW vector
index ready for semantic search.
- **Remaining (all Low, roadmapped):** offset pagination degrades on deep pages (keyset planned
in `docs/discovery/05`); frontend ships one **678 KB JS bundle** (no code-splitting — vite
`manualChunks`/dynamic import, effort S); no HTTP caching/ETags on read-heavy endpoints
(sidebar counts are fetched per page-load); `docker-compose` frontend nginx lacks gzip/brotli
confirmation for the bundle.
- No N+1 patterns found (queries project with joins; aggregates precomputed in
`AnalyticsAggregate`).
## 6. Code quality & architecture
- Clean Architecture discipline is genuinely observed (dependency rule intact; thin controllers;
DTO mapping at boundaries). Config is env-driven throughout.
- **L-8:** `AiService.GenerateQueryAsync` returns raw LLM output as a search query (advisory-only,
becomes a search string — harmless today; keep it that way when NL search lands: model output
must stay data, never an executable/action).
- Dead-ish code: none significant; `docs/specs/*` and discovery docs are current.
## 7. Testing & CI
- **41 tests** (unit: parser/guard/unsubscribe/embeddings-null; integration: authz challenge,
tenant isolation, pagination clamp, search fallback ordering). Critical security invariants
(IDOR filter, SSRF guard, confirmed-destructive) **are tested** — better than most.
- **Gap (M-7):** no live-Postgres test harness — FTS/ranking/fuzzy/pgvector paths are verified
manually against staging (documented in commit messages) but not repeatably in CI. A
Testcontainers-Postgres (pgvector image) job would convert those throwaway verifications into
permanent regression tests. Effort M.
- Gap (L-9): no coverage for `CleanupService`/`SyncService` beyond compilation; no E2E of the
OAuth flow (hard without creds — acceptable).
- CI: build+test+secrets+deps as **required** PR checks; auto-deploy to staging with a
post-deploy health gate; prod is tag-gated. Solid. Missing: lint/format check in CI (the
pre-commit hook enforces locally; add `dotnet format --verify-no-changes` job, effort S) (L-10).
---
## Prioritized remediation plan
| # | Finding | Sev | Fix effort | Blast radius |
|---|---------|-----|-----------|--------------|
| 1 | **H-1** Enable FluentValidation auto-validation + 400 tests | High | S | Isolated (1 line + tests; verify clients) |
| 2 | **H-2** Rate limiting (global + auth/export/unsub/AI policies) | High | SM | Isolated (Program.cs + tests) |
| 3 | **H-3** Data-at-rest posture: document now; retention setting + purge job + account-deletion; volume-encryption guidance; (defer field-level encryption decision) | High | S (doc) → M (retention) → L (encryption) | Doc: none · Retention: moderate · Encryption: large/architectural |
| 4 | **M-3** Protect Data Protection keys at rest | Med | SM | Isolated + key migration |
| 5 | **M-1** Absolute session lifetime (+ revocation groundwork) | Med | S | Isolated |
| 6 | **M-4** Remove default DB password; fail fast | Med | S | Isolated |
| 7 | **M-2** CSP + security headers on the SPA nginx | Med | S | Isolated (needs SPA smoke test) |
| 8 | **M-6** Fix EF query-filter warning (EmailLabel filter) | Med | S | Isolated + test |
| 9 | **M-5** Vite upgrade (dev-dep CVEs) | Med | S | Frontend build only |
| 10 | **M-7** Testcontainers live-Postgres CI job | Med | M | CI + new test project wiring |
| 11 | L-2/L-3/L-4/L-10 (AllowedHosts, SMTP log level, DB TLS note, CI format check) | Low | S each | Isolated |
| 12 | L-5 bundle split / grid-layout removal (with redesign) | Low | SM | Frontend |
---
# Phase 2 — Remediation status (2026-07-02, all items approved & implemented)
Shipped as four PRs (#20#23), each with tests, green CI, and a verified staging deploy.
| Finding | Status | How it was resolved | PR |
|---------|--------|---------------------|----|
| **H-1** validators never ran | ✅ **Resolved** | `AddFluentValidationAutoValidation()`; invalid DTOs now 400 at the boundary. Proven by 2 integration tests (pageSize=0, From>To → 400) via a new test-auth scheme | #20 |
| **H-2** no rate limiting | ✅ **Resolved** | Global 300/min per-user (per-IP anonymous) + `auth` 10/min + `expensive` 20/min (export/unsubscribe/AI), config-driven, 429/no-queue. Proven by a 429-on-3rd-request test | #20 |
| **H-3** plaintext bodies + unbounded retention | ✅ **Resolved (as scoped)** | `SECURITY.md` documents the deliberate posture (volume-encryption + backup guidance, delete-my-data procedure); **opt-in retention** (`DataRetention:*`, default off) + daily purge worker with 3 tests. Field-level encryption deliberately deferred (FTS can't index encrypted columns) — revisit before any multi-user deployment | #22 |
| **M-1** sliding-only sessions | ✅ **Resolved** | Absolute 30 d cap (`Auth:AbsoluteSessionDays`) via issued-at stamp checked in `OnValidatePrincipal`; 3 unit tests (incl. missing-stamp = expired) | #20 |
| **M-2** no CSP on the SPA | ✅ **Resolved** | Full CSP (`script-src 'self'`, `frame-ancestors 'none'`, …) + nosniff/XFO/Referrer-Policy + gzip on the SPA nginx; inline theme script moved to `/theme-init.js` to keep `script-src 'self'` honest. **Verified serving on staging** | #21 |
| **M-3** unprotected DP key ring | ✅ **Resolved (opt-in)** | `DataProtection:CertificatePath/Password``ProtectKeysWithCertificate`; documented in SECURITY.md as recommended for shared hosts | #22 |
| **M-4** default DB password | ✅ **Resolved** | Guessable default removed from `appsettings.json`; startup fails fast with a clear message when the connection string has no password | #20 |
| **M-5** dev-dep esbuild CVEs | ✅ **Resolved** | vite 5→8 + plugin-react 6; `npm audit` now clean **including dev deps**; build verified | #21 |
| **M-6** EF query-filter warning | ✅ **Resolved** | Matching tenant filter on `EmailLabel` (via Email navigation); boot warning confirmed gone from staging logs; cross-user invisibility test added | #20 |
| **M-7** no live-Postgres tests | ✅ **Resolved** | CI `db-tests` job with a `pgvector/pg16` service container runs 3 permanent `Category=LiveDb` regression tests (FTS weighting, ts_headline sentinels, trigram typo fallback, pgvector cosine). **Confirmed green on the actual runner** | #23 |
| **L-2** AllowedHosts `*` | ✅ Documented | Production checklist item in SECURITY.md (set at deployment) | #22 |
| **L-3** SMTP recipient at Info | ✅ **Resolved** | Downgraded to Debug | #20 |
| **L-4** DB TLS note | ✅ Documented | SECURITY.md: add `SSL Mode=Require` if Postgres ever leaves the host | #22 |
| **L-10** no CI format gate | ✅ **Resolved** | `format` CI job (`dotnet format --verify-no-changes`) — `--no-verify` pushes can no longer bypass formatting | #23 |
| L-1 CSRF (accepted risk) | ✅ Documented | SECURITY.md rationale (SameSite=Lax + CORS + JSON) with revisit conditions | #22 |
| L-5/L-6/L-7/L-8/L-9 | ⏸ Deferred by design | Bundle-split & grid-layout removal ride the UI redesign; SAST/CodeQL, sync transaction hardening, and broader service coverage are Phase 3 recommendation candidates | — |
**Test suite: 40 → 54 tests** (48 always-on + 3 retention + 3 live-DB in CI).
Every deploy through the pipeline stayed green; staging verified after each batch.
+36
View File
@@ -0,0 +1,36 @@
# Changelog
All notable changes to InboxIntel are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[Semantic Versioning](https://semver.org/). See [docs/WORKFLOW.md](docs/WORKFLOW.md).
## [Unreleased]
### Security
- Patched all High/Moderate NuGet advisories the new dependency gate surfaced:
`System.Text.Json` 8.0.0→8.0.6, `Microsoft.Extensions.Caching.Memory` 8.0.0→8.0.1
(+ `DependencyInjection.Abstractions`→8.0.2), `System.Security.Cryptography.Xml`
8.0.1→8.0.3 (transitive pins), and `MailKit`/`MimeKit` 4.13.0→4.17.0.
### Added
- CI/CD pipeline (`.gitea/workflows/`): `security` (gitleaks secret scan + NuGet/npm
vulnerability gate), `deploy-staging` (auto-redeploy local staging on `develop`,
with a post-deploy health gate), `deploy-prod` (tag-gated production promotion,
inactive until the server exists). Verified end-to-end on the self-hosted runner.
- Formal Git workflow & environment strategy (`docs/WORKFLOW.md`).
- Staging environment overlay (`docker-compose.staging.yml`) — production-shaped
Linux containers on Windows, isolated ports/volumes.
- Version-controlled Git hooks (`scripts/git-hooks/`) + installer
(`scripts/install-hooks.ps1`): pre-commit secret/format checks, pre-push
build+test gate.
- `VERSION` file as the single source of truth for the release number.
## [0.1.0] — scaffold
### Added
- .NET 8 Clean Architecture backend (Domain/Application/Infrastructure/Api) + React/Vite SPA.
- Docker Compose stack (Postgres 16, API, frontend, optional nginx proxy).
- Gitea Actions CI (backend build+test, frontend build) on `main`/`develop` + PRs.
- Security hardening: encrypted OAuth tokens, EF global query filters (IDOR),
loopback binds, non-root containers, SSRF egress guard.
- One-command deploy scripts (`deploy/up.ps1`, `deploy/up.sh`).
[Unreleased]: https://your-gitea-host/InboxIntel/compare/v0.1.0...HEAD
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
+121
View File
@@ -0,0 +1,121 @@
# InboxIntel — Phase 3 Recommendations
**Date:** 2026-07-02 · Follows the completed audit remediation ([AUDIT_REPORT.md](AUDIT_REPORT.md)).
Each item: what · concrete benefit · effort (S/M/L) · risk · sources. **Ranked by value-to-effort.**
> Research notes: grounded in official primary sources (fetched 2026-07-02) plus the
> competitor/feature research already performed in `docs/discovery/02-competitor-analysis.md`.
> (Live web *search* was quota-limited this session; the load-bearing facts below — support
> dates, EF 10 features, Npgsql 10, Renovate/Gitea — were verified against primary docs.)
---
## 1. Migrate .NET 8 → .NET 10 LTS ⚠️ deadline-driven
- **What:** move the solution to .NET 10 / EF Core 10 / Npgsql provider 10; bump
`Pgvector.EntityFrameworkCore` off the 0.2.0 EF8-pin at the same time.
- **Why (hard fact):** **.NET 8 support ends 2026-11-10 — ~4 months away.** After that: no
security patches. .NET 10 is LTS until Nov 2028. This is not optional, only *when*.
- **Bonus value:** EF 10 brings **named query filters** (exactly our multi-filter tenant
scenario — e.g. tenant + soft-delete filters, selectively ignorable), **redacted inlined
constants in SQL logs** (privacy win for an email app), first-class `LeftJoin`, and
better parameterized-collection SQL (plan-cache friendly).
- **Watch-outs:** Npgsql 10 changes `array.Contains(x)` translation to `= ANY(...)` (check
our GIN-indexed paths); the migration touches every csproj + CI images + Dockerfiles.
The live-DB CI job we just added is the safety net for the search paths.
- **Effort: M** (mechanical + verify) · **Risk: M** · Blast radius: whole repo, but staged
behind the pipeline.
- Sources: [.NET support policy](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core),
[EF Core 10 what's-new](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew),
[Npgsql EF 10 release notes](https://www.npgsql.org/efcore/release-notes/10.0.html).
## 2. Automated dependency updates via Renovate (self-hosted Gitea)
- **What:** run Renovate against the Gitea instance (PAT with repo/user/issue scopes,
`platform=gitea`); it opens update PRs that ride the existing required CI gates
(build, tests, gitleaks, vuln scan, live-DB).
- **Benefit:** closes the audit's supply-chain gap permanently — the MailKit/System.Text.Json
CVE round we did by hand becomes an automated PR you just merge. NuGet + npm + Dockerfile
+ Actions all covered.
- **Effort: S** (a config + a scheduled runner job) · **Risk: L** (PRs are gated by CI).
- Source: [Renovate Gitea platform docs](https://docs.renovatebot.com/modules/platform/gitea/).
## 3. Database backups (currently none!)
- **What:** nightly `pg_dump` sidecar/cron in compose, rotating N days, written to a
host path covered by your disk-encryption/backup regime (per SECURITY.md).
- **Benefit:** today a bad migration or volume loss = total data loss; the audit fixed
security but the **availability** story is a single Docker volume. Highest
value-per-line-of-config item on this list.
- **Effort: S** · **Risk: L**. Pair with a documented restore drill.
## 4. Activate semantic search (Ollama + embedding backfill + hybrid ranking)
- **What:** the pgvector column, HNSW index, `IEmbeddingProvider`, and live-DB tests are
already shipped. Remaining: an optional `ollama` compose profile, the embedding
backfill worker (batched, VRAM-aware), and RRF hybrid merge in `SearchService`
(design: `docs/discovery/05/06`).
- **Benefit:** the flagship differentiator from the discovery blueprint — *"gym receipt
march"* finds the email; nobody mainstream offers this locally/privately.
- **Effort: ML** · **Risk: M** (quality tuning) · Needs the RTX-3080 box to pull
`nomic-embed-text` (~0.5 GB, always-on per the AI strategy).
## 5. Observability: OpenTelemetry + a dashboard
- **What:** wire .NET's built-in OTel (traces/metrics for ASP.NET, EF, HttpClient) exported
to a compose-profile Prometheus+Grafana (or an OTLP endpoint later). Keep Serilog for logs.
- **Benefit:** today diagnosis = `docker logs`. This gives request latency, sync-job
timings, rate-limit hits, and search-performance baselines — the "what will break first
as usage grows" early-warning system.
- **Effort: M** · **Risk: L** (additive).
## 6. Named query filters for tenancy (after #1)
- **What:** convert the hand-rolled `CurrentUserId == Guid.Empty || …` filters to EF 10
named filters (`"Tenant"`, future `"SoftDelete"`), selectively ignorable per-query.
- **Benefit:** cleaner + safer than the worker-bypass convention; directly feeds the
multi-provider platform's isolation model.
- **Effort: S** (post-migration) · **Risk: L** (isolation tests already exist).
## 7. Frontend bundle code-splitting
- **What:** vite `manualChunks`/dynamic imports to split the 678 KB bundle (charts,
grid-layout, per-route chunks); drop `react-grid-layout` when the Analytics redesign
lands (it's the sole consumer).
- **Benefit:** faster cold loads; audit L-5 closed. Gzip is already on (batch B), so this
is the remaining lever.
- **Effort: SM** · **Risk: L**.
## 8. Keyset (cursor) pagination for search
- **What:** replace offset `Skip/Take` with keyset pagination for browse/date-ordered
paths; ranked paths already effectively top-N (design in `docs/discovery/05`).
- **Benefit:** deep-page latency stops degrading linearly at 100k+ mailboxes.
- **Effort: M** (API shape + frontend infinite-scroll cursor) · **Risk: M** (API change).
## 9. SAST in CI (Semgrep)
- **What:** a `semgrep` job (OSS rules for C#/JS + secrets/OWASP packs) in `ci.yml`
CodeQL is GitHub-centric; Semgrep runs anywhere Docker does.
- **Benefit:** closes audit L-6; catches injection/crypto misuse patterns the current
gates (gitleaks + dep-audit + tests) don't look for.
- **Effort: SM** (tuning noise) · **Risk: L** (advisory job first, required later).
## 10. Settings + feature-flag platform (multi-provider Phase 4)
- **What:** implement `docs/discovery/multi-provider/04``feature_flags`/`user_settings`
tables, `IFeatureFlags`/`IAiGate`, admin toggles later.
- **Benefit:** unblocks shipping AI features dark (`ai.enabled` master switch), the
Settings UI, and everything in the multi-provider plan; prerequisite for #4 to be
properly gated per the approved design.
- **Effort: L** · **Risk: M** — the biggest item, but the one the roadmap already commits to.
---
## Value-to-effort ranking (summary)
| # | Item | Effort | Why this rank |
|---|------|--------|---------------|
| 1 | .NET 10 migration | M | **EOL deadline Nov 2026**; unlocks #6 |
| 2 | Renovate | S | Permanent supply-chain automation for one config file |
| 3 | Backups | S | Only protection against total data loss |
| 4 | Semantic search activation | ML | Flagship product differentiator; infra already live |
| 5 | OpenTelemetry | M | Can't manage what you can't see |
| 6 | Named query filters | S | Cheap once #1 lands |
| 7 | Bundle splitting | SM | Perceived speed; last audit-perf leftover |
| 8 | Keyset pagination | M | Scales search; roadmap item |
| 9 | Semgrep SAST | SM | Last unautomated security layer |
| 10 | Settings/flags platform | L | Roadmap-committed foundation |
**Suggested sequencing:** 2+3 immediately (tiny, standalone) → 1 (deadline) → 6 → 4 (+10 gating if you want flags first) → 5 → 7/8/9 opportunistically.
**STOP — awaiting your selections before implementing anything (Phase 4).**
+65
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
0.1.0
+13 -4
View File
@@ -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
View File
@@ -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 }
+46
View File
@@ -0,0 +1,46 @@
# Staging overlay for InboxIntel.
#
# The base docker-compose.yml IS the production definition (Linux containers,
# ASPNETCORE_ENVIRONMENT=Production). This overlay layers a *staging* variant on
# top of it so you can run a production-shaped stack locally on Windows WITHOUT
# clobbering a real production deployment's data, ports, or volumes.
#
# It differs from prod only in the ways staging is meant to differ:
# - the dev/test banner is on (App__DevMode=true)
# - the initial Gmail sync is capped so a big mailbox doesn't take forever
# - ports are shifted into the 18xxx range so staging can run alongside prod
# - a distinct project name gives it its own isolated pgdata + keys volumes
#
# Run it with the -p (project name) flag so volumes/networks are namespaced:
#
# docker compose -p inboxintel-staging \
# --env-file deploy/.env.staging \
# -f docker-compose.yml -f docker-compose.staging.yml up --build -d
#
# (deploy/up.ps1 -Staging / up.sh --staging wrap this for you.)
services:
postgres:
# !override replaces the base port list instead of merging with it, so staging
# binds ONLY its shifted 18xxx/15432 ports and never squats on prod's 8080/8081.
ports: !override
- "127.0.0.1:15432:5432"
api:
environment:
# Same Production runtime as prod (real config binding, real build), but
# flagged as a non-production instance so the UI shows the staging banner
# and the first sync is bounded.
App__DevMode: "true"
GmailSync__MaxMessages: ${MAX_MESSAGES:-2000}
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:18081}
ports: !override
- "127.0.0.1:18080:8080"
frontend:
ports: !override
- "18081:80"
nginx:
ports: !override
- "18000:80"
+78 -1
View File
@@ -1,6 +1,8 @@
services:
postgres:
image: postgres:16-alpine
# pgvector-enabled Postgres 16 (semantic search). Drop-in for postgres:16 data;
# the 'vector' extension is created by the AddEmbeddingColumn migration.
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: inboxintel
POSTGRES_USER: inboxintel
@@ -20,6 +22,47 @@ services:
timeout: 5s
retries: 10
# Nightly logical backups (RECOMMENDATIONS #3 — previously there were NONE). Dumps
# rotate after BACKUP_KEEP_DAYS. The ./backups host directory should live on an
# encrypted disk and be included in your off-machine backup regime (see SECURITY.md).
# Restore: docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql
backup:
image: pgvector/pgvector:pg16
entrypoint: /bin/sh
command:
- -c
- |
while true; do
ts=$$(date -u +%Y%m%d-%H%M%S)
if pg_dump -h postgres -U inboxintel -d inboxintel > /backups/inboxintel-$$ts.sql.tmp; then
mv /backups/inboxintel-$$ts.sql.tmp /backups/inboxintel-$$ts.sql
echo "backup OK: inboxintel-$$ts.sql"
else
rm -f /backups/inboxintel-$$ts.sql.tmp
echo "backup FAILED at $$ts" >&2
fi
find /backups -name 'inboxintel-*.sql' -mtime +$${BACKUP_KEEP_DAYS:-7} -delete
sleep 86400
done
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-7}
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy
# One-shot: ensure the DataProtection 'keys' volume is owned by the API's non-root
# 'app' user (uid 1654). A volume created by an older root-running image is root-owned,
# which makes the app fail to read its key ring and 500s on login. Runs as root, chowns,
# exits; the api waits for it. Idempotent and cheap.
init-keys:
image: busybox
command: ["sh", "-c", "chown -R 1654:1654 /keys"]
volumes:
- keys:/keys
api:
build:
context: .
@@ -32,6 +75,10 @@ services:
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
Ai__Mode: ${AI_MODE:-Disabled}
# Points at the compose 'ollama' service when the ai profile is up; harmless otherwise.
Ai__OllamaBaseUrl: ${OLLAMA_BASE_URL:-http://ollama:11434}
# OTLP export activates only when set (e.g. http://lgtm:4317 with the observability profile).
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_ENDPOINT:-}
# Dev mode shows the dev banner and caps the initial sync. Set DEV_MODE=true
# and MAX_MESSAGES=1000 in deploy/.env to exercise it in this Docker setup.
App__DevMode: ${DEV_MODE:-false}
@@ -42,6 +89,8 @@ services:
depends_on:
postgres:
condition: service_healthy
init-keys:
condition: service_completed_successfully
# 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.
@@ -57,6 +106,33 @@ services:
ports:
- "8081:80"
# Local AI (semantic search + assistants). Enable with:
# docker compose --profile ai up -d && set AI_MODE=LocalOllama in deploy/.env
# First run: docker compose exec ollama ollama pull nomic-embed-text
# GPU (RTX 3080): uncomment the deploy block to pass the GPU through.
ollama:
image: ollama/ollama
profiles: ["ai"]
volumes:
- ollama:/root/.ollama
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# Observability (RECOMMENDATIONS #5): all-in-one Grafana+Tempo+Prometheus+Loki.
# Enable with: docker compose --profile observability up -d
# then set OTEL_ENDPOINT=http://lgtm:4317 in deploy/.env and restart the api.
# Grafana UI: http://localhost:3000 (admin/admin on first run).
lgtm:
image: grafana/otel-lgtm
profiles: ["observability"]
ports:
- "127.0.0.1:3000:3000"
# Optional reverse proxy. Enable with: docker compose --profile proxy up
nginx:
image: nginx:alpine
@@ -72,3 +148,4 @@ services:
volumes:
pgdata:
keys:
ollama:
+188
View File
@@ -0,0 +1,188 @@
# InboxIntel — Git Workflow & Environment Strategy
The single source of truth for how we branch, commit, version, and promote code
across environments. Optimised for a **solo developer on Windows 11 with a future
Linux production server**. Kept deliberately lightweight — every rule here earns
its place.
---
## 1. Branch strategy
A trimmed **GitHub Flow + a long-lived `develop`** model. Two permanent branches,
short-lived branches off `develop`.
| Branch | Lives for | Purpose | Deploys to |
|-----------------|-----------|------------------------------------------------------|------------|
| `main` | forever | Always releasable. Every commit is tagged & shippable | production |
| `develop` | forever | Integration branch. What staging runs | staging |
| `feature/*` | hoursdays| One feature or refactor | dev (local)|
| `fix/*` | hours | Non-urgent bug fix | dev (local)|
| `hotfix/*` | minuteshrs| Urgent prod fix, branched from `main` | prod (fast)|
| `release/x.y.0` | optional | Only if a release needs stabilisation before tagging | staging |
**Why this shape (not full GitFlow):** a solo dev doesn't need GitFlow's ceremony
(separate release managers, parallel release trains). But keeping `develop`
separate from `main` gives one thing that pure trunk-based can't: a **staging
environment that always mirrors `develop`** while `main` stays clean and
tag-perfect for production. `release/*` exists only when you want to freeze
features and stabilise — skip it for routine work.
### Normal flow
```
main ──────●────────────────────●────────── (tagged: v1.2.0, v1.3.0)
\ /
develop ──●──●──●──●──●──●──●──●──────────── (staging)
\ / \ /
feature/x ●─● \ /
fix/y ●───●
```
1. `git switch develop && git pull`
2. `git switch -c feature/sender-policy`
3. Commit in small conventional commits.
4. Push, open a PR **into `develop`** (Gitea). CI must be green.
5. Squash-merge. Delete the branch.
6. When `develop` is ready to ship → PR `develop → main`, tag, deploy.
---
## 2. Commit conventions — Conventional Commits
Format: `type(scope): short imperative summary`
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`,
`build`, `style`, `security`.
Rules:
- Summary ≤ 72 chars, imperative mood ("add", not "added").
- One logical change per commit.
- Body explains **why**, not what (the diff shows what).
- Breaking change → add `!` (`feat!:`) and a `BREAKING CHANGE:` footer.
Examples (matching this repo's history):
```
feat(ui): F1+F2 — component primitives + rebuilt app shell
fix(security): systemic IDOR safeguard via EF global query filters
ci: add Gitea Actions build + test pipeline
```
Why: conventional commits drive **automatic semver bumps** and a generated
CHANGELOG, and make `git log` scannable. `feat` → minor, `fix` → patch,
`BREAKING CHANGE` → major.
---
## 3. Pull request / merge flow (Gitea)
Even solo, PRs are worth it: they run CI, give a diff review checkpoint, and build
a paper trail.
- **Target:** `feature/*` and `fix/*``develop`. `develop`/`hotfix/*``main`.
- **Gate:** the `CI` workflow (backend build+test, frontend build) must pass.
- **Merge style:** **squash-merge** feature branches (one clean commit on
`develop`). **Merge commit** for `develop → main` (preserves the integration
history and makes the release boundary visible).
- **Branch protection (Gitea → Settings → Branches):** protect `main` and
`develop`; require status checks to pass; disallow force-push.
---
## 4. Versioning — Semantic Versioning (`MAJOR.MINOR.PATCH`)
- **MAJOR** — breaking API/behaviour change.
- **MINOR** — backward-compatible feature.
- **PATCH** — backward-compatible fix.
The current version lives in [`VERSION`](../VERSION) and is the one place bumped
per release. Pre-1.0 while scaffolding: stay on `0.x` (minor = features, patch =
fixes; anything may change).
---
## 5. Tagging & releases
Tags are cut **only on `main`**, annotated, prefixed `v`:
```bash
git switch main && git pull
# bump VERSION + CHANGELOG in a release commit, then:
git tag -a v1.3.0 -m "v1.3.0 — sender policy + staging overlay"
git push origin main --tags
```
- Tag == the exact commit deployed to production == the rollback target.
- The tag message summarises the release; details live in `CHANGELOG.md`.
---
## 6. Hotfix process
For a bug already in production:
```bash
git switch main && git pull
git switch -c hotfix/oauth-callback-500
# fix + test
git commit -m "fix(security): guard null OAuth state on callback"
# PR hotfix/* -> main, CI green, merge
git switch main && git pull
git tag -a v1.3.1 -m "v1.3.1 hotfix — OAuth callback" && git push origin main --tags
# deploy the tag, THEN back-merge so develop doesn't lose the fix:
git switch develop && git merge main && git push
```
The **back-merge to `develop`** is the step people forget — without it the next
release silently reintroduces the bug.
---
## 7. Environments
| Env | Host | Runtime | Config source | Purpose |
|-------------|-------------------------|--------------------------------------|--------------------------|--------------------|
| Development | Windows 11 (native) | `dotnet run` + `vite` (hot reload) | `appsettings.Development.json` + user-secrets | fast iteration, debugging |
| Staging | Windows 11 (Docker) | Linux containers, `ASPNETCORE_ENVIRONMENT=Production` | `deploy/.env.staging` + `docker-compose.staging.yml` | production rehearsal |
| Production | Linux server (Docker) | identical Linux containers | `deploy/.env` on the server | live |
**Parity principle:** dev is fast (native, Windows) and *not* production-shaped —
that's fine, it's for inner-loop speed. **Staging is the parity gate**: it runs the
*same Linux images* Docker builds for production, so "works in staging" genuinely
predicts "works in prod". The only staging↔prod differences are the dev banner,
capped sync, ports, and volume namespace — see `docker-compose.staging.yml`.
### Commands
```powershell
# DEV (native, hot reload) — two terminals
dotnet run --project src/InboxIntel.Api # http://localhost:5080
cd frontend; npm run dev # http://localhost:5173
# STAGING (production-shaped, on Windows via Docker)
./deploy/up.ps1 -Staging # http://localhost:18081
./deploy/down.ps1 -Staging
# PRODUCTION-shaped run locally (smoke test the real config)
./deploy/up.ps1 # http://localhost:8081
```
Dev and staging use **different ports and different volumes**, so they can run at
the same time and never share a database.
---
## 8. Git hooks
Installed via `core.hooksPath` (run once per clone):
```powershell
./scripts/install-hooks.ps1
```
- **pre-commit** (fast): blocks committed `.env`/secrets, verifies `dotnet format`.
- **pre-push** (thorough): `dotnet build -c Release` + `dotnet test` + frontend
build — the same gate CI runs, caught before the push.
Emergency bypass: `--no-verify`. CI still enforces the gate server-side, so a
bypassed push can still be rejected by branch protection.
+45
View File
@@ -0,0 +1,45 @@
# Design Brief (locked via interview)
The confirmed design direction from the Phase 4A interview. This governs the UX/UI
redesign, design system, themes, and search experience.
## Direction in one line
**An approachable, professional, "anyone can pick it up" email tool in the spirit of
Notion & Arc — a polished, responsive web app that is fast and premium without being
intimidating.**
## Confirmed preferences
| Dimension | Decision | Design implication |
|-----------|----------|--------------------|
| Reference feel | **Notion / Arc, but professional** | Friendly, spatial, low learning curve — not a power-user speed tool (not Superhuman). |
| Density | **Balanced** | Comfortable, readable rows; efficient but not cramped. |
| Input model | **Pointer-first / discoverable** | Clickable UI is the backbone. Command palette + shortcuts are **accelerators for power users**, layered on top — never required. |
| App metaphor | **Polished web app** | Exceptional in-browser experience; shareable URLs; no native-desktop assumptions. |
| Platforms | **Desktop (Win + Mac/Linux), Tablet, Mobile** | **Fully responsive** is mandatory — layouts must gracefully collapse desktop → mobile. |
| Default theme | **Dark-first** | Design primarily for a layered dark UI; light theme fully first-class (genuine white, not grey). |
| Motion | **Subtle & smooth** | Gentle, quick transitions and tasteful micro-interactions. Respect `prefers-reduced-motion`. |
| Colour personality | **Warm ∩ cool blend, green accent** | Accent built around **`#3ba31f`** (refined into an accessible ramp); neutrals lean subtly warm. |
| Icons | **Clean line, rounded** (Lucide-style) | Consistent stroke weight, soft corners. |
| Speed vs polish | **Balanced** | Polish is welcome only when it never costs perceptible speed. |
| Audience | **Mainstream, power-capable** | Simple by default; power features revealed progressively. |
| Accessibility | **Deferred (nice-to-have)** | AA basics baked in cheaply; deeper a11y is a pre-launch backlog item. See below. |
## The pivotal insight
The original brief leaned "keyboard-first"; the interview corrected this to
**discoverable-first**. That reframes the flagship **search** away from a syntax you
must learn (`from:x after:y`) toward an **inviting, visual, assisted** experience —
filter chips, live suggestions, and natural language — with the power syntax still
available underneath. This single decision shapes the entire redesign.
## Accent colour note
`#3ba31f` = rgb(59,163,31). It's a strong differentiator (most email apps default to
blue) and bridges warm/cool. It will be developed into a full ramp (50→900); the
**interactive** shade will be nudged for WCAG AA contrast on dark surfaces and on
buttons, and green will **never be the sole signal** for selection/status (always
paired with icon/shape) so the design is colour-blind-safe by construction.
## Deferred: accessibility
Per the user, accessibility is a **nice-to-have to revisit before public launch**, not
a hard requirement now. Cheap AA basics (contrast, focus rings, reduced-motion,
non-colour-only state) are still baked in; high-contrast mode, full screen-reader
semantics, font-scaling controls, and formal colour-blind audits are **backlog**.
+88
View File
@@ -0,0 +1,88 @@
# 01 — Architecture Review (current state)
Phase 1 deliverable: a grounded assessment of InboxIntel as it exists today, from the
source. This is the baseline the redesign builds on.
## Architecture
**Clean Architecture, .NET 8** — strict dependency rule `Api → Infrastructure → Application → Domain`.
| Layer | Responsibility | Key contents |
|-------|----------------|--------------|
| **Domain** | Entities + enums, no external deps | `Email`, `Sender`, `Domain`, `MailThread`, `Label`/`EmailLabel`, `Attachment`; `EmailCategory` |
| **Application** | Interfaces, DTOs, validation, parsing | `ISearchService` et al., `GmailQueryParser`, FluentValidation |
| **Infrastructure** | EF Core/Npgsql, integrations, workers | `SearchService`, `CleanupService`, `AnalyticsService`, `HeuristicClassifier`, **AI providers**, exports, `GmailSyncWorker`, `DigestWorker` |
| **Api** | ASP.NET Core Web API | Thin controllers, DI, Serilog, OAuth |
| **Frontend** | React 18 + Vite SPA | Chart.js, react-grid-layout (draggable dashboard), Tailwind |
- **Data:** PostgreSQL (EF Core + Npgsql). `Email` designed for 100k+ rows/user; a
**generated `tsvector`** column backs full-text search.
- **Background:** hosted workers — `GmailSyncWorker` (scheduled sync), `DigestWorker` (digest).
- **AI seam (already present):** `IAiProvider` with `NullAiProvider` (disabled),
`OllamaProvider` (local `/api/chat`), `OpenAiProvider` (cloud). Contract today is a
single `CompleteAsync(systemPrompt, userPrompt)`.
- **Security:** Google OAuth2 (read-only Gmail), cookie session + JWT, refresh tokens
encrypted via Data Protection API, **IDOR-safe global query filters**, SSRF egress
guard, non-root containers, destructive actions require `Confirmed` + server preview.
- **Delivery:** Docker Compose (Postgres/API/frontend/optional nginx) + Gitea CI/CD.
## Feature set
Gmail connect + sync → Postgres · analytics dashboard (draggable widgets) · advanced
search (Gmail operators + FTS) · safe bulk cleanup · unsubscribe management
(List-Unsubscribe / one-click) · heuristic categorisation (smart folders) · exports
(PDF/CSV/JSON) · optional **advisory** AI · sender/domain aggregation.
## Existing search implementation (flagship)
1. **`GmailQueryParser`** (Application) parses `from: to: domain: after: before:
is:unread|read has:attachment`; remaining text → free-text.
2. **`SearchService`** (Infrastructure) composes structured filters as EF `WHERE`
clauses, and free text via Postgres FTS:
`SearchVector.Matches(PlainToTsQuery('english', term))`, where
`SearchVector = to_tsvector('english', coalesce(Subject,'') || ' ' || coalesce(BodyText,''))`.
3. Results are per-user filtered (IDOR-safe), **ordered by `SentAtUtc DESC`**,
offset-paginated, projected to `EmailSummaryDto`.
## Strengths
- Clean, testable layering; DI throughout; 39 tests + CI/CD gate.
- **Real Postgres FTS** (generated tsvector), not naïve `LIKE`.
- Security-forward (IDOR filters, encrypted tokens, SSRF guard, confirmed destructive ops).
- **AI already decoupled behind `IAiProvider`** — the "no-AI / Ollama / future" requirement is architecturally seeded.
- Read-only scope + advisory AI = privacy-respecting.
## Weaknesses
- **Search is single-mode**: ordered by **date, not relevance** (no `ts_rank`); no
fuzzy/typo tolerance, no semantic/vector search, no grouping, no "why matched," no
saved/recent/suggested searches.
- FTS is **English-only** and covers **only Subject + Body** (not sender, attachment
names, labels).
- Sender/domain filters use `.Contains()` → **non-sargable ILIKE scans** (no `pg_trgm`).
- Category is a **single heuristic enum** — no multi-label, confidence, or learning.
- **`IAiProvider` is chat-only** — no embeddings/classification/extraction contract, so
semantic search & structured extraction can't yet be expressed.
- Threading (`ThreadId`) exists but isn't surfaced as conversation intelligence.
## Technical debt
- EF global-query-filter vs required `Email↔EmailLabel` relationship warning (in logs).
- Hardcoded English FTS config.
- **678 KB single JS chunk** (no code-splitting) — cold-load cost.
- Relevance-blind ordering.
- AI interface too narrow for the roadmap.
## Performance bottlenecks
- `.Contains()` sender/domain → sequential scans (need `pg_trgm` GIN indexes).
- **Offset pagination** (`Skip/Take` + `Count`) degrades on deep pages → keyset/cursor.
- No score-based top-N (no ranking) → sorts full match set by date.
- Single JS chunk (~219 KB gzip) slows cold loads.
- Full-mailbox sync latency at 100k+ (batching/Polly present, but per-message fetch).
## Documentation quality
Strong for a scaffold: `README`, `docs/ARCHITECTURE.md`, OAuth setup, `docs/specs/*`,
`AGENTS.md`, plus `WORKFLOW.md`/`CHANGELOG`; meaningful XML-doc comments.
**Gaps:** no API reference, no ERD/data-model doc, no ADRs, no search/AI design docs —
which this discovery produces.
## Verdict
A clean, secure, well-tested foundation with a **genuine FTS base** and an **AI seam
already in place**. The biggest opportunities are exactly where the product wants to
win: **relevance-ranked, multi-mode, assisted search**; a **richer AI contract**
(embeddings/classification/extraction); **conversation intelligence**; and a **modern,
approachable UX**. None of these require a rewrite — they extend the existing seams.
+73
View File
@@ -0,0 +1,73 @@
# 02 — Competitor Analysis (Phase 2)
Goal: understand the modern email landscape, isolate what users consistently praise
and complain about, and find **where InboxIntel can be genuinely better** — without
copying anyone. Analysis is framed against the [Design Brief](00-design-brief.md):
approachable-professional, fast, **local/private AI**, search-centric.
## Landscape snapshot
| Product | Positioning | Standout strength | Recurring weakness |
|---------|-------------|-------------------|--------------------|
| **Gmail** | Default mass-market webmail | Powerful search operators; scale; free; Gemini bolt-ons | Cluttered; privacy optics; AI feels tacked-on; dated triage |
| **Outlook** | Enterprise mail + calendar | Calendar/email fusion; Copilot; Rules | Heavy; **search is famously flaky/slow**; inconsistent "new Outlook" |
| **Superhuman** | Speed tool for pros | **Blazing speed, keyboard triage, polish**; Superhuman AI | $30/mo; Gmail/Outlook-only; **intimidating for casual users** |
| **Proton Mail** | Privacy / E2EE | Zero-access encryption; Swiss; open source; local-ish Scribe AI | **Search limited** (encrypted); fewer productivity features; slower |
| **Spark** | Smart inbox + team email | Collaboration (shared drafts, comments), smart inbox, cross-platform | Privacy history concerns; can feel cluttered; sync hiccups |
| **Shortwave** | AI-native Gmail client | **AI search + assistant that genuinely work**; bundles; fast | Gmail-only; **cloud AI (privacy)**; pricing crept up |
| **Thunderbird** | Open-source local client | Free, private, extensible, local storage, multi-account | UX still catching up; **no built-in AI**; search not modern |
| **HEY** | Opinionated workflow | Novel triage (Screener/Imbox/Feed/Paper Trail) | Locked-in; no folders/search-first; pricey |
| **Fastmail** | Fast private power-mail | Excellent fast search; reliable; standards-based | No AI; power-user aesthetic; niche |
| **Missive** | Team shared inbox | Best-in-class collaboration/assignment/rules | Team-first; overkill for individuals |
## Deep dive on the two axes that decide this market
### Search (InboxIntel's flagship battleground)
- **Gmail / Fastmail**: fast, operator-rich, but **syntax-first** and browse-anchored — great for people who already know `from:` / `has:attachment`, opaque to everyone else.
- **Outlook**: powerful on paper, but **unreliable/slow search is its most complained-about feature** for years.
- **Shortwave**: the modern bar — **AI/semantic search + "ask your inbox"** questions ("what did Sarah say about the invoice?"). Genuinely loved, but **cloud-processed**.
- **Proton/Thunderbird**: privacy-strong but **search is a known weak point** (encryption / dated indexing).
- **Gap:** nobody offers **fast, relevance-ranked, semantic, *explainable* search that is also local/private and approachable to non-power-users.** That is precisely InboxIntel's opening.
### AI
- The market has **split into two camps**:
1. **AI, but cloud** — Gmail/Gemini, Outlook/Copilot, Shortwave, Spark. Useful, but your email is processed off-device.
2. **Private, but little/no AI** — Proton, Thunderbird, Fastmail.
- Proton's **Scribe** (privacy-first, can run locally) hints at the future but is narrow (writing only).
- Common complaints: AI feels **generic/bolted-on**, **unexplained**, and **can't be turned off cleanly**.
- **Gap:** **genuinely useful AI that runs 100% locally (Ollama), is modular, explainable, and fully optional** — nobody mainstream owns this.
## What users consistently PRAISE (cross-product)
1. **Speed** — instant search/open/triage (Superhuman, Fastmail).
2. **AI that saves real time** — summaries of long threads, "ask your inbox," draft replies (Shortwave, Superhuman AI).
3. **Privacy you can trust** — Proton's whole brand.
4. **Effortless triage** — Split Inbox / Bundles / Screener (Superhuman, Shortwave, HEY).
5. **Collaboration** — assign, comment, share drafts (Missive, Spark).
6. **Clean, calm, modern UI** that reduces overwhelm.
## What users consistently COMPLAIN about
1. **Search that's slow, flaky, or syntax-only** (Outlook; Gmail/Proton on mobile).
2. **Privacy cost of AI** — "I want the AI but not to send my mail to a server."
3. **Clutter & overwhelm** — noisy inboxes, ads, too many features.
4. **AI that's generic and unexplained** — "why did it say that / categorise that?"
5. **Power tools that alienate casual users** (Superhuman's learning curve; HEY's rigidity).
6. **Price** — best experiences gated behind $1030/mo.
7. **Opaque automation/categorisation** you can't understand or correct.
## Opportunity gaps → where InboxIntel wins (ranked)
1. **Local, private, *useful* AI.** Resolve the market's central tension (AI **vs** privacy) by delivering both via Ollama. This is the headline differentiator and aligns with the existing `IAIProvider` seam and read-only scope.
2. **Approachable power-search as the home screen.** Discoverable, visual, assisted (filter chips + NL + suggestions) so a *mainstream* user searches confidently — with operators/semantic power underneath for those who grow into it.
3. **Explainability everywhere.** "Why this result matched," "why this was categorised/prioritised." No competitor does this well; it builds trust *and* teaches the product — perfect for the "anyone can pick it up" audience.
4. **Own-your-data intelligence.** Analytics, cleanup, unsubscribe, and local AI combine into a "your inbox, your machine, your intelligence" story that Gmail/Outlook structurally can't tell.
5. **Fast *and* friendly.** Superhuman is fast-but-intimidating; casual apps are friendly-but-slow. The brief's sweet spot (balanced, pointer-first, polished) is largely unoccupied.
6. **Honest, correctable automation.** Categories/priority a user can see, understand, and fix — the antidote to Gmail's opaque tabs.
## Explicitly NOT copying
- Not Superhuman's keyboard-only speed-sport (brief = discoverable-first).
- Not HEY's rigid, search-hostile opinionation.
- Not cloud-AI-at-any-cost (brief = local/private).
- Not enterprise-collaboration-first (individual mainstream user is primary; collaboration is a later opportunity, see roadmap).
## Positioning statement (draft)
> **InboxIntel** — the email tool that makes **search the fastest way to think about your
> inbox**, with **AI that runs on your own machine** and always explains itself.
> Powerful enough for pros, simple enough for anyone.
+107
View File
@@ -0,0 +1,107 @@
# 03 — User Research: Personas & Journey Maps (Phase 3)
Personas are prioritised against the [Design Brief](00-design-brief.md) audience:
**mainstream, power-capable.** So the **primary** personas are everyday individuals and
small-business owners; **secondary** personas are high-volume role users whose needs we
support *progressively* (power features revealed as people grow into them).
Legend: 🟢 primary · 🔵 secondary
---
## 🟢 Priya — Personal user (mainstream)
Busy professional with a personal Gmail full of subscriptions, receipts, travel, and the
occasional important thread buried in noise.
- **Goals:** find things fast; not miss the important stuff; keep the inbox from feeling overwhelming.
- **Pain points:** can't remember where things are; newsletters bury real mail; unsubscribing is tedious; search needs the "right words."
- **Daily workflow:** skim inbox on phone/desktop → star/leave a few → hunt for a receipt or booking → ignore the rest.
- **Search needs:** *natural, forgiving* ("flight to Lisbon", "gym receipt March") — no operators. Attachment/receipt finding. Typo tolerance.
- **AI opportunities:** thread summaries, "what needs a reply," smart unsubscribe suggestions, receipt/booking extraction, gentle priority.
## 🟢 Marcus — Business owner / solopreneur (mainstream, high-value)
Runs a small business from one inbox: clients, vendors, invoices, admin — **no assistant**.
- **Goals:** never drop a client ball; find any past agreement/invoice instantly; spend less time in email.
- **Pain points:** follow-ups slip; important buried under admin; digging for "what did we agree?"; context-switching.
- **Daily workflow:** triage first thing → reply to clients → chase unpaid invoices → search for prior context mid-reply.
- **Search needs:** people-centric ("everything with this client"), attachment/invoice search, timeline ("our thread about the contract"), "did they reply?"
- **AI opportunities:** follow-up detection, "you haven't heard back" nudges, thread/relationship summaries, task/commitment extraction, draft replies with context.
## 🔵 Dev — Developer / power user
Technical inbox: GitHub, CI alerts, monitoring, mailing lists, plus real mail.
- **Goals:** cut notification noise; automate; keep signal. **Strongly values local/private AI** and self-hostable.
- **Pain points:** alert floods; wants rules/automation and keyboard speed; distrusts cloud AI on their mail.
- **Daily workflow:** bulk-archive alerts → scan PR/issue threads → deep-search history → automate the repetitive.
- **Search needs:** operators + regex-ish precision, entity/sender/domain, saved searches, fast keyboard-driven search.
- **AI opportunities:** local summarisation of long threads, auto-labelling/rules suggestions, semantic search across archives, duplicate/near-duplicate detection.
## 🔵 Rina — Recruiter (role power user, high volume)
Hundreds of candidate threads, CV attachments, scheduling, multi-stage follow-ups.
- **Goals:** find any candidate/CV instantly; track stage; never lose a promising lead.
- **Pain points:** attachments scattered; who's at what stage; follow-up timing; duplicate applicants.
- **Search needs:** **attachment/document search** (search *inside* CVs), people search, stage/timeline, entity extraction (skills, roles).
- **AI opportunities:** CV/document understanding, candidate summaries, follow-up detection, duplicate detection, relationship mapping.
## 🔵 Sam — Sales · 🔵 Tom — Support (role power users)
- **Sam:** pipeline follow-ups, response tracking, templates. Needs "who hasn't replied," saved searches, sentiment, reply suggestions.
- **Tom:** triage by category/SLA, canned responses, categorisation accuracy. Needs reliable auto-categorisation, priority prediction, phishing/spam confidence, thread summaries.
- (Both hint at **collaboration/shared-inbox** — deliberately a *later* opportunity; individual mainstream user is primary.)
---
## Cross-persona signal
| Need | Who feels it most | Priority |
|------|-------------------|----------|
| **Forgiving, natural search** | Priya, Marcus (everyone) | 🔴 Highest |
| **Find people / attachments / past context** | Marcus, Rina, Dev | 🔴 High |
| **Follow-up / "did they reply?" detection** | Marcus, Sam, Rina | 🔴 High |
| **Thread & relationship summaries** | Marcus, Dev, Rina | 🟠 Medium-high |
| **Noise reduction (unsubscribe, bulk, categorise)** | Priya, Dev, Tom | 🟠 Medium-high |
| **Local/private AI** | Dev (loud), all (latent) | 🔴 Strategic |
| **Explainability of results/priority** | everyone (trust) | 🟠 Medium-high |
| Collaboration / shared inbox | Sam, Tom | 🟢 Later |
---
## Journey maps
Format: **stage → what they do → current pain → InboxIntel opportunity.**
### J1 — Morning triage ("what needs *me* today?") — Priya, Marcus
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Arrive | Open inbox | Wall of mixed noise + important | AI **priority lane** + summary of "needs you" (local, explainable) |
| Scan | Skim subjects | No signal of what's urgent/awaiting reply | **Follow-up/awaiting-reply** badges; thread one-liners |
| Act | Reply/defer/clean | Repetitive, context-switch heavy | One-click defer/snooze; context-aware **draft reply**; bulk clean noise |
| Exit | Close, hope nothing missed | Anxiety of missed items | "You're caught up on what matters" confidence state |
### J2 — Find one specific thing (the flagship moment) — everyone
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Recall | Remember fragments ("invoice, that vendor, spring") | Must guess exact keywords/operators | **Natural-language + semantic** search; typo-tolerant |
| Query | Type in search | Syntax anxiety; blank box | **Suggestions, recent, filter chips**; search-as-home |
| Scan results | Look through hits | Date-sorted, not relevant; no context | **Relevance ranking** + **"why this matched"** + instant preview |
| Confirm | Open the right one | Re-open several to be sure | Grouped results, inline preview, people/attachment facets |
### J3 — Reduce the noise — Priya, Dev, Tom
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Notice | "Too many newsletters" | Unsub links hidden/dark-patterned | **One-click unsubscribe** (already seeded) + AI suggestions of what to cut |
| Decide | Which to keep? | Manual, per-email | AI **bulk suggestions** by sender/category with confidence + preview |
| Act | Unsubscribe / bulk clean | Risky, irreversible-feeling | **Confirmed + previewed** bulk actions (already a strength) |
| Trust | Did it do the right thing? | Opaque | Explainable "why suggested," undo, audit |
### J4 — Track a commitment / follow-up — Marcus, Sam, Rina
| Stage | Action | Current pain | Opportunity |
|-------|--------|--------------|-------------|
| Send | Email a client/candidate | Then forget | AI **follow-up detection**: "expecting a reply?" |
| Wait | Time passes | No system nudges you | "**No reply in N days**" surfaced automatically |
| Recall | "What did we agree?" | Dig through thread | Thread **summary + extracted commitments/tasks** |
| Resolve | Chase / close | Re-draft from scratch | Context-aware **draft nudge** referencing the thread |
---
## Research-driven product principles
1. **Search is the front door**, and it must work for someone who types *"that gym receipt"* — not just `from:gym has:attachment`.
2. **Explain everything** the system decides (match, category, priority) — it builds trust and teaches the app.
3. **AI removes toil, never control** — advisory, previewed, undoable, and fully optional/local.
4. **Progressive power** — mainstream-simple by default; operators, saved searches, automation revealed as users grow.
5. **Reduce anxiety** — "you're caught up," undo, and confidence states matter as much as features.
@@ -0,0 +1,182 @@
# 04 — UX/UI Redesign, Information Architecture & Design System (Phase 4A)
A **complete redesign**, not an iterative tweak. Nothing about the current layout is
assumed to survive. Governed by the [Design Brief](00-design-brief.md): Notion/Arc-
professional, pointer-first & discoverable, balanced density, dark-first, green
`#3ba31f`, subtle motion, fully responsive, mainstream-friendly.
---
## Part A — UX Research (per-screen evaluation)
Evaluating the *jobs*, not the current pixels. For each screen: **goal · is it intuitive
· what's unnecessary · how to simplify · click reduction · what must be obvious · sources
of cognitive overload.**
| Screen | User goal | Redesign verdict |
|--------|-----------|------------------|
| **Dashboard (current draggable widgets)** | Understand my inbox | Becomes the **Analytics** view, not the home. A draggable widget grid is *configuration overhead* most users never want. Default = a curated overview; customisation is progressive. |
| **Inbox/list** | Triage what matters | Reframe from "all mail newest-first" to **lanes** (Needs you · Awaiting reply · Everything). Obvious: who/subject/one-line intent/time. Overload source: undifferentiated noise → fix with grouping + priority. |
| **Reading a message** | Understand + act | Add a **thread summary** header, **extracted actions/dates**, and inline reply. Reduce clicks: reply/snooze/label as one-key or one-click from the pane. |
| **Search** | Find a specific thing | **Promote to the front door.** Today it's an operator box; redesign to inviting, visual, assisted (see [05](05-search-redesign.md)). |
| **Cleanup / unsubscribe** | Reduce noise safely | Strong bones (confirm+preview). Make it **suggestion-led** ("cut these 12 newsletters?") with confidence + undo. |
| **Settings** | Configure incl. AI | Add a clear **AI panel**: off / local (Ollama) / provider, model status, VRAM. AI-off must feel first-class, not degraded. |
**Cross-cutting UX principles:** search-as-home · lanes over one big list · explain every
decision · progressive disclosure of power · confidence/undo everywhere · one primary
action per screen.
---
## Part B — Information Architecture
### Navigation shell (responsive 3-pane → 1-pane)
```
┌───────────────────────────────────────────────────────────────┐
│ TopBar: [ 🔍 Search your inbox… ⌘K ] ☾ ⚙ 👤 │
├──────────┬──────────────────────────┬─────────────────────────┤
│ Sidebar │ List / Results │ Reading / Preview │
│ (collaps)│ (virtualised) │ (thread + AI summary) │
│ │ │ │
│ Search │ ▸ Needs you (lane) │ Subject │
│ Priority │ ▸ Awaiting reply │ ⟶ AI summary (local) │
│ Unread │ ▸ Everything │ ⟶ extracted actions │
│ Saved ★ │ │ body … │
│ Categories │ [Reply] [Snooze] […] │
│ Cleanup │ │ │
│ Analytics│ │ │
│ ─────────│ │ │
│ 👤 acct │ │ │
└──────────┴──────────────────────────┴─────────────────────────┘
```
- **Search sits at the top of everything** (top bar) *and* as the first sidebar item — reinforcing search-as-home.
- **Responsive collapse:** 3-pane (wide desktop) → 2-pane (list+reading, laptop) → 1-pane with push navigation (tablet/mobile). Reading opens as an overlay sheet on mobile.
- **Progressive disclosure:** advanced filters, operators, saved-search management, and automation are revealed on demand — never in a beginner's face.
- **Command palette (⌘K / Ctrl+K):** an *accelerator* — navigate, act, and search — layered on top of the fully clickable UI (pointer-first per brief).
- **Right-click context menus** on rows/senders/threads (archive, label, unsubscribe, "find similar," "everything from this sender").
- **Multi-window / dockable panels:** deferred (web app); pop-out reading view is a v-later opportunity.
### Screen hierarchy
1. **Search-home** (front door) · 2. **List/Results** (lanes, ranked) · 3. **Reading/thread**
· 4. **Cleanup** · 5. **Analytics** · 6. **Settings (incl. AI)**.
---
## Part C — Design System
### Colour — accent ramp (from `#3ba31f`)
```
green-50 #f890? → use tint set:
--green-50: #f1f9ec --green-300: #93d07d --green-600: #2f8419
--green-100: #dcf0d0 --green-400: #63b84a --green-700: #266a15
--green-200: #bde3ab --green-500: #3ba31f --green-800: #1e5312
(brand base) --green-900: #163a0e
```
**Usage rules (dark-first):**
- Brand/base = `green-500`. On dark surfaces, interactive fills use `green-500`/`green-400`; **foreground on accent is contrast-checked** (near-black `#0f1a0b` on light greens, white on `green-600`+).
- Accent is used **sparingly** — primary actions, selection, active nav, positive status.
- **Never colour-only:** selection also shows a left-bar/checkbox; status pairs green with an icon/label (colour-blind-safe by construction, even though formal a11y is deferred).
### Colour — neutrals (warm-leaning)
| Token | Dark (default) | Light |
|-------|----------------|-------|
| `--bg` | `#1a1917` (warm charcoal, **not** pure black) | `#ffffff` (genuine white) |
| `--surface-1` | `#211f1d` | `#faf9f7` |
| `--surface-2` | `#2a2724` | `#f4f2ee` |
| `--surface-3` | `#34302c` | `#ebe8e2` |
| `--border` | `#3a3632` | `#e4e0d9` |
| `--text` | `#f2efe9` (warm off-white) | `#1c1a17` |
| `--text-muted` | `#a8a29a` | `#6b6459` |
| `--text-subtle` | `#7a746c` | `#928b7e` |
| semantic | `info #4a90d9 · warn #d9a441 · danger #d95a4a · success = green-500` | same, contrast-tuned |
### Typography
- **UI font:** Inter (or system fallback) — clean, neutral, highly legible.
- **Optional mono:** JetBrains Mono / ui-monospace for addresses, IDs, data.
- **Scale (px / line-height), UI base = 14 for balanced density:**
`xs 12/16 · sm 13/18 · base 14/20 · md 16/24 · lg 18/26 · xl 20/28 · 2xl 24/32 · 3xl 30/38`
- Weights: 400 body · 500 UI/labels · 600 headings/emphasis. Avoid 700 except brand.
### Spacing (4px base) & layout
`space: 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64`.
Grid: 12-col fluid content area; sidebar fixed (240px, collapsible to 56px icon rail);
reading pane min 420px. Density "balanced" → row height ~44px, 812px internal padding.
### Radius / elevation / motion
- **Radius:** `sm 4 · md 6 (buttons/inputs) · lg 8 (cards/panels, default) · xl 12 (modals) · pill 999`.
- **Elevation:** dark = surface-layering + faint shadow + 1px border; light = soft shadows
`e1 0 1 2 /6% · e2 0 4 12 /10% · e3 0 12 32 /16%`. Levels: e0 flat · e1 menus · e2 popovers · e3 modals.
- **Motion:** durations `120 / 180 / 240ms`; easing `cubic-bezier(0.2,0,0,1)` (ease-out) for enters, `cubic-bezier(0.4,0,1,1)` for exits; a spring only for selection/drag. **Respect `prefers-reduced-motion`.**
### Icons & illustration
- **Lucide** (line, rounded), stroke 1.5px, 20px default (16px dense, 24px feature).
- Illustration: minimal, single-accent line spot-art for empty states — friendly, not corporate stock.
### States (must all be designed)
- **Loading:** skeleton rows (list), shimmer summary card (reading) — never spinners for content.
- **Empty:** friendly line-art + one clear CTA ("Nothing here yet — connect Gmail" / "No results — try broader terms" with a *Did you mean* / *Broaden* action).
- **Error:** calm, specific, recoverable ("Couldn't reach Gmail — Retry"), never a raw stack.
- **Success:** toast + inline confirmation; destructive actions show **preview → confirm → undo**.
### Components (catalogue)
Buttons (`primary` green / `secondary` surface / `ghost` / `danger`) · icon-button · input
· **search field** (hero variant) · **filter chip** (removable, typed) · segmented control ·
toggle · dropdown menu · **context menu** · **command palette** · **email row** (avatar,
sender, subject, one-line intent, badges, time, hover-actions) · **sender chip/avatar** ·
**thread summary card** · **category badge** · **priority indicator** · tabs · tooltip ·
toast · modal · **side sheet** (mobile reading) · skeletons · empty-state · pagination /
**virtualised infinite scroll** · avatar/initials · progress/VRAM meter (AI panel).
---
## Part D — Themes (both fully polished)
### Dark (default)
Layered **warm charcoal** surfaces (`#1a1917``#34302c`), warm off-white text, green
accent nudged for on-dark contrast, faint borders to separate layers. Avoids pure black;
depth via surface elevation + hairline borders, not heavy shadow.
### Light
**Genuine white** base (`#ffffff`) with warm off-white surfaces — *not grey-pretending-to-
be-white*. Generous whitespace, soft shadows for elevation, restrained green accent.
Premium, low-noise, highly readable.
Both share tokens; only the neutral map + shadow strategy differ. Theme follows a
`data-theme` attribute; **dark is the design source of truth**, light is derived and
independently QA'd.
---
## Part E — Interaction design
- **Hover:** rows raise to `surface-2`, reveal quick-actions (archive/snooze/label/unsub).
- **Selection:** checkbox on hover + click-row-to-open; shift/⌘-click multi-select; a
sticky **bulk action bar** slides up when >1 selected.
- **Search:** instant results, **live filtering** as chips are added/removed, suggestions +
recent on focus (see [05](05-search-redesign.md)).
- **Previews:** hover peek + inline reading; attachments preview in a lightbox.
- **Drag & drop:** rows → labels/categories/cleanup; respects reduced-motion.
- **Notifications:** toasts (non-blocking), with undo for reversible actions.
- **Transitions:** pane content crossfades; mobile reading slides up as a sheet.
- **Scrolling:** **virtualised list** (mandatory for 100k+ rows) with sticky lane headers.
---
## Part F — Accessibility review (baseline; deeper a11y deferred)
Baked in cheaply now: AA-tuned contrast via the ramps, visible focus rings, `prefers-
reduced-motion`, **non-colour-only** state, semantic HTML + ARIA on lists/dialogs/menus,
full keyboard reachability of core actions. **Deferred to pre-launch backlog** (per brief):
high-contrast mode, formal screen-reader passes, font-scaling controls, colour-blind audit.
---
## Part G — Migration strategy (strangler, low-risk)
1. **Introduce tokens + component library** (Tailwind config from this doc) — no behaviour change.
2. **Rebuild the shell** (sidebar / top-bar / search-home) around the existing API.
3. **Migrate screen-by-screen behind a feature flag** (`ui.v2`): Search → Reading → List →
Cleanup → Analytics (the old draggable dashboard *becomes* Analytics).
4. **Keep the API stable**; frontend-only migration. Delete old screens once parity is verified.
5. Ship per-screen via the `develop → staging` pipeline; each screen is its own epic (see [Git plan](10-git-plan.md)).
## Part H — Future design opportunities
Pop-out / multi-window reading · dockable panels · custom accent picker · additional themes
· a Tauri/Electron shell if a true desktop build is ever wanted · plugin-contributed widgets
on the Analytics canvas · command-palette extensibility.
+129
View File
@@ -0,0 +1,129 @@
# 05 — Search Redesign (Phase 4B)
Search is the **flagship**. The goal: make people *want to search rather than browse*
an experience that's **inviting, visual, forgiving, ranked, and explainable**, works for
*"that gym receipt from March"* (not just `from:gym has:attachment`), and runs **locally
and privately**. Grounded in the existing Postgres FTS + `IAiProvider` seam (see
[01](01-architecture-review.md)); AI modes are **optional** — with AI off, lexical +
structured search is still excellent.
---
## The layered search engine
Four cooperating layers, each usable alone; higher layers *degrade gracefully* to lower
ones when AI is disabled.
```
┌ Layer 4 · AI-ASSISTED (optional, local) ────────────────────────────┐
│ NL→query · conversational "ask your inbox" · explanations · related │
├ Layer 3 · SEMANTIC (optional, local embeddings + pgvector) ──────────┤
│ meaning-based recall · "similar to this" · concept search │
├ Layer 2 · LEXICAL + RANKING (Postgres FTS, always on) ───────────────┤
│ websearch_to_tsquery · ts_rank · pg_trgm fuzzy · multi-field │
├ Layer 1 · STRUCTURED (SQL WHERE, always on) ─────────────────────────┤
│ sender/date/labels/flags/size/category · filter chips │
└───────────────────────────────────────────────────────────────────────┘
▲ hybrid fusion (RRF) ranks Layer 2+3 together ▲
```
**Hybrid ranking (RRF):** lexical and semantic candidate sets are merged via *Reciprocal
Rank Fusion*, then boosted by **recency**, **sender importance**, and **unread**. This
replaces today's date-only ordering — the single biggest search win.
---
## The experience: search as the front door
- **Hero search field** in the top bar + a **search-home** when the box is focused/empty:
**recent searches**, **suggested searches** ("Unread from people you reply to",
"Large attachments", "Receipts this month"), and **saved searches ★**.
- Type freely → **live results** with **filter chips** the user can add/remove by click
(pointer-first). Behind an unobtrusive " Filters" for the full builder.
- Every result shows **"why it matched"** (highlighted terms, or the semantic concept, or
the parsed NL interpretation as *editable chips*).
- **Zero-result recovery:** *Did you mean* · *Broaden* · *Search all mail/trash*.
- Fully keyboard-drivable as an **accelerator** (⌘K → type → arrow → enter), but never required.
---
## Search modes & features
Each with **Problem solved · Implementation · Complexity · Effort · User value.**
Effort = rough dev-days for one engineer; Complexity = S/M/L/XL.
| Feature | Problem solved | Implementation | Cx | Effort | Value |
|---------|----------------|----------------|----|--------|-------|
| **Relevance ranking** | Results sorted by date, not usefulness | `ts_rank_cd` + hybrid RRF + recency/sender/unread boosts; top-N by score | M | 35d | 🔴🔴🔴 |
| **Multi-field FTS** | Only Subject+Body indexed | Extend generated `tsvector` (sender name/addr, attachment filenames, labels) with weights (A/B/C/D) | S | 23d | 🔴🔴 |
| **Fuzzy / typo tolerance** | "recieved" finds nothing | `pg_trgm` GIN + similarity fallback when FTS is empty | S | 12d | 🔴🔴 |
| **Phrase / prefix / boolean** | `plainto_tsquery` too blunt | Swap to `websearch_to_tsquery` (quotes, OR, -exclude) | S | 1d | 🔴 |
| **Interactive filter chips** | Operators are a syntax wall | UI chips ↔ structured DTO; add/remove live | M | 34d | 🔴🔴🔴 |
| **Advanced query builder** | Power users want precision | Visual builder → same DTO; operators still work | M | 34d | 🔵🔵 |
| **Natural-language search** | "gym receipt March" | Local LLM (or rules-first) parses intent → chips + terms, shown editable | L | 58d | 🔴🔴🔴 |
| **Semantic search** | Recall by *meaning*, not keywords | `IEmbeddingProvider` (Ollama) → `pgvector` (HNSW); backfill job; hybrid with FTS | L | 812d | 🔴🔴🔴 |
| **Conversational "ask your inbox"** | "What did Sarah say about the invoice?" | RAG: semantic retrieve → local LLM answers with **citations** to source emails | XL | 1015d | 🔴🔴🔴 |
| **"Why this matched"** | Trust + learnability | `ts_headline` highlights (lexical); nearest-concept + snippet (semantic); parsed-intent chips (NL) | M | 34d | 🔴🔴 |
| **Saved searches ★** | Repeated queries | Persist DTO; pin to sidebar; optional live count | S | 2d | 🔴🔴 |
| **Recent searches / history** | Re-find what you searched | Per-user history table; privacy-clearable | S | 12d | 🔴 |
| **Suggested searches** | Blank-box paralysis | Rules over user's own stats (top senders, unreplied, big attachments) | S | 23d | 🔴🔴 |
| **People search** | "Everything with this person" | Sender/recipient facet + aggregation; person profile panel | M | 35d | 🔴🔴 |
| **Attachment search** | Find files, not emails | Index filenames now; **content/OCR** later (see below) | S→L | 2d → +8d | 🔴🔴 |
| **Entity extraction** | Search by amounts/dates/orgs | Local NER (LLM or rules) → entity index; facets (money, dates, companies) | L | 610d | 🔴🔴 |
| **Timeline search** | "our thread over time" | Date-bucketed results + a timeline scrubber UI | M | 34d | 🔵🔵 |
| **Related conversations** | Surface adjacent context | Vector nearest-neighbours + same-participants/thread heuristics | M | 35d | 🔴🔴 |
| **Duplicate / near-dup detection** | Clutter, repeated sends | Hash (exact) + embedding similarity (near) → group/cleanup | M | 46d | 🔵🔵 |
| **Conversation intelligence** | Long threads are walls | Thread summary + extracted actions/decisions (local LLM) surfaced in results & reading | L | 812d | 🔴🔴🔴 |
### Additional ideas (beyond the brief)
- **Search-driven bulk actions** — run cleanup/label/unsub on a *result set* ("archive all newsletters older than 6 months").
- **Scoped search** — constrain to this folder / label / person / thread inline.
- **"Find similar to this email"** — one-click semantic neighbours from any message.
- **Zero-result recovery** — *did-you-mean* (trigram), *broaden*, *search trash/all*.
- **Search from selection** — highlight text → "search inbox for this".
- **Sender reputation / safety facet** — filter by phishing/spam confidence (ties to AI).
- **Search templates** — parameterised saved searches ("invoices from {client}").
- **Search analytics (for the user)** — "you search 'invoice' most" → suggests a saved search / smart folder.
---
## Ranking design (detail)
```
score = RRF(lexical_rank, semantic_rank)
+ w_recency · decay(sentAt)
+ w_sender · senderImportance(userReplyRate, frequency)
+ w_unread · isUnread
```
- Weights are configurable; defaults tuned so **exact/lexical hits never lose to fuzzy noise**.
- `senderImportance` derives from the user's *own* behaviour (reply rate, frequency) —
computable from existing data, no AI required.
## Performance
- **Indexes:** GIN on `SearchVector`; GIN `pg_trgm` on sender/subject; **HNSW** (or IVFFlat)
on the `pgvector` embedding column.
- **Keyset/cursor pagination** for lexical/structured (replaces offset `Skip/Take`); score-
ordered top-N for ranked/semantic (fetch N, no deep offset).
- **Async embedding backfill** worker (reuse the hosted-worker pattern) so semantic search
builds in the background; new mail embedded on sync. Batch to respect the 3080's VRAM.
- Cache suggestions/recent; debounce live search (~120ms).
- Target: **<150ms** lexical, **<400ms** hybrid on 100k emails (local).
## Privacy
- Embeddings and NER computed **locally via Ollama**, stored **locally** in Postgres.
- Nothing leaves the machine unless the user explicitly selects a cloud provider.
- **AI-off fallback:** Layers 12 (structured + ranked lexical + fuzzy) — still a top-tier
search. Semantic/NL/conversational simply hide when AI is disabled.
## Mapping to the existing code (extend, don't rewrite)
- **`GmailQueryParser``SearchIntentParser`:** keep operator parsing; add a rules-first
NL layer, optional local-LLM disambiguation, emit the same `SearchRequestDto` (+ new fields).
- **`SearchService`:** add ranking (`ts_rank_cd`), `websearch_to_tsquery`, `pg_trgm`
fallback, multi-field vector, hybrid RRF, keyset pagination, `ts_headline` explanations.
- **`Email.SearchVector`:** widen the generated column (weighted A/B/C/D) + add a
`pgvector` `Embedding` column + `EmailEntities`/`ThreadSummary` tables.
- **AI seam:** extend to `IEmbeddingProvider.EmbedAsync` and a structured
`IAiProvider.CompleteStructuredAsync` (see [06 AI Strategy](06-ai-strategy.md)); Null
provider returns "unavailable" so callers fall back to lexical.
## Phasing (feeds the roadmap)
- **MVP search:** relevance ranking · multi-field FTS · fuzzy · chips · saved/recent/suggested · "why matched" (lexical) · keyset pagination.
- **v1.1:** natural-language parse · people search · attachment (filename) · search-driven bulk.
- **v1.2:** semantic (pgvector) · related · find-similar · thread summaries in results.
- **v2.0:** conversational "ask your inbox" (RAG + citations) · entity search · duplicate detection · attachment OCR/content.
+113
View File
@@ -0,0 +1,113 @@
# 06 — AI Strategy & Model Recommendations (Phase 5)
AI is **optional, modular, local-first, and explainable**. The app must be fully usable
with AI disabled. Primary hardware: **NVIDIA RTX 3080 (10 GB VRAM)**. All AI sits behind
an abstraction so models/providers swap without touching application logic.
---
## Part A — The AI abstraction (extends the existing seam)
Today there is one interface, `IAiProvider.CompleteAsync(system, user)`, with
`NullAiProvider` / `OllamaProvider` / `OpenAiProvider`. We extend it into a small set of
capability interfaces plus a task-oriented facade.
### Provider-level interfaces (Infrastructure)
```csharp
public interface IAiProvider { // chat / generation (exists)
AiProviderMode Mode { get; }
Task<string> CompleteAsync(string system, string user, CancellationToken ct = default);
// NEW: schema-constrained JSON (Ollama `format`, OpenAI `response_format`)
Task<T?> CompleteStructuredAsync<T>(string system, string user, CancellationToken ct = default);
}
public interface IEmbeddingProvider { // NEW: vectors for semantic search/dedup
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default);
}
public interface IAiCapabilities { // NEW: lets the UI hide what isn't available
bool Chat { get; } bool Embeddings { get; } bool Vision { get; } bool StructuredJson { get; }
}
```
`NullAiProvider`/`NullEmbeddingProvider` return empty/"unavailable" so **every caller has a
graceful non-AI path** (fall back to lexical search, heuristics, or hide the feature).
### Task facade (Application) — the only thing feature code calls
```csharp
public interface IInboxAi {
Task<AiResult<string>> SummarizeThreadAsync(Guid threadId, CancellationToken ct);
Task<AiResult<Extraction>> ExtractAsync(string body, ExtractKinds kinds, CancellationToken ct);
Task<AiResult<Classification>>ClassifyAsync(string subject, string body, CancellationToken ct);
Task<AiResult<Answer>> AskAsync(string question, SearchScope scope, CancellationToken ct); // RAG
Task<float[]?> EmbedAsync(string text, CancellationToken ct);
Task<AiResult<RiskVerdict>> AssessPhishingAsync(EmailContext ctx, CancellationToken ct);
}
```
- `AiResult<T>` carries `{ value, available, model, latencyMs, explanation }` → powers the
**"explainability"** and graceful-degradation requirements everywhere.
- A **model router** maps *logical task → model name* from config, so swapping a model is a
config change: `Ai:Models:{Chat,Summarize,Embed,Classify,Extract,Vision}`.
- Prompt templates are **versioned files**, separate from code.
- Cross-cutting: async, `CancellationToken`, per-call timeout + **Polly** fallback to the
Null path, and a token/VRAM-aware concurrency limiter.
### Config shape
```
Ai:Mode = Disabled | LocalOllama | Cloud...
Ai:Ollama:BaseUrl, KeepAliveSeconds, VramBudgetMb
Ai:Models:Chat = qwen2.5:7b-instruct
Ai:Models:Embed = nomic-embed-text
Ai:Models:Vision = qwen2.5vl:7b (load-on-demand)
```
---
## Part B — Model recommendations for the RTX 3080 (10 GB)
**Budget reality:** a 78B instruct model at Q4/Q5 (~5 GB + ~1 GB KV cache at 8k context)
runs **alongside** a small embedding model (~0.5 GB) inside 10 GB with headroom. 14B is
possible (~9 GB) but leaves no room for concurrency and is slower — **78B is the sweet spot.**
| Task | Recommended (primary) | Alt | ~VRAM (Q4) | Latency | License | Notes |
|------|----------------------|-----|-----------|---------|---------|-------|
| **Chat / reasoning / RAG answers** | **Qwen2.5-7B-Instruct** | Llama-3.1-8B, Gemma-2-9B | ~5.5 GB | ~3060 tok/s | Apache-2.0 | Strong reasoning, multilingual, great JSON |
| **Summarisation** | reuse **Qwen2.5-7B** | Llama-3.1-8B | (shared) | fast | Apache-2.0 | No separate model needed |
| **Extraction (tasks/dates/entities)** | reuse **Qwen2.5-7B** + `format:json` | Phi-4 | (shared) | fast | Apache-2.0 | Schema-constrained JSON output |
| **Classification / tagging** | **Qwen2.5-3B** (fast) *or* embeddings-zero-shot | rules-first (exists) | ~2.2 GB | very fast | Apache-2.0/Qwen | High-volume → small model or embeddings, not 7B |
| **Embeddings (semantic search, dedup, zero-shot class.)** | **nomic-embed-text** (768-d, 8k ctx) | mxbai-embed-large, **bge-m3** (multilingual) | ~0.5 GB | very fast | Apache-2.0 | **Keep permanently loaded** |
| **OCR / attachment vision** | **Qwen2.5-VL-7B** *or* **MiniCPM-V** | llama3.2-vision-11B, moondream (tiny) | ~7 GB | slow | Apache/varied | **Load-on-demand**; evicts the text LLM |
| **Language detection** | **library, not an LLM** (fastText-lid / CLD3) | — | ~0 | instant | MIT/Apache | Don't waste VRAM on this |
| **Translation** | reuse **Qwen2.5-7B** (multilingual) | NLLB-200 (dedicated MT) | (shared) | med | Apache-2.0 | Good for common pairs; on-demand |
| **Spam detection** | **rules + small classifier/embeddings** | 3B LLM for edge cases | ~02 GB | fast | — | Traditional-first; LLM only for ambiguity |
| **Phishing detection** | **rules/URL analysis + embeddings**, LLM **reasoning** for suspicious | Qwen2.5-7B for explanation | shared | on-demand | — | LLM explains *why*; runs async on flagged mail |
| **Duplicate detection** | **hashing (exact) + embedding cosine (near)** | — | (uses embed) | fast | — | No chat model required |
**Selection rationale:** accuracy + **Apache-2.0 licensing** (commercial-safe) + fits 10 GB
+ strong **structured-JSON** and multilingual (mailboxes aren't English-only). Qwen2.5-7B is
a single versatile workhorse for chat/summarise/extract/translate; nomic-embed-text is a
tiny always-on retrieval engine; a vision model is a heavy, rare, on-demand guest.
### Load policy (VRAM management)
| State | Models | Rationale |
|-------|--------|-----------|
| **Hot (permanent)** | `nomic-embed-text` (~0.5 GB) | Used constantly (search, dedup, classify, backfill) — must be instant |
| **Warm (keep-alive)** | `qwen2.5:7b-instruct` (~5.5 GB) | Loaded on first chat/summarise, kept warm ~510 min idle via Ollama `keep_alive`, then unloaded |
| **Cold (on-demand)** | `qwen2.5vl:7b` vision, `nllb` translation | Loaded only for the specific job; unloaded after (would otherwise exceed budget alongside the 7B) |
- Ollama handles load/unload; we set `keep_alive` per task and a **VRAM budget guard** that
serialises a vision job behind unloading the text LLM.
- Embedding **backfill** runs as a hosted background worker, batched, low priority, so it
never starves interactive requests.
---
## Part C — Principles
1. **Traditional-first.** If rules/heuristics/library solve it well (language detection,
exact dedup, basic spam, most categorisation), use them — AI only where it *clearly* wins.
2. **Always optional.** Null providers + capability flags → the app is whole without AI.
3. **Local & private by default.** Ollama on-device; data never leaves unless a cloud
provider is explicitly chosen.
4. **Explainable.** Every AI output carries a short rationale + the model used.
5. **Swappable.** Logical-task→model routing via config; prompts versioned; no feature code
references a model name.
6. **Bounded.** Timeouts, cancellation, Polly fallback, VRAM-aware concurrency — AI can never
hang or crash the core experience.
+70
View File
@@ -0,0 +1,70 @@
# 07 — AI Feature Catalogue (Phase 6)
Every realistic AI feature, judged honestly against the guiding rule: **AI only where it
clearly beats traditional approaches.** Each entry: *problem · AI role & why · could
traditional solve it? · complexity · performance · privacy.* All AI is local (Ollama),
optional, advisory, and explainable (see [06](06-ai-strategy.md)). Complexity = S/M/L/XL.
Legend for the verdict column:
🟢 **AI clearly wins** · 🟡 **traditional-first, AI for the hard tail** · ⚪ **not AI at all**
---
## Group 1 — 🟢 AI clearly wins
LLM/embeddings are genuinely the right tool; traditional approaches are weak here.
| Feature | Problem | Why AI (and why traditional falls short) | Cx | Perf | Privacy |
|---------|---------|------------------------------------------|----|------|---------|
| **Thread / conversation summary** | Long threads are walls of text | LLMs summarise free text; regex/extractive summaries miss nuance & context | L | Warm 7B; cache per-thread, invalidate on new msg | Body → local LLM only |
| **Conversational "ask your inbox"** (RAG) | "What did Sarah say about the invoice?" | Retrieval + generation over many emails; impossible with filters alone | XL | Semantic retrieve → 7B answer w/ **citations**; ~13s | Retrieval + gen fully local |
| **Reply suggestions / writing assistant** | Blank-page drafting, tone | LLM drafts context-aware replies; templates can't adapt to content | L | Warm 7B, streamed | Thread context → local |
| **Task / meeting / calendar / reminder extraction** | Commitments hide in prose | LLM structured-JSON extraction of {task, date, attendee}; regex catches only rigid formats | L | 7B `format:json`, async on read/sync | Body → local |
| **Entity extraction** (amounts, orgs, dates, order #s) | Can't search/facet by meaning | LLM/NER generalises across phrasings; regex is brittle per-vendor | L | 7B or small NER, batched at sync | Local |
| **Document / attachment understanding** | Can't search *inside* files | OCR/vision + summarise; no traditional equivalent for images/PDF meaning | XL | Vision model **on-demand** (heavy); OCR async | File content → local |
| **Cross-thread linking / related conversations** | Related context is scattered | Embedding nearest-neighbours find semantic links; keyword join misses paraphrase | M | pgvector HNSW; precomputed | Vectors local |
| **Relationship mapping / knowledge graph** | No view of who/what connects | Extraction + embeddings build a people/topic graph; not expressible in SQL alone | XL | Batch build; incremental | Local graph store |
| **Conversation insights** (decisions, sentiment shift) | "What was decided / how's this going?" | LLM reads intent/sentiment over a thread; rules can't | L | 7B; cache | Local |
| **Sentiment analysis** | Gauge tone (angry client?) | Small model/LLM classifies tone; lexicon methods are crude/misleading | M | small model or embeddings | Local |
| **Email comparison** ("what changed vs last quote?") | Manual diffing of prose | LLM semantic diff; text-diff shows characters, not meaning | M | 7B on two bodies | Local |
| **Explain search results / decisions** | Trust & learnability | For semantic/NL, only the model can say *why*; lexical uses `ts_headline` (non-AI) | M | cheap (reuse retrieval) | Local |
## Group 2 — 🟡 Traditional-first, AI for the hard tail
Heuristics/rules do 7090% cheaply and instantly; AI handles ambiguity and adds explanations.
The existing `HeuristicClassifier` and unsubscribe signals are the traditional backbone.
| Feature | Problem | Traditional core | Where AI adds value | Cx | Perf / Privacy |
|---------|---------|------------------|---------------------|----|----------------|
| **Automatic categorisation** | Sort inbox into buckets | Rules on sender/domain/headers (exists) | Embedding zero-shot / small-LLM for the ambiguous long tail + confidence | M | Rules instant; LLM only on "unknown"; local |
| **Smart filing / smart labels** | Where should this go? | Rules + user's past filing patterns | LLM/embedding *suggestions* with confidence, user-correctable | M | Suggest async; local |
| **Priority prediction** | What needs me now? | Behavioural signals: your reply-rate to sender, frequency, VIPs, keywords, direct-to-me | ML/LLM refines ranking for edge cases | M | Mostly SQL/heuristic; local |
| **Follow-up / awaiting-reply detection** | Dropped balls | Heuristic: *you* sent, contains a question, no reply in N days | LLM confirms "expects a reply" & drafts nudge | M | Heuristic instant; LLM optional; local |
| **Smart notifications** | Notification fatigue | Rules over priority + quiet hours | LLM tunes "is this actually urgent" for borderline | S | Rules-first; local |
| **Spam detection** | Junk | Rules/Bayesian + provider signals | Small model for novel spam; LLM explains | M | Fast; local |
| **Phishing detection** | Safety | URL/domain analysis, SPF/DKIM hints, lookalike detection (+ existing SSRF guard) | **LLM reasons about social-engineering cues**; runs async on flagged mail, explains risk | L | Rules sync; LLM async on suspicious; local |
| **Duplicate email detection** | Clutter / repeats | **Exact hash** for identical | **Embedding cosine** for near-duplicates | M | hash instant; vector cheap; local |
| **Inbox assistant** (daily brief) | "Catch me up" | Compose from priority/follow-up/counts (rules) | LLM writes the natural-language brief over that structured data | L | 7B once/session; local |
## Group 3 — ⚪ Not AI (don't waste VRAM)
| Feature | Do it with | Why not AI |
|---------|-----------|------------|
| **Language detection** | fastText-lid / CLD3 library | Instant, accurate, ~0 VRAM; an LLM is pure overhead |
| **Exact duplicate detection** | content hash | Deterministic and free |
| **Unsubscribe detection** | List-Unsubscribe header parsing (exists) | Structured signal already present |
---
## Selection guidance (feeds the roadmap)
- **First AI wins (highest value / lowest risk):** thread summary · follow-up detection
(heuristic + AI confirm) · reply suggestions · NL search parse. All reuse the one warm 7B.
- **Semantic tier (needs pgvector + embeddings):** related/find-similar · near-dup ·
conversation insights · categorisation long-tail.
- **Ambitious tier:** ask-your-inbox (RAG) · knowledge graph · attachment/vision understanding.
- **Never gate the core on any of these** — each has a non-AI fallback or simply hides when
AI is off.
## Privacy posture (applies to all)
Email bodies and attachments are processed **on-device via Ollama**; embeddings, summaries,
extractions, and graphs are **stored locally in Postgres**. No content leaves the machine
unless the user deliberately configures a cloud provider — and even then, per-feature
consent should gate it. This is the product's defining trust advantage (see
[02](02-competitor-analysis.md)).
+115
View File
@@ -0,0 +1,115 @@
# 08 — Technical Architecture (Phase 7)
How the redesign is built on the **existing Clean Architecture** — extending seams, not
rewriting. Everything here preserves the dependency rule `Api → Infrastructure →
Application → Domain` and the security posture from [01](01-architecture-review.md).
> **Update — multi-provider platform.** This architecture now assumes the
> [multi-provider design](multi-provider/README.md): the Gmail-centric `Email`/`Sender`
> model generalises to **account-scoped, provider-normalised** entities behind
> `IEmailProvider`; the app becomes **small-team multi-user** (Admin/Member) with
> **OAuth-as-login**, settings, and feature flags; and **AI gating is feature-flag-driven**
> (`ai.enabled` system flag → user pref). See that folder for the provider abstraction,
> DB schema, security model, and admin/settings design; this doc remains the search/AI/
> caching/indexing reference they build on.
## Overall
```
React SPA (v2 shell)
│ REST (+ SSE for streaming AI/search)
Api ──────────────────────────────────────────────
Application: IInboxAi · ISearchService · SearchIntentParser · DTOs
Infrastructure:
├ Search: SearchService (structured + lexical rank + hybrid)
├ AI: IAiProvider / IEmbeddingProvider / model router / VRAM guard
├ Jobs: EmbeddingBackfill · AiEnrichment · IndexMaintenance (IHostedService + Channel queue)
├ Gmail/Sync/Cleanup/Analytics (existing)
└ Persistence: EF Core + Npgsql (+ pgvector)
Domain: entities/enums (+ Embedding, Entities, ThreadSummary, SavedSearch)
External (optional): Ollama (local, own container) · cloud AI (opt-in only)
```
## Search architecture
The 4-layer engine from [05](05-search-redesign.md):
- **Structured** (SQL WHERE) + **Lexical** (`websearch_to_tsquery` + `ts_rank_cd`, weighted
multi-field `tsvector`, `pg_trgm` fuzzy fallback) — **always on**.
- **Semantic** (pgvector, HNSW) + **AI-assisted** (intent parse, RAG) — **optional**.
- **Hybrid fusion (RRF)** merges lexical+semantic; boosts recency/sender/unread.
- **Keyset pagination** replaces offset; score-ordered top-N for ranked queries.
- `SearchIntentParser` (evolves `GmailQueryParser`): operators → rules-NL → optional LLM.
## AI architecture
- Provider interfaces + `IInboxAi` facade + **model router** (task→model via config) +
**prompt templates** (versioned files) + **VRAM guard** (serialises heavy vision jobs
behind unloading the 7B; embeddings stay hot). See [06](06-ai-strategy.md).
- **Streaming** via SSE for summaries/replies/RAG answers (perceived speed).
- **Bounded**: per-call timeout, `CancellationToken`, Polly fallback to Null path.
## Plugin / modularity architecture
- **Analyzer pipeline:** email enrichment is a set of `IEmailAnalyzer` plugins
(classifier, entity-extractor, summariser, phishing, dedup). Each declares required
capabilities (`Chat`/`Embeddings`/none) and is **skipped gracefully** if unavailable.
New AI features = new analyzers; no core changes.
- **Search providers** implement a common `ISearchLayer` so semantic/AI layers plug in.
- **AI providers** already pluggable (`IAiProvider`); adding a provider = one class + config.
- This is the "modular AI" requirement realised structurally.
## Caching
- **Embeddings & summaries**: persisted in Postgres (compute once, invalidate on new msg).
- **Search suggestions / recent**: cached per-user (memory + DB); debounced live search.
- **Query results**: short-TTL cache for identical repeated queries; ETag on read endpoints.
- **Model warmth**: Ollama `keep_alive` keeps the 7B hot between calls.
## Indexing
| Index | Column | Purpose |
|-------|--------|---------|
| GIN | `SearchVector` (weighted A/B/C/D) | Full-text |
| GIN `pg_trgm` | sender addr/name, subject | Fuzzy / substring (fixes non-sargable `.Contains`) |
| HNSW | `Email.Embedding` (pgvector) | Semantic k-NN |
| btree | `(UserId, SentAtUtc)`, `(UserId, ThreadId)` | Keyset pagination, threading |
- Built incrementally on sync; **EmbeddingBackfillWorker** batch-fills history (VRAM-aware, low priority).
## Background jobs
- Keep the existing `IHostedService` worker pattern; add a **`Channel<T>` in-process queue**
with a bounded concurrency worker for AI enrichment (summaries/entities/embeddings on new
mail). Idempotent, resumable, backpressured. (Upgrade path: durable queue if multi-node.)
- Jobs: `GmailSyncWorker` (exists), `DigestWorker` (exists), `EmbeddingBackfillWorker`,
`AiEnrichmentWorker`, `IndexMaintenanceWorker`.
## Database schema improvements
- **Widen** `Email.SearchVector` (subject/sender/filename/labels, weighted).
- **Add**: `Email.Embedding vector(768)`; tables `EmailEntities`, `ThreadSummary`,
`SavedSearch`, `SearchHistory`, `AiJob` (status/retry), `SenderImportance` (materialised).
- **Fix debt**: resolve the `Email↔EmailLabel` global-query-filter warning (optional nav or
matching filters); make FTS **language-aware** (detect → per-language config) instead of hardcoded English.
- **Scale**: consider per-`UserId` list partitioning of `Emails` if single users exceed ~1M rows.
## Scalability
- Single-user/self-host is the primary shape → vertical scaling + good indexes is enough.
- Connection pooling (Npgsql), keyset pagination, top-N-by-score, batched embeddings.
- **Ollama is the throughput bottleneck** → serialise/queue AI, cache aggressively, prefer
the small/embedding models for high-volume paths.
- Multi-tenant future: read replicas, per-user partitioning, durable job queue.
## Deployment
- Extend the Compose stack (from the CI/CD we built): add an **optional `ollama` service**
(profile `ai`) and a **pgvector-enabled Postgres image**. AI-off deployments omit the
profile entirely. Dev→staging auto-deploy already proven; prod is tag-gated.
## Offline support
- Data is **already local** (Postgres on the user's machine) — the product is local-first
by nature. SPA: service-worker cache for shell + last-viewed mail (read offline); queue
mutations (label/cleanup) to replay on reconnect. AI is inherently offline (Ollama local).
## Security model
- Retain: OAuth **read-only** scope, encrypted refresh tokens (Data Protection), **IDOR
global query filters**, SSRF egress guard, non-root containers, confirmed+previewed
destructive actions.
- **New AI concerns:**
- **Prompt injection** — email content is untrusted input to the LLM. Treat model output
as **advisory only**; never let it trigger actions directly; sanitise/format; the LLM
can *suggest* but a human/rule confirms (aligns with "AI never acts").
- **Attachment/vision** — sandbox parsing; size/type limits; never execute.
- **Local-only by default** — cloud provider is explicit opt-in with per-feature consent
and egress logging; the SSRF guard already constrains outbound calls.
- Secrets stay in `deploy/.env` / Actions secrets (never in git) — as established.
+93
View File
@@ -0,0 +1,93 @@
# 09 — Product Roadmap (Phase 8)
Everything from discovery, split into releases and **prioritised by user value vs
engineering effort**. The guiding sequence: **ship the redesign + dramatically better
(deterministic) search first**, then layer **optional local AI** in value order, each tier
reusing infrastructure the previous one built.
> **Update — multi-provider platform epic.** The
> [multi-provider + admin/settings design](multi-provider/README.md) is a **v1.x platform
> epic** (its own 6-phase plan) that the search/AI features build on. Sequencing note: the
> **provider abstraction, unified email model, settings, and feature-flag engine land early
> in v1.x** (they underpin multi-user + AI gating); the AI features from this roadmap then
> plug into that flag system. Both plans share the same flag-gated, ship-dark discipline.
## Prioritisation framework
Score each item **Value (15) × (6 Effort 15)**, then sequence so that (a) high-value/
low-effort ships first, (b) risky/expensive AI comes only after its infrastructure exists,
and (c) **nothing in an early release depends on AI being enabled.**
```
Value ▲ ★ MVP first ● do next ○ later
5 │ ★ranking ★redesign ●NL search ○ask-inbox
4 │ ★fuzzy ★chips ★summary ●semantic ●brief ○knowledge graph
3 │ ★perf/indexes ●people ●extract ○collaboration
2 │ ●categorise-tail ○plugins ○mobile
1 │ ○multi-provider
└───────────────────────────────────────────────────►
low effort ──────────────────────────► high effort
```
---
## MVP → **v1.0.0** — "The redesign + world-class deterministic search"
Rationale: the single biggest perceived-quality jump, built mostly on **deterministic**
tech (low risk). AI appears only as a few optional, reversible wins behind a toggle.
- **UX v2 shell + design system + both themes** (dark-first, green ramp) — the "world-class" feel.
- **Search core:** relevance ranking (RRF/`ts_rank`), multi-field weighted FTS, `pg_trgm`
fuzzy, **filter chips**, saved/recent/suggested, **"why matched"** (lexical), keyset pagination.
- **Perf/debt:** `pg_trgm` + FTS indexes, virtualised list, code-splitting, fix EF
query-filter warning, language-aware FTS.
- **AI foundation:** extended abstraction (`IAiProvider`+`IEmbeddingProvider`+facade+router),
Null + Ollama wired, **VRAM guard**, prompt templates.
- **First AI wins (optional):** thread summary · follow-up detection (heuristic + AI confirm)
· reply suggestions.
- **Why now:** delivers the flagship promise (fast, approachable, ranked, explainable search
+ a premium UI) even with AI off.
## v1.1 — "Assisted search & productivity"
Rationale: build on MVP's embedding groundwork; assist without heavy compute.
- **Natural-language search** parse (rules-first + optional LLM), shown as editable chips.
- **People search** · **attachment (filename) search** · **search-driven bulk actions**.
- **Inbox assistant / daily brief** · **task/calendar/reminder extraction**.
- **Categorisation long-tail** (embedding zero-shot + confidence) · **priority prediction v1**.
- **Why now:** the "assisted, not syntactic" search vision, plus the highest-value AI
productivity features — all still light on VRAM.
## v1.2 — "Semantic tier"
Rationale: introduces `pgvector` + embedding backfill; unlocks meaning-based features.
- **Semantic search** (pgvector HNSW, hybrid RRF) · **related / find-similar**.
- **Near-duplicate detection** · **thread summaries surfaced in results** · **conversation insights**.
- **Relationship mapping (basics)**.
- **Why now:** semantic recall is a headline differentiator but needs the embedding
infrastructure and backfill worker to be mature and VRAM-safe.
## v2.0 — "Ambitious AI"
Rationale: flagship, compute-heavy features that need a mature semantic + vision stack.
- **Conversational "ask your inbox"** (RAG + **citations**).
- **Entity search & facets** (amounts, orgs, dates) · **attachment OCR/content understanding** (vision, on-demand).
- **Phishing reasoning** (LLM over flagged mail, async, explained) · **knowledge graph**.
- **Why now:** highest wow-factor and the strongest "local private AI" story, but only
worthwhile once retrieval, extraction, and vision infra are proven.
## v3.0 — "Platform"
Rationale: expand beyond the individual power-user once the core is best-in-class.
- **Collaboration / shared inbox** (assign, comment) · **automation / rules engine**
(a `docs/specs/feature-rules-engine.md` already exists — fold it in).
- **Multi-account & additional providers** (IMAP/Outlook) · **plugin ecosystem** (analyzer
API) · **mobile app** · **multi-window / desktop shell** (Tauri) · **multi-provider AI**.
- **Why now:** platform bets that only pay off on top of a beloved core product.
---
## Sequencing principles (explained)
1. **Deterministic value before AI.** MVP's biggest wins (ranking, chips, redesign) need no
AI — they de-risk the release and prove the product before compute-heavy features.
2. **Infrastructure amortised.** Embeddings introduced once (v1.1 foundations → v1.2 usage)
power search, dedup, related, categorisation, and RAG — spread the cost.
3. **Value-first within a release.** Inside each version, highest value/effort ships first so
partial delivery is still shippable.
4. **AI is always additive.** Every release is complete and excellent with AI disabled —
protecting the "works without AI" mandate and the mainstream audience.
5. **Platform last.** Collaboration/plugins/mobile are large and only worthwhile once the
individual experience is genuinely best-in-class.
+60
View File
@@ -0,0 +1,60 @@
# 10 — Git Implementation Plan (Phase 10)
Turns the [roadmap](09-roadmap.md) into executable Git work, on the workflow already in
place ([../WORKFLOW.md](../WORKFLOW.md)): trunked `develop`/`main`, Conventional Commits,
PR-gated CI/Security, auto-deploy to staging, tag-gated production.
## Structure: Epics → Features → Tasks
- **Epic** = a roadmap theme → a milestone + a long-lived integration effort.
- **Feature** = one shippable slice → one `feature/*` branch → one PR into `develop`.
- **Task** = one atomic commit (conventional) within a feature branch.
## Epics (mapped to releases)
| Milestone | Epic | Example feature branches |
|-----------|------|--------------------------|
| **v1.0.0** | `epic/design-system` | `feature/design-tokens` · `feature/app-shell` · `feature/themes-dark-light` |
| **v1.0.0** | `epic/search-core` | `feature/search-ranking` · `feature/fts-multifield` · `feature/search-fuzzy-trgm` · `feature/search-chips` · `feature/saved-recent-searches` · `feature/search-why-matched` · `feature/keyset-pagination` |
| **v1.0.0** | `epic/perf-and-debt` | `feature/trgm-indexes` · `feature/virtualised-list` · `feature/code-splitting` · `fix/ef-queryfilter-warning` · `feature/language-aware-fts` |
| **v1.0.0** | `epic/ai-foundation` | `feature/ai-abstraction` · `feature/embedding-provider` · `feature/ai-vram-guard` · `feature/ai-thread-summary` · `feature/ai-followup-detect` · `feature/ai-reply-suggest` |
| **v1.1.0** | `epic/assisted-search` | `feature/nl-search-parse` · `feature/people-search` · `feature/attachment-filename-search` · `feature/search-bulk-actions` |
| **v1.1.0** | `epic/ai-productivity` | `feature/inbox-brief` · `feature/task-calendar-extract` · `feature/categorise-tail` · `feature/priority-v1` |
| **v1.2.0** | `epic/semantic` | `feature/pgvector-schema` · `feature/embedding-backfill` · `feature/semantic-search` · `feature/find-similar` · `feature/near-dup` · `feature/thread-insights` |
| **v2.0.0** | `epic/ambitious-ai` | `feature/ask-your-inbox-rag` · `feature/entity-facets` · `feature/attachment-ocr` · `feature/phishing-reasoning` · `feature/knowledge-graph` |
| **v3.0.0** | `epic/platform` | `feature/rules-engine` · `feature/collaboration` · `feature/multi-account` · `feature/plugin-api` · `feature/mobile` |
## Branch naming
- `feature/<kebab-scope>` · `fix/<kebab>` · `hotfix/<kebab>` (off `main`) · optional
`release/x.y.0` for stabilisation. Epics tracked via milestone/label, not a long branch
(avoid merge hell); features integrate continuously into `develop`.
## Commit strategy
- **Conventional Commits** (already used): `type(scope): summary`. Types drive SemVer:
`feat`→minor, `fix`→patch, `feat!`/`BREAKING CHANGE`→major.
- One logical change per commit; **docs updated in the same commit/PR** as the behaviour
they describe (enforced by review checklist — see below).
## PR strategy
- `feature/* → develop`, **squash-merge**; `develop → main`, **merge commit** (release boundary).
- **Required checks** (already enforced): `CI/backend`, `CI/frontend`, `Security/secrets`,
`Security/dependencies`. Merge to `develop` auto-deploys **staging**.
- **PR checklist:** tests added (unit + integration for API changes) · docs updated ·
AI features have a **Null/AI-off path** · no secret committed · perf-sensitive paths have
an index/plan note.
## Release milestones (tags on `main`)
| Tag | Contents | Gate |
|-----|----------|------|
| `v1.0.0` | Redesign + deterministic search + AI foundation & first wins | Redesign parity + search benchmarks green |
| `v1.1.0` | Assisted search + AI productivity | NL parse accuracy + extraction quality bar |
| `v1.2.0` | Semantic tier | Backfill complete + hybrid-rank quality bar |
| `v2.0.0` | Ambitious AI (RAG, vision, graph) | RAG citation accuracy + VRAM stability |
| `v3.0.0` | Platform (collab, rules, plugins, mobile) | — |
- Cutting a tag = the production-promotion action (see [../WORKFLOW.md](../WORKFLOW.md) §56);
`deploy-prod.yml` fires on `v*` once the Linux server + its runner exist.
- Interim work ships as `0.x`/pre-release increments on `develop`; `v1.0.0` is the first
"world-class" cut.
## Docs-alongside-code (hard rule)
Every feature PR updates the relevant doc: `docs/discovery/*` decisions graduate into
`docs/` living docs (architecture, search, AI, API reference) as they're implemented, and
`CHANGELOG.md` gains an entry. Discovery docs are the *source*; implementation keeps them true.
+37
View File
@@ -0,0 +1,37 @@
# 11 — Risk Assessment & Future Opportunities
## Risk assessment
Likelihood (L) / Impact (I): H/M/L.
| # | Risk | L | I | Mitigation |
|---|------|---|---|------------|
| R1 | **Full UI redesign destabilises a working app** | M | H | Strangler migration behind `ui.v2` flag, screen-by-screen; API untouched; ship via the proven `develop→staging` pipeline; keep old screens until parity verified |
| R2 | **VRAM (10 GB) can't hold desired models concurrently** | M | M | 78B sweet-spot (not 14B); embeddings hot + LLM warm + vision on-demand; VRAM guard serialises heavy jobs; small models for high-volume paths |
| R3 | **Local AI quality/latency disappoints** | M | M | Traditional-first (AI only where it clearly wins); AI advisory + optional; stream responses; cache; model routing lets us swap models without code change |
| R4 | **Prompt injection via email content** | M | H | Treat all model output as advisory; **AI never triggers actions**; human/rule confirms; sanitise; SSRF/egress guards; local-only by default |
| R5 | **Gmail API quota / sync scale at 100k+ mailboxes** | M | M | Batching + Polly backoff (exists); incremental sync; background enrichment queue with backpressure; keyset pagination |
| R6 | **Semantic infra (pgvector/embeddings) ops complexity** | M | M | Introduce once (v1.2), backfill worker VRAM-aware + resumable; HNSW tuning; feature hides if unavailable |
| R7 | **Search relevance regressions vs today** | L | M | Lexical hits never lose to fuzzy noise (weighting); benchmark suite as a release gate; keep date-sort as a user option |
| R8 | **Scope creep — trying to beat everyone at once** | H | M | Roadmap value/effort discipline; MVP is deterministic + small AI; platform features deferred to v3 |
| R9 | **Solo-dev bandwidth / single-machine staging** | H | M | Small shippable features; CI/CD automation already reduces toil; staging owned by automation (don't hand-run it — see memory note) |
| R10 | **Mainstream-vs-power tension dilutes the UX** | M | M | Progressive disclosure: simple default, power revealed on demand; pointer-first with keyboard as accelerator |
| R11 | **Privacy promise broken by a cloud provider option** | L | H | Local default; cloud is explicit per-feature opt-in with egress logging + consent; never silent |
| R12 | **pgvector image / Ollama container adds deploy friction** | L | L | Optional Compose profiles (`ai`); AI-off deployments omit them entirely |
## Future opportunities (beyond v3.0)
- **Additional mail backends** — IMAP/JMAP, Outlook/Graph — become a true multi-provider client.
- **On-device personalisation** — light fine-tuning / user-preference adapters for priority & tone.
- **Calendar & tasks integration** — close the loop from extraction to action.
- **Voice** — dictate replies, "ask your inbox" by voice (local Whisper).
- **Plugin marketplace** — third-party analyzers/widgets on the analyzer + command-palette APIs.
- **Team knowledge base** — shared, permissioned knowledge graph across a team inbox.
- **Native desktop shell** (Tauri) for OS integration, global hotkey, tray, true multi-window.
- **Smart compose surfaces** — templates that learn, snippet library, per-recipient tone memory.
- **Local model upgrades** — swap in newer/quantised models as they ship (router makes it a config change).
## Recommendation
The foundation is strong and the wedge is real. **Proceed with v1.0.0 (redesign +
deterministic search + AI foundation)** — it's high-value, low-risk, and independent of AI
being enabled — then layer local AI in value order. The biggest watch-items are **R1
(migration discipline)** and **R8 (scope)**; both are controlled by the strangler approach
and the value/effort-sequenced roadmap.
+70
View File
@@ -0,0 +1,70 @@
# Executive Summary — InboxIntel Discovery Blueprint
**Ambition:** become one of the best **email search and management** experiences available
— fast, scalable, secure, intuitive — with **AI used only where it clearly beats
traditional approaches**, running **locally and privately**, and **never as a hard
dependency**.
## The opportunity (the wedge)
The market has split in two, and both halves leave a gap:
- **AI, but cloud** (Gmail/Gemini, Outlook/Copilot, Shortwave, Spark) — useful, but your
mail is processed off-device.
- **Private, but little/no AI** (Proton, Thunderbird, Fastmail).
**No mainstream product owns "genuinely useful AI that runs on your own machine."**
InboxIntel can — and it already has the seams for it. Combined with three more openings —
**approachable power-search as the home screen**, **explainability** ("why this matched /
was categorised"), and **fast *and* friendly** (Superhuman is fast-but-intimidating; casual
apps are friendly-but-slow) — this is a defensible, differentiated position.
> **Positioning:** *Make search the fastest way to think about your inbox, with AI that runs
> on your own machine and always explains itself. Powerful for pros, simple for anyone.*
## Current state — verdict: extend, don't rewrite
A clean, secure, well-tested .NET 8 / React foundation with **real Postgres full-text
search** and **an AI provider abstraction already in place** (`NullAiProvider` /
`OllamaProvider` / `OpenAiProvider`). The gaps are exactly where the product wants to win:
relevance-ranked multi-mode search, a richer AI contract (embeddings/extraction),
conversation intelligence, and a modern approachable UX. **None require a rewrite.**
## Design direction (from the interview)
Notion/Arc-**professional**, "anyone can pick it up": **pointer-first & discoverable**
(palette/shortcuts as accelerators), **balanced density**, **dark-first** (genuine light
too), **subtle motion**, **green `#3ba31f`** accent, clean rounded line icons, **fully
responsive** desktop→mobile. The pivotal decision — *discoverable-first over keyboard-first*
— reframes search from "a syntax you learn" into "an inviting, assisted experience."
## Search & AI in a nutshell
- **Search:** a 4-layer engine — Structured → **Lexical+Ranking** (always on) → Semantic
(pgvector) → AI-assisted (NL / ask-your-inbox / explanations) — with **hybrid RRF ranking**
replacing today's date-only sort. Degrades gracefully with AI off.
- **AI:** extend the abstraction to embeddings + structured output behind a task facade with
**config-driven model routing**; on the **RTX 3080 (10 GB)**, **Qwen2.5-7B** (warm) +
**nomic-embed-text** (hot) do most jobs, vision on-demand. Traditional-first everywhere;
every AI feature has a non-AI fallback.
## Roadmap shape
**v1.0** redesign + world-class *deterministic* search + AI foundation → **v1.1** assisted
search & productivity → **v1.2** semantic tier → **v2.0** ambitious AI (RAG, vision, graph)
**v3.0** platform (collaboration, rules, plugins, mobile). Sequenced so **deterministic
value ships before AI**, infrastructure is amortised, and **every release is complete with
AI disabled.**
## Key risks
Migration of a full redesign (**mitigated by strangler + `ui.v2` flag**), VRAM limits
(**78B sweet spot + load policy**), prompt injection (**AI advisory-only, never acts**), and
scope creep (**value/effort-sequenced roadmap**). See [11](11-risks-and-future.md).
## Recommendation
**Proceed to v1.0.0** — the redesign, ranked/assisted search, and AI foundation. It's the
biggest quality jump, it's low-risk and deterministic, and it stands entirely on its own
without AI. Then layer local, private, explainable AI in value order.
---
### Read the full blueprint
[01 Architecture](01-architecture-review.md) · [02 Competitors](02-competitor-analysis.md) ·
[03 Users](03-user-research.md) · [04 UX + Design System](04-ux-redesign-and-design-system.md) ·
[05 Search](05-search-redesign.md) · [06 AI Strategy](06-ai-strategy.md) ·
[07 AI Features](07-ai-feature-catalogue.md) · [08 Architecture](08-technical-architecture.md) ·
[09 Roadmap](09-roadmap.md) · [10 Git Plan](10-git-plan.md) ·
[11 Risks & Future](11-risks-and-future.md) · [Design Brief](00-design-brief.md)
+48
View File
@@ -0,0 +1,48 @@
# InboxIntel — Discovery & Blueprint
This folder is the **product discovery output**: a world-class specification that will
become the blueprint for the next generation of InboxIntel. It is a **planning
artifact** — no application code is changed during discovery.
> Vision: become one of the best **email search and management** experiences available —
> extremely fast, scalable, secure, and intuitive. AI is introduced **only where it
> clearly beats traditional approaches**, and never as a hard dependency.
## Non-negotiable constraints (carried into every phase)
- **AI is optional & modular.** The app must work fully with AI disabled. All AI sits
behind an `IAIProvider` abstraction (already seeded: `NullAiProvider` / `OllamaProvider`
/ `OpenAiProvider`) so providers swap without touching application logic.
- **Local-first AI.** Primary target: Ollama on an NVIDIA RTX 3080 (10 GB).
- **Privacy.** Read-only Gmail scope; AI advisory-only; no destructive AI actions.
- **Speed & security are features**, not afterthoughts.
## Document index
> **Start here:** [Executive Summary](EXECUTIVE-SUMMARY.md)
| # | Document | Phase | Status |
|---|----------|-------|--------|
| — | [Executive Summary](EXECUTIVE-SUMMARY.md) | all | ✅ draft |
| 00 | [Design Brief](00-design-brief.md) | 4A (interview) | ✅ locked |
| 01 | [Architecture Review](01-architecture-review.md) | 1 | ✅ draft |
| 02 | [Competitor Analysis](02-competitor-analysis.md) | 2 | ✅ draft |
| 03 | [User Research & Journey Maps](03-user-research.md) | 3 | ✅ draft |
| 04 | [UX/UI Redesign + Design System](04-ux-redesign-and-design-system.md) | 4A | ✅ draft |
| 05 | [Search Redesign](05-search-redesign.md) | 4B | ✅ draft |
| 06 | [AI Strategy & Model Recommendations](06-ai-strategy.md) | 5 | ✅ draft |
| 07 | [AI Feature Catalogue](07-ai-feature-catalogue.md) | 6 | ✅ draft |
| 08 | [Technical Architecture](08-technical-architecture.md) | 7 | ✅ draft |
| 09 | [Product Roadmap (MVP→v3)](09-roadmap.md) | 8 | ✅ draft |
| 10 | [Git Implementation Plan](10-git-plan.md) | 10 | ✅ draft |
| 11 | [Risk Assessment & Future Opportunities](11-risks-and-future.md) | cross-cutting | ✅ draft |
## Extension designs
- [**Multi-Provider Email Platform + Admin/Settings**](multi-provider/README.md) — evolves
InboxIntel into a small-team, multi-provider (Gmail/Outlook/IMAP) platform with OAuth-as-
login, settings, feature flags, and an admin panel. Reshapes the core assumptions above
(see the "Update" callouts in [08](08-technical-architecture.md) and [09](09-roadmap.md)).
## Conventions
- Every recommendation is a durable markdown doc suitable for long-term maintenance.
- Each feature is specified with: **problem solved · why AI (or why not) · complexity ·
estimated effort · user value · perf & privacy considerations**.
- Nothing here is implemented until explicitly approved and scheduled via the Git plan.
@@ -0,0 +1,80 @@
# 01 — Provider Abstraction (Part 1)
A unified layer so Gmail, Outlook/Graph, and future IMAP look identical to the rest of the
app. **No provider-specific logic in Domain**; specifics live in Infrastructure adapters.
## Layering
```
Domain : Account, EmailMessage, EmailThread, Label (provider-agnostic)
Application : IEmailProvider (contract) · ISyncOrchestrator · DTOs
Infrastructure : GmailProvider · OutlookProvider · ImapProvider (adapters)
ProviderFactory (ProviderType → adapter) · ITokenStore
```
## The contract
```csharp
public enum ProviderType { Google, Microsoft, Imap }
public interface IEmailProvider {
ProviderType Type { get; }
ProviderCapabilities Capabilities { get; } // read, modifyFlags, folders, delta, send?
// Auth (details in 02-auth-and-signin.md)
Task<OAuthResult> ExchangeCodeAsync(string code, CancellationToken ct);
Task<TokenSet> RefreshAsync(TokenSet current, CancellationToken ct);
Task<ProviderIdentity> GetIdentityAsync(TokenSet tokens, CancellationToken ct); // sub + email
// Sync (pull-based, incremental)
Task<SyncPage> SyncAsync(SyncCursor cursor, TokenSet tokens, CancellationToken ct);
Task<RawMessage> FetchMessageAsync(string providerMessageId, TokenSet tokens, CancellationToken ct);
// Mutations (only if Capabilities allow; mirrors current gmail.modify scope)
Task ApplyFlagAsync(string providerMessageId, MailFlagChange change, TokenSet tokens, CancellationToken ct);
}
```
- `SyncPage` = `{ IReadOnlyList<RawMessage> upserts, IReadOnlyList<string> deletes, SyncCursor next, bool hasMore }`.
- `RawMessage` is the **provider-shaped** payload; a **normaliser** maps it to the domain
`EmailMessage`. The rest of the app never sees `RawMessage`.
- Capabilities let the UI/engine **degrade gracefully** (e.g., an IMAP server without
CONDSTORE falls back to full-scan sync; no `send` today for any provider).
## Provider implementations
| Provider | API | Incremental cursor | Threading | Folders/Labels | Notes |
|----------|-----|--------------------|-----------|----------------|-------|
| **Gmail** | Gmail REST | `historyId` (History API) | `threadId` | labels | Reuses existing client; read + modify (no send), as today |
| **Outlook/365** | Microsoft Graph | **delta query** `@odata.deltaLink` | `conversationId` | mailFolders | OAuth via Microsoft identity platform |
| **IMAP** | IMAP4rev1 | `UIDVALIDITY`+`UIDNEXT`, `HIGHESTMODSEQ` (CONDSTORE/QRESYNC) | heuristic (References/In-Reply-To) | folders | Fallback = periodic UID scan if no CONDSTORE; MailKit already a dependency |
## Normalisation (the unified model)
Each adapter maps provider fields → domain via a `IMessageNormaliser`:
| Domain field | Gmail | Graph | IMAP |
|--------------|-------|-------|------|
| `ProviderMessageId` | message id | message id | `UIDVALIDITY:UID` |
| `ProviderThreadId` | threadId | conversationId | derived (References) |
| flags (unread/star/important/trashed/inbox) | labelIds | isRead/flag/folder | `\Seen \Flagged`, folder |
| labels/folders | labels | mailFolders | folders |
| sent/received, from, subject, snippet, body, attachments, size | headers/parts | message resource | RFC822 parse (MailKit) |
- **Threads are per-account** (each provider defines its own). *Cross-provider* thread
linking is a later semantic/AI feature (see blueprint [07](../07-ai-feature-catalogue.md)),
not part of core normalisation.
## Sync engine
- **`ISyncOrchestrator`** replaces the Gmail-specific worker: for each active `Account`, it
loads the `SyncCursor`, calls `provider.SyncAsync`, **upserts** normalised messages
(idempotent on `(AccountId, ProviderMessageId)`), applies deletes, and **persists the next
cursor** atomically.
- Runs as the existing hosted-worker pattern (`AccountSyncWorker`), one logical job per
account, bounded concurrency, Polly backoff, resumable.
- **Token refresh:** `SyncAsync`/mutations get a valid `TokenSet` from `ITokenStore`, which
refreshes on expiry/401 and **re-encrypts** at rest; a failed refresh flips the account to
`reauth_needed` (surfaced in UI, see [07](07-ux-flows.md)) — never crashes sync.
- **New mail** triggers AI enrichment + embedding jobs (blueprint [08](../08-technical-architecture.md)).
## Search across providers
Because all providers normalise into **one `email_messages` store scoped by `UserId`**,
search (structured + FTS + semantic) **already spans every account a user has connected**
no per-provider search code. An optional `AccountId` facet lets users scope to one mailbox.
## Extensibility
Adding a provider = one `IEmailProvider` adapter + one normaliser + register in
`ProviderFactory` + a feature flag to enable it. **Zero changes to Domain, search, or AI.**
@@ -0,0 +1,70 @@
# 02 — Auth & Sign-in (Part 2)
**OAuth is the login.** No passwords. A user's identity is the set of provider accounts
linked to them; any one can authenticate the session. Identity key is **`(provider, sub)`**
— never email (emails change; `sub` is stable, and the same email can exist on Google *and*
Microsoft as distinct accounts).
## Provider-selection sign-in (first-time)
```
[ Choose how to sign in ]
▸ Continue with Google ▸ Continue with Microsoft ( ▸ IMAP — future )
```
1. User picks a provider → redirect to provider OAuth (**PKCE**, `state`, `nonce`).
2. Callback → exchange code → `GetIdentityAsync` returns `(provider, sub, email, name)`.
3. **Resolve:** look up `accounts (provider, sub)`.
- **No match →** first-time. Create `user` (**first user ever = Admin**, otherwise `Member`
if `system_settings.registration_open`, else reject) + `account` (`is_login_identity=true`)
+ encrypted `provider_tokens`. Start session.
- **Match →** existing user. Refresh tokens, start session.
4. Kick off the account's initial sync.
## Adding another account later (linking) — the security-critical flow
The user is **already authenticated**. "Add account" → provider OAuth in **link mode**:
- Callback identity `(provider, sub)`:
- **Unlinked →** attach a new `account` (mailbox) to the **current** user. ✅
- **Already linked to *this* user →** no-op / "already connected."
- **Already linked to *another* user →** **blocked** by the unique `(provider, provider_account_id)`
constraint + explicit check → error "This mailbox is connected to a different InboxIntel
user." **This is the anti-hijack guarantee** — you can only link an identity you can
authenticate *and* that no one else owns.
- A single user can hold **N accounts** across Google/Microsoft/IMAP; each can also serve as a
login identity (any of them signs you into the same user).
## Switching
- **Switch mailbox (same user):** an **account switcher** changes the active mailbox context
(or "All accounts" unified view). No re-auth — it's all one user. Search can scope to one
account or span all.
- **Switch user (different person):** full sign-out → sign-in. Optional "fast switch" could
hold multiple sessions, but for a small team, explicit re-auth is simplest and safest.
## Sessions
- **Opaque server-side session** (`sessions` table) referenced by an **HttpOnly · Secure ·
SameSite=Lax** cookie. **Provider tokens are never exposed to the browser.**
- Rotate session id on login (anti-fixation); **idle (e.g., 7d) + absolute (e.g., 30d)**
expiry; revoke on logout; **"sign out everywhere"** and **admin revoke** delete session rows.
- CSRF: SameSite + anti-CSRF token on state-changing requests.
## Token lifecycle
- Stored **encrypted at rest** (Data Protection); decrypted only in-memory for API calls.
- **Refresh** on expiry/401 via `ITokenStore` → re-encrypt + persist; failure flips account to
**`ReauthNeeded`** (banner + "Reconnect" CTA, see [07](07-ux-flows.md)) — sync/AI for that
account pause, the rest of the app is unaffected.
## Provider OAuth specifics
| | Google | Microsoft (Graph) |
|--|--------|-------------------|
| Endpoint | accounts.google.com | login.microsoftonline.com (`common`) |
| Scopes | `openid email profile gmail.readonly gmail.modify` | `openid email profile offline_access Mail.Read Mail.ReadWrite` |
| Identity | `sub` (+ verified email) | `oid`/`sub` (+ email) |
| Refresh | refresh_token (offline) | refresh_token (`offline_access`) |
| Redirect | `/signin/google` | `/signin/microsoft` |
- **Least privilege:** request read/modify only (no send today — matches current posture).
Extra scopes are added per-feature with consent, never up-front.
## Edge cases
- **Same email, two providers** → two distinct accounts (identity is `sub`), unless the user
links both to one InboxIntel user.
- **Provider disabled by admin flag** (`provider.microsoft=false`) → hide it on the picker;
existing accounts of that provider pause sync and show a notice.
- **Reused browser / stale cookie** → session validated server-side each request; revoked/expired → re-auth.
@@ -0,0 +1,79 @@
# 03 — Database Design (Part 6)
Schema for a **small-team, self-hosted, one-org** platform: multi-provider accounts per
user, a normalised email store scaling to **millions of messages**, settings, feature
flags, and audit. PostgreSQL + EF Core (+ pgvector for the blueprint's semantic tier).
## Entity map
```
users ─┬─< accounts ─┬─< provider_tokens (1:1, encrypted)
│ ├─< email_threads ─< email_messages ─┬─< attachments
│ │ └─< message_labels >─ labels
│ └─ sync_state (cursor)
├─< user_settings (1:1)
└─< audit_logs (actor)
system_settings (singleton) feature_flags
```
## Tables
### Identity & access
- **`users`** — the app identity (from OAuth).
`id (uuid pk) · primary_email (citext, unique) · display_name · avatar_url · role (enum: Admin|Member) · status (enum: Active|Suspended) · created_at · last_login_at`
*First-ever user is bootstrapped as **Admin** (see [05](05-admin-system.md)).*
- **`accounts`** — a connected mailbox **and** a login identity (OAuth-is-login).
`id (uuid pk) · user_id (fk) · provider (enum: Google|Microsoft|Imap) · provider_account_id (text, the OAuth 'sub' — immutable) · email (citext) · display_name · is_login_identity (bool) · status (enum: Active|ReauthNeeded|Disabled) · scopes (text[]) · added_at · last_sync_at`
**Unique:** `(provider, provider_account_id)` → resolves an OAuth login to exactly one account→user; prevents the same mailbox linking twice.
- **`provider_tokens`** — 1:1 with `accounts`, **encrypted at rest** (Data Protection API).
`account_id (pk/fk) · access_token_enc (bytea) · refresh_token_enc (bytea) · expires_at_utc · token_type · rotated_at`
*Never logged; see [06](06-security-model.md).*
- **`sessions`** — server-side app sessions (opaque cookie).
`id · user_id · created_at · expires_at · ip · user_agent · revoked_at` (supports "sign out everywhere" + admin revoke).
### Email (normalised, provider-agnostic)
- **`email_threads`** — per account.
`id (uuid pk) · account_id (fk) · user_id (denorm) · provider_thread_id · subject · participants (jsonb) · message_count · last_message_at`
**Unique:** `(account_id, provider_thread_id)`.
- **`email_messages`** — the big table.
`id (uuid pk) · account_id (fk) · user_id (denorm) · thread_id (fk) · provider_message_id · sender_id (fk) · subject · snippet · body_text · sent_at_utc · received_at_utc · size_bytes · flags (unread/starred/important/in_inbox/trashed as bits or bools) · has_attachments · category (enum) · has_list_unsubscribe · supports_one_click_unsub · search_vector (tsvector, generated, weighted) · embedding (vector(768), nullable) · created_at`
**Unique:** `(account_id, provider_message_id)` (idempotent upsert).
- **`labels`** (`id · account_id · provider_label_id · name · type`) + **`message_labels`** (`message_id · label_id`, pk both).
- **`attachments`** (`id · message_id · filename · mime · size · provider_attachment_id · content_text nullable` for future OCR/search).
- **`senders`** / **`domains`** (existing) — kept, scoped per user (or global with per-user stats materialised in `sender_importance`).
### Settings, flags, audit
- **`user_settings`** — 1:1 with `users`.
`user_id (pk) · theme (enum: system|light|dark) · inbox_layout (jsonb) · notifications (jsonb) · ai_prefs (jsonb) · provider_prefs (jsonb) · updated_at`
- **`system_settings`** — singleton (org-wide, admin-managed).
`id (const) · maintenance_mode (bool) · default_theme · registration_open (bool) · updated_by · updated_at` (+ arbitrary `values jsonb` for growth).
- **`feature_flags`** — the flag system (drives AI gating).
`key (pk text) · enabled (bool) · scope (enum: SystemOnly|UserOverridable) · description · rollout (jsonb, e.g. per-role) · updated_by · updated_at`
Seeded flags: `ai.enabled`, `ai.semantic_search`, `ai.ask_inbox`, `provider.google`, `provider.microsoft`, `provider.imap`, `maintenance.readonly`.
- **`audit_logs`** — admin + security events.
`id (bigserial) · actor_user_id (fk, nullable for system) · action (text) · target_type · target_id · metadata (jsonb) · ip · created_at`
Append-only; indexed on `(created_at)`, `(actor_user_id)`, `(action)`.
## Indexing & performance
| Index | Column(s) | For |
|-------|-----------|-----|
| GIN | `email_messages.search_vector` | FTS |
| GIN `pg_trgm` | sender/subject | fuzzy (fixes non-sargable `.Contains`) |
| HNSW | `email_messages.embedding` | semantic k-NN |
| btree | `(user_id, sent_at_utc desc)`, `(account_id, sent_at_utc desc)`, `(thread_id)` | keyset pagination, scoping, threading |
| unique | `(account_id, provider_message_id)`, `(provider, provider_account_id)` | idempotency, login resolution |
## Scalability (millions of emails, multi-account, incremental)
- **Multi-account** = first-class via `accounts`; every email carries `account_id` + denormalised `user_id` (so cross-account per-user search is a single indexed scan).
- **Millions of rows:** keyset (cursor) pagination, top-N-by-score ranking, `pg_trgm`/GIN/HNSW indexes. If a single user exceeds ~12M messages, **list-partition `email_messages` by `account_id`** (or hash by `user_id`).
- **Incremental sync:** per-account `sync_state` cursor (`historyId` / `deltaLink` / `uidvalidity+modseq`) → only deltas fetched; idempotent upserts on the unique key.
- **Idempotency & resumability:** all sync writes keyed on `(account_id, provider_message_id)`; cursor advanced atomically with the batch.
## RBAC in the schema
Small-team model = a `role` column on `users` (`Admin|Member`) — no separate roles/permissions tables yet. **Extensible** to `roles`/`permissions`/`org_id` if this ever grows to multi-tenant, without reshaping the email tables.
## Migration from today (see full [Migration Guide](12-migration-guide.md))
1. Create `users` from existing OAuth identity; set first user = **Admin**.
2. Create one `Google` **`account`** per existing user; move current Gmail tokens → `provider_tokens`.
3. Backfill `email_messages.account_id`/`user_id`, rename/extend from the current `Email` table; widen `search_vector`; add nullable `embedding`.
4. Add `user_settings`, `system_settings`, `feature_flags` (seed `ai.enabled` from the current `Ai:Mode`), `audit_logs`.
5. All additive + backfill; no destructive step — safe to run behind a maintenance window.
@@ -0,0 +1,68 @@
# 04 — Settings & Feature Flags (Part 3)
Two layers of configuration — **per-user preferences** and **org-wide system settings /
feature flags** — with a clear precedence. Critically: **AI is governed by a feature flag
(admin, global), not merely a user preference.**
## User settings (per user)
Stored in `user_settings`; editable by the user.
| Setting | Values |
|---------|--------|
| `theme` | system · light · dark (dark-first default) |
| `inbox_layout` | density, pane layout, default view/lane, per-account or unified |
| `notifications` | channels, quiet hours, priority-only |
| `ai_prefs` | per-feature opt-in (summaries, replies, semantic, ask-inbox…) — **only effective if the flag allows** |
| `provider_prefs` | default account, sync frequency, signature per account |
## System settings (admin, org-wide)
Stored in `system_settings` (singleton); Admin-only ([05](05-admin-system.md)).
- `maintenance_mode` (off · read-only · locked-except-admin) · `default_theme` ·
`registration_open` · org display name · retention defaults.
## Feature flag system
`feature_flags` rows: `key · enabled · scope · rollout · description · updated_by/at`.
- **`scope = SystemOnly`** — a hard org-wide switch; users cannot override (e.g., `provider.microsoft`, `maintenance.readonly`).
- **`scope = UserOverridable`** — a default that a user preference can turn *off* (never *on* beyond what the flag permits) — e.g., `ai.summaries`.
- **`rollout`** (jsonb) — optional per-role/percentage gating (e.g., enable a beta for Admins first).
### Evaluation service
```csharp
public interface IFeatureFlags {
bool IsEnabled(string key, UserContext user); // system flag ∧ scope ∧ role rollout
}
public interface IAiGate { // the AI-specific resolver
bool IsAiEnabled(UserContext user); // ai.enabled (system) ∧ user.ai_prefs.master
bool IsAiFeatureEnabled(string feature, UserContext user); // ∧ ai.<feature> ∧ user opt-in
}
```
- Flags are **cached** with change notification (hot-reload on admin edit); every read is cheap.
- All flag reads are **fail-closed**: unknown/errored flag ⇒ treated as **off**.
## AI gating precedence (the key requirement)
Effective AI availability is an **AND** down a chain — the system flag is the master gate:
```
AI feature X is available for user U ⇔
feature_flags["ai.enabled"].enabled (admin master switch — SYSTEM)
∧ feature_flags["ai." + X].enabled (per-feature flag — SYSTEM)
∧ providerCapability(X) (Ollama/provider actually available)
∧ user.ai_prefs.master_opt_in (user hasn't disabled AI for themselves)
∧ user.ai_prefs[X] (user opted into this feature)
```
- **Admin turns `ai.enabled` off ⇒ AI vanishes for everyone**, regardless of any user
preference. This is the behaviour the brief mandates.
- With AI on at the system level, users still choose per-feature. The **Null AI provider +
capability flags** (blueprint [06](../06-ai-strategy.md)) mean a disabled path **falls back
or hides** — never errors, never blocks core email.
## Settings precedence (general)
```
system default → feature flag (may hard-disable) → user override (only where UserOverridable)
```
## Maintenance mode
- `read-only`: mutations (send/cleanup/label) blocked with a banner; browsing/search stay up.
- `locked-except-admin`: only Admins can use the app (for migrations/upgrades).
- Enforced at the API via a middleware policy + surfaced as a global banner in the UI.
## Auditing
Flag and system-setting changes are **admin actions → audit-logged** (who/what/old→new/when).
@@ -0,0 +1,45 @@
# 05 — Admin System (Part 4)
An Admin-only panel to run the instance. **Admins manage the platform, not people's
inboxes** — no admin route can read another user's mail (see [06](06-security-model.md)).
## Sections
| Section | Admin can | Notes |
|---------|-----------|-------|
| **Users** | List users; view role/status/last-login; **promote/demote** (Admin↔Member); **suspend/reactivate**; **revoke sessions**; remove user (with data-deletion policy) | **Never** view a user's email contents |
| **Feature flags** | List all flags; toggle `enabled`; set scope/rollout; per-role rollout | Includes AI + provider flags |
| **AI (global)** | Master `ai.enabled` toggle + per-feature (`ai.summaries`, `ai.semantic_search`, `ai.ask_inbox`…); see Ollama/model status | Off ⇒ AI hidden for everyone ([04](04-settings-and-flags.md)) |
| **Providers** | Enable/disable `provider.google` / `provider.microsoft` / `provider.imap` | Disabled ⇒ hidden on login picker; existing accounts pause |
| **System config** | Maintenance mode (off/read-only/locked); `registration_open`; org name; default theme; retention | Sensitive → step-up + audit |
| **Monitoring** | Basic health overview (below) | Read-only |
| **Audit log** | Search/filter admin + security events | Append-only |
## Monitoring overview (basic)
- **Sync health:** per-account last-sync time, `ReauthNeeded` count, error rate; job-queue depth.
- **AI/Ollama:** reachable? loaded models, VRAM headroom, recent latency, failure rate.
- **Sessions:** active session count; recent logins.
- **System:** DB size / message count; background-job backlog; recent errors (from Serilog).
- Deliberately **overview-only** — deep observability is a future opportunity, not v1.
## Access control & bootstrap
- Every admin route requires the **Admin policy**; sensitive mutations require **confirmation/
step-up** + are **rate-limited** and **audited**.
- **Bootstrap:** the first user to sign in becomes **Admin** (one-time). Afterwards, admin is
granted only by an existing Admin (audited, forces target session refresh so new/removed
privileges take effect immediately).
- Guardrails: an Admin cannot demote/suspend the **last remaining Admin** (lock-out prevention).
## Audit logging (what's recorded)
Actor · action · target (user/flag/setting/provider) · old→new · ip · timestamp — for **all**
admin mutations and security events (role change, flag toggle, provider disable, maintenance
on/off, session revoke, user suspend). Append-only `audit_logs`; visible in the Audit section;
exportable.
## API surface (Admin-scoped, all audited)
```
GET/PATCH /admin/users /admin/users/{id}/role /admin/users/{id}/status
GET/PATCH /admin/flags /admin/flags/{key}
GET/PATCH /admin/system-settings
GET /admin/monitoring /admin/audit
```
All behind the Admin policy + maintenance-aware middleware.
@@ -0,0 +1,79 @@
# 06 — Security Model (Part 5)
Builds on the existing hardening (read-only scope, encrypted tokens, IDOR global query
filters, SSRF egress guard, non-root containers, confirmed destructive actions) and adds
what multi-user + admin + multi-provider require.
## RBAC
- Roles: **Admin** · **Member** (small-team, one org). First user bootstraps as Admin.
- Enforced by **policy-based authorization** at the API (ASP.NET Core policies), not in the UI.
| Capability | Member | Admin |
|------------|:------:|:-----:|
| Read/manage **own** mail & accounts | ✅ | ✅ |
| Own user settings | ✅ | ✅ |
| Link/unlink **own** provider accounts | ✅ | ✅ |
| View/manage **other users** | ❌ | ✅ |
| Toggle **feature flags** (incl. AI global) | ❌ | ✅ |
| Enable/disable **providers** | ❌ | ✅ |
| **Maintenance mode**, system settings | ❌ | ✅ |
| View **audit log** & monitoring | ❌ | ✅ |
- **No cross-user data access, ever** — Admin manages *accounts/flags/system*, **not** other
users' email contents (privacy). Admin power is over the *platform*, not people's inboxes.
## OAuth token storage
- Refresh/access tokens **encrypted at rest** with the Data Protection API (AES); keys persist
to the mounted `/keys` volume (existing). Decrypted **only in-memory** for the moment of an
API call. **Never logged, never sent to the browser.**
- 1:1 `provider_tokens` per account; rotation timestamped; a compromised/rotated token is
replaced atomically. Token columns are `bytea` ciphertext, not readable in DB dumps.
## Session handling
- Opaque **server-side sessions** (DB-backed) + HttpOnly/Secure/SameSite cookie; **id rotated
on login** (anti-fixation); idle + absolute expiry; server-side **revocation** (logout,
sign-out-everywhere, admin revoke, role change). CSRF via SameSite + token.
## Admin access protection
- Admin routes require the **Admin policy**; sensitive mutations (toggle AI global, disable a
provider, suspend a user, enter maintenance) require a **confirmation / step-up** and are
**rate-limited**.
- **Every admin action is audit-logged** (`audit_logs`: actor, action, target, metadata, ip,
time) — append-only.
- First-admin bootstrap is one-time; afterwards admin is grant-only by an existing Admin
(logged). Guard against privilege escalation: role changes are Admin-only + audited + force
session refresh.
## API security boundaries
- **Per-user isolation** via EF **global query filters** (extended to `account_id`/`user_id`)
so a query can *never* return another user's rows — the IDOR safeguard, now multi-account.
- **Input validation** (FluentValidation) on all DTOs; **mass-assignment safe** (explicit DTOs,
no entity binding).
- **Rate limiting** on auth, admin, search, and AI endpoints.
- **SSRF egress guard** (existing) constrains all outbound calls — provider APIs, IMAP hosts,
Ollama, and any opt-in cloud AI — to an allowlist; user-supplied IMAP hosts are validated.
- **Security headers** (CSP, HSTS, X-Frame-Options, etc.) via the reverse proxy/API; strict CORS.
## Multi-provider & AI specifics
- **Least-privilege scopes** per provider; extra scopes added per-feature with consent.
- **Provider isolation:** disabling a provider flag revokes its use cleanly; per-account tokens
are independent (one reauth doesn't affect others).
- **Prompt injection:** email content is untrusted → LLM output is **advisory only, never
triggers actions**; a human/rule confirms. AI runs **local by default**; cloud AI is explicit
opt-in with per-feature consent + egress logging.
- **Attachments/vision:** sandboxed parsing, size/type limits, never executed.
## Threat model (summary)
| Threat | Mitigation |
|--------|------------|
| Account hijack via linking | Must authenticate as target user; unique `(provider, sub)`; linking an owned identity blocked |
| Token theft / DB exposure | Encryption at rest; tokens never in logs/browser; rotation |
| Privilege escalation | Admin-only role changes, audited, session refresh; policy checks server-side |
| IDOR / cross-user leakage | Global query filters on user_id/account_id |
| CSRF / session fixation | SameSite + token; session id rotation; server-side revoke |
| SSRF (providers/IMAP/AI) | Egress allowlist guard; validate user-supplied hosts |
| Prompt injection | AI advisory-only; never acts; local-first |
| Mass admin abuse | Rate limit + step-up + full audit trail |
## Non-negotiables
Admins manage the platform, **not** users' inboxes · tokens encrypted & browser-invisible ·
every privileged action audited · AI never required and never acts autonomously.
@@ -0,0 +1,75 @@
# 07 — UX Flows (Part 7)
Applies the [Design Brief](../00-design-brief.md) (Notion/Arc-professional, dark-first,
green accent, pointer-first, responsive). **Keep it simple** — these are utility surfaces,
not the daily driver.
## 1. Provider selection on login
A calm, centred card — the *only* thing on screen.
```
InboxIntel
───────────────────────────
Sign in to continue
[ ▸ Continue with Google ]
[ ▸ Continue with Microsoft ]
( IMAP — coming soon, disabled )
───────────────────────────
Your email stays on your machine.
```
- Only **enabled** providers show (driven by `provider.*` flags).
- One click → provider OAuth → back into the app. First-timer lands on an empty, friendly
inbox with a "syncing your mail…" state.
## 2. Connected accounts page
Reached from the account switcher or Settings → Accounts.
```
Accounts [ + Add account ]
┌─────────────────────────────────────────────────────────┐
│ 🟢 Google me@gmail.com Synced 2m ago ⋯ │
│ 🟢 Microsoft me@outlook.com Synced 5m ago ⋯ │
│ 🟠 Google old@gmail.com Reconnect needed → [Reconnect]│
└─────────────────────────────────────────────────────────┘
```
- Status badges: 🟢 Active · 🟠 ReauthNeeded (with **Reconnect**) · ⚪ Disabled.
- Per-account row menu (⋯): set as default · sync now · rename · **remove account** (confirm +
explains local data deletion).
- **+ Add account** → provider picker → OAuth in link-mode → new row appears.
- **Account switcher** (top bar): "All accounts" (unified) or pick one to scope the inbox/search.
## 3. Settings page (user)
Left sub-nav, one panel at a time — no overwhelm.
```
Settings
Appearance ▸ Theme (System/Light/Dark) · density · layout
Accounts ▸ (the page above)
Notifications ▸ channels · quiet hours · priority-only
AI ▸ (visible only if ai.enabled; see below)
Privacy ▸ data, export, clear search history
```
## 4. AI toggle visibility
- If **`ai.enabled` (system) is OFF** → the **AI section is hidden entirely** (or shown as a
single disabled note: "AI features are turned off by your administrator"). No dead toggles.
- If **ON** → a master **"Use AI features"** switch (user opt-in) + per-feature toggles
(Summaries · Reply suggestions · Semantic search · Ask your inbox), each reflecting its
`ai.<feature>` flag. Toggling off a feature instantly falls back to the non-AI path.
- A small **status chip** ("Local · Ollama · ready") reassures it's on-device.
## 5. Admin dashboard
Only visible to Admins (nav item appears for the Admin role).
```
Admin
Overview ▸ sync health · Ollama/VRAM · sessions · errors (cards)
Users ▸ table: name · role · status · last login · [actions]
Flags ▸ toggles grouped: AI · Providers · Maintenance
System ▸ maintenance mode · registration · defaults
Audit ▸ searchable event log
```
- Clean tables + toggles; destructive/sensitive actions show a **confirm dialog** (step-up).
- Maintenance mode shows a **global banner** to all users while active.
## Cross-cutting
- **Reduced-motion & keyboard** reachability on all of the above (baseline a11y).
- **Empty/loading/error states** per the design system (skeletons, friendly empties, calm errors).
- Fully **responsive**: settings/admin sub-nav collapses to a top tab bar on mobile; the login
card is centred on all sizes.
@@ -0,0 +1,68 @@
# 08 — AI Feature-Flag Integration (Part 8)
How the AI layer plugs into the flag system while staying **completely separable** from core
email logic. Extends the blueprint AI strategy ([../06](../06-ai-strategy.md)); the gate
math lives in [04](04-settings-and-flags.md).
## Principle: AI is a guest, never a host
Core email (sync, search-lexical, cleanup, settings, admin) **never references an AI type**.
It calls domain services; those *optionally* consult AI through a single gate + facade. Remove
AI entirely and nothing in the core path breaks.
```
Core feature code
│ (never touches Ollama/IAiProvider directly)
IAiGate.IsAiFeatureEnabled("summaries", user) ──► false ─► non-AI path / hide
│ true
IInboxAi facade (Application) ──► model router ──► IAiProvider / IEmbeddingProvider
(Null | Ollama | future)
```
## Toggle behaviour (flag-driven)
- Before *any* AI call, code asks `IAiGate` (which folds in `ai.enabled` + `ai.<feature>` +
provider capability + user opt-in — the AND-chain from [04](04-settings-and-flags.md)).
- **Admin `ai.enabled` = off** ⇒ gate returns false everywhere ⇒ AI UI hidden, AI code paths
skipped. Flip on ⇒ features reappear (hot-reloaded flag cache) with **no redeploy**.
- Per-feature flags allow shipping AI features **dark** and enabling gradually (rollout).
## Fallback when AI is disabled (per feature)
| AI feature | Fallback with AI off |
|------------|----------------------|
| Semantic / NL search | Lexical + structured + fuzzy search (still excellent) |
| Thread summary | Hidden; show first snippet + metadata |
| Reply suggestions | Hidden; normal compose |
| Follow-up detection | Heuristic-only (sent + question + no reply in N days) |
| Categorisation | `HeuristicClassifier` rules only |
| Ask-your-inbox | Feature hidden |
| Dedup | Exact-hash only (no near-dup) |
Every fallback is **first-class**, not a broken/greyed feature — this satisfies "AI must never
be required for core functionality."
## Ollama integration layer (local models)
- `OllamaProvider` (`IAiProvider`) + `OllamaEmbeddingProvider` (`IEmbeddingProvider`) talk to a
local Ollama (own container, optional Compose `ai` profile).
- **Model router** maps logical task → model via config (`Ai:Models:{Chat,Embed,Vision}`);
swapping a model is a config change, not code.
- **VRAM guard** (RTX 3080 / 10 GB): embeddings hot, 7B warm, vision on-demand
([../06](../06-ai-strategy.md)).
- **Health surfaced to admin** ([05](05-admin-system.md)): reachable? models loaded? VRAM?
latency? If Ollama is down, capability = false ⇒ gate falls back gracefully (no user errors).
## Safe abstraction (`IAIProvider`) — separation guarantees
1. **Interface boundary:** only Infrastructure implements providers; Application depends on
`IInboxAi`/`IAiGate` abstractions.
2. **Null objects:** `NullAiProvider`/`NullEmbeddingProvider` return "unavailable" so the DI
graph is always valid, AI on or off.
3. **Analyzer pipeline:** AI enrichers (`IEmailAnalyzer`) declare required capabilities and are
**skipped** when unavailable — adding/removing AI features never touches core sync/search.
4. **Bounded:** every AI call has timeout + `CancellationToken` + Polly fallback to the Null
path; AI can never hang or crash the core.
5. **Provider-swap:** adding a future AI provider = one class + config + (optionally) a flag —
no feature-code changes.
## Precedence recap (single source of truth)
The effective availability chain and admin master-switch semantics are defined once in
[04 — Settings & Feature Flags](04-settings-and-flags.md#ai-gating-precedence-the-key-requirement);
this document is the *architecture* of how features consume that decision.
@@ -0,0 +1,57 @@
# 09 — Implementation Plan (Part 9)
Six phases (from the brief), each **shippable behind feature flags** so `main`/staging never
break and providers activate only when ready. Follows the established
[../../WORKFLOW.md](../../WORKFLOW.md) pipeline (PR → checks → staging → tag→prod).
## Phase 1 — Provider abstraction + Google login refactor
- **Goal:** introduce the seam and move the *existing* Gmail behaviour behind it, with
OAuth-as-login and the multi-user identity foundation.
- **Deliverables:** `IEmailProvider` + `ProviderFactory`; **`GmailProvider`** adapter wrapping
today's Gmail code; `users` / `accounts` / `provider_tokens` / `sessions` tables;
OAuth-as-login for Google; first-user→Admin bootstrap.
- **Flags:** `provider.google` (on). **Exit:** existing Gmail users function unchanged through
the new abstraction; sign-in creates a `user`+`account`; all tests green.
## Phase 2 — Microsoft Outlook integration
- **Goal:** prove the abstraction with a second provider.
- **Deliverables:** **`OutlookProvider`** (Microsoft Graph, delta query); Microsoft OAuth login
+ link-mode; scope config; normaliser mappings.
- **Flags:** `provider.microsoft` (**off** until verified, then rollout). **Exit:** a user can
link an Outlook mailbox; it syncs and searches alongside Gmail; no core changes needed.
## Phase 3 — Unified email model + sync engine
- **Goal:** formalise the normalised store and provider-agnostic sync.
- **Deliverables:** normalised `email_messages`/`email_threads` (widened `search_vector`,
nullable `embedding`); **`ISyncOrchestrator`** + `AccountSyncWorker` (per-account cursors,
idempotent upserts, incremental); **data migration** of existing Gmail rows → default account.
- **Flags:** none user-facing; migration behind a maintenance window. **Exit:** all providers
sync through one orchestrator into one store; cross-account search works.
## Phase 4 — Settings system
- **Goal:** user + system settings + the flag engine.
- **Deliverables:** `user_settings`, `system_settings`, **`feature_flags`** + `IFeatureFlags`/
`IAiGate` (cached, fail-closed); settings UI; maintenance-mode middleware.
- **Flags:** self-hosting (the engine that hosts the rest). **Exit:** users edit prefs; admins
can flip flags; AI gate resolves the AND-chain.
## Phase 5 — Admin panel + feature flags
- **Goal:** the Admin surface + RBAC + audit.
- **Deliverables:** Admin API (policy-gated) + UI (Users/Flags/AI/Providers/System/Monitoring/
Audit); `audit_logs`; role management; basic monitoring.
- **Flags:** admin nav shown by role. **Exit:** an Admin can manage users/flags/providers,
every action audited; step-up + rate-limit enforced.
## Phase 6 — AI integration layer
- **Goal:** wire optional AI behind the gate.
- **Deliverables:** extend `IAiProvider` (+`CompleteStructuredAsync`, `IEmbeddingProvider`);
`IInboxAi` facade + model router + VRAM guard; analyzer pipeline; first AI features
(summaries, reply, follow-up-confirm) each **flag-gated + fallback**.
- **Flags:** `ai.enabled` + `ai.<feature>` (rollout). **Exit:** AI features work when enabled,
**vanish/fallback** when off; core unaffected; Ollama health in admin.
## Sequencing notes
- Phases 13 are the platform spine; 45 the control plane; 6 the optional intelligence.
- **Nothing activates on merge** — flags gate everything, so partial phases are safe on `develop`/`main`.
- Aligns with the blueprint roadmap ([../09](../09-roadmap.md)): this multi-provider work is a
**v1.x platform epic** that the search/AI features then build on.
@@ -0,0 +1,50 @@
# 10 — Git Workflow (Part 11)
Extends [../../WORKFLOW.md](../../WORKFLOW.md) for multi-phase, multi-provider work. The
core idea: **feature flags decouple *merging* from *activating*, which makes every
integration safe to land and trivial to roll back.**
## Branching per phase
| Phase | Milestone | Branches |
|-------|-----------|----------|
| 1 Provider abstraction + Google | `v1.x` | `epic/provider-platform``feature/email-provider-interface` · `feature/gmail-adapter` · `feature/oauth-login-users` |
| 2 Microsoft | `v1.x` | `feature/outlook-provider` · `feature/microsoft-oauth` |
| 3 Unified model + sync | `v1.x` | `feature/normalised-email-model` · `feature/sync-orchestrator` · `feature/data-migration` |
| 4 Settings | `v1.x` | `feature/settings-store` · `feature/feature-flags-engine` |
| 5 Admin | `v1.x` | `feature/admin-api` · `feature/admin-ui` · `feature/audit-log` |
| 6 AI layer | `v1.x` | `feature/ai-abstraction-ext` · `feature/ai-analyzers` · `feature/ai-flag-gating` |
- `feature/* → develop` (squash), `develop → main` (merge commit) — as established. Epics are
tracked by milestone/label; features integrate continuously (no long epic branch).
## PR structure per provider integration
Each provider is a self-contained PR set that lands **dark**:
1. **Adapter PR**`IEmailProvider` impl + normaliser + unit tests (mocked provider).
2. **Auth PR** — OAuth login/link for that provider.
3. **Enablement PR** — register in `ProviderFactory` + seed `provider.<x>` flag **OFF**.
4. **Activation** — flip the flag on in staging → verify end-to-end → roll out in prod.
- **PR checklist adds:** provider behind a flag (off by default) · normaliser tests · token
encryption verified · no Domain leakage · docs updated.
## Feature flags prevent breaking changes
- Merge = code present but **inert** until its flag is on. So half-finished providers/AI can
live on `main` safely; CI stays green; no long-lived divergence.
- AI ships behind `ai.*`; providers behind `provider.*`; risky changes behind their own flag.
## Rollback strategy (per provider / per feature)
| Level | Action | Speed |
|-------|--------|-------|
| **Flag** (first resort) | Admin flips `provider.<x>` / `ai.<x>` **off** | **Instant, no deploy** — feature disappears, existing data untouched |
| **Deploy** | Redeploy the previous **tag** (`vX.Y.Z-1`) | Minutes (pipeline) |
| **Revert** | `git revert` the PR → PR → merge → deploy | Minuteshours |
| **Data** | Provider accounts are isolated; disabling a provider **pauses** its sync — no destructive change to migrate back | Safe by design |
- Because providers are isolated and flag-gated, a bad integration **never blocks the others**
and never requires a risky data rollback.
## Release milestones
- Cut a tag when a phase reaches its exit criteria (`deploy-prod.yml` fires on `v*`).
- Suggested: `v1.1` provider platform + Google · `v1.2` +Outlook · `v1.3` unified sync ·
`v1.4` settings+admin · `v1.5` AI layer — folded into the blueprint roadmap ([../09](../09-roadmap.md)).
## Docs alongside code
Every feature PR updates the relevant `multi-provider/*` doc + `CHANGELOG.md`; on approval the
design docs graduate into living `docs/` references (provider system, settings, admin, security).
@@ -0,0 +1,31 @@
# 11 — Risk Analysis
Risks specific to the multi-provider + multi-user + admin evolution (the blueprint-wide risks
are in [../11](../11-risks-and-future.md)). L/I = Likelihood/Impact (H/M/L).
| # | Risk | L | I | Mitigation |
|---|------|---|---|------------|
| M1 | **Account-linking hijack** (attach someone's mailbox to your user) | L | H | Must authenticate as the target user; unique `(provider, sub)`; explicit "already owned" block; audited ([02](02-auth-and-signin.md)) |
| M2 | **Cross-user data leakage** (multi-user IDOR) | M | H | EF global query filters on `user_id`/`account_id`; policy-based authz; no admin route reads user mail; tests for isolation |
| M3 | **OAuth token theft / exposure** | L | H | Encrypted at rest (Data Protection); never logged or sent to browser; rotation; `bytea` ciphertext |
| M4 | **Provider quirks break sync** (Graph delta resets, IMAP `UIDVALIDITY` change, Gmail history gaps) | M | M | Per-provider cursor handling with **full-resync fallback**; idempotent upserts; capability flags; account flips to needs-attention, never crashes |
| M5 | **Migration corrupts existing Gmail data** | L | H | Additive-only schema; backfill is idempotent; maintenance window; tested on a staging copy; reversible ([12](12-migration-guide.md)) |
| M6 | **RBAC bug grants Member admin powers** | L | H | Server-side policies (not UI); admin-only role changes audited + force session refresh; can't demote last Admin; authz tests |
| M7 | **Feature-flag misconfiguration** (AI/provider on when not ready) | M | M | Flags default **off/fail-closed**; land dark; enable in staging first; audited toggles; rollout by role |
| M8 | **AI gate bypass** (feature runs when disabled) | L | M | Single `IAiGate` chokepoint before any AI call; Null providers; capability checks; no direct provider refs in core |
| M9 | **Session/CSRF weaknesses** across new surfaces | M | M | Server-side sessions, id rotation, SameSite + CSRF token, revoke-all, short idle expiry |
| M10 | **Admin abuse / mistake** (mass suspend, wrong flag) | M | M | Step-up confirm + rate-limit + full audit trail + reversible flags |
| M11 | **Scaling: millions of msgs × multiple accounts** | M | M | Denormalised `user_id`, keyset pagination, GIN/trgm/HNSW indexes, optional `account_id` partitioning, per-account sync throttling |
| M12 | **Provider OAuth app setup burden** (separate Google + Microsoft app registrations, redirect URIs, verification) | M | L | Documented setup per provider; providers flag-gated so an unconfigured one is simply hidden |
| M13 | **Scope/verification friction** (Google restricted scopes, MS admin consent) | M | M | Least-privilege scopes; document the consent/verification path; self-host uses the operator's own OAuth apps |
## Top watch-items
- **M2 (isolation)** and **M6 (RBAC)** — the two ways multi-user can go wrong; both mitigated
by server-side authz + query filters + isolation tests as a release gate.
- **M4 (provider sync quirks)** — the most likely *operational* pain; the full-resync fallback
and per-account isolation contain it.
## Overall
The flag-gated, additive, isolated design makes this evolution **low-blast-radius**: each
provider and the AI layer land dark and roll back by flag, migrations are additive, and the
security model closes the new multi-user gaps. Proceed **phase by phase behind flags**.
@@ -0,0 +1,52 @@
# 12 — Migration Guide (Part 10)
Moving the current **single-account Gmail** app to the **multi-provider, multi-user** model
**additive and reversible**, no destructive step. Executed as ordered EF Core migrations +
idempotent backfills, behind a short maintenance window.
## Principles
- **Additive first:** create new tables/columns before moving data; keep old columns until parity is verified.
- **Idempotent backfills:** safe to re-run; keyed on stable ids.
- **Flag-gated cutover:** the new sign-in/model activates behind flags; the old path stays until removed.
- **Reversible:** each step has a documented rollback; no data is deleted during migration.
## Step-by-step
1. **Schema (additive migration)**
- Create `users`, `accounts`, `provider_tokens`, `sessions`, `user_settings`,
`system_settings`, `feature_flags`, `audit_logs`.
- Add `account_id`, `user_id` (nullable) to the current email/thread tables; widen
`search_vector`; add nullable `embedding vector(768)` + `pgvector` extension.
2. **Identity backfill**
- For the existing operator/user, create a `users` row; mark the **first user = Admin**.
- Create one **`Google` `account`** per existing identity (`is_login_identity=true`); move
current encrypted Gmail tokens → `provider_tokens`.
3. **Email backfill**
- Set `account_id`/`user_id` on all existing `Email`/thread rows to the default Google account.
- Regenerate the widened `search_vector`; leave `embedding` null (backfilled later by the AI phase).
- Enforce the new unique keys `(account_id, provider_message_id)` / `(account_id, provider_thread_id)`.
4. **Config seed**
- Seed `feature_flags`: `provider.google=on`, `provider.microsoft=off`, `provider.imap=off`,
`ai.enabled` = derived from the current `Ai:Mode` (Disabled→off), `ai.*`=off, `maintenance.*`=off.
- Create `system_settings` singleton; create `user_settings` from any existing per-user prefs (else defaults).
5. **Cutover**
- Enable the new OAuth-as-login + unified sync behind their flags; verify on **staging** first
(the pipeline we built), then production via a tagged release.
6. **Cleanup (later, separate migration)**
- Once parity is confirmed in production, drop obsolete columns/paths. Not part of the cutover.
## Verification checklist
- Existing user signs in via Google → lands on their mail unchanged.
- Email counts match pre/post; search returns identical results for sample queries.
- Tokens decrypt and refresh; sync resumes from the correct cursor.
- No cross-user rows visible (isolation test).
## Rollback
- **Pre-cutover:** additive changes are inert → simply don't flip the flags; drop new tables if aborting.
- **Post-cutover issue:** flip flags off / redeploy previous **tag**; old columns still present →
the legacy path still works. No data was deleted, so no data rollback is needed.
## Provider-app prerequisites (operator setup)
- **Google:** OAuth client (existing) + redirect `/(…)/signin/google`; scopes `gmail.readonly gmail.modify`.
- **Microsoft:** register an Entra app; redirect `/(…)/signin/microsoft`; scopes `Mail.Read Mail.ReadWrite offline_access`; admin consent if required.
- **IMAP (future):** per-account host/credentials; validated against the SSRF allowlist.
- Because providers are flag-gated, an unconfigured provider is simply hidden — configure, then enable.
+48
View File
@@ -0,0 +1,48 @@
# Multi-Provider Email Platform + Admin/Settings — Design
**Design + architecture phase. No implementation until approved.**
Evolves InboxIntel from a single-account Gmail tool into a **multi-provider platform**
(Gmail · Outlook/Graph · future IMAP) for a **small self-hosted team**, with settings,
feature flags, and an admin panel. Extends — does not discard — the
[discovery blueprint](../README.md).
## Locked decisions (from interview)
1. **Tenancy:** **small team, self-hosted, one org.** Roles = **Admin / Member**. Shared
system settings + feature flags; each member's mail is private to them. No multi-tenant
org table (one implicit org); the model stays extensible to multi-org later.
2. **App identity = provider OAuth.** The first Google/Microsoft sign-in **creates/authenticates
the InboxIntel user**; additional mailboxes **link** to that same user. **No passwords stored.**
## How this reshapes the blueprint (the "review" deltas)
| Blueprint assumption | New reality |
|----------------------|-------------|
| Single-user, local-first | **Multi-user (small team)** with Admin/Member RBAC + admin panel |
| Gmail-centric `Email`/`Sender` | **Account-scoped, provider-normalised** model (`IEmailProvider`) |
| AI gated by `Ai:Mode` + user pref | **AI gated by system feature flag → user pref → capability** (flag wins) |
| One implicit mailbox | **N provider accounts per user** (`accounts` table + per-account sync cursors) |
| Sync = `GmailSyncWorker` | **Provider-agnostic sync orchestrator** dispatching to provider adapters |
These deltas will be back-ported into main-blueprint docs [08](../08-technical-architecture.md)
and [09](../09-roadmap.md) when this design is approved.
## Documents
| # | Doc | Covers (brief part) | Status |
|---|-----|---------------------|--------|
| 01 | [Provider Abstraction](01-provider-abstraction.md) | Part 1 | ✅ draft |
| 02 | [Auth & Sign-in](02-auth-and-signin.md) | Part 2 | ✅ draft |
| 03 | [Database Design](03-database-design.md) | Part 6 | ✅ draft |
| 04 | [Settings & Feature Flags](04-settings-and-flags.md) | Part 3 | ✅ draft |
| 05 | [Admin System](05-admin-system.md) | Part 4 | ✅ draft |
| 06 | [Security Model](06-security-model.md) | Part 5 | ✅ draft |
| 07 | [UX Flows](07-ux-flows.md) | Part 7 | ✅ draft |
| 08 | [AI Feature-Flag Integration](08-ai-feature-flags.md) | Part 8 | ✅ draft |
| 09 | [Implementation Plan](09-implementation-plan.md) | Part 9 | ✅ draft |
| 10 | [Git Workflow](10-git-workflow.md) | Part 11 | ✅ draft |
| 11 | [Risk Analysis](11-risk-analysis.md) | output | ✅ draft |
| 12 | [Migration Guide](12-migration-guide.md) | Part 10 | ✅ draft |
## Non-negotiables carried forward
Provider logic **never leaks into Domain** · search works **across all a user's accounts**
· emails stored in **one unified format** · **AI never required** for core function ·
tokens **encrypted at rest** · admin actions **audit-logged**.
+2 -10
View File
@@ -5,16 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InboxIntel — Gmail analytics & cleanup</title>
<script>
// Apply the saved theme before first paint to avoid a flash of the wrong mode.
(function () {
try {
var t = localStorage.getItem('ii:theme');
if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (t === 'dark') document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
<!-- Theme applied before first paint; external file so CSP can use script-src 'self'. -->
<script src="/theme-init.js"></script>
</head>
<body>
<div id="root"></div>
+13
View File
@@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html;
index index.html;
# AUDIT M-2: security headers on the SPA. script-src 'self' works because the theme
# bootstrap lives in /theme-init.js (no inline scripts); style-src needs 'unsafe-inline'
# for React/Chart.js/grid-layout inline style attributes (low risk with script-src locked).
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
# Compress the SPA bundle (AUDIT perf note: ~680 KB JS).
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# SPA fallback.
location / {
try_files $uri $uri/ /index.html;
+545 -1113
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -9,6 +9,7 @@
"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",
@@ -34,10 +35,10 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"vite": "^5.3.1"
"vite": "^8.1.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
// Applied before first paint to avoid a flash of the wrong theme. Lives in a file (not
// inline) so the SPA can ship a CSP with script-src 'self' (AUDIT M-2).
(function () {
try {
// Dark-first: default new users to dark unless they've chosen light.
var t = localStorage.getItem('ii:theme') || 'dark';
if (t === 'dark') document.documentElement.classList.add('dark');
} catch (e) {}
})();
+11 -3
View File
@@ -38,7 +38,6 @@ export const AnalyticsApi = {
health: () => api.get('/analytics/health').then((r) => r.data),
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
attachments: () => api.get('/analytics/attachments').then((r) => r.data),
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
@@ -94,13 +93,22 @@ function folderToRequest(slug, page, pageSize) {
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 };
// Archive = filed away: not in inbox, not trashed, and NOT Sent/Spam/Draft/Chat.
// Excluding those labels stops sent mail leaking into Archive.
case 'archive': return { ...base, isInInbox: false, isTrashed: false, excludeGmailLabels: ['SENT', 'DRAFT', 'SPAM', 'TRASH', 'CHAT'] };
case 'spam': return { ...base, gmailLabel: 'SPAM' };
case 'trash': return { ...base, isTrashed: true };
// Pinned = Gmail's "Important" marker (a real per-message flag), not "everything".
case 'pinned': return { ...base, isImportant: true };
// Read Later = only emails the user explicitly flagged (local marker).
case 'readlater': return { ...base, isReadLater: true };
// Unlabelled = emails not filed under any user-created label.
case 'unlabelled': return { ...base, hasUserLabels: false };
// ── Special filters ──
case 'large': return { ...base, minSizeBytes: 5_000_000 };
// Old Mail = strictly older than 6 months.
case 'old': {
const d = new Date(); d.setFullYear(d.getFullYear() - 1);
const d = new Date(); d.setMonth(d.getMonth() - 6);
return { ...base, to: d.toISOString().slice(0, 10) };
}
// ── Smart folders ──
+49 -20
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react';
import { BulkApi } from '../api/client.js';
import {
Button, useToast,
@@ -49,25 +49,54 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) {
};
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>
<div
role="toolbar"
aria-label={`${count} email${count === 1 ? '' : 's'} selected`}
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm"
>
{/* Selection count — primary emphasis so it reads first. */}
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold tabular-nums text-primary-foreground"
>
{count}
</span>
<span className="text-sm font-medium text-foreground">
selected
</span>
{/* Obvious clear-selection affordance, kept next to the count. */}
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"
>
<X />
</Button>
</div>
<div className="flex-1 basis-full sm:basis-0" />
<div className="flex flex-wrap items-center gap-2">
<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>
</div>
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
<DialogContent>
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { EmailApi } from '../api/client.js';
// ── helpers ──────────────────────────────────────────────────────────────────
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`;
};
/**
* Shared reading-pane / full single-email view.
*
* Accepts either an `email` summary object (as emitted by the list rows) or a
* bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI
* summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI
* summary button, the body, and per-email actions including "Open in Gmail".
*
* `onClose` collapses the pane.
*/
export default function EmailDetail({ email: summary, emailId, onClose }) {
const id = summary?.id ?? emailId;
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [aiSummary, setAiSummary] = useState(null);
const [aiLoading, setAiLoading] = useState(false);
useEffect(() => {
if (id == null) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setAiSummary(null);
setAiLoading(false);
EmailApi.get(id)
.then((d) => { if (!cancelled) setDetail(d); })
.catch(() => { if (!cancelled) setDetail(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
const email = detail ?? summary ?? {};
const fetchAiSummary = () => {
if (id == null) return;
setAiLoading(true);
EmailApi.summary(id)
.then((r) => setAiSummary(r.summary))
.catch(() => setAiSummary(null))
.finally(() => setAiLoading(false));
};
const openInGmail = () => window.open(
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
'_blank', 'noopener,noreferrer'
);
return (
<div className="sv-detail">
<div className="sv-detail-topbar">
<button className="sv-close" onClick={onClose} aria-label="Close reading pane" title="Close">
</button>
</div>
<div className="sv-detail-header">
<div className="sv-detail-subject">{email.subject || '(no subject)'}</div>
<div className="sv-detail-meta">
<span>{email.senderDisplayName || email.senderAddress}</span>
{email.sentAtUtc && (
<>
<span className="sv-detail-sep">·</span>
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
</>
)}
{email.sizeEstimateBytes > 0 && (
<>
<span className="sv-detail-sep">·</span>
<span>{fmtSize(email.sizeEstimateBytes)}</span>
</>
)}
</div>
</div>
{!loading && (
<div className="sv-ai-summary">
{aiSummary != null ? (
<div className="sv-ai-summary-text"> {aiSummary}</div>
) : (
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
{aiLoading ? 'Summarising…' : '✨ AI summary'}
</button>
)}
</div>
)}
{loading && <div className="sv-body-loading muted">Loading message</div>}
{!loading && detail?.bodyText && (
<div className="sv-body">{detail.bodyText}</div>
)}
{!loading && !detail?.bodyText && email.snippet && (
<div className="sv-snippet">{email.snippet}</div>
)}
<div className="sv-detail-actions">
<button className="btn-sm" onClick={openInGmail}>Open in Gmail </button>
</div>
</div>
);
}
+36 -5
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { EmailApi } from '../api/client.js';
import { Checkbox } from './ui/checkbox.jsx';
const fmtDate = (iso) => {
const d = new Date(iso);
@@ -16,7 +17,26 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`;
};
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
// "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, onOpen }) {
const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false);
@@ -69,12 +89,16 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
return (
<tr
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
onClick={openInGmail}
title="Open in Gmail"
onClick={() => onOpen ? onOpen(email) : openInGmail()}
title={onOpen ? 'Open' : 'Open in Gmail'}
>
{onToggleSelect && (
<td className="el-select" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
<Checkbox
checked={!!selected}
onCheckedChange={() => onToggleSelect(email.id)}
aria-label={selected ? 'Deselect email' : 'Select email'}
/>
</td>
)}
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
@@ -83,7 +107,9 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
</td>
<td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span>
{email.snippet && <span className="el-snippet"> {email.snippet}</span>}
{email.matchHighlight
? <span className="el-snippet">{renderHighlight(email.matchHighlight)}</span>
: email.snippet && <span className="el-snippet">{email.snippet}</span>}
</td>
<td className="el-meta">
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
@@ -113,6 +139,11 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
disabled={email._unsubDone}
>{email._unsubDone ? '✓' : '✉✕'}</button>
)}
<button
className="action-btn"
title="Open in Gmail"
onClick={(e) => { e.stopPropagation(); openInGmail(); }}
></button>
<button
className="action-btn action-btn--danger"
title="Move to trash"
+1 -1
View File
@@ -256,7 +256,7 @@ export default function Layout() {
<Input
ref={searchInputRef}
type="search"
placeholder="Search… from: is:unread has:attachment ( / )"
placeholder="Search your inbox…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label="Search emails"
+43
View File
@@ -0,0 +1,43 @@
import { forwardRef } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '../../lib/utils.js';
/**
* Accessible, design-token styled checkbox.
* Wraps Radix Checkbox so it is keyboard-accessible with a visible focus ring.
* Accepts `checked`, `onCheckedChange` (Radix) and, for convenience, `onChange`
* (called with a synthetic-ish `{ target: { checked } }`) so it can drop into
* places that previously used a bare <input type="checkbox">.
*/
const Checkbox = forwardRef(function Checkbox(
{ className, onCheckedChange, onChange, ...props },
ref
) {
const handleCheckedChange = (checked) => {
onCheckedChange?.(checked);
onChange?.({ target: { checked } });
};
return (
<CheckboxPrimitive.Root
ref={ref}
onCheckedChange={handleCheckedChange}
className={cn(
'peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-border bg-card transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
'hover:border-primary/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-3 w-3" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
export { Checkbox };
+1
View File
@@ -2,6 +2,7 @@
export { Button, buttonVariants } from './button.jsx';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx';
export { Badge, badgeVariants } from './badge.jsx';
export { Checkbox } from './checkbox.jsx';
export { Input, Textarea } from './input.jsx';
export { Switch } from './switch.jsx';
export { Separator } from './separator.jsx';
+62 -56
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bar, Line, Doughnut } from 'react-chartjs-2';
import { Line, Doughnut } from 'react-chartjs-2';
import { Inbox } from 'lucide-react';
import { AnalyticsApi } from '../api/client.js';
import { Skeleton } from './ui/skeleton.jsx';
import { EmptyState } from './ui/misc.jsx';
import {
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
LineElement, ArcElement, Tooltip, Legend
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend);
const fmtBytes = (b) => {
if (!b) return '0 B';
@@ -77,32 +80,6 @@ export function VolumeWidget({ volume }) {
);
}
export function HeatmapWidget({ heatmap }) {
if (!heatmap) return <Empty />;
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const max = Math.max(1, ...heatmap.map((c) => c.count));
const grid = {};
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
return (
<div className="widget">
<h3>Activity Heatmap</h3>
<div className="heatmap">
{days.map((d, dow) => (
<div className="hm-row" key={dow}>
<span className="hm-day">{d}</span>
{Array.from({ length: 24 }, (_, h) => {
const v = grid[`${dow}-${h}`] || 0;
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
})}
</div>
))}
</div>
</div>
);
}
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',
@@ -135,41 +112,64 @@ const CAT_SLUG = {
Unknown: '/app/folder/allmail',
};
export function CategoryHeatmapWidget() {
// "Emails by Category" — ranked horizontal bar list. Aggregates the
// category×day-of-week cells into per-category totals; each bar links to
// that category's folder via CAT_SLUG.
export function CategoryBreakdownWidget() {
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>;
if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet run a sync.</div></div>;
if (!cells) {
return (
<div className="widget">
<h3>Emails by Category</h3>
<div className="cat-bars">
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
</div>
</div>
);
}
const categories = [...new Set(cells.map((c) => c.category))].sort();
const max = Math.max(1, ...cells.map((c) => c.count));
const grid = {};
cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
const totals = {};
cells.forEach((c) => { totals[c.category] = (totals[c.category] || 0) + c.count; });
const ranked = Object.entries(totals)
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
if (ranked.length === 0) {
return (
<div className="widget">
<h3>Emails by Category</h3>
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
</div>
);
}
const max = Math.max(1, ...ranked.map((r) => r.count));
return (
<div className="widget">
<h3>Category Heatmap</h3>
<div className="cat-heatmap">
<div className="chm-row chm-head">
<span className="chm-label" />
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
</div>
{categories.map((cat) => {
const dest = CAT_SLUG[cat];
<h3>Emails by Category</h3>
<div className="cat-bars">
{ranked.map(({ category, count }) => {
const dest = CAT_SLUG[category];
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>
<button
type="button"
className={`cat-bar${dest ? ' cat-bar--link' : ''}`}
key={category}
disabled={!dest}
title={dest ? `View ${category} emails` : category}
onClick={dest ? () => navigate(dest) : undefined}
>
<span className="cat-bar-label">{category}</span>
<span className="cat-bar-track">
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} />
</span>
<span className="cat-bar-count">{count.toLocaleString()}</span>
</button>
);
})}
</div>
@@ -196,4 +196,10 @@ export function StorageWidget({ bytes }) {
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
}
function Empty() { return <div className="widget"><div className="muted">No data yet run a sync.</div></div>; }
function Empty() {
return (
<div className="widget">
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
</div>
);
}
+57 -23
View File
@@ -2,9 +2,15 @@ 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.
/// Keyboard navigation for an email list. Shortcuts:
/// j / ArrowDown move focus down
/// k / ArrowUp move focus up
/// e archive the focused email
/// u mark the focused email unread
/// # (shift+3) trash the focused email
/// All shortcuts are ignored while an input/textarea/select (or any
/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left
/// alone, so typing and browser/OS shortcuts are never hijacked.
/// `onRemoved(id)` lets the caller drop the row from local state after a
/// successful archive/trash.
/// </summary>
@@ -12,31 +18,59 @@ export default function useListKeyboardNav(emails, onRemoved) {
const [focusedId, setFocusedId] = useState(null);
useEffect(() => {
const isEditable = (el) => {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
};
const handler = async (e) => {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…).
if (isEditable(document.activeElement)) return;
if (e.ctrlKey || e.metaKey || e.altKey) 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);
switch (e.key) {
case 'j':
case 'ArrowDown': {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
setFocusedId(emails[next].id);
break;
}
case 'k':
case 'ArrowUp': {
e.preventDefault();
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
setFocusedId(emails[prev].id);
break;
}
case 'e': {
if (idx < 0) break;
e.preventDefault();
const id = emails[idx].id;
await BulkApi.archive([id]);
onRemoved(id);
break;
}
case 'u': {
if (idx < 0) break;
e.preventDefault();
await BulkApi.markUnread([emails[idx].id]);
break;
}
case '#': {
if (idx < 0) break;
e.preventDefault();
const id = emails[idx].id;
await BulkApi.trash([id]);
onRemoved(id);
break;
}
default:
break;
}
};
+70 -29
View File
@@ -8,46 +8,55 @@
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 {
/* Neutrals — warm-tinted slate (Notion-ish paper) */
/* Light — genuine white, warm off-white surfaces */
--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%;
--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 — Indigo #5b5bf0 */
--primary: 245 75% 59%;
/* Brand accent — green (from #3ba31f), darkened for AA white-on-green */
--primary: 110 62% 33%;
--primary-foreground: 0 0% 100%;
/* Semantic */
--success: 152 56% 40%;
--warning: 38 92% 50%;
--danger: 0 72% 51%;
--success: 145 55% 38%;
--warning: 38 92% 45%;
--danger: 4 74% 50%;
--danger-foreground: 0 0% 100%;
--radius: 0.625rem;
--radius: 0.5rem; /* lg 8px · md 6px · sm 4px */
}
.dark {
--background: 224 32% 9%;
--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%;
/* 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%;
--primary: 245 80% 67%;
--primary-foreground: 224 32% 9%;
/* Brand accent — luminous green for dark surfaces */
--primary: 110 52% 42%;
--primary-foreground: 0 0% 100%;
--success: 152 50% 50%;
--warning: 38 92% 58%;
--danger: 0 70% 60%;
--success: 145 50% 48%;
--warning: 38 90% 56%;
--danger: 4 72% 58%;
--danger-foreground: 0 0% 100%;
}
@@ -55,10 +64,19 @@
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;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
Helvetica, Arial, sans-serif;
/* 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. */
@@ -82,6 +100,29 @@
}
}
@layer base {
/* Consistent, visible focus ring using the accent token (keyboard users). */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/*
Respect the OS "reduce motion" setting — near-instant, no large transitions.
Baseline accessibility (the deeper a11y pass is deferred; see the design brief).
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer utilities {
.sr-only {
position: absolute;
+36 -21
View File
@@ -1,39 +1,54 @@
import React from 'react';
import React, { Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Landing from './pages/Landing.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Senders from './pages/Senders.jsx';
import Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
import FolderView from './pages/FolderView.jsx';
import SearchResults from './pages/SearchResults.jsx';
import Layout from './components/Layout.jsx';
import { ToastProvider, TooltipProvider } from './components/ui';
import '@fontsource-variable/inter';
import './index.css';
import './styles.css';
import './split.css';
// Route-level code splitting: each page loads its own chunk on first visit, so the
// initial bundle no longer carries Chart.js / grid-layout / every page at once.
// Landing + Layout stay eager (they're the first paint).
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));
const Senders = lazy(() => import('./pages/Senders.jsx'));
const Cleanup = lazy(() => import('./pages/Cleanup.jsx'));
const Unsubscribe = lazy(() => import('./pages/Unsubscribe.jsx'));
const FolderView = lazy(() => import('./pages/FolderView.jsx'));
const SearchResults = lazy(() => import('./pages/SearchResults.jsx'));
const DesignSystem = lazy(() => import('./pages/DesignSystem.jsx'));
// Minimal, theme-correct route fallback (skeleton-style, per the design system).
const RouteFallback = () => (
<div className="p-6 text-sm text-muted-foreground" aria-busy="true">Loading</div>
);
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ToastProvider>
<TooltipProvider delayDuration={200}>
<BrowserRouter>
<Routes>
{/* Public landing page */}
<Route path="/" element={<Landing />} />
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Public landing page */}
<Route path="/" element={<Landing />} />
{/* Authenticated app */}
<Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="senders" element={<Senders />} />
<Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} />
<Route path="folder/:slug" element={<FolderView />} />
<Route path="search" element={<SearchResults />} />
</Route>
{/* Authenticated app */}
<Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="senders" element={<Senders />} />
<Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} />
<Route path="folder/:slug" element={<FolderView />} />
<Route path="search" element={<SearchResults />} />
<Route path="design" element={<DesignSystem />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
</TooltipProvider>
</ToastProvider>
+25 -10
View File
@@ -6,8 +6,9 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
import SyncSplash from '../components/SyncSplash.jsx';
import {
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
CategoryBreakdownWidget, AttachmentsWidget, StorageWidget
} from '../components/widgets.jsx';
import { Skeleton } from '../components/ui/skeleton.jsx';
// Default grid geometry; overridden by the user's saved layout.
const DEFAULT_LAYOUT = [
@@ -17,12 +18,23 @@ const DEFAULT_LAYOUT = [
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
{ i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
{ i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 },
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
];
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
function WidgetSkeleton() {
return (
<div className="widget">
<Skeleton className="mb-3 h-4 w-1/3" />
<Skeleton className="mb-2 h-3 w-full" />
<Skeleton className="mb-2 h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
);
}
export default function Dashboard() {
const [data, setData] = useState(null);
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
@@ -87,15 +99,18 @@ export default function Dashboard() {
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
const render = (key) => {
// category-breakdown self-fetches, so it renders regardless of dashboard load state.
if (key === 'category-breakdown') return <CategoryBreakdownWidget />;
// While the dashboard payload loads, show a skeleton placeholder per widget.
if (!data) return <WidgetSkeleton />;
switch (key) {
case 'inbox-health': return <HealthWidget health={data?.health} />;
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} />;
case 'category-heatmap': return <CategoryHeatmapWidget />;
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
case 'inbox-health': return <HealthWidget health={data.health} />;
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} />;
case 'attachments': return <AttachmentsWidget attachments={data.attachmentBreakdown} />;
default: return null;
}
};
+177
View File
@@ -0,0 +1,177 @@
import React from 'react';
import {
Button, Badge, Input, Textarea, Switch, Skeleton, Separator,
Card, CardHeader, CardTitle, CardDescription, CardContent,
Tabs, TabsList, TabsTrigger, TabsContent, EmptyState, ThemeToggle,
} from '../components/ui';
/*
Design-system preview (v2). A living reference for the tokens + primitives so the
redesign can be visually verified in both themes. Route: /app/design.
Not linked in primary nav — it's a developer/design surface.
*/
const TOKENS = [
['background', 'App background'],
['card', 'Card / surface'],
['muted', 'Muted surface'],
['border', 'Border'],
['primary', 'Brand (green)'],
['success', 'Success'],
['warning', 'Warning'],
['danger', 'Danger'],
];
// Full literal class names so Tailwind's JIT includes them.
const GREEN = [
'bg-green-50', 'bg-green-100', 'bg-green-200', 'bg-green-300', 'bg-green-400',
'bg-green-500', 'bg-green-600', 'bg-green-700', 'bg-green-800', 'bg-green-900',
];
const TYPE = [
['text-3xl', '30px — Display'],
['text-2xl', '24px — Heading'],
['text-xl', '20px — Subheading'],
['text-lg', '18px — Large'],
['text-base', '16px — Body'],
['text-sm', '14px — UI base'],
['text-xs', '12px — Caption'],
];
function Swatch({ token, label }) {
return (
<div className="flex flex-col gap-1">
<div
className="h-14 w-full rounded-lg border"
style={{ background: `hsl(var(--${token}))` }}
/>
<div className="text-xs font-medium">{label}</div>
<div className="text-xs text-muted-foreground">--{token}</div>
</div>
);
}
function Section({ title, children }) {
return (
<section className="mb-10">
<h2 className="mb-3 text-lg font-semibold">{title}</h2>
{children}
</section>
);
}
export default function DesignSystem() {
return (
<div className="mx-auto max-w-4xl p-2">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Design system</h1>
<p className="text-sm text-muted-foreground">
v2 tokens &amp; primitives green accent, warm neutrals, dark-first. Toggle the
theme to verify both.
</p>
</div>
<ThemeToggle />
</div>
<Section title="Semantic tokens">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{TOKENS.map(([t, l]) => <Swatch key={t} token={t} label={l} />)}
</div>
</Section>
<Section title="Brand ramp (green, from #3ba31f)">
<div className="flex overflow-hidden rounded-lg border">
{GREEN.map((cls) => (
<div key={cls} className={`h-12 flex-1 ${cls}`} title={cls} />
))}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>50</span><span>500 (brand)</span><span>900</span>
</div>
</Section>
<Section title="Typography (Inter)">
<div className="space-y-2">
{TYPE.map(([cls, label]) => (
<div key={cls} className={`${cls} font-medium`}>
{label}
</div>
))}
</div>
</Section>
<Section title="Buttons">
<div className="flex flex-wrap items-center gap-3">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Danger</Button>
<Button disabled>Disabled</Button>
</div>
</Section>
<Section title="Badges">
<div className="flex flex-wrap items-center gap-3">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Danger</Badge>
</div>
</Section>
<Section title="Inputs">
<div className="grid max-w-md gap-3">
<Input placeholder="Search your inbox…" />
<Textarea placeholder="Write a reply…" rows={3} />
<label className="flex items-center gap-2 text-sm">
<Switch defaultChecked /> Use AI features
</label>
</div>
</Section>
<Section title="Card & tabs">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Thread summary</CardTitle>
<CardDescription>AI-generated · local</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="summary">
<TabsList>
<TabsTrigger value="summary">Summary</TabsTrigger>
<TabsTrigger value="actions">Actions</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="pt-3 text-sm text-muted-foreground">
A concise overview of the conversation would appear here.
</TabsContent>
<TabsContent value="actions" className="pt-3 text-sm text-muted-foreground">
Reply to the client · Follow up in 3 days
</TabsContent>
</Tabs>
</CardContent>
</Card>
</Section>
<Section title="Loading & empty states">
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
<div className="rounded-lg border p-4">
<EmptyState
title="No results"
description="Try broader terms or clear a filter."
/>
</div>
</div>
</Section>
<Separator className="my-8" />
<p className="text-xs text-muted-foreground">
InboxIntel design system · see docs/discovery/04-ux-redesign-and-design-system.md
</p>
</div>
);
}
+66 -25
View File
@@ -2,7 +2,9 @@ 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 EmailDetail from '../components/EmailDetail.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx';
import { Skeleton, EmptyState } from '../components/ui';
import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
@@ -35,6 +37,20 @@ const FOLDER_META = {
const PAGE_SIZE = 50;
function ListSkeleton({ rows = 6 }) {
return (
<div className="sv-skeleton-list" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<div className="sv-skeleton-row" key={i}>
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}
export default function FolderView() {
const { slug } = useParams();
@@ -46,6 +62,7 @@ export default function FolderView() {
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedEmail, setSelectedEmail] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
@@ -56,6 +73,7 @@ export default function FolderView() {
setPage(1);
setHasMore(true);
setError(null);
setSelectedEmail(null);
clear();
}, [slug, clear]);
@@ -115,34 +133,57 @@ export default function FolderView() {
}}
/>
{!loading && !error && emails.length === 0 && (
<div className="fv-empty">No emails in this folder.</div>
)}
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
<div className="sv-list-pane">
{loading && emails.length === 0 && !error && <ListSkeleton />}
{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>
)}
{!loading && !error && emails.length === 0 && (
<EmptyState
title="No emails in this folder"
description="Nothing here yet — try another folder or run a sync."
/>
)}
{/* Sentinel — triggers next page load when scrolled into view */}
<div ref={sentinelRef} className="fv-sentinel" />
{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}
onOpen={(email) => setSelectedEmail(email)}
onRemove={(id) => {
setEmails((prev) => prev.filter((x) => x.id !== id));
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
}}
/>
))}
</tbody>
</table>
)}
{loading && <div className="fv-loading-more">Loading</div>}
{!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} emails </div>
)}
{/* Sentinel — triggers next page load when scrolled into view */}
<div ref={sentinelRef} className="fv-sentinel" />
{loading && emails.length > 0 && <div className="fv-loading-more">Loading</div>}
{!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} emails </div>
)}
</div>
{selectedEmail && (
<aside className="sv-reading-pane">
<EmailDetail
key={selectedEmail.id}
email={selectedEmail}
onClose={() => setSelectedEmail(null)}
/>
</aside>
)}
</div>
</div>
);
}
+73 -24
View File
@@ -2,13 +2,29 @@ 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 EmailDetail from '../components/EmailDetail.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx';
import { Skeleton, EmptyState } from '../components/ui';
import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
import useSavedSearches from '../hooks/useSavedSearches.js';
const PAGE_SIZE = 50;
function ListSkeleton({ rows = 6 }) {
return (
<div className="sv-skeleton-list" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<div className="sv-skeleton-row" key={i}>
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}
export default function SearchResults() {
const [searchParams] = useSearchParams();
@@ -20,6 +36,7 @@ export default function SearchResults() {
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [selectedEmail, setSelectedEmail] = 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();
@@ -36,6 +53,7 @@ export default function SearchResults() {
setPage(1);
setHasMore(true);
setError(null);
setSelectedEmail(null);
clear();
}, [q, clear]);
@@ -83,11 +101,13 @@ export default function SearchResults() {
)}
</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>
{!q.trim() && (
<EmptyState
title="Search your mail"
description="Enter a search query above to find emails."
/>
)}
{error && <div className="fv-error">{error}</div>}
<BulkToolbar
selectedIds={selectedIds}
@@ -100,27 +120,56 @@ export default function SearchResults() {
}}
/>
{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>
)}
{q.trim() && (
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
<div className="sv-list-pane">
{loading && emails.length === 0 && !error && <ListSkeleton />}
<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>
{!loading && !error && emails.length === 0 && !hasMore && (
<EmptyState
title={`No results for "${q}"`}
description="Try a different search term or filter."
/>
)}
{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}
onOpen={(email) => setSelectedEmail(email)}
onRemove={(id) => {
setEmails((prev) => prev.filter((x) => x.id !== id));
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
}}
/>
))}
</tbody>
</table>
)}
<div ref={sentinelRef} className="fv-sentinel" />
{loading && emails.length > 0 && <div className="fv-loading-more">Loading</div>}
{!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} results </div>
)}
</div>
{selectedEmail && (
<aside className="sv-reading-pane">
<EmailDetail
key={selectedEmail.id}
email={selectedEmail}
onClose={() => setSelectedEmail(null)}
/>
</aside>
)}
</div>
)}
</div>
);
+148
View File
@@ -0,0 +1,148 @@
/* ── Split-view reading pane ────────────────────────────────────────────────
* Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on
* the left, a collapsible + horizontally resizable reading pane sits on the
* right. Below 768px the pane overlays the list (mobile stack) instead of
* squishing the columns side-by-side.
*
* These pages (.folder-view) are styled from styles.css, so we reference its
* legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with
* hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL
* channel triplets and only work via hsl(var(--x)), so they are NOT used bare
* here.
* ------------------------------------------------------------------------- */
.sv-split {
display: flex;
align-items: stretch;
gap: 0;
width: 100%;
}
/* Left column — the list. Flexes to fill remaining space and scrolls itself. */
.sv-list-pane {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
/* Right column — the reading pane. Resizable via the native handle; the width
* gives it a sensible default, and the user can drag the bottom-right corner.
* `resize: horizontal` needs `overflow` other than visible. */
.sv-reading-pane {
flex: 0 0 auto;
width: clamp(320px, 42%, 640px);
min-width: 300px;
max-width: 80vw;
border-left: 1px solid var(--panel-2, #222a3d);
overflow: auto;
resize: horizontal;
background: var(--panel, #1a2030);
align-self: stretch;
}
/* ── Reading pane inner content ─────────────────────────────────────────── */
.sv-detail { padding: 18px 20px; }
.sv-detail-topbar {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
}
.sv-close {
background: transparent;
border: 1px solid var(--panel-2, #222a3d);
color: var(--muted, #8b93a7);
border-radius: 6px;
width: 28px;
height: 28px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.sv-close:hover {
background: var(--panel-2, #222a3d);
color: var(--text, #e6e9f0);
}
.sv-detail-header { margin-bottom: 14px; }
.sv-detail-subject {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
color: var(--text, #e6e9f0);
word-break: break-word;
}
.sv-detail-meta {
font-size: 12px;
color: var(--muted, #8b93a7);
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.sv-detail-sep { opacity: 0.4; }
.sv-ai-summary { margin-bottom: 14px; }
.sv-ai-summary-text {
background: var(--panel-2, #222a3d);
border: 1px solid var(--panel-2, #222a3d);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--text, #e6e9f0);
}
.sv-body-loading { padding: 8px 0 18px; }
.sv-body {
background: var(--panel, #1a2030);
border: 1px solid var(--panel-2, #222a3d);
border-radius: 8px;
padding: 16px 18px;
font-size: 13px;
line-height: 1.7;
color: var(--text, #e6e9f0);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 18px;
max-height: 60vh;
overflow-y: auto;
}
.sv-snippet {
font-size: 13px;
line-height: 1.6;
color: var(--muted, #8b93a7);
margin-bottom: 18px;
}
.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* ── List loading / empty states ───────────────────────────────────────── */
.sv-skeleton-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
}
.sv-skeleton-row {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 8px;
}
/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */
@media (max-width: 767px) {
.sv-reading-pane {
position: fixed;
inset: 56px 0 0 0; /* below the topbar */
width: 100% !important;
max-width: 100vw;
min-width: 0;
border-left: none;
resize: none;
z-index: 40;
}
/* When the pane is open, hide the underlying list to avoid double-scroll. */
.sv-split.sv-split--open .sv-list-pane {
display: none;
}
}
+95 -67
View File
@@ -1,18 +1,20 @@
: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); }
@@ -21,13 +23,13 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
/* ── 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 #2c3550; border-right: none;
flex: 1; background: var(--panel-2); border: 1px solid #332f2b; border-right: none;
color: var(--text); border-radius: 6px 0 0 6px; padding: 7px 10px; font-size: 13px;
min-width: 0;
}
.search-input:focus { outline: none; border-color: var(--accent); }
.search-btn {
background: var(--panel-2); border: 1px solid #2c3550; border-left: none;
background: var(--panel-2); border: 1px solid #332f2b; border-left: none;
color: var(--muted); border-radius: 0 6px 6px 0; padding: 7px 10px; cursor: pointer; font-size: 13px;
}
.search-btn:hover { color: var(--text); border-color: var(--accent); }
@@ -38,7 +40,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
.sidebar {
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;
}
@@ -47,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; }
@@ -57,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;
}
@@ -87,7 +89,7 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-
background: var(--panel-2); border-radius: 8px; padding: 1px 5px;
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 {
@@ -108,15 +110,13 @@ 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; }
@@ -131,21 +131,25 @@ 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; }
.heatmap { display: flex; flex-direction: column; gap: 2px; }
.hm-row { display: flex; align-items: center; gap: 2px; }
.hm-day { width: 30px; font-size: 10px; color: var(--muted); }
.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
/* Category heatmap */
.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; }
.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; }
.chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
/* Emails by Category — ranked horizontal bars */
.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; }
.cat-bar {
display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center;
width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent;
border-radius: 6px; text-align: left; font: inherit; color: inherit;
}
.cat-bar--link { cursor: pointer; }
.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); }
.cat-bar:disabled { cursor: default; }
.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; }
.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; }
.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.cat-bar--link:hover .cat-bar-label { color: var(--accent); }
/* react-grid-layout resize handle — make it clearly visible on the dark theme */
.react-resizable-handle {
@@ -172,7 +176,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sl-panel {
display: flex;
flex-direction: column;
border-right: 1px solid #2c3550;
border-right: 1px solid #332f2b;
overflow: hidden;
}
.sl-search-wrap { padding: 10px 12px 6px; }
@@ -180,7 +184,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
width: 100%;
box-sizing: border-box;
background: var(--panel-2);
border: 1px solid #2c3550;
border: 1px solid #332f2b;
color: var(--text);
border-radius: 6px;
padding: 7px 10px;
@@ -191,7 +195,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sl-item {
display: block; width: 100%; text-align: left;
background: none; border: none; cursor: pointer;
padding: 10px 14px; border-bottom: 1px solid #1e2540;
padding: 10px 14px; border-bottom: 1px solid #26231f;
color: var(--text);
}
.sl-item:hover { background: var(--panel-2); }
@@ -211,7 +215,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
}
.sd-panel-header {
padding: 16px 20px 12px;
border-bottom: 1px solid #2c3550;
border-bottom: 1px solid #332f2b;
background: var(--panel);
position: sticky; top: 0; z-index: 1;
}
@@ -223,7 +227,7 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sd-detail { padding: 20px; max-width: 760px; }
.sd-back {
display: inline-flex; align-items: center; gap: 4px;
background: none; border: 1px solid #2c3550; border-radius: 6px;
background: none; border: 1px solid #332f2b; border-radius: 6px;
color: var(--text); font-size: 13px; cursor: pointer;
padding: 5px 12px; margin-bottom: 18px;
}
@@ -232,9 +236,9 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.sd-detail-subject { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.sd-detail-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 #2c3550; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; }
.sd-snippet { background: var(--panel); border: 1px solid #332f2b; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; }
.sd-ai-summary { margin-bottom: 14px; }
.sd-ai-summary-text { background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--text); }
.sd-ai-summary-text { background: var(--panel-2); border: 1px solid #332f2b; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--text); }
.sd-detail-actions { display: flex; gap: 10px; }
.btn-sm {
background: var(--accent); color: #fff; border: none; border-radius: 6px;
@@ -245,8 +249,8 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t
.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); }
@@ -259,14 +263,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; }
@@ -277,13 +281,13 @@ 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; }
@@ -294,32 +298,57 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.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 #2c3550; cursor: pointer; }
.email-row {
height: 44px;
border-bottom: 1px solid #332f2b;
cursor: pointer;
transition: background 0.1s ease;
}
.email-row > td { padding-top: 0; padding-bottom: 0; vertical-align: middle; }
/* Single, consistent hover state for the whole row. */
.email-row:hover { background: var(--panel); }
.email-row--unread .el-sender,
/* Unread: prominent subject, keep sender readable but not shouty. */
.email-row--unread .el-subj-text { font-weight: 700; color: var(--text); }
.email-row--unread .el-sender { font-weight: 600; color: var(--text); }
.el-unread { width: 14px; padding: 10px 4px 10px 0; }
.el-select { width: 34px; padding: 0 4px 0 10px; text-align: center; }
.el-select > * { vertical-align: middle; }
.el-unread { width: 14px; padding: 0 4px 0 0; text-align: center; }
.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; }
/* Sender: secondary in the hierarchy — muted by default. */
.el-sender {
width: 180px; padding: 0 12px 0 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--muted); font-size: 12.5px;
}
/* Subject + snippet share one line: subject prominent, snippet muted. */
.el-subject { padding: 0 8px; overflow: hidden; max-width: 0; white-space: nowrap; text-overflow: ellipsis; }
.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-snippet {
color: var(--muted); font-size: 12.5px;
}
.el-snippet::before { content: '—'; margin: 0 6px; opacity: 0.55; }
.el-meta { width: 80px; padding: 0 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-date { width: 70px; padding: 0 0 0 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; }
.el-actions { width: 96px; 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;
background: none; border: none; padding: 4px 5px; cursor: pointer;
font-size: 13px; line-height: 1; opacity: 0;
transition: opacity 0.1s ease, color 0.1s ease, background 0.1s ease;
border-radius: 4px; color: var(--muted);
}
.action-btn:hover { background: var(--panel-2); opacity: 1 !important; }
.action-btn:hover { background: var(--panel-2); color: var(--text); 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:hover .action-btn { opacity: 0.65; }
.action-btn:focus-visible { opacity: 1 !important; outline: 2px solid var(--accent); outline-offset: 1px; }
.email-row--acting { opacity: 0.6; pointer-events: none; }
.action-btn--unsub { font-size: 11px; }
.action-btn--done { opacity: 1 !important; color: var(--ok); }
@@ -337,7 +366,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.splash-card { text-align: center; max-width: 440px; padding: 32px; }
.splash-card 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; }
@@ -348,7 +377,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.sync-label--err { color: var(--danger); }
.sync-spinner {
width: 10px; height: 10px; border-radius: 50%;
border: 2px solid #36405c; border-top-color: var(--accent);
border: 2px solid #3d3833; border-top-color: var(--accent);
animation: sync-spin 0.7s linear infinite;
}
@keyframes sync-spin { to { transform: rotate(360deg); } }
@@ -358,7 +387,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
/* ── Email detail body (Senders page) ──────────────────────────────────── */
.sd-body-loading { padding: 8px 0 18px; }
.sd-body {
background: var(--panel); border: 1px solid #2c3550; border-radius: 8px;
background: var(--panel); border: 1px solid #332f2b; border-radius: 8px;
padding: 16px 18px; font-size: 13px; line-height: 1.7; color: var(--text);
white-space: pre-wrap; word-break: break-word; margin-bottom: 18px;
max-height: 60vh; overflow-y: auto;
@@ -369,7 +398,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.unsub-toast { color: var(--ok); font-size: 13px; margin-left: 6px; }
.unsub-tabs { display: flex; gap: 6px; margin: 16px 0 10px; }
.unsub-tab {
background: var(--panel); border: 1px solid #2c3550; color: var(--muted);
background: var(--panel); border: 1px solid #332f2b; color: var(--muted);
border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
@@ -377,7 +406,7 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.unsub-tab--active { color: var(--text); border-color: var(--accent); background: var(--panel-2); }
.unsub-tab-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 #36405c; }
.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); }
@@ -392,27 +421,26 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
/* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */
.bulk-toolbar {
display: flex; align-items: center; gap: 10px;
background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px;
background: var(--panel-2); border: 1px solid #332f2b; border-radius: 8px;
padding: 8px 14px; margin-bottom: 10px; font-size: 13px;
}
.bulk-toolbar .muted { font-size: 12px; }
.bulk-toolbar-spacer { flex: 1; }
.bulk-btn {
background: var(--panel); border: 1px solid #2c3550; color: var(--text);
background: var(--panel); border: 1px solid #332f2b; color: var(--text);
border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer;
}
.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 #2c3550; border-radius: 6px; padding: 4px 10px; opacity: 0.7; }
.kbd-hint kbd { background: var(--panel-2); border: 1px solid #36405c; border-radius: 3px; padding: 0 4px; font-family: inherit; }
.kbd-hint { 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 #2c3550; color: var(--muted);
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); }
+26
View File
@@ -31,6 +31,32 @@ export default {
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)',
+27
View File
@@ -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/**"]
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env sh
# InboxIntel pre-commit hook — fast checks only (keep it under a few seconds).
# Heavier build/test verification runs in pre-push. Bypass in an emergency with:
# git commit --no-verify
set -eu
echo "[pre-commit] running fast checks..."
# 1) Block obvious secrets from being committed. Scans only staged, added lines.
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$STAGED" ]; then
# Never allow a real .env (only *.example templates are tracked).
echo "$STAGED" | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' >/tmp/ii_env_hits 2>/dev/null || true
if [ -s /tmp/ii_env_hits ]; then
echo "[pre-commit] BLOCKED: attempting to commit an env/secret file:"
cat /tmp/ii_env_hits
echo " -> add it to .gitignore or commit .env.example instead."
exit 1
fi
# Heuristic secret scan on added lines (private keys, obvious credential assigns).
if git diff --cached --unified=0 -- $STAGED \
| grep -E '^\+' \
| grep -Ei 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|(client_secret|password|api[_-]?key|secret)\s*[:=]\s*["'"'"']?[A-Za-z0-9/_+=-]{16,}' \
| grep -Evi 'change-me|your-|example|placeholder|\$\{' >/tmp/ii_secret_hits 2>/dev/null; then
echo "[pre-commit] BLOCKED: possible hard-coded secret in staged changes:"
cat /tmp/ii_secret_hits
echo " -> use deploy/.env / user-secrets. Override with 'git commit --no-verify' if this is a false positive."
exit 1
fi
fi
# 2) Format .NET code (only if the tool + staged .cs files are present). Verify-only,
# non-mutating, so it never rewrites files out from under your staged diff.
if echo "$STAGED" | grep -q '\.cs$'; then
if command -v dotnet >/dev/null 2>&1 && dotnet format --help >/dev/null 2>&1; then
echo "[pre-commit] dotnet format --verify-no-changes"
dotnet format InboxIntel.sln --verify-no-changes --verbosity quiet \
|| { echo " -> run 'dotnet format InboxIntel.sln' and re-stage."; exit 1; }
fi
fi
echo "[pre-commit] OK"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env sh
# InboxIntel pre-push hook — build + test gate. Mirrors what Gitea CI runs so you
# catch failures before they reach the server. Bypass with: git push --no-verify
set -eu
echo "[pre-push] building solution (Release)..."
dotnet build InboxIntel.sln -c Release --nologo \
|| { echo "[pre-push] BLOCKED: backend build failed."; exit 1; }
echo "[pre-push] running tests..."
dotnet test InboxIntel.sln -c Release --no-build --nologo \
|| { echo "[pre-push] BLOCKED: tests failed."; exit 1; }
# Frontend build (only if the app is present and npm is installed).
if [ -f frontend/package.json ] && command -v npm >/dev/null 2>&1; then
echo "[pre-push] frontend build..."
( cd frontend && npm run build --silent ) \
|| { echo "[pre-push] BLOCKED: frontend build failed."; exit 1; }
fi
echo "[pre-push] OK — safe to push."
+24
View File
@@ -0,0 +1,24 @@
<#
.SYNOPSIS
Point git at the version-controlled hooks in scripts/git-hooks.
.DESCRIPTION
Uses `git config core.hooksPath` so the hooks live in the repo (reviewable,
shared, updatable) instead of the un-tracked .git/hooks directory. Run once
per clone. Git for Windows ships the bash needed to execute the POSIX hooks.
.EXAMPLE
./scripts/install-hooks.ps1
#>
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Push-Location $root
try {
git config core.hooksPath scripts/git-hooks
# Best-effort exec bit (matters on Linux/WSL; harmless on Windows). Only applies
# once the hooks are tracked; ignored on a first run before they're committed.
foreach ($h in 'pre-commit','pre-push') {
try { git update-index --chmod=+x "scripts/git-hooks/$h" 2>$null } catch {}
}
Write-Host "Installed git hooks -> scripts/git-hooks (core.hooksPath set)." -ForegroundColor Green
Write-Host "Bypass in an emergency with --no-verify." -ForegroundColor DarkGray
}
finally { Pop-Location }
@@ -7,6 +7,7 @@ namespace InboxIntel.Api.Controllers;
/// AI endpoints are read-only / advisory. They never trigger destructive
/// actions - suggestions are returned for the user to act on via /cleanup.
/// </summary>
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: LLM calls are the most expensive path
public class AiController : ApiControllerBase
{
private readonly IAiService _ai;
@@ -5,10 +5,12 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace InboxIntel.Api.Controllers;
[ApiController]
[EnableRateLimiting("auth")] // AUDIT H-2: throttle login/challenge attempts per IP
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class AuthController : ControllerBase
@@ -72,6 +72,22 @@ public class EmailController : ApiControllerBase
[HttpPost("{id:guid}/untrash")]
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
/// <summary>Toggle the local-only Read Later marker (no Gmail side effect).</summary>
[HttpPost("{id:guid}/readlater")]
public Task<IActionResult> ReadLater(Guid id, CancellationToken ct) => SetReadLater(id, true, ct);
[HttpPost("{id:guid}/unreadlater")]
public Task<IActionResult> UnreadLater(Guid id, CancellationToken ct) => SetReadLater(id, false, ct);
private async Task<IActionResult> SetReadLater(Guid id, bool value, CancellationToken ct)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
if (email is null) return NotFound();
email.IsReadLater = value;
await _db.SaveChangesAsync(ct);
return Ok();
}
/// <summary>
/// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender,
/// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: PDF/CSV generation is costly
public class ExportController : ApiControllerBase
{
private readonly IExportService _export;
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
namespace InboxIntel.Api.Controllers;
[Microsoft.AspNetCore.RateLimiting.EnableRateLimiting("expensive")] // AUDIT H-2: triggers server-side outbound HTTP
public class UnsubscribeController : ApiControllerBase
{
private readonly IUnsubscribeService _unsub;
@@ -42,8 +42,15 @@ public class WidgetLayoutController : ApiControllerBase
{
_db.WidgetLayouts.Add(new WidgetLayout
{
UserId = UserId, WidgetKey = dto.WidgetKey, X = dto.X, Y = dto.Y, W = dto.W, H = dto.H,
Visible = dto.Visible, SortOrder = dto.SortOrder, SettingsJson = dto.SettingsJson
UserId = UserId,
WidgetKey = dto.WidgetKey,
X = dto.X,
Y = dto.Y,
W = dto.W,
H = dto.H,
Visible = dto.Visible,
SortOrder = dto.SortOrder,
SettingsJson = dto.SettingsJson
});
}
}
+10 -2
View File
@@ -1,5 +1,5 @@
# Multi-stage build for the ASP.NET Core API.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution + project files first for layer-cached restore.
@@ -13,8 +13,16 @@ RUN dotnet restore src/InboxIntel.Api/InboxIntel.Api.csproj
COPY src/ src/
RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# The slim aspnet:10.0 image dropped libgssapi_krb5, which Npgsql tries to load during
# connection negotiation ("Cannot load library libgssapi_krb5.so.2"). Harmless for password
# auth but noisy and a latent failure on some paths — install the Kerberos runtime lib.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the
+8 -3
View File
@@ -5,16 +5,21 @@
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<!-- Required on the startup project for `dotnet ef migrations` to work. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.3" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.16.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
+116 -5
View File
@@ -1,5 +1,7 @@
using System.Security.Claims;
using System.Threading.RateLimiting;
using Asp.Versioning;
using FluentValidation.AspNetCore;
using InboxIntel.Api.Auth;
using InboxIntel.Application;
using InboxIntel.Application.Abstractions;
@@ -8,10 +10,16 @@ using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -23,9 +31,20 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.Console());
// Persist Data Protection keys so encrypted refresh tokens survive restarts.
builder.Services.AddDataProtection()
// AUDIT M-3: optionally encrypt the Data Protection key ring with an X.509 certificate so
// the keys are not readable in plaintext from the /keys volume (which would otherwise let
// anyone with volume access decrypt all stored refresh tokens). Configure
// DataProtection:CertificatePath (+ CertificatePassword) to enable; without it, keys are
// persisted unprotected and a startup warning documents the residual risk.
var dp = builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys"))
.SetApplicationName("InboxIntel");
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
if (!string.IsNullOrWhiteSpace(dpCertPath))
{
dp.ProtectKeysWithCertificate(System.Security.Cryptography.X509Certificates.X509CertificateLoader
.LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
}
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
@@ -57,6 +76,24 @@ builder.Services.AddAuthentication(options =>
options.Cookie.Name = "inboxintel.session";
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
// AUDIT M-1: sliding expiration alone lets a stolen cookie renew forever. Stamp an
// absolute start at sign-in and reject principals older than the configured cap,
// forcing a full re-login. (Pre-existing sessions without the stamp are rejected
// once — a single re-login, then they carry the stamp.)
var absoluteDays = builder.Configuration.GetValue("Auth:AbsoluteSessionDays", 30);
options.Events.OnSigningIn = ctx =>
{
ctx.Properties.SetString("abs-start", DateTimeOffset.UtcNow.ToString("O"));
return Task.CompletedTask;
};
options.Events.OnValidatePrincipal = async ctx =>
{
if (SessionLifetime.IsExpired(ctx.Properties.GetString("abs-start"), DateTimeOffset.UtcNow, TimeSpan.FromDays(absoluteDays)))
{
ctx.RejectPrincipal();
await ctx.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
};
// API-style behaviour: return status codes rather than redirecting to a login page.
options.Events.OnRedirectToLogin = ctx =>
{
@@ -89,6 +126,28 @@ builder.Services.AddAuthentication(options =>
builder.Services.AddAuthorization();
// RECOMMENDATIONS #5: OpenTelemetry traces + metrics (ASP.NET, outbound HTTP, Npgsql).
// The OTLP exporter only activates when Otel:Endpoint (or the standard
// OTEL_EXPORTER_OTLP_ENDPOINT env var) is configured — zero overhead otherwise.
// Logs stay on Serilog. Pair with the compose "observability" profile (grafana/otel-lgtm).
var otlpEndpoint = builder.Configuration["Otel:Endpoint"]
?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
if (!string.IsNullOrWhiteSpace(otlpEndpoint))
{
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("inboxintel-api"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddNpgsql()
.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddNpgsqlInstrumentation()
.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)));
}
builder.Services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1, 0);
@@ -98,6 +157,35 @@ builder.Services.AddApiVersioning(o =>
}).AddApiExplorer(o => { o.GroupNameFormat = "'v'VVV"; o.SubstituteApiVersionInUrl = true; });
builder.Services.AddControllers();
// AUDIT H-1: the validators in InboxIntel.Application/Validation were registered but never
// executed (FluentValidation 11.x needs explicit auto-validation). This wires them into
// model binding so invalid DTOs 400 at the boundary instead of reaching services.
builder.Services.AddFluentValidationAutoValidation();
// AUDIT H-2: rate limiting. Global per-user (or per-IP when anonymous) window, plus stricter
// named policies for auth and expensive endpoints (export/unsubscribe/AI). Limits are
// config-driven so tests and deployments can tune them.
var rl = builder.Configuration.GetSection("RateLimiting");
int Limit(string key, int def) => rl.GetValue(key, def);
var rlWindow = TimeSpan.FromSeconds(Limit("WindowSeconds", 60));
static string Partition(HttpContext ctx) =>
ctx.User.Identity?.IsAuthenticated == true
? ctx.User.FindFirstValue("inboxintel:uid") ?? "auth-unknown"
: ctx.Connection.RemoteIpAddress?.ToString() ?? "anon";
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("GlobalPermitLimit", 300), Window = rlWindow, QueueLimit = 0 }));
o.AddPolicy("auth", ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("AuthPermitLimit", 10), Window = rlWindow, QueueLimit = 0 }));
o.AddPolicy("expensive", ctx =>
RateLimitPartition.GetFixedWindowLimiter(Partition(ctx), _ =>
new FixedWindowRateLimiterOptions { PermitLimit = Limit("ExpensivePermitLimit", 20), Window = rlWindow, QueueLimit = 0 }));
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// V-06: RFC7807 ProblemDetails so the global exception handler returns a safe,
@@ -115,7 +203,18 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (db.Database.IsRelational() && app.Configuration.GetValue("Database:AutoMigrate", true))
{
// AUDIT M-4: the shipped appsettings no longer carries a guessable default DB
// password. Fail fast with a clear message rather than connecting with weak or
// missing credentials (compose/staging/prod inject the full connection string).
var connStr = app.Configuration.GetConnectionString("Postgres") ?? string.Empty;
var csb = new Npgsql.NpgsqlConnectionStringBuilder(connStr);
if (string.IsNullOrWhiteSpace(csb.Password))
throw new InvalidOperationException(
"ConnectionStrings:Postgres has no password. Set the full connection string via " +
"environment/user-secrets (see README) — a default password is deliberately not shipped.");
await db.Database.MigrateAsync();
}
}
// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and
@@ -128,15 +227,14 @@ var forwardedOptions = new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
};
forwardedOptions.KnownNetworks.Clear();
forwardedOptions.KnownIPNetworks.Clear();
forwardedOptions.KnownProxies.Clear();
var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>()
?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" };
foreach (var cidr in trustedNetworks)
{
var parts = cidr.Split('/');
if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len))
forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len));
if (System.Net.IPNetwork.TryParse(cidr, out var network))
forwardedOptions.KnownIPNetworks.Add(network);
}
app.UseForwardedHeaders(forwardedOptions);
@@ -169,9 +267,22 @@ app.Use(async (ctx, next) =>
app.UseSerilogRequestLogging();
app.UseCors("frontend");
app.UseAuthentication();
// AUDIT H-2: after authentication so authenticated traffic partitions per-user; anonymous
// traffic partitions per-IP. Endpoint policies ("auth", "expensive") apply via attributes.
app.UseRateLimiter();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program { }
/// <summary>
/// AUDIT M-1: absolute session lifetime check, extracted for unit testing. A session with no
/// issued stamp (pre-dating this feature) is treated as expired — one forced re-login.
/// </summary>
public static class SessionLifetime
{
public static bool IsExpired(string? issuedAtIso, DateTimeOffset now, TimeSpan maxAge)
=> !DateTimeOffset.TryParse(issuedAtIso, out var issued) || now - issued > maxAge;
}
+8 -2
View File
@@ -1,12 +1,18 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel"
"Postgres": ""
},
"Database": {
"AutoMigrate": true
},
"DataProtection": {
"KeyPath": "/keys"
"KeyPath": "/keys",
"CertificatePath": "",
"CertificatePassword": ""
},
"DataRetention": {
"PurgeTrashedAfterDays": 0,
"PurgeAllAfterDays": 0
},
"GoogleOAuth": {
"ClientId": "",
@@ -82,6 +82,25 @@ public interface IAiProvider
Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default);
}
/// <summary>
/// Produces vector embeddings for text (the foundation for semantic search, near-duplicate
/// detection, and "find similar"). Kept separate from <see cref="IAiProvider"/> because
/// embeddings are a distinct capability with their own model. The Null implementation returns
/// an empty vector and <see cref="IsAvailable"/> = false, so callers detect unavailability and
/// fall back to lexical search — AI is never required for core functionality.
/// </summary>
public interface IEmbeddingProvider
{
/// <summary>False for the Null provider (AI off / no embedding model configured).</summary>
bool IsAvailable { get; }
/// <summary>Embed a single text. Returns an empty array when unavailable.</summary>
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
/// <summary>Embed many texts, result aligned to input order. Empty list when unavailable.</summary>
Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default);
}
public enum ExportFormat { Pdf, Csv, Json }
public interface IExportService
@@ -101,3 +120,15 @@ public interface IDigestService
{
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
}
/// <summary>System feature flags (fail-closed: unknown key = disabled).</summary>
public interface IFeatureFlags
{
Task<bool> IsEnabledAsync(string key, CancellationToken ct = default);
}
/// <summary>Policy gate for AI features: system flag AND the user's opt-in.</summary>
public interface IAiGate
{
Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
}
+9 -1
View File
@@ -2,6 +2,13 @@ using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
/// <summary>
/// Row summary for search/list results. <c>MatchHighlight</c> is a "why this matched" body
/// fragment (ts_headline) with matched terms wrapped in U+E000/U+E001 sentinels — NOT HTML;
/// the client renders them as escaped &lt;mark&gt; elements, so untrusted email content can
/// never inject markup. Null unless the search had a free-text query; optional/last so other
/// DTO constructors are unaffected.
/// </summary>
public record EmailSummaryDto(
Guid Id,
string GmailMessageId,
@@ -16,7 +23,8 @@ public record EmailSummaryDto(
long SizeEstimateBytes,
EmailCategory Category,
bool HasListUnsubscribe,
bool SupportsOneClick);
bool SupportsOneClick,
string? MatchHighlight = null);
/// <summary>Full single-email view, including body text, for the detail pane.</summary>
public record EmailDetailDto(
+14 -1
View File
@@ -21,6 +21,19 @@ public record SearchRequestDto(
bool? IsInInbox = null,
bool? IsStarred = null,
bool? IsTrashed = null,
bool? IsImportant = null, // Gmail "important" marker — backs the Pinned smart folder
bool? IsReadLater = null, // local Read Later marker
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
// Emails carrying NONE of these Gmail labels (by GmailLabelId). Backs Archive, which
// must exclude Sent/Spam/Draft/Chat/Trash rather than just "not in inbox".
IReadOnlyList<string>? ExcludeGmailLabels = null,
// true = only emails with at least one USER label; false = only emails with NO user
// labels (the Unlabelled folder). System labels (INBOX/SENT/…) don't count.
bool? HasUserLabels = null,
string? Category = null, // EmailCategory name, e.g. "Finance"
long? MinSizeBytes = null);
long? MinSizeBytes = null,
// Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last
// row's SentAtUtc+Id to fetch the next window without OFFSET (O(pageSize), not O(page)).
// When set, TotalCount is not recomputed (-1). Additive; offset paging still works.
DateTimeOffset? AfterSentAtUtc = null,
Guid? AfterId = null);
@@ -6,10 +6,13 @@
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. -->
<PackageReference Include="System.Text.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
+11
View File
@@ -40,6 +40,10 @@ public class Email : AuditableEntity
public bool IsTrashed { get; set; }
public bool HasAttachments { get; set; }
/// <summary>Local "Read Later" marker (not a Gmail concept) — toggled by the user so the
/// Read Later smart folder shows only explicitly flagged mail, never everything.</summary>
public bool IsReadLater { get; set; }
// Unsubscribe signals captured at parse time.
public bool HasListUnsubscribe { get; set; }
public string? ListUnsubscribeRaw { get; set; }
@@ -54,6 +58,13 @@ public class Email : AuditableEntity
/// </summary>
public NpgsqlTypes.NpgsqlTsVector? SearchVector { get; set; }
/// <summary>
/// Semantic-search embedding (pgvector, 768-dim for nomic-embed-text). Populated by the
/// embedding backfill worker when AI is enabled; null otherwise (search falls back to
/// lexical). Nullable so the InMemory test provider and AI-off deployments work unchanged.
/// </summary>
public Pgvector.Vector? Embedding { get; set; }
public ICollection<EmailLabel> EmailLabels { get; set; } = new List<EmailLabel>();
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
}
@@ -0,0 +1,42 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// System-wide feature flag (docs/discovery/multi-provider/04). The admin master switches:
/// a disabled flag turns its feature off for EVERYONE regardless of user preferences.
/// Reads are fail-closed — an unknown key counts as disabled.
/// </summary>
public class FeatureFlag : AuditableEntity
{
/// <summary>Stable key, e.g. "ai.enabled", "provider.google".</summary>
public string Key { get; set; } = string.Empty;
public bool Enabled { get; set; }
/// <summary>True = a user preference may turn the feature OFF for themselves
/// (never on beyond the flag); false = system-only switch.</summary>
public bool UserOverridable { get; set; }
public string? Description { get; set; }
}
/// <summary>
/// Per-user preferences (docs/discovery/multi-provider/04). One row per user, created
/// lazily; absent row = defaults. AiOptIn defaults true so enabling the ai.enabled flag
/// behaves exactly like today until a user opts out.
/// </summary>
public class UserSetting : AuditableEntity
{
public Guid UserId { get; set; }
public User? User { get; set; }
/// <summary>"system" | "light" | "dark".</summary>
public string Theme { get; set; } = "dark";
/// <summary>Master per-user AI opt-in (effective only while ai.enabled is on).</summary>
public bool AiOptIn { get; set; } = true;
/// <summary>Free-form UI preferences (layout, density, notifications) as JSON.</summary>
public string? PreferencesJson { get; set; }
}
@@ -4,7 +4,9 @@
<AssemblyName>InboxIntel.Domain</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- NpgsqlTypes.NpgsqlTsVector is used on the Email entity for FTS mapping. -->
<PackageReference Include="Npgsql" Version="8.0.3" />
<!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as
column types on the Email entity — same pragmatic precedent for both. -->
<PackageReference Include="Npgsql" Version="10.0.2" />
<PackageReference Include="Pgvector" Version="0.3.0" />
</ItemGroup>
</Project>
@@ -2,6 +2,7 @@ using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
using System.Linq;
using System.Net.Http.Json;
using System.Text.Json;
@@ -15,6 +16,53 @@ public class NullAiProvider : IAiProvider
=> Task.FromResult(string.Empty);
}
/// <summary>No-op embeddings used when AI is disabled. Returns empty vectors so callers fall
/// back to lexical search.</summary>
public class NullEmbeddingProvider : IEmbeddingProvider
{
public bool IsAvailable => false;
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<float>());
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<float[]>>(Array.Empty<float[]>());
}
/// <summary>Local embeddings via Ollama's /api/embeddings endpoint (e.g. nomic-embed-text).</summary>
public class OllamaEmbeddingProvider : IEmbeddingProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OllamaEmbeddingProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("ollama");
_http.BaseAddress = new Uri(_options.OllamaBaseUrl);
}
public bool IsAvailable => true;
public async Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
{
var payload = new { model = _options.EmbeddingModel, prompt = text };
var resp = await _http.PostAsJsonAsync("/api/embeddings", payload, ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
return doc.RootElement.GetProperty("embedding").EnumerateArray()
.Select(e => e.GetSingle()).ToArray();
}
// Ollama's /api/embeddings takes one prompt per call, so batch is a sequential loop.
// Kept behind the interface so a future batch endpoint is a drop-in swap.
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
{
var results = new List<float[]>(texts.Count);
foreach (var t in texts)
results.Add(await EmbedAsync(t, ct));
return results;
}
}
/// <summary>Local LLM via Ollama's /api/chat endpoint.</summary>
public class OllamaProvider : IAiProvider
{
@@ -0,0 +1,104 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Ai;
/// <summary>
/// Fills <c>Email.Embedding</c> (pgvector) for semantic search, in small background batches
/// so interactive requests are never starved (per docs/discovery/06: embeddings are the
/// small always-on model; the batch pause keeps VRAM/CPU pressure low). Exits immediately
/// when the embedding provider is unavailable (AI disabled / Ollama down) — semantic search
/// simply stays dormant and lexical search is unaffected.
/// </summary>
public class EmbeddingBackfillWorker : BackgroundService
{
private const int BatchSize = 32;
private static readonly TimeSpan BatchPause = TimeSpan.FromSeconds(2);
private static readonly TimeSpan IdleRescan = TimeSpan.FromMinutes(15);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<EmbeddingBackfillWorker> _logger;
public EmbeddingBackfillWorker(IServiceScopeFactory scopeFactory, ILogger<EmbeddingBackfillWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Provider availability is fixed by configuration for the process lifetime.
using (var probe = _scopeFactory.CreateScope())
{
if (!probe.ServiceProvider.GetRequiredService<IEmbeddingProvider>().IsAvailable)
{
_logger.LogDebug("EmbeddingBackfillWorker idle: no embedding provider (AI disabled).");
return;
}
}
_logger.LogInformation("EmbeddingBackfillWorker started (batch {Batch}, pause {Pause}s)",
BatchSize, BatchPause.TotalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
int processed;
try
{
processed = await ProcessBatchAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
// Ollama hiccups must never crash the host; back off and retry.
_logger.LogWarning(ex, "Embedding batch failed; retrying after idle pause.");
processed = 0;
}
await Task.Delay(processed > 0 ? BatchPause : IdleRescan, stoppingToken);
}
}
/// <summary>Embeds one batch. Public-ish (internal) for direct testing.</summary>
internal async Task<int> ProcessBatchAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var embeddings = scope.ServiceProvider.GetRequiredService<IEmbeddingProvider>();
var batch = await db.Emails
.Where(e => e.Embedding == null)
.OrderByDescending(e => e.SentAtUtc) // newest mail becomes searchable first
.Take(BatchSize)
.ToListAsync(ct);
if (batch.Count == 0) return 0;
// Subject + snippet is the semantic core; bodies are noisy (signatures, quoting)
// and slow to embed. Truncate defensively to keep well inside the model context.
var texts = batch
.Select(e => Truncate($"{e.Subject}\n{e.Snippet ?? e.BodyText}", 2000))
.ToList();
var vectors = await embeddings.EmbedBatchAsync(texts, ct);
if (vectors.Count != batch.Count)
{
_logger.LogWarning("Embedding batch returned {Got} vectors for {Want} emails; skipping batch.",
vectors.Count, batch.Count);
return 0;
}
for (var i = 0; i < batch.Count; i++)
{
if (vectors[i].Length == 0) continue; // provider soft-failure for one item
batch[i].Embedding = new Pgvector.Vector(vectors[i]);
}
await db.SaveChangesAsync(ct);
_logger.LogDebug("Embedded {Count} emails", batch.Count);
return batch.Count;
}
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
}
@@ -133,13 +133,13 @@ public class AnalyticsService : IAnalyticsService
var emails = _db.Emails.Where(e => e.UserId == userId);
var allMail = await emails.CountAsync(ct);
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
var unread = await emails.CountAsync(e => e.IsUnread, ct);
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
var unread = await emails.CountAsync(e => e.IsUnread, ct);
var starred = await emails.CountAsync(e => e.IsStarred, ct);
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
// Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels)
async Task<int> LabelCount(string gmailId)
@@ -153,9 +153,9 @@ public class AnalyticsService : IAnalyticsService
: 0;
}
var sent = await LabelCount("SENT");
var sent = await LabelCount("SENT");
var drafts = await LabelCount("DRAFT");
var spam = await LabelCount("SPAM");
var spam = await LabelCount("SPAM");
// Category → smart-folder slug mapping
var catCounts = await emails
@@ -167,31 +167,31 @@ public class AnalyticsService : IAnalyticsService
var smartFolders = new Dictionary<string, int>
{
["automated"] = Cat(EmailCategory.Notification),
["finance"] = Cat(EmailCategory.Finance),
["social"] = Cat(EmailCategory.Social),
["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional),
["noreply"] = Cat(EmailCategory.Notification),
["gaming"] = Cat(EmailCategory.Gaming),
["sales"] = Cat(EmailCategory.SeasonalSales),
["ridesharing"] = Cat(EmailCategory.RideSharing),
["food"] = Cat(EmailCategory.FoodDelivery),
["wellness"] = Cat(EmailCategory.Wellness),
["automated"] = Cat(EmailCategory.Notification),
["finance"] = Cat(EmailCategory.Finance),
["social"] = Cat(EmailCategory.Social),
["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional),
["noreply"] = Cat(EmailCategory.Notification),
["gaming"] = Cat(EmailCategory.Gaming),
["sales"] = Cat(EmailCategory.SeasonalSales),
["ridesharing"] = Cat(EmailCategory.RideSharing),
["food"] = Cat(EmailCategory.FoodDelivery),
["wellness"] = Cat(EmailCategory.Wellness),
// New categories
["travel"] = Cat(EmailCategory.Travel),
["travel"] = Cat(EmailCategory.Travel),
["subscriptions"] = Cat(EmailCategory.Subscriptions),
["parcels"] = Cat(EmailCategory.Parcels),
["recruitment"] = Cat(EmailCategory.Recruitment),
["events"] = Cat(EmailCategory.Events),
["security"] = Cat(EmailCategory.SecurityAlerts),
["healthcare"] = Cat(EmailCategory.Healthcare),
["education"] = Cat(EmailCategory.Education),
["news"] = Cat(EmailCategory.NewsMedia),
["property"] = Cat(EmailCategory.PropertyUtilities),
["charity"] = Cat(EmailCategory.Charity),
["government"] = Cat(EmailCategory.Government),
["crypto"] = Cat(EmailCategory.CryptoInvesting),
["family"] = Cat(EmailCategory.FamilySchool),
["parcels"] = Cat(EmailCategory.Parcels),
["recruitment"] = Cat(EmailCategory.Recruitment),
["events"] = Cat(EmailCategory.Events),
["security"] = Cat(EmailCategory.SecurityAlerts),
["healthcare"] = Cat(EmailCategory.Healthcare),
["education"] = Cat(EmailCategory.Education),
["news"] = Cat(EmailCategory.NewsMedia),
["property"] = Cat(EmailCategory.PropertyUtilities),
["charity"] = Cat(EmailCategory.Charity),
["government"] = Cat(EmailCategory.Government),
["crypto"] = Cat(EmailCategory.CryptoInvesting),
["family"] = Cat(EmailCategory.FamilySchool),
};
return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders);
@@ -43,6 +43,8 @@ public class AiOptions
// Ollama (local)
public string OllamaBaseUrl { get; set; } = "http://localhost:11434";
public string OllamaModel { get; set; } = "llama3.1";
// Embedding model for semantic search (per docs/discovery/06). Small, always-on when local.
public string EmbeddingModel { get; set; } = "nomic-embed-text";
// OpenAI (cloud, optional)
public string OpenAiApiKey { get; set; } = string.Empty;
@@ -70,3 +72,13 @@ public class DigestOptions
/// <summary>Hour (UTC) the background worker checks for due digests.</summary>
public int SendHourUtc { get; set; } = 8;
}
/// <summary>AUDIT H-3: opt-in local data retention. 0 = disabled (keep forever).</summary>
public class DataRetentionOptions
{
public const string SectionName = "DataRetention";
/// <summary>Purge locally stored emails flagged Trashed older than this many days.</summary>
public int PurgeTrashedAfterDays { get; set; } = 0;
/// <summary>Purge ALL locally stored emails older than this many days (local copy only).</summary>
public int PurgeAllAfterDays { get; set; } = 0;
}
@@ -21,10 +21,11 @@ public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
{
// EF Core / PostgreSQL
// EF Core / PostgreSQL. UseVector() enables pgvector mapping for the semantic-search
// embedding column (requires the 'vector' extension — added by the AddEmbeddingColumn migration).
services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("Postgres"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName).UseVector()));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
// Options
@@ -59,6 +60,11 @@ public static class DependencyInjection
services.AddScoped<IDigestService, DigestService>();
services.AddHostedService<DigestWorker>();
// AUDIT H-3: opt-in local data retention (worker no-ops while disabled).
services.Configure<DataRetentionOptions>(config.GetSection(DataRetentionOptions.SectionName));
services.AddScoped<Retention.RetentionService>();
services.AddHostedService<Retention.RetentionWorker>();
// HTTP clients
// V-01: do NOT follow redirects — a validated external URL must not be able to
// 3xx-redirect into an internal target after SafeHttpGuard has checked it.
@@ -67,15 +73,33 @@ public static class DependencyInjection
services.AddHttpClient("ollama");
services.AddHttpClient("openai");
// AI provider selected by configured mode.
// AI provider selected by configured mode. Embeddings come from Ollama when local,
// otherwise the Null provider (empty vectors) so semantic features degrade to lexical.
var aiMode = config.GetSection(AiOptions.SectionName).GetValue<AiProviderMode>("Mode");
switch (aiMode)
{
case AiProviderMode.LocalOllama: services.AddScoped<IAiProvider, OllamaProvider>(); break;
case AiProviderMode.CloudOpenAi: services.AddScoped<IAiProvider, OpenAiProvider>(); break;
default: services.AddScoped<IAiProvider, NullAiProvider>(); break;
case AiProviderMode.LocalOllama:
services.AddScoped<IAiProvider, OllamaProvider>();
services.AddScoped<IEmbeddingProvider, OllamaEmbeddingProvider>();
break;
case AiProviderMode.CloudOpenAi:
services.AddScoped<IAiProvider, OpenAiProvider>();
services.AddScoped<IEmbeddingProvider, NullEmbeddingProvider>(); // OpenAI embeddings: future
break;
default:
services.AddScoped<IAiProvider, NullAiProvider>();
services.AddScoped<IEmbeddingProvider, NullEmbeddingProvider>();
break;
}
services.AddScoped<IAiService, AiService>();
// Feature flags + AI policy gate (docs/discovery/multi-provider/04). Cached 15s,
// fail-closed. Admin toggle surface arrives with the multi-provider admin phase.
services.AddMemoryCache();
services.AddScoped<IFeatureFlags, Features.FeatureFlagService>();
services.AddScoped<IAiGate, Features.AiGate>();
// Semantic search: fills Email.Embedding in the background; no-ops when the
// embedding provider is unavailable (AI disabled), so lexical search is unaffected.
services.AddHostedService<EmbeddingBackfillWorker>();
// Background worker (daily incremental sync + aggregate refresh)
services.AddHostedService<GmailSyncWorker>();
@@ -0,0 +1,72 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace InboxIntel.Infrastructure.Features;
/// <summary>
/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an
/// admin toggle takes effect within seconds and per-request reads stay free.
/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled.
/// </summary>
public class FeatureFlagService : IFeatureFlags
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
public FeatureFlagService(AppDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<bool> IsEnabledAsync(string key, CancellationToken ct = default)
{
try
{
var flags = await _cache.GetOrCreateAsync("feature-flags", async e =>
{
e.AbsoluteExpirationRelativeToNow = CacheTtl;
return await _db.FeatureFlags.AsNoTracking()
.ToDictionaryAsync(f => f.Key, f => f.Enabled, ct);
});
return flags is not null && flags.TryGetValue(key, out var enabled) && enabled;
}
catch
{
return false; // fail closed
}
}
}
/// <summary>
/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user
/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true,
/// only consulted while the flag is on). Callers still check provider availability
/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing.
/// </summary>
public class AiGate : IAiGate
{
public const string MasterFlag = "ai.enabled";
private readonly IFeatureFlags _flags;
private readonly AppDbContext _db;
public AiGate(IFeatureFlags flags, AppDbContext db)
{
_flags = flags;
_db = db;
}
public async Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default)
{
if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false;
// Absent settings row = default opt-in true.
var optIn = await _db.UserSettings.AsNoTracking()
.Where(s => s.UserId == userId)
.Select(s => (bool?)s.AiOptIn)
.FirstOrDefaultAsync(ct);
return optIn ?? true;
}
}
@@ -95,7 +95,9 @@ public class GmailApiService : IGmailService
var req = client.Users.Messages.List("me");
req.MaxResults = _options.PageSize;
req.PageToken = pageToken;
req.IncludeSpamTrash = false;
// Include spam & trash so those folders aren't structurally empty; their state is
// captured via the SPAM/TRASH labels (IsTrashed + EmailLabels) during upsert.
req.IncludeSpamTrash = true;
return await req.ExecuteAsync(token);
}, ct);
@@ -4,25 +4,35 @@
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by
EF Core / ASP.NET / DataProtection. Remove once the parent packages ship
these versions transitively. -->
<PackageReference Include="System.Text.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="InboxIntel.IntegrationTests" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

Some files were not shown because too many files have changed in this diff Show More