Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ac20b124 | |||
| a3785a45cb | |||
| 3333e19452 | |||
| 1e8feeef71 | |||
| 074d39d9a2 | |||
| b7d4b87d75 | |||
| fb6b89b6dc | |||
| 402628a3a7 | |||
| 5191dd010f | |||
| 7ec4f1ddb8 | |||
| a3d8654198 | |||
| 86ecf03963 | |||
| dc9d5fc301 |
@@ -0,0 +1,13 @@
|
|||||||
|
# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with
|
||||||
|
# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git
|
||||||
|
# stores LF, and the pre-commit/CI format gates flip-flop forever.
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
@@ -15,3 +15,6 @@ FRONTEND_ORIGIN=http://localhost:8081
|
|||||||
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
|
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
|
||||||
DEV_MODE=false
|
DEV_MODE=false
|
||||||
MAX_MESSAGES=0
|
MAX_MESSAGES=0
|
||||||
|
|
||||||
|
# Nightly DB backup rotation (days of dumps to keep in ./backups)
|
||||||
|
BACKUP_KEEP_DAYS=7
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '10.0.x'
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore InboxIntel.sln
|
run: dotnet restore InboxIntel.sln
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '10.0.x'
|
||||||
- name: dotnet format (verify only)
|
- name: dotnet format (verify only)
|
||||||
run: dotnet format InboxIntel.sln --verify-no-changes
|
run: dotnet format InboxIntel.sln --verify-no-changes
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '10.0.x'
|
||||||
- name: Wait for Postgres
|
- name: Wait for Postgres
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
# RECOMMENDATIONS #2: automated dependency-update PRs (NuGet, npm, Dockerfiles, Actions)
|
||||||
|
# that ride the existing required CI gates. Runs weekly + on demand.
|
||||||
|
#
|
||||||
|
# ONE-TIME SETUP (manual): create a Gitea personal access token with scopes
|
||||||
|
# repo (rw) + user (r) + issue (rw) + organization (r), and add it as the Actions
|
||||||
|
# secret RENOVATE_TOKEN (repo Settings -> Actions -> Secrets). Without the secret this
|
||||||
|
# workflow fails fast with a clear message. See https://docs.renovatebot.com/modules/platform/gitea/
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '30 4 * * 1' # Mondays 04:30 UTC
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Require RENOVATE_TOKEN
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.RENOVATE_TOKEN }}" ]; then
|
||||||
|
echo "RENOVATE_TOKEN secret is not set — see the comment at the top of this workflow." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- name: Run Renovate
|
||||||
|
uses: https://github.com/renovatebot/github-action@v40.3.6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
env:
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://git.cesnimda.uk/api/v1
|
||||||
|
RENOVATE_REPOSITORIES: cesnimda/Inboxintel
|
||||||
|
RENOVATE_ONBOARDING: "false"
|
||||||
|
RENOVATE_REQUIRE_CONFIG: optional
|
||||||
|
LOG_LEVEL: info
|
||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-dotnet@v4
|
- uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: '8.0.x'
|
dotnet-version: '10.0.x'
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore InboxIntel.sln
|
run: dotnet restore InboxIntel.sln
|
||||||
- name: .NET vulnerable packages (fail on any)
|
- name: .NET vulnerable packages (fail on any)
|
||||||
@@ -52,3 +52,21 @@ jobs:
|
|||||||
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm audit --omit=dev --audit-level=high
|
run: npm audit --omit=dev --audit-level=high
|
||||||
|
|
||||||
|
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
|
||||||
|
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
|
||||||
|
# don't look for. Advisory at first (not a required check); promote once tuned.
|
||||||
|
sast:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# The runner image lacks pip, and a semgrep job-container lacks the node that
|
||||||
|
# actions/checkout needs — so install pip via apt on the standard image.
|
||||||
|
- name: Install semgrep
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
|
||||||
|
pipx install semgrep
|
||||||
|
- name: Semgrep scan
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ frontend/.vite/
|
|||||||
appsettings.*.local.json
|
appsettings.*.local.json
|
||||||
secrets.json
|
secrets.json
|
||||||
|
|
||||||
|
## DB backups (never commit dumps)
|
||||||
|
backups/
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
|
|||||||
+3
-1
@@ -29,7 +29,9 @@ reverse proxy.
|
|||||||
mitigate (a third party reading the DB files) reduces to "someone with access to your
|
mitigate (a third party reading the DB files) reduces to "someone with access to your
|
||||||
machine" — mitigate it at the layer that actually works:
|
machine" — mitigate it at the layer that actually works:
|
||||||
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
|
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
|
||||||
- **Encrypt backups** of the `pgdata` volume the same way.
|
- **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
|
- 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).
|
(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 /
|
2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
|
||||||
|
|||||||
@@ -22,6 +22,47 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
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:
|
api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -34,6 +75,10 @@ services:
|
|||||||
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
|
GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-}
|
||||||
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
Ai__Mode: ${AI_MODE:-Disabled}
|
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
|
# 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.
|
# and MAX_MESSAGES=1000 in deploy/.env to exercise it in this Docker setup.
|
||||||
App__DevMode: ${DEV_MODE:-false}
|
App__DevMode: ${DEV_MODE:-false}
|
||||||
@@ -44,6 +89,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
init-keys:
|
||||||
|
condition: service_completed_successfully
|
||||||
# V-08: bind to loopback so the API is not directly reachable from the network
|
# 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
|
# (only via the frontend/nginx proxy over the internal compose network). This
|
||||||
# prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers.
|
# prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers.
|
||||||
@@ -59,6 +106,33 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "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
|
# Optional reverse proxy. Enable with: docker compose --profile proxy up
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
@@ -74,3 +148,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
keys:
|
keys:
|
||||||
|
ollama:
|
||||||
|
|||||||
+34
-23
@@ -1,42 +1,53 @@
|
|||||||
import React from 'react';
|
import React, { Suspense, lazy } from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Landing from './pages/Landing.jsx';
|
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 Layout from './components/Layout.jsx';
|
||||||
import DesignSystem from './pages/DesignSystem.jsx';
|
|
||||||
import { ToastProvider, TooltipProvider } from './components/ui';
|
import { ToastProvider, TooltipProvider } from './components/ui';
|
||||||
import '@fontsource-variable/inter';
|
import '@fontsource-variable/inter';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
// Route-level code splitting: each page loads its own chunk on first visit, so the
|
||||||
|
// initial bundle no longer carries Chart.js / grid-layout / every page at once.
|
||||||
|
// Landing + Layout stay eager (they're the first paint).
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));
|
||||||
|
const Senders = lazy(() => import('./pages/Senders.jsx'));
|
||||||
|
const Cleanup = lazy(() => import('./pages/Cleanup.jsx'));
|
||||||
|
const Unsubscribe = lazy(() => import('./pages/Unsubscribe.jsx'));
|
||||||
|
const FolderView = lazy(() => import('./pages/FolderView.jsx'));
|
||||||
|
const SearchResults = lazy(() => import('./pages/SearchResults.jsx'));
|
||||||
|
const DesignSystem = lazy(() => import('./pages/DesignSystem.jsx'));
|
||||||
|
|
||||||
|
// Minimal, theme-correct route fallback (skeleton-style, per the design system).
|
||||||
|
const RouteFallback = () => (
|
||||||
|
<div className="p-6 text-sm text-muted-foreground" aria-busy="true">Loading…</div>
|
||||||
|
);
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
{/* Public landing page */}
|
<Routes>
|
||||||
<Route path="/" element={<Landing />} />
|
{/* Public landing page */}
|
||||||
|
<Route path="/" element={<Landing />} />
|
||||||
|
|
||||||
{/* Authenticated app */}
|
{/* Authenticated app */}
|
||||||
<Route path="/app" element={<Layout />}>
|
<Route path="/app" element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="senders" element={<Senders />} />
|
<Route path="senders" element={<Senders />} />
|
||||||
<Route path="cleanup" element={<Cleanup />} />
|
<Route path="cleanup" element={<Cleanup />} />
|
||||||
<Route path="unsubscribe" element={<Unsubscribe />} />
|
<Route path="unsubscribe" element={<Unsubscribe />} />
|
||||||
<Route path="folder/:slug" element={<FolderView />} />
|
<Route path="folder/:slug" element={<FolderView />} />
|
||||||
<Route path="search" element={<SearchResults />} />
|
<Route path="search" element={<SearchResults />} />
|
||||||
<Route path="design" element={<DesignSystem />} />
|
<Route path="design" element={<DesignSystem />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"timezone": "Europe/Berlin",
|
||||||
|
"schedule": ["before 6am on monday"],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"commitMessagePrefix": "chore(deps):",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Group safe minor+patch updates into one weekly PR per ecosystem",
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "{{manager}} minor & patch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Major updates stay individual PRs for careful review",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"dependencyDashboardApproval": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vulnerabilityAlerts": {
|
||||||
|
"enabled": true,
|
||||||
|
"labels": ["security"],
|
||||||
|
"schedule": ["at any time"]
|
||||||
|
},
|
||||||
|
"ignorePaths": ["**/node_modules/**", "**/bin/**", "**/obj/**"]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Multi-stage build for the ASP.NET Core API.
|
# 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
|
WORKDIR /src
|
||||||
|
|
||||||
# Copy solution + project files first for layer-cached restore.
|
# 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/
|
COPY src/ src/
|
||||||
RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
|
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
|
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 .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the
|
# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the
|
||||||
|
|||||||
@@ -5,16 +5,21 @@
|
|||||||
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
|
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||||
<!-- Required on the startup project for `dotnet ef migrations` to work. -->
|
<!-- 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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.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.AspNetCore" Version="8.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ using Microsoft.AspNetCore.HttpOverrides;
|
|||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Npgsql;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Resources;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -38,8 +42,8 @@ var dp = builder.Services.AddDataProtection()
|
|||||||
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
|
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
|
||||||
if (!string.IsNullOrWhiteSpace(dpCertPath))
|
if (!string.IsNullOrWhiteSpace(dpCertPath))
|
||||||
{
|
{
|
||||||
dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2(
|
dp.ProtectKeysWithCertificate(System.Security.Cryptography.X509Certificates.X509CertificateLoader
|
||||||
dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
|
.LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
@@ -122,6 +126,28 @@ builder.Services.AddAuthentication(options =>
|
|||||||
|
|
||||||
builder.Services.AddAuthorization();
|
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 =>
|
builder.Services.AddApiVersioning(o =>
|
||||||
{
|
{
|
||||||
o.DefaultApiVersion = new ApiVersion(1, 0);
|
o.DefaultApiVersion = new ApiVersion(1, 0);
|
||||||
@@ -201,15 +227,14 @@ var forwardedOptions = new ForwardedHeadersOptions
|
|||||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
|
||||||
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
|
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
|
||||||
};
|
};
|
||||||
forwardedOptions.KnownNetworks.Clear();
|
forwardedOptions.KnownIPNetworks.Clear();
|
||||||
forwardedOptions.KnownProxies.Clear();
|
forwardedOptions.KnownProxies.Clear();
|
||||||
var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>()
|
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" };
|
?? 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)
|
foreach (var cidr in trustedNetworks)
|
||||||
{
|
{
|
||||||
var parts = cidr.Split('/');
|
if (System.Net.IPNetwork.TryParse(cidr, out var network))
|
||||||
if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len))
|
forwardedOptions.KnownIPNetworks.Add(network);
|
||||||
forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len));
|
|
||||||
}
|
}
|
||||||
app.UseForwardedHeaders(forwardedOptions);
|
app.UseForwardedHeaders(forwardedOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -120,3 +120,15 @@ public interface IDigestService
|
|||||||
{
|
{
|
||||||
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,4 +23,9 @@ public record SearchRequestDto(
|
|||||||
bool? IsTrashed = null,
|
bool? IsTrashed = null,
|
||||||
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
|
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
|
||||||
string? Category = null, // EmailCategory name, e.g. "Finance"
|
string? Category = null, // EmailCategory name, e.g. "Finance"
|
||||||
long? MinSizeBytes = null);
|
long? MinSizeBytes = null,
|
||||||
|
// Keyset cursor for the date-ordered browse path (RECOMMENDATIONS #8): pass the last
|
||||||
|
// row's SentAtUtc+Id to fetch the next window without OFFSET (O(pageSize), not O(page)).
|
||||||
|
// When set, TotalCount is not recomputed (-1). Additive; offset paging still works.
|
||||||
|
DateTimeOffset? AfterSentAtUtc = null,
|
||||||
|
Guid? AfterId = null);
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentValidation" Version="11.9.2" />
|
<PackageReference Include="FluentValidation" Version="11.9.2" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||||
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
|
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
|
||||||
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
|
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. -->
|
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. -->
|
||||||
<PackageReference Include="System.Text.Json" Version="8.0.6" />
|
<PackageReference Include="System.Text.Json" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
|
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as
|
<!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as
|
||||||
column types on the Email entity — same pragmatic precedent for both. -->
|
column types on the Email entity — same pragmatic precedent for both. -->
|
||||||
<PackageReference Include="Npgsql" Version="8.0.3" />
|
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||||
<PackageReference Include="Pgvector" Version="0.2.0" />
|
<PackageReference Include="Pgvector" Version="0.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -92,6 +92,14 @@ public static class DependencyInjection
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
services.AddScoped<IAiService, AiService>();
|
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)
|
// Background worker (daily incremental sync + aggregate refresh)
|
||||||
services.AddHostedService<GmailSyncWorker>();
|
services.AddHostedService<GmailSyncWorker>();
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace InboxIntel.Infrastructure.Features;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an
|
||||||
|
/// admin toggle takes effect within seconds and per-request reads stay free.
|
||||||
|
/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled.
|
||||||
|
/// </summary>
|
||||||
|
public class FeatureFlagService : IFeatureFlags
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
|
public FeatureFlagService(AppDbContext db, IMemoryCache cache)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsEnabledAsync(string key, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var flags = await _cache.GetOrCreateAsync("feature-flags", async e =>
|
||||||
|
{
|
||||||
|
e.AbsoluteExpirationRelativeToNow = CacheTtl;
|
||||||
|
return await _db.FeatureFlags.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(f => f.Key, f => f.Enabled, ct);
|
||||||
|
});
|
||||||
|
return flags is not null && flags.TryGetValue(key, out var enabled) && enabled;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false; // fail closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user
|
||||||
|
/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true,
|
||||||
|
/// only consulted while the flag is on). Callers still check provider availability
|
||||||
|
/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing.
|
||||||
|
/// </summary>
|
||||||
|
public class AiGate : IAiGate
|
||||||
|
{
|
||||||
|
public const string MasterFlag = "ai.enabled";
|
||||||
|
private readonly IFeatureFlags _flags;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public AiGate(IFeatureFlags flags, AppDbContext db)
|
||||||
|
{
|
||||||
|
_flags = flags;
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false;
|
||||||
|
// Absent settings row = default opt-in true.
|
||||||
|
var optIn = await _db.UserSettings.AsNoTracking()
|
||||||
|
.Where(s => s.UserId == userId)
|
||||||
|
.Select(s => (bool?)s.AiOptIn)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
return optIn ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,18 +4,18 @@
|
|||||||
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
|
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
|
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
|
||||||
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
|
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.0" />
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||||
<PackageReference Include="Polly" Version="8.4.1" />
|
<PackageReference Include="Polly" Version="8.4.1" />
|
||||||
<PackageReference Include="QuestPDF" Version="2024.7.0" />
|
<PackageReference Include="QuestPDF" Version="2024.7.0" />
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
@@ -24,12 +24,15 @@
|
|||||||
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by
|
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by
|
||||||
EF Core / ASP.NET / DataProtection. Remove once the parent packages ship
|
EF Core / ASP.NET / DataProtection. Remove once the parent packages ship
|
||||||
these versions transitively. -->
|
these versions transitively. -->
|
||||||
<PackageReference Include="System.Text.Json" Version="8.0.6" />
|
<PackageReference Include="System.Text.Json" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
|
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
|
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
|
||||||
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
|
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="InboxIntel.IntegrationTests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Pgvector;
|
using Pgvector;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|||||||
Generated
+831
@@ -0,0 +1,831 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
using Pgvector;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace InboxIntel.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260702152850_FeatureFlagsAndUserSettings")]
|
||||||
|
partial class FeatureFlagsAndUserSettings
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Day")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("HourHistogramJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("NewsletterCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TotalReceived")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long>("TotalSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("TotalUnread")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("WithAttachments")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Day")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("analytics_aggregates", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("EmailId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("GmailAttachmentId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<long>("SizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EmailId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "MimeType");
|
||||||
|
|
||||||
|
b.ToTable("attachments", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("BodyText")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Category")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Vector>("Embedding")
|
||||||
|
.HasColumnType("vector(768)");
|
||||||
|
|
||||||
|
b.Property<string>("GmailMessageId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<bool>("HasAttachments")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HasListUnsubscribe")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsImportant")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsInInbox")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsStarred")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrashed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsUnread")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ListUnsubscribeRaw")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ReceivedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<NpgsqlTsVector>("SearchVector")
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("tsvector")
|
||||||
|
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("SentAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("SizeEstimateBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Snippet")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("SupportsOneClickUnsubscribe")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid>("ThreadId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Embedding");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("SearchVector");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
|
||||||
|
|
||||||
|
b.HasIndex("SenderId");
|
||||||
|
|
||||||
|
b.HasIndex("ThreadId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Category");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "GmailMessageId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "IsInInbox");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "IsUnread");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "SenderId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "SentAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("emails", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("EmailId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("LabelId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("EmailId", "LabelId");
|
||||||
|
|
||||||
|
b.HasIndex("LabelId");
|
||||||
|
|
||||||
|
b.ToTable("email_labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("UserOverridable")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("feature_flags", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ColorHex")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("GmailLabelId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "GmailLabelId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("EmailCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBulkSender")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("domains", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("FirstMessageUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("GmailThreadId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastMessageUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("MessageCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Snippet")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "GmailThreadId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("threads", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(320)
|
||||||
|
.HasColumnType("character varying(320)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("character varying(255)");
|
||||||
|
|
||||||
|
b.Property<Guid>("DomainId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("EmailCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("HasUnsubscribe")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastReceivedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<long>("TotalSizeBytes")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("UnreadCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Address");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("DisplayName");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("DomainId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Address")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "EmailCount");
|
||||||
|
|
||||||
|
b.ToTable("senders", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CompletedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("ConsecutiveFailures")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("character varying(4000)");
|
||||||
|
|
||||||
|
b.Property<string>("LastHistoryId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("LastSyncType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MessagesProcessed")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ResumePageToken")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("StartedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TotalMessagesEstimate")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("sync_states", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<double>("Confidence")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("EmailCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastAttemptUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Method")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ResultMessage")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("UnsubscribeTarget")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SenderId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "SenderId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("unsubscribe_items", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("DigestEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(320)
|
||||||
|
.HasColumnType("character varying(320)");
|
||||||
|
|
||||||
|
b.Property<byte[]>("EncryptedRefreshToken")
|
||||||
|
.HasColumnType("bytea");
|
||||||
|
|
||||||
|
b.Property<string>("GoogleSubjectId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastDigestSentUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PictureUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("GoogleSubjectId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("AiOptIn")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PreferencesJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("UserId");
|
||||||
|
|
||||||
|
b.ToTable("user_settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("H")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SettingsJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Visible")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("W")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("WidgetKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("X")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Y")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "WidgetKey")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("widget_layouts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
|
||||||
|
.WithMany("Attachments")
|
||||||
|
.HasForeignKey("EmailId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Email");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
|
||||||
|
.WithMany("Emails")
|
||||||
|
.HasForeignKey("SenderId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
|
||||||
|
.WithMany("Emails")
|
||||||
|
.HasForeignKey("ThreadId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||||
|
.WithMany("Emails")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Sender");
|
||||||
|
|
||||||
|
b.Navigation("Thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
|
||||||
|
.WithMany("EmailLabels")
|
||||||
|
.HasForeignKey("EmailId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
|
||||||
|
.WithMany("EmailLabels")
|
||||||
|
.HasForeignKey("LabelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Email");
|
||||||
|
|
||||||
|
b.Navigation("Label");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
|
||||||
|
.WithMany("Senders")
|
||||||
|
.HasForeignKey("DomainId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Domain");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SenderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Sender");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||||
|
.WithMany("WidgetLayouts")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Attachments");
|
||||||
|
|
||||||
|
b.Navigation("EmailLabels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("EmailLabels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Senders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Emails");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Emails");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Emails");
|
||||||
|
|
||||||
|
b.Navigation("WidgetLayouts");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace InboxIntel.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FeatureFlagsAndUserSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "feature_flags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
UserOverridable = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_feature_flags", x => x.Key);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "user_settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Theme = table.Column<string>(type: "text", nullable: false),
|
||||||
|
AiOptIn = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
PreferencesJson = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_user_settings", x => x.UserId);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_user_settings_users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Behaviour-preserving defaults (docs/discovery/multi-provider/04): ai.enabled on
|
||||||
|
// (AI availability still requires Ai:Mode + provider), Google on, others off.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
INSERT INTO feature_flags ("Key", "Enabled", "UserOverridable", "Description", "CreatedAtUtc", "UpdatedAtUtc")
|
||||||
|
VALUES
|
||||||
|
('ai.enabled', TRUE, TRUE, 'Master AI switch: off hides AI for everyone', NOW(), NOW()),
|
||||||
|
('provider.google', TRUE, FALSE, 'Google/Gmail provider', NOW(), NOW()),
|
||||||
|
('provider.microsoft', FALSE, FALSE, 'Microsoft/Outlook provider (future)', NOW(), NOW()),
|
||||||
|
('provider.imap', FALSE, FALSE, 'IMAP provider (future)', NOW(), NOW())
|
||||||
|
ON CONFLICT ("Key") DO NOTHING;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "feature_flags");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "user_settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -19,7 +19,7 @@ namespace InboxIntel.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "8.0.4")
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||||
@@ -243,6 +243,32 @@ namespace InboxIntel.Infrastructure.Migrations
|
|||||||
b.ToTable("email_labels", (string)null);
|
b.ToTable("email_labels", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("UserOverridable")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("feature_flags", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -591,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations
|
|||||||
b.ToTable("users", (string)null);
|
b.ToTable("users", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("AiOptIn")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PreferencesJson")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Theme")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("UserId");
|
||||||
|
|
||||||
|
b.ToTable("user_settings", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -717,6 +769,17 @@ namespace InboxIntel.Infrastructure.Migrations
|
|||||||
b.Navigation("Sender");
|
b.Navigation("Sender");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("InboxIntel.Domain.Entities.User", "User")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
b.HasOne("InboxIntel.Domain.Entities.User", null)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext
|
|||||||
public DbSet<Email> Emails => Set<Email>();
|
public DbSet<Email> Emails => Set<Email>();
|
||||||
public DbSet<MailThread> Threads => Set<MailThread>();
|
public DbSet<MailThread> Threads => Set<MailThread>();
|
||||||
public DbSet<Sender> Senders => Set<Sender>();
|
public DbSet<Sender> Senders => Set<Sender>();
|
||||||
|
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
|
||||||
|
public DbSet<UserSetting> UserSettings => Set<UserSetting>();
|
||||||
public DbSet<MailDomain> Domains => Set<MailDomain>();
|
public DbSet<MailDomain> Domains => Set<MailDomain>();
|
||||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||||
public DbSet<Label> Labels => Set<Label>();
|
public DbSet<Label> Labels => Set<Label>();
|
||||||
@@ -47,20 +49,36 @@ public class AppDbContext : DbContext, IAppDbContext
|
|||||||
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
|
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
|
||||||
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered
|
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered
|
||||||
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
|
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
|
||||||
modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<Email>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<Sender>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<MailThread>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<MailDomain>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<Attachment>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<Label>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
|
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
|
||||||
// entity; without a matching filter EF warns on boot and joins could surface rows whose
|
// entity; without a matching filter EF warns on boot and joins could surface rows whose
|
||||||
// parent is filtered out. Filter via the Email navigation so the pair is consistent.
|
// parent is filtered out. Filter via the Email navigation so the pair is consistent.
|
||||||
modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
|
modelBuilder.Entity<EmailLabel>().HasQueryFilter("Tenant", el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<SyncState>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
|
modelBuilder.Entity<UserSetting>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
||||||
|
|
||||||
|
// Feature flags are system-wide (no tenant filter). Key is the natural PK.
|
||||||
|
modelBuilder.Entity<FeatureFlag>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("feature_flags");
|
||||||
|
b.HasKey(f => f.Key);
|
||||||
|
b.Property(f => f.Key).HasMaxLength(128);
|
||||||
|
});
|
||||||
|
modelBuilder.Entity<UserSetting>(b =>
|
||||||
|
{
|
||||||
|
b.ToTable("user_settings");
|
||||||
|
b.HasKey(x => x.UserId);
|
||||||
|
// 1:1 with User sharing the PK — prevents a shadow UserId1 FK column.
|
||||||
|
b.HasOne(x => x.User).WithOne().HasForeignKey<UserSetting>(x => x.UserId);
|
||||||
|
});
|
||||||
|
|
||||||
// PostgreSQL full-text search: generated tsvector over subject + body with a
|
// PostgreSQL full-text search: generated tsvector over subject + body with a
|
||||||
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
|
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using InboxIntel.Domain.Entities;
|
|||||||
using InboxIntel.Domain.Enums;
|
using InboxIntel.Domain.Enums;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace InboxIntel.Infrastructure.Search;
|
namespace InboxIntel.Infrastructure.Search;
|
||||||
|
|
||||||
@@ -18,7 +19,13 @@ namespace InboxIntel.Infrastructure.Search;
|
|||||||
public class SearchService : ISearchService
|
public class SearchService : ISearchService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
public SearchService(AppDbContext db) => _db = db;
|
private readonly IEmbeddingProvider? _embeddings;
|
||||||
|
|
||||||
|
public SearchService(AppDbContext db, IEmbeddingProvider? embeddings = null)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_embeddings = embeddings;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
|
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -73,6 +80,18 @@ public class SearchService : ISearchService
|
|||||||
|
|
||||||
var total = await matched.CountAsync(ct);
|
var total = await matched.CountAsync(ct);
|
||||||
|
|
||||||
|
// Hybrid semantic fusion (docs/discovery/05): when embeddings are available, fuse
|
||||||
|
// lexical top-K with vector top-K via Reciprocal Rank Fusion. Runs BEFORE the fuzzy
|
||||||
|
// fallback so a query with ZERO lexical hits (pure semantic recall — "gym receipt"
|
||||||
|
// phrased differently) still surfaces results. Exact lexical hits keep winning (they
|
||||||
|
// rank in both lists). Deeper pages fall through to lexical paging; any failure
|
||||||
|
// (Ollama down, nothing embedded yet) silently degrades to the lexical/fuzzy path.
|
||||||
|
if (hasFreeTextQuery && _embeddings is { IsAvailable: true })
|
||||||
|
{
|
||||||
|
var hybrid = await TryHybridAsync(structured, matched, term, total, r, ct);
|
||||||
|
if (hybrid is not null) return hybrid;
|
||||||
|
}
|
||||||
|
|
||||||
// Fuzzy/typo fallback: ONLY when a free-text search found nothing exact. word_similarity
|
// Fuzzy/typo fallback: ONLY when a free-text search found nothing exact. word_similarity
|
||||||
// with an explicit 0.3 threshold — pg_trgm's default 0.6 misses real typos
|
// with an explicit 0.3 threshold — pg_trgm's default 0.6 misses real typos
|
||||||
// ("recieved" -> "received" scores ~0.39). Rare path, so the (non-indexed) scan over the
|
// ("recieved" -> "received" scores ~0.39). Rare path, so the (non-indexed) scan over the
|
||||||
@@ -95,9 +114,23 @@ public class SearchService : ISearchService
|
|||||||
ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
||||||
.ThenByDescending(e => e.SentAtUtc);
|
.ThenByDescending(e => e.SentAtUtc);
|
||||||
else
|
else
|
||||||
ranked = matched.OrderByDescending(e => e.SentAtUtc);
|
ranked = matched.OrderByDescending(e => e.SentAtUtc).ThenByDescending(e => e.Id);
|
||||||
|
|
||||||
var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
// Keyset (cursor) pagination for the browse path: O(pageSize) regardless of depth,
|
||||||
|
// vs OFFSET's O(page*pageSize). The (SentAtUtc, Id) pair with the Id tie-break above
|
||||||
|
// makes the ordering total, so windows never duplicate or skip rows.
|
||||||
|
IQueryable<Email> paged;
|
||||||
|
if (!hasFreeTextQuery && r is { AfterSentAtUtc: { } afterAt, AfterId: { } afterId })
|
||||||
|
{
|
||||||
|
paged = ranked
|
||||||
|
.Where(e => e.SentAtUtc < afterAt || (e.SentAtUtc == afterAt && e.Id.CompareTo(afterId) < 0))
|
||||||
|
.Take(r.PageSize);
|
||||||
|
total = -1; // not recomputed on cursor windows (that's the point)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
|
||||||
|
}
|
||||||
|
|
||||||
// "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no
|
// "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no
|
||||||
// highlight — a fuzzy hit has no literal match to headline). Unconditional projections
|
// highlight — a fuzzy hit has no literal match to headline). Unconditional projections
|
||||||
@@ -137,4 +170,73 @@ public class SearchService : ISearchService
|
|||||||
TotalCount = total
|
TotalCount = total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int HybridK = 50; // candidates taken from each layer
|
||||||
|
private const int RrfConstant = 60; // standard RRF dampening constant
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RRF fusion of lexical and vector candidates. Returns null when the requested page
|
||||||
|
/// lies beyond the fused window or anything fails — caller falls back to lexical.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PagedResult<EmailSummaryDto>?> TryHybridAsync(
|
||||||
|
IQueryable<Email> structured, IQueryable<Email> lexical, string term, int lexicalTotal,
|
||||||
|
SearchRequestDto r, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lexIds = await lexical
|
||||||
|
.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
||||||
|
.ThenByDescending(e => e.SentAtUtc)
|
||||||
|
.Take(HybridK).Select(e => e.Id).ToListAsync(ct);
|
||||||
|
|
||||||
|
var queryVec = await _embeddings!.EmbedAsync(term, ct);
|
||||||
|
List<Guid> vecIds = new();
|
||||||
|
if (queryVec.Length > 0)
|
||||||
|
{
|
||||||
|
var qv = new Pgvector.Vector(queryVec);
|
||||||
|
vecIds = await structured
|
||||||
|
.Where(e => e.Embedding != null)
|
||||||
|
.OrderBy(e => e.Embedding!.CosineDistance(qv))
|
||||||
|
.Take(HybridK).Select(e => e.Id).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
if (vecIds.Count == 0) return null; // nothing embedded yet → lexical path
|
||||||
|
|
||||||
|
var scores = new Dictionary<Guid, double>();
|
||||||
|
for (var i = 0; i < lexIds.Count; i++)
|
||||||
|
scores[lexIds[i]] = scores.GetValueOrDefault(lexIds[i]) + 1.0 / (RrfConstant + i + 1);
|
||||||
|
for (var i = 0; i < vecIds.Count; i++)
|
||||||
|
scores[vecIds[i]] = scores.GetValueOrDefault(vecIds[i]) + 1.0 / (RrfConstant + i + 1);
|
||||||
|
|
||||||
|
var fused = scores.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToList();
|
||||||
|
var pageIds = fused.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize).ToList();
|
||||||
|
if (pageIds.Count == 0 && r.Page > 1) return null; // deep page → lexical paging
|
||||||
|
|
||||||
|
var headlineOpts =
|
||||||
|
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
|
||||||
|
var rows = await _db.Emails.AsNoTracking()
|
||||||
|
.Where(e => pageIds.Contains(e.Id))
|
||||||
|
.Select(e => new EmailSummaryDto(
|
||||||
|
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
|
||||||
|
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
|
||||||
|
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
|
||||||
|
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
|
||||||
|
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var byId = rows.ToDictionary(x => x.Id);
|
||||||
|
var items = pageIds.Where(byId.ContainsKey).Select(id => byId[id]).ToList();
|
||||||
|
|
||||||
|
return new PagedResult<EmailSummaryDto>
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Page = r.Page,
|
||||||
|
PageSize = r.PageSize,
|
||||||
|
// Semantic recall can exceed the lexical match count.
|
||||||
|
TotalCount = Math.Max(lexicalTotal, fused.Count)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null; // AI must never break search — degrade to lexical
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace InboxIntel.IntegrationTests;
|
|||||||
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
|
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
|
||||||
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||||
{
|
{
|
||||||
public const string Scheme = "Test";
|
public new const string Scheme = "Test";
|
||||||
// Stable across requests so per-user rate-limit partitions accumulate correctly.
|
// Stable across requests so per-user rate-limit partitions accumulate correctly.
|
||||||
public static readonly string Uid = Guid.NewGuid().ToString();
|
public static readonly string Uid = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
@@ -52,6 +52,9 @@ public class AuditTestAppFactory : WebApplicationFactory<Program>
|
|||||||
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["Database:AutoMigrate"] = "false",
|
["Database:AutoMigrate"] = "false",
|
||||||
|
// Npgsql 10 eagerly validates the connection string when the DbContext is
|
||||||
|
// resolved (8.x was lazy); these tests never connect, but the string must parse.
|
||||||
|
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
|
||||||
["GoogleOAuth:ClientId"] = "test-client-id",
|
["GoogleOAuth:ClientId"] = "test-client-id",
|
||||||
["GoogleOAuth:ClientSecret"] = "test-client-secret",
|
["GoogleOAuth:ClientSecret"] = "test-client-secret",
|
||||||
// H-2: make the auth policy trip on the 3rd request within the window.
|
// H-2: make the auth policy trip on the 3rd request within the window.
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ public class TestAppFactory : WebApplicationFactory<Program>
|
|||||||
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["Database:AutoMigrate"] = "false",
|
["Database:AutoMigrate"] = "false",
|
||||||
|
// Npgsql 10 eagerly validates the connection string when the DbContext is
|
||||||
|
// resolved (8.x was lazy); these tests never connect, but the string must parse.
|
||||||
|
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
|
||||||
// Dummy OAuth creds so the Google challenge produces a real 302 redirect
|
// Dummy OAuth creds so the Google challenge produces a real 302 redirect
|
||||||
// (an empty ClientId can make the handler throw instead of redirecting).
|
// (an empty ClientId can make the handler throw instead of redirecting).
|
||||||
["GoogleOAuth:ClientId"] = "test-client-id",
|
["GoogleOAuth:ClientId"] = "test-client-id",
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using InboxIntel.Domain.Entities;
|
||||||
|
using InboxIntel.Infrastructure.Ai;
|
||||||
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Pgvector.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace InboxIntel.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Semantic-search backfill: the worker embeds emails lacking an Embedding and persists the
|
||||||
|
/// vectors. Runs against live Postgres (pgvector) since the Embedding column is ignored under
|
||||||
|
/// the InMemory provider; the CI db-tests job provides the database. Uses a deterministic fake
|
||||||
|
/// embedding provider — real-Ollama integration is verified separately (endpoint contract:
|
||||||
|
/// /api/embeddings, 768 dims).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "LiveDb")]
|
||||||
|
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
|
||||||
|
public class EmbeddingBackfillTests
|
||||||
|
{
|
||||||
|
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
|
||||||
|
|
||||||
|
private sealed class FakeCurrentUser : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid UserId => Guid.Empty; // worker scope sees all rows
|
||||||
|
public bool IsAuthenticated => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeEmbeddings : IEmbeddingProvider
|
||||||
|
{
|
||||||
|
public bool IsAvailable => true;
|
||||||
|
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(Vec(text));
|
||||||
|
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<float[]>>(texts.Select(Vec).ToList());
|
||||||
|
private static float[] Vec(string text)
|
||||||
|
{
|
||||||
|
var v = new float[768];
|
||||||
|
v[0] = text.Length; // deterministic, content-dependent
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Worker_embeds_pending_emails_and_persists_vectors()
|
||||||
|
{
|
||||||
|
if (Conn is null) return; // soft-skip outside the live-db CI job
|
||||||
|
var uid = Guid.NewGuid();
|
||||||
|
var opts = new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(Conn!, o => o.UseVector()).Options;
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddScoped<ICurrentUser, FakeCurrentUser>();
|
||||||
|
services.AddScoped(_ => new AppDbContext(opts, new FakeCurrentUser()));
|
||||||
|
services.AddScoped<IEmbeddingProvider, FakeEmbeddings>();
|
||||||
|
using var sp = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||||
|
{
|
||||||
|
await seed.Database.MigrateAsync();
|
||||||
|
seed.Users.Add(new User { Id = uid, GoogleSubjectId = "g" + uid, Email = uid + "@t.t" });
|
||||||
|
var dom = new MailDomain { UserId = uid, Name = "t.t" };
|
||||||
|
var snd = new Sender { UserId = uid, Address = "a@t.t", Domain = dom };
|
||||||
|
var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid };
|
||||||
|
seed.AddRange(dom, snd, thr);
|
||||||
|
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e1" + uid, Subject = "hello world", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
|
||||||
|
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e2" + uid, Subject = "quarterly invoice", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var worker = new EmbeddingBackfillWorker(
|
||||||
|
sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<EmbeddingBackfillWorker>.Instance);
|
||||||
|
var processed = await worker.ProcessBatchAsync(CancellationToken.None);
|
||||||
|
processed.Should().BeGreaterThanOrEqualTo(2);
|
||||||
|
|
||||||
|
using var check = new AppDbContext(opts, new FakeCurrentUser());
|
||||||
|
var mine = await check.Emails.Where(e => e.UserId == uid).ToListAsync();
|
||||||
|
mine.Should().OnlyContain(e => e.Embedding != null);
|
||||||
|
mine.First().Embedding!.ToArray().Length.Should().Be(768);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
using var c = new AppDbContext(opts, new FakeCurrentUser());
|
||||||
|
await c.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync();
|
||||||
|
await c.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync();
|
||||||
|
await c.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync();
|
||||||
|
await c.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync();
|
||||||
|
await c.Users.Where(u => u.Id == uid).ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using InboxIntel.Domain.Entities;
|
||||||
|
using InboxIntel.Infrastructure.Features;
|
||||||
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace InboxIntel.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feature-flag + AI-gate contracts (docs/discovery/multi-provider/04):
|
||||||
|
/// flags are FAIL-CLOSED, and the admin master switch (ai.enabled) beats any user opt-in.
|
||||||
|
/// </summary>
|
||||||
|
public class FeatureFlagTests
|
||||||
|
{
|
||||||
|
private sealed class FakeCurrentUser : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext NewDb(string name) =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
|
||||||
|
|
||||||
|
private static FeatureFlagService Flags(AppDbContext db) =>
|
||||||
|
new(db, new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unknown_flag_is_disabled_fail_closed()
|
||||||
|
{
|
||||||
|
using var db = NewDb(nameof(Unknown_flag_is_disabled_fail_closed));
|
||||||
|
(await Flags(db).IsEnabledAsync("does.not.exist")).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Enabled_flag_reads_true_disabled_reads_false()
|
||||||
|
{
|
||||||
|
using var db = NewDb(nameof(Enabled_flag_reads_true_disabled_reads_false));
|
||||||
|
db.FeatureFlags.AddRange(
|
||||||
|
new FeatureFlag { Key = "on.flag", Enabled = true },
|
||||||
|
new FeatureFlag { Key = "off.flag", Enabled = false });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var flags = Flags(db);
|
||||||
|
(await flags.IsEnabledAsync("on.flag")).Should().BeTrue();
|
||||||
|
(await flags.IsEnabledAsync("off.flag")).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ai_gate_master_flag_off_beats_user_opt_in()
|
||||||
|
{
|
||||||
|
using var db = NewDb(nameof(Ai_gate_master_flag_off_beats_user_opt_in));
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = false });
|
||||||
|
db.UserSettings.Add(new UserSetting { UserId = user, AiOptIn = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
(await new AiGate(Flags(db), db).IsAiEnabledForUserAsync(user)).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out()
|
||||||
|
{
|
||||||
|
using var db = NewDb(nameof(Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out));
|
||||||
|
var optedOut = Guid.NewGuid();
|
||||||
|
var noRow = Guid.NewGuid();
|
||||||
|
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = true });
|
||||||
|
db.UserSettings.Add(new UserSetting { UserId = optedOut, AiOptIn = false });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var gate = new AiGate(Flags(db), db);
|
||||||
|
|
||||||
|
(await gate.IsAiEnabledForUserAsync(noRow)).Should().BeTrue("no settings row = default opt-in");
|
||||||
|
(await gate.IsAiEnabledForUserAsync(optedOut)).Should().BeFalse("explicit opt-out wins while the flag is on");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
<PackageReference Include="xunit" Version="2.9.0" />
|
<PackageReference Include="xunit" Version="2.9.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />
|
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using InboxIntel.Application.Abstractions;
|
||||||
|
using InboxIntel.Application.DTOs;
|
||||||
|
using InboxIntel.Domain.Entities;
|
||||||
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
|
using InboxIntel.Infrastructure.Search;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace InboxIntel.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RECOMMENDATIONS #8: keyset (cursor) pagination for the browse path — the window after a
|
||||||
|
/// (SentAtUtc, Id) cursor returns the next rows with no duplicates/skips and no OFFSET scan.
|
||||||
|
/// </summary>
|
||||||
|
public class KeysetPaginationTests
|
||||||
|
{
|
||||||
|
private sealed class FakeCurrentUser : ICurrentUser
|
||||||
|
{
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cursor_window_continues_exactly_after_the_previous_page()
|
||||||
|
{
|
||||||
|
var user = Guid.NewGuid();
|
||||||
|
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(nameof(Cursor_window_continues_exactly_after_the_previous_page)).Options;
|
||||||
|
|
||||||
|
var baseline = DateTimeOffset.UtcNow;
|
||||||
|
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
|
||||||
|
{
|
||||||
|
var sender = new Sender { UserId = user, Address = "s@x.x" };
|
||||||
|
seed.Senders.Add(sender);
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
seed.Emails.Add(new Email { UserId = user, GmailMessageId = $"m{i}", Sender = sender, SentAtUtc = baseline.AddMinutes(-i) });
|
||||||
|
await seed.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user });
|
||||||
|
var svc = new SearchService(ctx);
|
||||||
|
|
||||||
|
// First window via offset (page 1, size 2): m0, m1 (newest first).
|
||||||
|
var page1 = await svc.SearchAsync(user, new SearchRequestDto(null, null, null, null, null, null, null, false, 1, 2));
|
||||||
|
page1.Items.Select(i => i.GmailMessageId).Should().Equal("m0", "m1");
|
||||||
|
|
||||||
|
// Next window via cursor from the last row of page 1.
|
||||||
|
var last = page1.Items[^1];
|
||||||
|
var page2 = await svc.SearchAsync(user, new SearchRequestDto(
|
||||||
|
null, null, null, null, null, null, null, false, 1, 2,
|
||||||
|
AfterSentAtUtc: last.SentAtUtc, AfterId: last.Id));
|
||||||
|
|
||||||
|
page2.Items.Select(i => i.GmailMessageId).Should().Equal("m2", "m3"); // no dupes, no skips
|
||||||
|
page2.TotalCount.Should().Be(-1, "cursor windows skip the COUNT — that's the perf win");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ namespace InboxIntel.IntegrationTests;
|
|||||||
/// InMemory test run is unaffected.
|
/// InMemory test run is unaffected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "LiveDb")]
|
[Trait("Category", "LiveDb")]
|
||||||
|
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
|
||||||
public class LiveDbSearchTests
|
public class LiveDbSearchTests
|
||||||
{
|
{
|
||||||
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
|
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
|
||||||
@@ -122,4 +123,54 @@ public class LiveDbSearchTests
|
|||||||
}
|
}
|
||||||
finally { await CleanupAsync(opts, uid); }
|
finally { await CleanupAsync(opts, uid); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class DirectionalFakeEmbeddings : IEmbeddingProvider
|
||||||
|
{
|
||||||
|
public bool IsAvailable => true;
|
||||||
|
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Deterministic "semantics": anything fruit-flavoured points one way, else the other.
|
||||||
|
var v = new float[768];
|
||||||
|
if (text.Contains("banana") || text.Contains("tropical")) v[0] = 1; else v[1] = 1;
|
||||||
|
return Task.FromResult(v);
|
||||||
|
}
|
||||||
|
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var list = new List<float[]>();
|
||||||
|
foreach (var t in texts) list.Add(await EmbedAsync(t, ct));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hybrid_search_surfaces_semantic_match_with_zero_keyword_overlap()
|
||||||
|
{
|
||||||
|
if (Conn is null) return;
|
||||||
|
var opts = Options();
|
||||||
|
var uid = await SeedAsync(opts);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var embeddings = new DirectionalFakeEmbeddings();
|
||||||
|
using (var prep = new AppDbContext(opts, new FakeCurrentUser()))
|
||||||
|
{
|
||||||
|
// "Weekly notes" gets a fruit-direction embedding (semantically related to the
|
||||||
|
// query); "Invoice March" points elsewhere. Neither subject contains "banana".
|
||||||
|
var near = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Weekly notes");
|
||||||
|
near.Embedding = new Vector(await embeddings.EmbedAsync("tropical"));
|
||||||
|
var far = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Invoice March");
|
||||||
|
far.Embedding = new Vector(await embeddings.EmbedAsync("finance"));
|
||||||
|
await prep.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
|
||||||
|
var res = await new SearchService(ctx, embeddings)
|
||||||
|
.SearchAsync(uid, GmailQueryParser.Parse("banana", 1, 10));
|
||||||
|
|
||||||
|
// Zero lexical hits for "banana" — hybrid must still surface the semantically
|
||||||
|
// nearest email, ranked first.
|
||||||
|
res.Items.Should().NotBeEmpty("semantic recall should fire with zero keyword overlap");
|
||||||
|
res.Items[0].Subject.Should().Be("Weekly notes");
|
||||||
|
}
|
||||||
|
finally { await CleanupAsync(opts, uid); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user