Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 733bfc9bae perf(ui): route-level code splitting (RECOMMENDATIONS #7)
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 15s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 51s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 51s
React.lazy per page: the initial bundle drops 678 KB -> 381 KB (gzip 221 -> 125 KB);
Dashboard's 263 KB chunk (Chart.js + react-grid-layout) now loads only when the
dashboard route is visited. Theme-correct Suspense fallback. Landing + Layout stay
eager for first paint. Verified: build green with per-route chunks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:40:52 +02:00
72 changed files with 283 additions and 3695 deletions
-13
View File
@@ -1,13 +0,0 @@
# Root editor/formatter config. end_of_line=lf makes dotnet-format agree with
# .gitattributes (eol=lf) — without this, format-on-Windows wants CRLF while git
# stores LF, and the pre-commit/CI format gates flip-flop forever.
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
[*.cs]
indent_style = space
indent_size = 4
-3
View File
@@ -15,6 +15,3 @@ 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
+3 -3
View File
@@ -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: '10.0.x' dotnet-version: '8.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: '10.0.x' dotnet-version: '8.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: '10.0.x' dotnet-version: '8.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
-35
View File
@@ -1,35 +0,0 @@
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
+1 -19
View File
@@ -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: '10.0.x' dotnet-version: '8.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,21 +52,3 @@ 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
-6
View File
@@ -21,9 +21,6 @@ frontend/.vite/
appsettings.*.local.json appsettings.*.local.json
secrets.json secrets.json
## DB backups (never commit dumps)
backups/
## Logs ## Logs
logs/ logs/
*.log *.log
@@ -70,6 +67,3 @@ vendor/
coverage/ coverage/
.cache/ .cache/
tmp/ tmp/
# agent worktrees (never commit)
.claude/worktrees/
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
+1 -3
View File
@@ -29,9 +29,7 @@ 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**: nightly `pg_dump` rotation runs via the compose `backup` service - **Encrypt backups** of the `pgdata` volume the same way.
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 /
-75
View File
@@ -22,47 +22,6 @@ 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: .
@@ -75,10 +34,6 @@ 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}
@@ -89,8 +44,6 @@ 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.
@@ -106,33 +59,6 @@ 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
@@ -148,4 +74,3 @@ services:
volumes: volumes:
pgdata: pgdata:
keys: keys:
ollama:
+3 -11
View File
@@ -38,6 +38,7 @@ export const AnalyticsApi = {
health: () => api.get('/analytics/health').then((r) => r.data), health: () => api.get('/analytics/health').then((r) => r.data),
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data), topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data), volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data), categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data),
attachments: () => api.get('/analytics/attachments').then((r) => r.data), attachments: () => api.get('/analytics/attachments').then((r) => r.data),
sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data), sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data),
@@ -93,22 +94,13 @@ function folderToRequest(slug, page, pageSize) {
case 'starred': return { ...base, isStarred: true }; case 'starred': return { ...base, isStarred: true };
case 'sent': return { ...base, gmailLabel: 'SENT' }; case 'sent': return { ...base, gmailLabel: 'SENT' };
case 'drafts': return { ...base, gmailLabel: 'DRAFT' }; case 'drafts': return { ...base, gmailLabel: 'DRAFT' };
// Archive = filed away: not in inbox, not trashed, and NOT Sent/Spam/Draft/Chat. case 'archive': return { ...base, isInInbox: false, isTrashed: false };
// Excluding those labels stops sent mail leaking into Archive.
case 'archive': return { ...base, isInInbox: false, isTrashed: false, excludeGmailLabels: ['SENT', 'DRAFT', 'SPAM', 'TRASH', 'CHAT'] };
case 'spam': return { ...base, gmailLabel: 'SPAM' }; case 'spam': return { ...base, gmailLabel: 'SPAM' };
case 'trash': return { ...base, isTrashed: true }; case 'trash': return { ...base, isTrashed: true };
// Pinned = Gmail's "Important" marker (a real per-message flag), not "everything".
case 'pinned': return { ...base, isImportant: true };
// Read Later = only emails the user explicitly flagged (local marker).
case 'readlater': return { ...base, isReadLater: true };
// Unlabelled = emails not filed under any user-created label.
case 'unlabelled': return { ...base, hasUserLabels: false };
// ── Special filters ── // ── Special filters ──
case 'large': return { ...base, minSizeBytes: 5_000_000 }; case 'large': return { ...base, minSizeBytes: 5_000_000 };
// Old Mail = strictly older than 6 months.
case 'old': { case 'old': {
const d = new Date(); d.setMonth(d.getMonth() - 6); const d = new Date(); d.setFullYear(d.getFullYear() - 1);
return { ...base, to: d.toISOString().slice(0, 10) }; return { ...base, to: d.toISOString().slice(0, 10) };
} }
// ── Smart folders ── // ── Smart folders ──
+5 -34
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react'; import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
import { BulkApi } from '../api/client.js'; import { BulkApi } from '../api/client.js';
import { import {
Button, useToast, Button, useToast,
@@ -49,38 +49,9 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) {
}; };
return ( return (
<div <div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
role="toolbar" <span className="text-sm font-medium">{count} selected</span>
aria-label={`${count} email${count === 1 ? '' : 's'} selected`} <div className="flex-1" />
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm"
>
{/* Selection count — primary emphasis so it reads first. */}
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold tabular-nums text-primary-foreground"
>
{count}
</span>
<span className="text-sm font-medium text-foreground">
selected
</span>
{/* Obvious clear-selection affordance, kept next to the count. */}
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"
>
<X />
</Button>
</div>
<div className="flex-1 basis-full sm:basis-0" />
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}> <Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
<MailOpen /> Read <MailOpen /> Read
</Button> </Button>
@@ -96,7 +67,7 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) {
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}> <Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
<Trash2 /> Trash <Trash2 /> Trash
</Button> </Button>
</div> <Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}> <Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
<DialogContent> <DialogContent>
-115
View File
@@ -1,115 +0,0 @@
import { useEffect, useState } from 'react';
import { EmailApi } from '../api/client.js';
// ── helpers ──────────────────────────────────────────────────────────────────
const fmtSize = (b) => {
if (!b) return '';
if (b < 1024) return `${b} B`;
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
return `${(b / 1048576).toFixed(1)} MB`;
};
/**
* Shared reading-pane / full single-email view.
*
* Accepts either an `email` summary object (as emitted by the list rows) or a
* bare `emailId`. Fetches the full detail via EmailApi.get(id) and the AI
* summary lazily via EmailApi.summary(id). Renders subject, metadata, an AI
* summary button, the body, and per-email actions including "Open in Gmail".
*
* `onClose` collapses the pane.
*/
export default function EmailDetail({ email: summary, emailId, onClose }) {
const id = summary?.id ?? emailId;
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [aiSummary, setAiSummary] = useState(null);
const [aiLoading, setAiLoading] = useState(false);
useEffect(() => {
if (id == null) return;
let cancelled = false;
setDetail(null);
setLoading(true);
setAiSummary(null);
setAiLoading(false);
EmailApi.get(id)
.then((d) => { if (!cancelled) setDetail(d); })
.catch(() => { if (!cancelled) setDetail(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
const email = detail ?? summary ?? {};
const fetchAiSummary = () => {
if (id == null) return;
setAiLoading(true);
EmailApi.summary(id)
.then((r) => setAiSummary(r.summary))
.catch(() => setAiSummary(null))
.finally(() => setAiLoading(false));
};
const openInGmail = () => window.open(
`https://mail.google.com/mail/u/0/#all/${email.gmailMessageId}`,
'_blank', 'noopener,noreferrer'
);
return (
<div className="sv-detail">
<div className="sv-detail-topbar">
<button className="sv-close" onClick={onClose} aria-label="Close reading pane" title="Close">
</button>
</div>
<div className="sv-detail-header">
<div className="sv-detail-subject">{email.subject || '(no subject)'}</div>
<div className="sv-detail-meta">
<span>{email.senderDisplayName || email.senderAddress}</span>
{email.sentAtUtc && (
<>
<span className="sv-detail-sep">·</span>
<span>{new Date(email.sentAtUtc).toLocaleString()}</span>
</>
)}
{email.sizeEstimateBytes > 0 && (
<>
<span className="sv-detail-sep">·</span>
<span>{fmtSize(email.sizeEstimateBytes)}</span>
</>
)}
</div>
</div>
{!loading && (
<div className="sv-ai-summary">
{aiSummary != null ? (
<div className="sv-ai-summary-text"> {aiSummary}</div>
) : (
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
{aiLoading ? 'Summarising…' : '✨ AI summary'}
</button>
)}
</div>
)}
{loading && <div className="sv-body-loading muted">Loading message</div>}
{!loading && detail?.bodyText && (
<div className="sv-body">{detail.bodyText}</div>
)}
{!loading && !detail?.bodyText && email.snippet && (
<div className="sv-snippet">{email.snippet}</div>
)}
<div className="sv-detail-actions">
<button className="btn-sm" onClick={openInGmail}>Open in Gmail </button>
</div>
</div>
);
}
+6 -16
View File
@@ -1,6 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { EmailApi } from '../api/client.js'; import { EmailApi } from '../api/client.js';
import { Checkbox } from './ui/checkbox.jsx';
const fmtDate = (iso) => { const fmtDate = (iso) => {
const d = new Date(iso); const d = new Date(iso);
@@ -36,7 +35,7 @@ function renderHighlight(s) {
return out; return out;
} }
export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused, onOpen }) { export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -89,16 +88,12 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
return ( return (
<tr <tr
className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`} className={`email-row${email.isUnread ? ' email-row--unread' : ''}${acting ? ' email-row--acting' : ''}${focused ? ' email-row--focused' : ''}`}
onClick={() => onOpen ? onOpen(email) : openInGmail()} onClick={openInGmail}
title={onOpen ? 'Open' : 'Open in Gmail'} title="Open in Gmail"
> >
{onToggleSelect && ( {onToggleSelect && (
<td className="el-select" onClick={(e) => e.stopPropagation()}> <td className="el-select" onClick={(e) => e.stopPropagation()}>
<Checkbox <input type="checkbox" checked={!!selected} onChange={() => onToggleSelect(email.id)} />
checked={!!selected}
onCheckedChange={() => onToggleSelect(email.id)}
aria-label={selected ? 'Deselect email' : 'Select email'}
/>
</td> </td>
)} )}
<td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td> <td className="el-unread">{email.isUnread && <span className="unread-dot" />}</td>
@@ -108,8 +103,8 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
<td className="el-subject"> <td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span> <span className="el-subj-text">{email.subject || '(no subject)'}</span>
{email.matchHighlight {email.matchHighlight
? <span className="el-snippet">{renderHighlight(email.matchHighlight)}</span> ? <span className="el-snippet"> {renderHighlight(email.matchHighlight)}</span>
: email.snippet && <span className="el-snippet">{email.snippet}</span>} : email.snippet && <span className="el-snippet"> {email.snippet}</span>}
</td> </td>
<td className="el-meta"> <td className="el-meta">
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>} {email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
@@ -139,11 +134,6 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
disabled={email._unsubDone} disabled={email._unsubDone}
>{email._unsubDone ? '✓' : '✉✕'}</button> >{email._unsubDone ? '✓' : '✉✕'}</button>
)} )}
<button
className="action-btn"
title="Open in Gmail"
onClick={(e) => { e.stopPropagation(); openInGmail(); }}
></button>
<button <button
className="action-btn action-btn--danger" className="action-btn action-btn--danger"
title="Move to trash" title="Move to trash"
-43
View File
@@ -1,43 +0,0 @@
import { forwardRef } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '../../lib/utils.js';
/**
* Accessible, design-token styled checkbox.
* Wraps Radix Checkbox so it is keyboard-accessible with a visible focus ring.
* Accepts `checked`, `onCheckedChange` (Radix) and, for convenience, `onChange`
* (called with a synthetic-ish `{ target: { checked } }`) so it can drop into
* places that previously used a bare <input type="checkbox">.
*/
const Checkbox = forwardRef(function Checkbox(
{ className, onCheckedChange, onChange, ...props },
ref
) {
const handleCheckedChange = (checked) => {
onCheckedChange?.(checked);
onChange?.({ target: { checked } });
};
return (
<CheckboxPrimitive.Root
ref={ref}
onCheckedChange={handleCheckedChange}
className={cn(
'peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] border border-border bg-card transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
'hover:border-primary/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-3 w-3" strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
export { Checkbox };
-1
View File
@@ -2,7 +2,6 @@
export { Button, buttonVariants } from './button.jsx'; export { Button, buttonVariants } from './button.jsx';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx'; export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card.jsx';
export { Badge, badgeVariants } from './badge.jsx'; export { Badge, badgeVariants } from './badge.jsx';
export { Checkbox } from './checkbox.jsx';
export { Input, Textarea } from './input.jsx'; export { Input, Textarea } from './input.jsx';
export { Switch } from './switch.jsx'; export { Switch } from './switch.jsx';
export { Separator } from './separator.jsx'; export { Separator } from './separator.jsx';
+55 -61
View File
@@ -1,16 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Line, Doughnut } from 'react-chartjs-2'; import { Bar, Line, Doughnut } from 'react-chartjs-2';
import { Inbox } from 'lucide-react';
import { AnalyticsApi } from '../api/client.js'; import { AnalyticsApi } from '../api/client.js';
import { Skeleton } from './ui/skeleton.jsx';
import { EmptyState } from './ui/misc.jsx';
import { import {
Chart as ChartJS, CategoryScale, LinearScale, PointElement, Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
LineElement, ArcElement, Tooltip, Legend LineElement, ArcElement, Tooltip, Legend
} from 'chart.js'; } from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Tooltip, Legend); ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
const fmtBytes = (b) => { const fmtBytes = (b) => {
if (!b) return '0 B'; if (!b) return '0 B';
@@ -80,6 +77,32 @@ export function VolumeWidget({ volume }) {
); );
} }
export function HeatmapWidget({ heatmap }) {
if (!heatmap) return <Empty />;
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const max = Math.max(1, ...heatmap.map((c) => c.count));
const grid = {};
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
return (
<div className="widget">
<h3>Activity Heatmap</h3>
<div className="heatmap">
{days.map((d, dow) => (
<div className="hm-row" key={dow}>
<span className="hm-day">{d}</span>
{Array.from({ length: 24 }, (_, h) => {
const v = grid[`${dow}-${h}`] || 0;
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
})}
</div>
))}
</div>
</div>
);
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Maps EmailCategory enum name → folder slug or search query // Maps EmailCategory enum name → folder slug or search query
const CAT_SLUG = { const CAT_SLUG = {
Finance: '/app/folder/finance', Finance: '/app/folder/finance',
@@ -112,64 +135,41 @@ const CAT_SLUG = {
Unknown: '/app/folder/allmail', Unknown: '/app/folder/allmail',
}; };
// "Emails by Category" — ranked horizontal bar list. Aggregates the export function CategoryHeatmapWidget() {
// category×day-of-week cells into per-category totals; each bar links to
// that category's folder via CAT_SLUG.
export function CategoryBreakdownWidget() {
const [cells, setCells] = useState(null); const [cells, setCells] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []); useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
if (!cells) { if (!cells) return <div className="widget"><div className="muted">Loading</div></div>;
return ( if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet run a sync.</div></div>;
<div className="widget">
<h3>Emails by Category</h3>
<div className="cat-bars">
{Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-6 w-full" />)}
</div>
</div>
);
}
const totals = {}; const categories = [...new Set(cells.map((c) => c.category))].sort();
cells.forEach((c) => { totals[c.category] = (totals[c.category] || 0) + c.count; }); const max = Math.max(1, ...cells.map((c) => c.count));
const ranked = Object.entries(totals) const grid = {};
.map(([category, count]) => ({ category, count })) cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
.sort((a, b) => b.count - a.count)
.slice(0, 8);
if (ranked.length === 0) {
return (
<div className="widget">
<h3>Emails by Category</h3>
<EmptyState icon={Inbox} title="No data yet — run a sync" description="Category totals appear once your inbox has been synced." />
</div>
);
}
const max = Math.max(1, ...ranked.map((r) => r.count));
return ( return (
<div className="widget"> <div className="widget">
<h3>Emails by Category</h3> <h3>Category Heatmap</h3>
<div className="cat-bars"> <div className="cat-heatmap">
{ranked.map(({ category, count }) => { <div className="chm-row chm-head">
const dest = CAT_SLUG[category]; <span className="chm-label" />
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
</div>
{categories.map((cat) => {
const dest = CAT_SLUG[cat];
return ( return (
<button <div className="chm-row" key={cat}>
type="button" <span
className={`cat-bar${dest ? ' cat-bar--link' : ''}`} className={`chm-label${dest ? ' chm-label--link' : ''}`}
key={category} title={dest ? `View ${cat} emails` : cat}
disabled={!dest}
title={dest ? `View ${category} emails` : category}
onClick={dest ? () => navigate(dest) : undefined} onClick={dest ? () => navigate(dest) : undefined}
> >{cat}</span>
<span className="cat-bar-label">{category}</span> {DOW.map((_, dow) => {
<span className="cat-bar-track"> const v = grid[`${cat}-${dow}`] || 0;
<span className="cat-bar-fill" style={{ width: `${(count / max) * 100}%` }} /> return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]}${v}`}>{v || ''}</span>;
</span> })}
<span className="cat-bar-count">{count.toLocaleString()}</span> </div>
</button>
); );
})} })}
</div> </div>
@@ -196,10 +196,4 @@ export function StorageWidget({ bytes }) {
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />; return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
} }
function Empty() { function Empty() { return <div className="widget"><div className="muted">No data yet run a sync.</div></div>; }
return (
<div className="widget">
<EmptyState icon={Inbox} title="No data yet — run a sync" description="This widget populates after your inbox has been synced." />
</div>
);
}
+9 -43
View File
@@ -2,15 +2,9 @@ import { useEffect, useState } from 'react';
import { BulkApi } from '../api/client.js'; import { BulkApi } from '../api/client.js';
/// <summary> /// <summary>
/// Keyboard navigation for an email list. Shortcuts: /// j/k move focus down/up the list, e archives the focused email, # (shift+3)
/// j / ArrowDown move focus down /// trashes it. Ignored while an input/textarea/select has focus, or while
/// k / ArrowUp move focus up /// the "/" search shortcut is active, so typing is never hijacked.
/// e archive the focused email
/// u mark the focused email unread
/// # (shift+3) trash the focused email
/// All shortcuts are ignored while an input/textarea/select (or any
/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left
/// alone, so typing and browser/OS shortcuts are never hijacked.
/// `onRemoved(id)` lets the caller drop the row from local state after a /// `onRemoved(id)` lets the caller drop the row from local state after a
/// successful archive/trash. /// successful archive/trash.
/// </summary> /// </summary>
@@ -18,59 +12,31 @@ export default function useListKeyboardNav(emails, onRemoved) {
const [focusedId, setFocusedId] = useState(null); const [focusedId, setFocusedId] = useState(null);
useEffect(() => { useEffect(() => {
const isEditable = (el) => {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
};
const handler = async (e) => { const handler = async (e) => {
// Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…). const tag = document.activeElement?.tagName;
if (isEditable(document.activeElement)) return; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (!emails.length) return; if (!emails.length) return;
const idx = emails.findIndex((x) => x.id === focusedId); const idx = emails.findIndex((x) => x.id === focusedId);
switch (e.key) { if (e.key === 'j') {
case 'j':
case 'ArrowDown': {
e.preventDefault(); e.preventDefault();
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1); const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
setFocusedId(emails[next].id); setFocusedId(emails[next].id);
break; } else if (e.key === 'k') {
}
case 'k':
case 'ArrowUp': {
e.preventDefault(); e.preventDefault();
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0); const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
setFocusedId(emails[prev].id); setFocusedId(emails[prev].id);
break; } else if (e.key === 'e' && idx >= 0) {
}
case 'e': {
if (idx < 0) break;
e.preventDefault(); e.preventDefault();
const id = emails[idx].id; const id = emails[idx].id;
await BulkApi.archive([id]); await BulkApi.archive([id]);
onRemoved(id); onRemoved(id);
break; } else if (e.key === '#' && idx >= 0) {
}
case 'u': {
if (idx < 0) break;
e.preventDefault();
await BulkApi.markUnread([emails[idx].id]);
break;
}
case '#': {
if (idx < 0) break;
e.preventDefault(); e.preventDefault();
const id = emails[idx].id; const id = emails[idx].id;
await BulkApi.trash([id]); await BulkApi.trash([id]);
onRemoved(id); onRemoved(id);
break;
}
default:
break;
} }
}; };
-1
View File
@@ -7,7 +7,6 @@ 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';
import './split.css';
// Route-level code splitting: each page loads its own chunk on first visit, so the // 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. // initial bundle no longer carries Chart.js / grid-layout / every page at once.
+10 -25
View File
@@ -6,9 +6,8 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
import SyncSplash from '../components/SyncSplash.jsx'; import SyncSplash from '../components/SyncSplash.jsx';
import { import {
HealthWidget, StatCard, TopSendersWidget, VolumeWidget, HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
CategoryBreakdownWidget, AttachmentsWidget, StorageWidget CategoryHeatmapWidget, AttachmentsWidget, StorageWidget
} from '../components/widgets.jsx'; } from '../components/widgets.jsx';
import { Skeleton } from '../components/ui/skeleton.jsx';
// Default grid geometry; overridden by the user's saved layout. // Default grid geometry; overridden by the user's saved layout.
const DEFAULT_LAYOUT = [ const DEFAULT_LAYOUT = [
@@ -18,23 +17,12 @@ const DEFAULT_LAYOUT = [
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 }, { i: 'storage', x: 7, y: 0, w: 2, h: 2 },
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 }, { i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 }, { i: 'volume', x: 6, y: 2, w: 6, h: 4 },
{ i: 'category-breakdown', x: 0, y: 5, w: 6, h: 5 }, { i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 },
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 }, { i: 'attachments', x: 6, y: 6, w: 4, h: 4 },
]; ];
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i); const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
function WidgetSkeleton() {
return (
<div className="widget">
<Skeleton className="mb-3 h-4 w-1/3" />
<Skeleton className="mb-2 h-3 w-full" />
<Skeleton className="mb-2 h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [layout, setLayout] = useState(DEFAULT_LAYOUT); const [layout, setLayout] = useState(DEFAULT_LAYOUT);
@@ -99,18 +87,15 @@ export default function Dashboard() {
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]); const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
const render = (key) => { const render = (key) => {
// category-breakdown self-fetches, so it renders regardless of dashboard load state.
if (key === 'category-breakdown') return <CategoryBreakdownWidget />;
// While the dashboard payload loads, show a skeleton placeholder per widget.
if (!data) return <WidgetSkeleton />;
switch (key) { switch (key) {
case 'inbox-health': return <HealthWidget health={data.health} />; case 'inbox-health': return <HealthWidget health={data?.health} />;
case 'total-emails': return <StatCard label="Total Emails" value={(data.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />; case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} to="/app/folder/allmail" />;
case 'unread-emails': return <StatCard label="Unread" value={(data.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />; case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} to="/app/folder/unread" />;
case 'storage': return <StorageWidget bytes={data.storageEstimateBytes} />; case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
case 'top-senders': return <TopSendersWidget senders={data.topSenders} />; case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
case 'volume': return <VolumeWidget volume={data.volumeOverTime} />; case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
case 'attachments': return <AttachmentsWidget attachments={data.attachmentBreakdown} />; case 'category-heatmap': return <CategoryHeatmapWidget />;
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
default: return null; default: return null;
} }
}; };
+3 -44
View File
@@ -2,9 +2,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js'; import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import EmailDetail from '../components/EmailDetail.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx';
import { Skeleton, EmptyState } from '../components/ui';
import useSelection from '../hooks/useSelection.js'; import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
@@ -37,20 +35,6 @@ const FOLDER_META = {
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
function ListSkeleton({ rows = 6 }) {
return (
<div className="sv-skeleton-list" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<div className="sv-skeleton-row" key={i}>
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}
export default function FolderView() { export default function FolderView() {
const { slug } = useParams(); const { slug } = useParams();
@@ -62,7 +46,6 @@ export default function FolderView() {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [selectedEmail, setSelectedEmail] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
@@ -73,7 +56,6 @@ export default function FolderView() {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setError(null); setError(null);
setSelectedEmail(null);
clear(); clear();
}, [slug, clear]); }, [slug, clear]);
@@ -133,15 +115,8 @@ export default function FolderView() {
}} }}
/> />
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
<div className="sv-list-pane">
{loading && emails.length === 0 && !error && <ListSkeleton />}
{!loading && !error && emails.length === 0 && ( {!loading && !error && emails.length === 0 && (
<EmptyState <div className="fv-empty">No emails in this folder.</div>
title="No emails in this folder"
description="Nothing here yet — try another folder or run a sync."
/>
)} )}
{emails.length > 0 && ( {emails.length > 0 && (
@@ -154,11 +129,7 @@ export default function FolderView() {
selected={selected.has(e.id)} selected={selected.has(e.id)}
onToggleSelect={toggle} onToggleSelect={toggle}
focused={focusedId === e.id} focused={focusedId === e.id}
onOpen={(email) => setSelectedEmail(email)} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
onRemove={(id) => {
setEmails((prev) => prev.filter((x) => x.id !== id));
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
}}
/> />
))} ))}
</tbody> </tbody>
@@ -168,22 +139,10 @@ export default function FolderView() {
{/* Sentinel — triggers next page load when scrolled into view */} {/* Sentinel — triggers next page load when scrolled into view */}
<div ref={sentinelRef} className="fv-sentinel" /> <div ref={sentinelRef} className="fv-sentinel" />
{loading && emails.length > 0 && <div className="fv-loading-more">Loading</div>} {loading && <div className="fv-loading-more">Loading</div>}
{!hasMore && emails.length > 0 && ( {!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} emails </div> <div className="fv-end"> {emails.length.toLocaleString()} emails </div>
)} )}
</div> </div>
{selectedEmail && (
<aside className="sv-reading-pane">
<EmailDetail
key={selectedEmail.id}
email={selectedEmail}
onClose={() => setSelectedEmail(null)}
/>
</aside>
)}
</div>
</div>
); );
} }
+6 -55
View File
@@ -2,29 +2,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js'; import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx'; import EmailRow from '../components/EmailRow.jsx';
import EmailDetail from '../components/EmailDetail.jsx';
import BulkToolbar from '../components/BulkToolbar.jsx'; import BulkToolbar from '../components/BulkToolbar.jsx';
import { Skeleton, EmptyState } from '../components/ui';
import useSelection from '../hooks/useSelection.js'; import useSelection from '../hooks/useSelection.js';
import useListKeyboardNav from '../hooks/useListKeyboardNav.js'; import useListKeyboardNav from '../hooks/useListKeyboardNav.js';
import useSavedSearches from '../hooks/useSavedSearches.js'; import useSavedSearches from '../hooks/useSavedSearches.js';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
function ListSkeleton({ rows = 6 }) {
return (
<div className="sv-skeleton-list" aria-hidden="true">
{Array.from({ length: rows }).map((_, i) => (
<div className="sv-skeleton-row" key={i}>
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
);
}
export default function SearchResults() { export default function SearchResults() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -36,7 +20,6 @@ export default function SearchResults() {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [selectedEmail, setSelectedEmail] = useState(null);
const { selected, toggle, clear, removeIds, selectedIds } = useSelection(); const { selected, toggle, clear, removeIds, selectedIds } = useSelection();
const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id))); const focusedId = useListKeyboardNav(emails, (id) => setEmails((prev) => prev.filter((x) => x.id !== id)));
const { searches: savedSearches, add: addSavedSearch } = useSavedSearches(); const { searches: savedSearches, add: addSavedSearch } = useSavedSearches();
@@ -53,7 +36,6 @@ export default function SearchResults() {
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setError(null); setError(null);
setSelectedEmail(null);
clear(); clear();
}, [q, clear]); }, [q, clear]);
@@ -101,13 +83,11 @@ export default function SearchResults() {
)} )}
</div> </div>
{!q.trim() && ( {!q.trim() && <div className="fv-empty">Enter a search query above.</div>}
<EmptyState
title="Search your mail"
description="Enter a search query above to find emails."
/>
)}
{error && <div className="fv-error">{error}</div>} {error && <div className="fv-error">{error}</div>}
{!loading && !error && q.trim() && emails.length === 0 && !hasMore && (
<div className="fv-empty">No results for "{q}".</div>
)}
<BulkToolbar <BulkToolbar
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -120,18 +100,6 @@ export default function SearchResults() {
}} }}
/> />
{q.trim() && (
<div className={`sv-split${selectedEmail ? ' sv-split--open' : ''}`}>
<div className="sv-list-pane">
{loading && emails.length === 0 && !error && <ListSkeleton />}
{!loading && !error && emails.length === 0 && !hasMore && (
<EmptyState
title={`No results for "${q}"`}
description="Try a different search term or filter."
/>
)}
{emails.length > 0 && ( {emails.length > 0 && (
<table className="email-list"> <table className="email-list">
<tbody> <tbody>
@@ -142,11 +110,7 @@ export default function SearchResults() {
selected={selected.has(e.id)} selected={selected.has(e.id)}
onToggleSelect={toggle} onToggleSelect={toggle}
focused={focusedId === e.id} focused={focusedId === e.id}
onOpen={(email) => setSelectedEmail(email)} onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
onRemove={(id) => {
setEmails((prev) => prev.filter((x) => x.id !== id));
setSelectedEmail((cur) => (cur?.id === id ? null : cur));
}}
/> />
))} ))}
</tbody> </tbody>
@@ -154,23 +118,10 @@ export default function SearchResults() {
)} )}
<div ref={sentinelRef} className="fv-sentinel" /> <div ref={sentinelRef} className="fv-sentinel" />
{loading && emails.length > 0 && <div className="fv-loading-more">Loading</div>} {loading && <div className="fv-loading-more">Loading</div>}
{!hasMore && emails.length > 0 && ( {!hasMore && emails.length > 0 && (
<div className="fv-end"> {emails.length.toLocaleString()} results </div> <div className="fv-end"> {emails.length.toLocaleString()} results </div>
)} )}
</div> </div>
{selectedEmail && (
<aside className="sv-reading-pane">
<EmailDetail
key={selectedEmail.id}
email={selectedEmail}
onClose={() => setSelectedEmail(null)}
/>
</aside>
)}
</div>
)}
</div>
); );
} }
-148
View File
@@ -1,148 +0,0 @@
/* ── Split-view reading pane ────────────────────────────────────────────────
* Owned by Unit 1. Gmail/Outlook-style master-detail: the email list stays on
* the left, a collapsible + horizontally resizable reading pane sits on the
* right. Below 768px the pane overlays the list (mobile stack) instead of
* squishing the columns side-by-side.
*
* These pages (.folder-view) are styled from styles.css, so we reference its
* legacy CSS variables (--panel, --panel-2, --text, --muted, --accent) with
* hardcoded hex fallbacks. The modern index.css tokens are stored as raw HSL
* channel triplets and only work via hsl(var(--x)), so they are NOT used bare
* here.
* ------------------------------------------------------------------------- */
.sv-split {
display: flex;
align-items: stretch;
gap: 0;
width: 100%;
}
/* Left column — the list. Flexes to fill remaining space and scrolls itself. */
.sv-list-pane {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
/* Right column — the reading pane. Resizable via the native handle; the width
* gives it a sensible default, and the user can drag the bottom-right corner.
* `resize: horizontal` needs `overflow` other than visible. */
.sv-reading-pane {
flex: 0 0 auto;
width: clamp(320px, 42%, 640px);
min-width: 300px;
max-width: 80vw;
border-left: 1px solid var(--panel-2, #222a3d);
overflow: auto;
resize: horizontal;
background: var(--panel, #1a2030);
align-self: stretch;
}
/* ── Reading pane inner content ─────────────────────────────────────────── */
.sv-detail { padding: 18px 20px; }
.sv-detail-topbar {
display: flex;
justify-content: flex-end;
margin-bottom: 6px;
}
.sv-close {
background: transparent;
border: 1px solid var(--panel-2, #222a3d);
color: var(--muted, #8b93a7);
border-radius: 6px;
width: 28px;
height: 28px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.sv-close:hover {
background: var(--panel-2, #222a3d);
color: var(--text, #e6e9f0);
}
.sv-detail-header { margin-bottom: 14px; }
.sv-detail-subject {
font-size: 18px;
font-weight: 700;
margin-bottom: 6px;
color: var(--text, #e6e9f0);
word-break: break-word;
}
.sv-detail-meta {
font-size: 12px;
color: var(--muted, #8b93a7);
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.sv-detail-sep { opacity: 0.4; }
.sv-ai-summary { margin-bottom: 14px; }
.sv-ai-summary-text {
background: var(--panel-2, #222a3d);
border: 1px solid var(--panel-2, #222a3d);
border-radius: 8px;
padding: 10px 14px;
font-size: 13px;
color: var(--text, #e6e9f0);
}
.sv-body-loading { padding: 8px 0 18px; }
.sv-body {
background: var(--panel, #1a2030);
border: 1px solid var(--panel-2, #222a3d);
border-radius: 8px;
padding: 16px 18px;
font-size: 13px;
line-height: 1.7;
color: var(--text, #e6e9f0);
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 18px;
max-height: 60vh;
overflow-y: auto;
}
.sv-snippet {
font-size: 13px;
line-height: 1.6;
color: var(--muted, #8b93a7);
margin-bottom: 18px;
}
.sv-detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* ── List loading / empty states ───────────────────────────────────────── */
.sv-skeleton-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
}
.sv-skeleton-row {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 8px;
}
/* ── Mobile: stack / overlay the reading pane ──────────────────────────── */
@media (max-width: 767px) {
.sv-reading-pane {
position: fixed;
inset: 56px 0 0 0; /* below the topbar */
width: 100% !important;
max-width: 100vw;
min-width: 0;
border-left: none;
resize: none;
z-index: 40;
}
/* When the pane is open, hide the underlying list to avoid double-scroll. */
.sv-split.sv-split--open .sv-list-pane {
display: none;
}
}
+27 -53
View File
@@ -117,6 +117,8 @@ button:disabled { opacity: 0.5; cursor: default; }
.widget--link:hover { border-color: var(--accent); } .widget--link:hover { border-color: var(--accent); }
.mini-row--link { cursor: pointer; } .mini-row--link { cursor: pointer; }
.mini-row--link:hover td { color: var(--accent); } .mini-row--link:hover td { color: var(--accent); }
.chm-label--link { cursor: pointer; text-decoration: underline dotted; }
.chm-label--link:hover { color: var(--accent); }
.widget canvas { flex: 1; min-height: 0; } .widget canvas { flex: 1; min-height: 0; }
.stat { align-items: flex-start; justify-content: center; } .stat { align-items: flex-start; justify-content: center; }
@@ -135,21 +137,17 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #332f2b; t
.num { text-align: right; } .num { text-align: right; }
.muted { color: var(--muted); font-size: 12px; } .muted { color: var(--muted); font-size: 12px; }
/* Emails by Category — ranked horizontal bars */ .heatmap { display: flex; flex-direction: column; gap: 2px; }
.cat-bars { display: flex; flex-direction: column; gap: 6px; overflow: auto; } .hm-row { display: flex; align-items: center; gap: 2px; }
.cat-bar { .hm-day { width: 30px; font-size: 10px; color: var(--muted); }
display: grid; grid-template-columns: 92px 1fr 40px; gap: 8px; align-items: center; .hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
width: 100%; padding: 3px 4px; margin: 0; border: none; background: transparent;
border-radius: 6px; text-align: left; font: inherit; color: inherit; /* Category heatmap */
} .cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; }
.cat-bar--link { cursor: pointer; } .chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; }
.cat-bar--link:hover { background: rgba(255, 255, 255, 0.04); } .chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; }
.cat-bar:disabled { cursor: default; } .chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cat-bar-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
.cat-bar-track { height: 14px; background: rgba(255, 255, 255, 0.06); border-radius: 4px; overflow: hidden; }
.cat-bar-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; min-width: 2px; }
.cat-bar-count { font-size: 11px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.cat-bar--link:hover .cat-bar-label { color: var(--accent); }
/* react-grid-layout resize handle — make it clearly visible on the dark theme */ /* react-grid-layout resize handle — make it clearly visible on the dark theme */
.react-resizable-handle { .react-resizable-handle {
@@ -298,57 +296,32 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va
.fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; } .fv-error { color: var(--danger); font-size: 14px; padding: 12px 0; }
.email-list { width: 100%; border-collapse: collapse; font-size: 13px; } .email-list { width: 100%; border-collapse: collapse; font-size: 13px; }
.email-row { .email-row { border-bottom: 1px solid #332f2b; cursor: pointer; }
height: 44px;
border-bottom: 1px solid #332f2b;
cursor: pointer;
transition: background 0.1s ease;
}
.email-row > td { padding-top: 0; padding-bottom: 0; vertical-align: middle; }
/* Single, consistent hover state for the whole row. */
.email-row:hover { background: var(--panel); } .email-row:hover { background: var(--panel); }
/* Unread: prominent subject, keep sender readable but not shouty. */ .email-row--unread .el-sender,
.email-row--unread .el-subj-text { font-weight: 700; color: var(--text); } .email-row--unread .el-subj-text { font-weight: 700; color: var(--text); }
.email-row--unread .el-sender { font-weight: 600; color: var(--text); }
.el-select { width: 34px; padding: 0 4px 0 10px; text-align: center; } .el-unread { width: 14px; padding: 10px 4px 10px 0; }
.el-select > * { vertical-align: middle; }
.el-unread { width: 14px; padding: 0 4px 0 0; text-align: center; }
.unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); } .unread-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.el-sender { width: 180px; padding: 10px 12px 10px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
/* Sender: secondary in the hierarchy — muted by default. */ .el-subject { padding: 10px 8px; overflow: hidden; }
.el-sender {
width: 180px; padding: 0 12px 0 4px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--muted); font-size: 12.5px;
}
/* Subject + snippet share one line: subject prominent, snippet muted. */
.el-subject { padding: 0 8px; overflow: hidden; max-width: 0; white-space: nowrap; text-overflow: ellipsis; }
.el-subj-text { color: var(--text); } .el-subj-text { color: var(--text); }
.el-snippet { .el-snippet { color: var(--muted); }
color: var(--muted); font-size: 12.5px; .el-meta { width: 80px; padding: 10px 8px; text-align: right; white-space: nowrap; }
}
.el-snippet::before { content: '—'; margin: 0 6px; opacity: 0.55; }
.el-meta { width: 80px; padding: 0 8px; text-align: right; white-space: nowrap; }
.el-attach { margin-right: 4px; font-size: 12px; } .el-attach { margin-right: 4px; font-size: 12px; }
.el-size { font-size: 11px; color: var(--muted); } .el-size { font-size: 11px; color: var(--muted); }
.el-date { width: 70px; padding: 0 0 0 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; } .el-date { width: 70px; padding: 10px 0 10px 8px; text-align: right; color: var(--muted); white-space: nowrap; font-size: 12px; }
.el-actions { width: 96px; padding: 0 6px; text-align: right; white-space: nowrap; } .el-actions { width: 80px; padding: 0 6px; text-align: right; white-space: nowrap; }
.action-btn { .action-btn {
background: none; border: none; padding: 4px 5px; cursor: pointer; background: none; border: none; padding: 3px 4px; cursor: pointer;
font-size: 13px; line-height: 1; opacity: 0; font-size: 13px; opacity: 0; transition: opacity 0.1s, color 0.1s;
transition: opacity 0.1s ease, color 0.1s ease, background 0.1s ease;
border-radius: 4px; color: var(--muted); border-radius: 4px; color: var(--muted);
} }
.action-btn:hover { background: var(--panel-2); color: var(--text); opacity: 1 !important; } .action-btn:hover { background: var(--panel-2); opacity: 1 !important; }
.action-btn--active { opacity: 1 !important; } .action-btn--active { opacity: 1 !important; }
.action-btn--danger:hover { color: var(--danger); } .action-btn--danger:hover { color: var(--danger); }
.email-row:hover .action-btn { opacity: 0.65; } .email-row:hover .action-btn { opacity: 0.6; }
.action-btn:focus-visible { opacity: 1 !important; outline: 2px solid var(--accent); outline-offset: 1px; }
.email-row--acting { opacity: 0.6; pointer-events: none; } .email-row--acting { opacity: 0.6; pointer-events: none; }
.action-btn--unsub { font-size: 11px; } .action-btn--unsub { font-size: 11px; }
.action-btn--done { opacity: 1 !important; color: var(--ok); } .action-btn--done { opacity: 1 !important; color: var(--ok); }
@@ -432,6 +405,7 @@ input, select { background: var(--panel-2); border: 1px solid #332f2b; color: va
} }
.bulk-btn:hover { border-color: var(--accent); } .bulk-btn:hover { border-color: var(--accent); }
.bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); } .bulk-btn--danger:hover { border-color: var(--danger); color: var(--danger); }
.el-select { width: 28px; text-align: center; }
/* ── Keyboard shortcut help ──────────────────────────────────────────────── */ /* ── Keyboard shortcut help ──────────────────────────────────────────────── */
.kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; } .kbd-hint { position: fixed; bottom: 14px; right: 14px; font-size: 11px; color: var(--muted); background: var(--panel); border: 1px solid #332f2b; border-radius: 6px; padding: 4px 10px; opacity: 0.7; }
-27
View File
@@ -1,27 +0,0 @@
{
"$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/**"]
}
@@ -24,6 +24,9 @@ public class AnalyticsController : ApiControllerBase
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default) public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
=> Ok(await _analytics.GetVolumeOverTimeAsync(UserId, Math.Clamp(days, 1, 3660), ct)); => Ok(await _analytics.GetVolumeOverTimeAsync(UserId, Math.Clamp(days, 1, 3660), ct));
[HttpGet("heatmap")]
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
[HttpGet("category-heatmap")] [HttpGet("category-heatmap")]
public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct)); public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct));
@@ -72,22 +72,6 @@ public class EmailController : ApiControllerBase
[HttpPost("{id:guid}/untrash")] [HttpPost("{id:guid}/untrash")]
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct); public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
/// <summary>Toggle the local-only Read Later marker (no Gmail side effect).</summary>
[HttpPost("{id:guid}/readlater")]
public Task<IActionResult> ReadLater(Guid id, CancellationToken ct) => SetReadLater(id, true, ct);
[HttpPost("{id:guid}/unreadlater")]
public Task<IActionResult> UnreadLater(Guid id, CancellationToken ct) => SetReadLater(id, false, ct);
private async Task<IActionResult> SetReadLater(Guid id, bool value, CancellationToken ct)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
if (email is null) return NotFound();
email.IsReadLater = value;
await _db.SaveChangesAsync(ct);
return Ok();
}
/// <summary> /// <summary>
/// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender, /// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender,
/// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent /// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent
+2 -10
View File
@@ -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:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:8.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,16 +13,8 @@ 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:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:8.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
+3 -8
View File
@@ -5,21 +5,16 @@
<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="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<!-- 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="10.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<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" />
+6 -31
View File
@@ -16,10 +16,6 @@ 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);
@@ -42,8 +38,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(System.Security.Cryptography.X509Certificates.X509CertificateLoader dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2(
.LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"])); dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
} }
builder.Services.AddApplication(); builder.Services.AddApplication();
@@ -126,28 +122,6 @@ 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);
@@ -227,14 +201,15 @@ 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.KnownIPNetworks.Clear(); forwardedOptions.KnownNetworks.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)
{ {
if (System.Net.IPNetwork.TryParse(cidr, out var network)) var parts = cidr.Split('/');
forwardedOptions.KnownIPNetworks.Add(network); if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len))
forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len));
} }
app.UseForwardedHeaders(forwardedOptions); app.UseForwardedHeaders(forwardedOptions);
@@ -22,6 +22,7 @@ public interface IAnalyticsService
Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default); Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default); Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default); Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default); Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default); Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default); Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default);
@@ -119,15 +120,3 @@ 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);
}
@@ -12,6 +12,7 @@ public record InboxHealthDto(
public record TimeSeriesPointDto(DateOnly Day, int Count); public record TimeSeriesPointDto(DateOnly Day, int Count);
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
/// <summary>Email counts per category per day-of-week, for the category heatmap.</summary> /// <summary>Email counts per category per day-of-week, for the category heatmap.</summary>
public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count); public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count);
@@ -38,5 +39,6 @@ public record DashboardSummaryDto(
int UnreadEmails, int UnreadEmails,
IReadOnlyList<SenderStatDto> TopSenders, IReadOnlyList<SenderStatDto> TopSenders,
IReadOnlyList<TimeSeriesPointDto> VolumeOverTime, IReadOnlyList<TimeSeriesPointDto> VolumeOverTime,
IReadOnlyList<HeatmapCellDto> Heatmap,
IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown, IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown,
long StorageEstimateBytes); long StorageEstimateBytes);
+1 -14
View File
@@ -21,19 +21,6 @@ public record SearchRequestDto(
bool? IsInInbox = null, bool? IsInInbox = null,
bool? IsStarred = null, bool? IsStarred = null,
bool? IsTrashed = null, bool? IsTrashed = null,
bool? IsImportant = null, // Gmail "important" marker — backs the Pinned smart folder
bool? IsReadLater = null, // local Read Later marker
string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM" string? GmailLabel = null, // e.g. "SENT", "DRAFT", "SPAM"
// Emails carrying NONE of these Gmail labels (by GmailLabelId). Backs Archive, which
// must exclude Sent/Spam/Draft/Chat/Trash rather than just "not in inbox".
IReadOnlyList<string>? ExcludeGmailLabels = null,
// true = only emails with at least one USER label; false = only emails with NO user
// labels (the Unlabelled folder). System labels (INBOX/SENT/…) don't count.
bool? HasUserLabels = null,
string? Category = null, // EmailCategory name, e.g. "Finance" 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="10.0.9" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query. <!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. --> Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<!-- 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="10.0.9" /> <PackageReference Include="System.Text.Json" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" /> <ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
-4
View File
@@ -40,10 +40,6 @@ public class Email : AuditableEntity
public bool IsTrashed { get; set; } public bool IsTrashed { get; set; }
public bool HasAttachments { get; set; } public bool HasAttachments { get; set; }
/// <summary>Local "Read Later" marker (not a Gmail concept) — toggled by the user so the
/// Read Later smart folder shows only explicitly flagged mail, never everything.</summary>
public bool IsReadLater { get; set; }
// Unsubscribe signals captured at parse time. // Unsubscribe signals captured at parse time.
public bool HasListUnsubscribe { get; set; } public bool HasListUnsubscribe { get; set; }
public string? ListUnsubscribeRaw { get; set; } public string? ListUnsubscribeRaw { get; set; }
@@ -1,42 +0,0 @@
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="10.0.2" /> <PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="Pgvector" Version="0.3.0" /> <PackageReference Include="Pgvector" Version="0.2.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,104 +0,0 @@
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];
}
@@ -16,10 +16,11 @@ public class AnalyticsService : IAnalyticsService
var health = await GetInboxHealthAsync(userId, ct); var health = await GetInboxHealthAsync(userId, ct);
var top = await GetTopSendersAsync(userId, 10, ct); var top = await GetTopSendersAsync(userId, 10, ct);
var volume = await GetVolumeOverTimeAsync(userId, 90, ct); var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
var heatmap = await GetHeatmapAsync(userId, ct);
var attachments = await GetAttachmentBreakdownAsync(userId, ct); var attachments = await GetAttachmentBreakdownAsync(userId, ct);
return new DashboardSummaryDto( return new DashboardSummaryDto(
health, health.TotalEmails, health.UnreadEmails, top, volume, attachments, health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
health.EstimatedStorageBytes); health.EstimatedStorageBytes);
} }
@@ -74,6 +75,19 @@ public class AnalyticsService : IAnalyticsService
.ToList(); .ToList();
} }
public async Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Emails
.Where(e => e.UserId == userId)
.Select(e => new { e.SentAtUtc })
.ToListAsync(ct);
return raw
.GroupBy(x => new { Dow = (int)x.SentAtUtc.DayOfWeek, Hour = x.SentAtUtc.Hour })
.Select(g => new HeatmapCellDto(g.Key.Dow, g.Key.Hour, g.Count()))
.ToList();
}
public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default) public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default)
{ {
var raw = await _db.Emails var raw = await _db.Emails
@@ -92,14 +92,6 @@ 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>();
@@ -1,72 +0,0 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace InboxIntel.Infrastructure.Features;
/// <summary>
/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an
/// admin toggle takes effect within seconds and per-request reads stay free.
/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled.
/// </summary>
public class FeatureFlagService : IFeatureFlags
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
public FeatureFlagService(AppDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<bool> IsEnabledAsync(string key, CancellationToken ct = default)
{
try
{
var flags = await _cache.GetOrCreateAsync("feature-flags", async e =>
{
e.AbsoluteExpirationRelativeToNow = CacheTtl;
return await _db.FeatureFlags.AsNoTracking()
.ToDictionaryAsync(f => f.Key, f => f.Enabled, ct);
});
return flags is not null && flags.TryGetValue(key, out var enabled) && enabled;
}
catch
{
return false; // fail closed
}
}
}
/// <summary>
/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user
/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true,
/// only consulted while the flag is on). Callers still check provider availability
/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing.
/// </summary>
public class AiGate : IAiGate
{
public const string MasterFlag = "ai.enabled";
private readonly IFeatureFlags _flags;
private readonly AppDbContext _db;
public AiGate(IFeatureFlags flags, AppDbContext db)
{
_flags = flags;
_db = db;
}
public async Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default)
{
if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false;
// Absent settings row = default opt-in true.
var optIn = await _db.UserSettings.AsNoTracking()
.Where(s => s.UserId == userId)
.Select(s => (bool?)s.AiOptIn)
.FirstOrDefaultAsync(ct);
return optIn ?? true;
}
}
@@ -95,9 +95,7 @@ public class GmailApiService : IGmailService
var req = client.Users.Messages.List("me"); var req = client.Users.Messages.List("me");
req.MaxResults = _options.PageSize; req.MaxResults = _options.PageSize;
req.PageToken = pageToken; req.PageToken = pageToken;
// Include spam & trash so those folders aren't structurally empty; their state is req.IncludeSpamTrash = false;
// captured via the SPAM/TRASH labels (IsTrashed + EmailLabels) during upsert.
req.IncludeSpamTrash = true;
return await req.ExecuteAsync(token); return await req.ExecuteAsync(token);
}, ct); }, ct);
@@ -4,18 +4,18 @@
<AssemblyName>InboxIntel.Infrastructure</AssemblyName> <AssemblyName>InboxIntel.Infrastructure</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<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="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" /> <PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.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,15 +24,12 @@
<!-- 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="10.0.9" /> <PackageReference Include="System.Text.Json" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" /> <PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" /> <ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
<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,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,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
@@ -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
@@ -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 NpgsqlTypes; using NpgsqlTypes;
#nullable disable #nullable disable
@@ -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
@@ -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
@@ -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
@@ -1,831 +0,0 @@
// <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
}
}
}
@@ -1,75 +0,0 @@
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,834 +0,0 @@
// <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("20260704141744_AddIsReadLater")]
partial class AddIsReadLater
{
/// <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>("IsReadLater")
.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
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddIsReadLater : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsReadLater",
table: "emails",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsReadLater",
table: "emails");
}
}
}
@@ -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", "10.0.9") .HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
@@ -147,9 +147,6 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property<bool>("IsInInbox") b.Property<bool>("IsInInbox")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsReadLater")
.HasColumnType("boolean");
b.Property<bool>("IsStarred") b.Property<bool>("IsStarred")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -246,32 +243,6 @@ 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")
@@ -620,32 +591,6 @@ 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")
@@ -772,17 +717,6 @@ 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,8 +29,6 @@ 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>();
@@ -49,36 +47,20 @@ 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("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Sender>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailThread>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailDomain>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Attachment>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Label>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Label>().HasQueryFilter(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("Tenant", el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId); modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
modelBuilder.Entity<SyncState>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(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,7 +5,6 @@ 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;
@@ -19,13 +18,7 @@ namespace InboxIntel.Infrastructure.Search;
public class SearchService : ISearchService public class SearchService : ISearchService
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IEmbeddingProvider? _embeddings; public SearchService(AppDbContext db) => _db = db;
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)
{ {
@@ -52,32 +45,8 @@ public class SearchService : ISearchService
q = q.Where(e => e.IsStarred == starred); q = q.Where(e => e.IsStarred == starred);
if (r.IsTrashed is { } trashed) if (r.IsTrashed is { } trashed)
q = q.Where(e => e.IsTrashed == trashed); q = q.Where(e => e.IsTrashed == trashed);
if (r.IsImportant is { } important)
q = q.Where(e => e.IsImportant == important);
if (r.IsReadLater is { } readLater)
q = q.Where(e => e.IsReadLater == readLater);
if (r.MinSizeBytes is { } minSize) if (r.MinSizeBytes is { } minSize)
q = q.Where(e => e.SizeEstimateBytes >= minSize); q = q.Where(e => e.SizeEstimateBytes >= minSize);
// Unlabelled: emails with (no) USER labels. System labels (INBOX/SENT/…) don't count,
// so Unlabelled means "not filed into any user-created label".
if (r.HasUserLabels is { } hasUserLabels)
{
if (hasUserLabels)
q = q.Where(e => e.EmailLabels.Any(el => el.Label!.Type == "user"));
else
q = q.Where(e => !e.EmailLabels.Any(el => el.Label!.Type == "user"));
}
// Archive excludes Sent/Spam/Draft/Chat/Trash by label absence, not just "not in inbox".
if (r.ExcludeGmailLabels is { Count: > 0 } exclude)
{
var excludeUpper = exclude.Select(x => x.ToUpperInvariant()).ToList();
var excludeIds = await _db.Labels
.Where(l => l.UserId == userId && excludeUpper.Contains(l.GmailLabelId.ToUpper()))
.Select(l => l.Id)
.ToListAsync(ct);
if (excludeIds.Count > 0)
q = q.Where(e => !e.EmailLabels.Any(el => excludeIds.Contains(el.LabelId)));
}
if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse<EmailCategory>(r.Category, true, out var cat)) if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse<EmailCategory>(r.Category, true, out var cat))
q = q.Where(e => e.Category == cat); q = q.Where(e => e.Category == cat);
if (!string.IsNullOrWhiteSpace(r.GmailLabel)) if (!string.IsNullOrWhiteSpace(r.GmailLabel))
@@ -104,18 +73,6 @@ 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
@@ -138,23 +95,9 @@ 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).ThenByDescending(e => e.Id); ranked = matched.OrderByDescending(e => e.SentAtUtc);
// Keyset (cursor) pagination for the browse path: O(pageSize) regardless of depth, var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
// 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
@@ -194,73 +137,4 @@ 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
}
}
} }
@@ -228,34 +228,9 @@ public class SyncService : ISyncService
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
// Per-sync-run cache of the user's GmailLabelId -> local Label.Id, so email/label linkage /// <summary>Resolves domain/sender/thread, then inserts the email and attachment metadata.</summary>
// costs no extra query per message. Populated lazily; labels are synced before messages.
private Dictionary<string, Guid>? _labelCache;
private Guid _labelCacheUserId;
private async Task<Dictionary<string, Guid>> GetLabelMapAsync(Guid userId, CancellationToken ct)
{
if (_labelCache is null || _labelCacheUserId != userId)
{
_labelCache = await _db.Labels
.Where(l => l.UserId == userId)
.ToDictionaryAsync(l => l.GmailLabelId, l => l.Id, ct);
_labelCacheUserId = userId;
}
return _labelCache;
}
/// <summary>Resolves domain/sender/thread, then upserts the email, its labels, and
/// attachment metadata. Idempotent: re-syncing a message replaces the prior row (and its
/// labels) rather than duplicating it.</summary>
private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct) private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct)
{ {
// True upsert: drop any existing copy first so labels/flags re-populate cleanly and
// an incrementally-changed message can't be duplicated. EmailLabels/Attachments cascade.
var prior = await _db.Emails.FirstOrDefaultAsync(
e => e.UserId == userId && e.GmailMessageId == d.GmailMessageId, ct);
if (prior is not null) _db.Emails.Remove(prior);
var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct); var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct);
var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct); var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct);
@@ -281,7 +256,6 @@ public class SyncService : ISyncService
IsInInbox = d.LabelIds.Contains("INBOX"), IsInInbox = d.LabelIds.Contains("INBOX"),
IsStarred = d.LabelIds.Contains("STARRED"), IsStarred = d.LabelIds.Contains("STARRED"),
IsImportant = d.LabelIds.Contains("IMPORTANT"), IsImportant = d.LabelIds.Contains("IMPORTANT"),
IsTrashed = d.LabelIds.Contains("TRASH"),
HasAttachments = d.HasAttachments, HasAttachments = d.HasAttachments,
HasListUnsubscribe = d.HasListUnsubscribe, HasListUnsubscribe = d.HasListUnsubscribe,
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048), ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
@@ -290,14 +264,6 @@ public class SyncService : ISyncService
}; };
_db.Emails.Add(email); _db.Emails.Add(email);
// Link the email to its Gmail labels (system + user) so label-based folders — Sent,
// Spam, and "Unlabelled" (no user label) — resolve correctly. Previously no EmailLabel
// rows were ever created, so every label folder was empty and every email looked unlabelled.
var labelMap = await GetLabelMapAsync(userId, ct);
foreach (var gmailLabelId in d.LabelIds.Distinct())
if (labelMap.TryGetValue(gmailLabelId, out var localLabelId))
_db.Set<EmailLabel>().Add(new EmailLabel { EmailId = email.Id, LabelId = localLabelId });
foreach (var (fileName, mime, size, attId) in d.Attachments) foreach (var (fileName, mime, size, attId) in d.Attachments)
{ {
_db.Attachments.Add(new Attachment _db.Attachments.Add(new Attachment
@@ -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 new const string Scheme = "Test"; public 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,9 +52,6 @@ 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,9 +19,6 @@ 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",
@@ -1,115 +0,0 @@
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>
/// PHASE 2 (smart-category correctness): each reported folder bug — Archive leaking Sent,
/// Read Later / Pinned / Unlabelled returning everything, Old Mail duration — is fixed at the
/// filter source. These prove the SearchService filters that back the corrected folder mappings.
/// </summary>
public class CategoryFilterTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
private static AppDbContext Db(string name, Guid uid) =>
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options,
new FakeCurrentUser { UserId = uid });
// A small fixture: an inbox email, a sent email (SENT label), one important, one read-later,
// and one filed under a user label.
private static async Task<Guid> SeedAsync(string name)
{
var uid = Guid.NewGuid();
using var db = Db(name, Guid.Empty);
var sender = new Sender { UserId = uid, Address = "s@x.x" };
db.Senders.Add(sender);
var sentLabel = new Label { UserId = uid, GmailLabelId = "SENT", Name = "Sent", Type = "system" };
var userLabel = new Label { UserId = uid, GmailLabelId = "Label_1", Name = "Projects", Type = "user" };
db.Labels.AddRange(sentLabel, userLabel);
Email E(string id, Action<Email> cfg)
{
var e = new Email { UserId = uid, GmailMessageId = id, Subject = id, Sender = sender, SentAtUtc = DateTimeOffset.UtcNow };
cfg(e);
db.Emails.Add(e);
return e;
}
var inbox = E("inbox", e => e.IsInInbox = true);
var sent = E("sent", e => e.IsInInbox = false); // archived-looking, but it's Sent
var important = E("important", e => e.IsImportant = true);
var later = E("later", e => e.IsReadLater = true);
var filed = E("filed", e => e.IsInInbox = false);
await db.SaveChangesAsync();
db.Set<EmailLabel>().AddRange(
new EmailLabel { EmailId = sent.Id, LabelId = sentLabel.Id },
new EmailLabel { EmailId = filed.Id, LabelId = userLabel.Id });
await db.SaveChangesAsync();
return uid;
}
private static async Task<List<string>> RunAsync(string name, Guid uid, SearchRequestDto req)
{
using var db = Db(name, uid);
var res = await new SearchService(db).SearchAsync(uid, req);
return res.Items.Select(i => i.GmailMessageId).OrderBy(x => x).ToList();
}
private static SearchRequestDto Base() => new(null, null, null, null, null, null, null, false, 1, 50);
[Fact]
public async Task Archive_excludes_sent_mail()
{
var name = nameof(Archive_excludes_sent_mail);
var uid = await SeedAsync(name);
// Archive: not-in-inbox, not-trashed, excluding SENT — must NOT contain "sent".
var items = await RunAsync(name, uid, Base() with
{
IsInInbox = false,
IsTrashed = false,
ExcludeGmailLabels = new[] { "SENT", "SPAM", "DRAFT" }
});
items.Should().NotContain("sent");
items.Should().Contain("filed"); // a genuinely archived, user-filed mail stays
}
[Fact]
public async Task Pinned_returns_only_important_not_everything()
{
var name = nameof(Pinned_returns_only_important_not_everything);
var uid = await SeedAsync(name);
var items = await RunAsync(name, uid, Base() with { IsImportant = true });
items.Should().Equal("important");
}
[Fact]
public async Task ReadLater_returns_only_flagged_not_everything()
{
var name = nameof(ReadLater_returns_only_flagged_not_everything);
var uid = await SeedAsync(name);
var items = await RunAsync(name, uid, Base() with { IsReadLater = true });
items.Should().Equal("later");
}
[Fact]
public async Task Unlabelled_excludes_user_labelled_mail()
{
var name = nameof(Unlabelled_excludes_user_labelled_mail);
var uid = await SeedAsync(name);
// "filed" has a user label → must be absent; everything else (no user label) present.
var items = await RunAsync(name, uid, Base() with { HasUserLabels = false });
items.Should().NotContain("filed");
items.Should().Contain("inbox");
}
}
@@ -1,95 +0,0 @@
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();
}
}
}
@@ -1,76 +0,0 @@
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="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" /> <ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />
@@ -1,57 +0,0 @@
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,7 +21,6 @@ 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");
@@ -123,54 +122,4 @@ 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); }
}
} }