Compare commits

..

7 Commits

Author SHA1 Message Date
cesnimda 7b8d609185 feat(platform): feature flags + user settings foundation (RECOMMENDATIONS #10)
CI / backend (pull_request) Successful in 58s
CI / frontend (pull_request) Successful in 14s
CI / format (pull_request) Successful in 53s
CI / db-tests (pull_request) Successful in 59s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 59s
Implements the flag/settings backend from docs/discovery/multi-provider/04:
feature_flags + user_settings tables (migration seeds behaviour-preserving
defaults, verified on a fresh real Postgres); IFeatureFlags (15s cache,
FAIL-CLOSED); IAiGate (ai.enabled master flag beats any user opt-in; absent row
= default opt-in). UserSetting tenant-filtered, PK-shared with User. 4 contract
tests; suite 59 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:32:55 +02:00
cesnimda 402628a3a7 refactor(db): named tenant query filters (#31)
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 50s
CI / db-tests (push) Successful in 52s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m18s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 23s
CI / format (pull_request) Successful in 1m2s
CI / db-tests (pull_request) Successful in 1m11s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m10s
2026-07-02 17:24:21 +02:00
cesnimda 5191dd010f feat(ai): semantic-search backfill + Ollama profile (#30)
CI / backend (push) Successful in 55s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 42s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 58s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
2026-07-02 16:59:30 +02:00
cesnimda 7ec4f1ddb8 perf(ui): route-level code splitting (#29)
CI / backend (push) Successful in 54s
CI / frontend (push) Successful in 13s
CI / format (push) Successful in 52s
CI / db-tests (push) Successful in 56s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 52s
Deploy Staging / deploy (push) Successful in 26s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m0s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m2s
2026-07-02 16:53:31 +02:00
cesnimda a3d8654198 feat!: migrate to .NET 10 LTS (#28)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 48s
CI / db-tests (push) Successful in 51s
Deploy Staging / deploy (push) Successful in 26s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 46s
CI / db-tests (pull_request) Successful in 50s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 59s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 57s
2026-07-02 16:53:24 +02:00
cesnimda 86ecf03963 feat(ci): Renovate dependency automation (#27)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 47s
CI / db-tests (push) Successful in 49s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 15s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 50s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 52s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 53s
2026-07-02 16:28:02 +02:00
cesnimda dc9d5fc301 feat(ops): nightly DB backups with rotation (#26)
CI / backend (push) Successful in 1m0s
CI / frontend (push) Successful in 14s
CI / format (push) Successful in 52s
CI / db-tests (push) Successful in 1m10s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 50s
CI / db-tests (pull_request) Successful in 51s
Deploy Staging / deploy (push) Successful in 17s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 55s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 56s
2026-07-02 16:27:57 +02:00
20 changed files with 1568 additions and 36 deletions
+3
View File
@@ -15,3 +15,6 @@ FRONTEND_ORIGIN=http://localhost:8081
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox. # 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
+35
View File
@@ -0,0 +1,35 @@
name: Renovate
# RECOMMENDATIONS #2: automated dependency-update PRs (NuGet, npm, Dockerfiles, Actions)
# that ride the existing required CI gates. Runs weekly + on demand.
#
# ONE-TIME SETUP (manual): create a Gitea personal access token with scopes
# repo (rw) + user (r) + issue (rw) + organization (r), and add it as the Actions
# secret RENOVATE_TOKEN (repo Settings -> Actions -> Secrets). Without the secret this
# workflow fails fast with a clear message. See https://docs.renovatebot.com/modules/platform/gitea/
on:
schedule:
- cron: '30 4 * * 1' # Mondays 04:30 UTC
workflow_dispatch: {}
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Require RENOVATE_TOKEN
run: |
if [ -z "${{ secrets.RENOVATE_TOKEN }}" ]; then
echo "RENOVATE_TOKEN secret is not set — see the comment at the top of this workflow." >&2
exit 1
fi
- name: Run Renovate
uses: https://github.com/renovatebot/github-action@v40.3.6
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.cesnimda.uk/api/v1
RENOVATE_REPOSITORIES: cesnimda/Inboxintel
RENOVATE_ONBOARDING: "false"
RENOVATE_REQUIRE_CONFIG: optional
LOG_LEVEL: info
+3
View File
@@ -21,6 +21,9 @@ frontend/.vite/
appsettings.*.local.json appsettings.*.local.json
secrets.json secrets.json
## DB backups (never commit dumps)
backups/
## Logs ## Logs
logs/ logs/
*.log *.log
+3 -1
View File
@@ -29,7 +29,9 @@ reverse proxy.
mitigate (a third party reading the DB files) reduces to "someone with access to your mitigate (a third party reading the DB files) reduces to "someone with access to your
machine" — mitigate it at the layer that actually works: machine" — mitigate it at the layer that actually works:
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended. - **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
- **Encrypt backups** of the `pgdata` volume the same way. - **Encrypt backups**: nightly `pg_dump` rotation runs via the compose `backup` service
into `./backups/` (git-ignored) — keep that directory on an encrypted disk and copy it
off-machine. Restore: `docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql`.
- Before any **multi-user** deployment, revisit per the multi-provider security design - Before any **multi-user** deployment, revisit per the multi-provider security design
(host admins must not be able to read members' mail — plaintext bodies break that promise). (host admins must not be able to read members' mail — plaintext bodies break that promise).
2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network / 2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
+51
View File
@@ -22,6 +22,37 @@ 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
api: api:
build: build:
context: . context: .
@@ -34,6 +65,8 @@ 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}
# 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}
@@ -59,6 +92,23 @@ 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]
# Optional reverse proxy. Enable with: docker compose --profile proxy up # Optional reverse proxy. Enable with: docker compose --profile proxy up
nginx: nginx:
image: nginx:alpine image: nginx:alpine
@@ -74,3 +124,4 @@ services:
volumes: volumes:
pgdata: pgdata:
keys: keys:
ollama:
+34 -23
View File
@@ -1,42 +1,53 @@
import React from 'react'; import React, { Suspense, lazy } from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Landing from './pages/Landing.jsx'; import Landing from './pages/Landing.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Senders from './pages/Senders.jsx';
import Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
import FolderView from './pages/FolderView.jsx';
import SearchResults from './pages/SearchResults.jsx';
import Layout from './components/Layout.jsx'; import Layout from './components/Layout.jsx';
import DesignSystem from './pages/DesignSystem.jsx';
import { ToastProvider, TooltipProvider } from './components/ui'; import { ToastProvider, TooltipProvider } from './components/ui';
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import './index.css'; import './index.css';
import './styles.css'; import './styles.css';
// Route-level code splitting: each page loads its own chunk on first visit, so the
// initial bundle no longer carries Chart.js / grid-layout / every page at once.
// Landing + Layout stay eager (they're the first paint).
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));
const Senders = lazy(() => import('./pages/Senders.jsx'));
const Cleanup = lazy(() => import('./pages/Cleanup.jsx'));
const Unsubscribe = lazy(() => import('./pages/Unsubscribe.jsx'));
const FolderView = lazy(() => import('./pages/FolderView.jsx'));
const SearchResults = lazy(() => import('./pages/SearchResults.jsx'));
const DesignSystem = lazy(() => import('./pages/DesignSystem.jsx'));
// Minimal, theme-correct route fallback (skeleton-style, per the design system).
const RouteFallback = () => (
<div className="p-6 text-sm text-muted-foreground" aria-busy="true">Loading</div>
);
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<ToastProvider> <ToastProvider>
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<BrowserRouter> <BrowserRouter>
<Routes> <Suspense fallback={<RouteFallback />}>
{/* Public landing page */} <Routes>
<Route path="/" element={<Landing />} /> {/* Public landing page */}
<Route path="/" element={<Landing />} />
{/* Authenticated app */} {/* Authenticated app */}
<Route path="/app" element={<Layout />}> <Route path="/app" element={<Layout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="senders" element={<Senders />} /> <Route path="senders" element={<Senders />} />
<Route path="cleanup" element={<Cleanup />} /> <Route path="cleanup" element={<Cleanup />} />
<Route path="unsubscribe" element={<Unsubscribe />} /> <Route path="unsubscribe" element={<Unsubscribe />} />
<Route path="folder/:slug" element={<FolderView />} /> <Route path="folder/:slug" element={<FolderView />} />
<Route path="search" element={<SearchResults />} /> <Route path="search" element={<SearchResults />} />
<Route path="design" element={<DesignSystem />} /> <Route path="design" element={<DesignSystem />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</TooltipProvider> </TooltipProvider>
</ToastProvider> </ToastProvider>
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"timezone": "Europe/Berlin",
"schedule": ["before 6am on monday"],
"labels": ["dependencies"],
"prConcurrentLimit": 5,
"commitMessagePrefix": "chore(deps):",
"packageRules": [
{
"description": "Group safe minor+patch updates into one weekly PR per ecosystem",
"matchUpdateTypes": ["minor", "patch"],
"groupName": "{{manager}} minor & patch"
},
{
"description": "Major updates stay individual PRs for careful review",
"matchUpdateTypes": ["major"],
"dependencyDashboardApproval": true
}
],
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["security"],
"schedule": ["at any time"]
},
"ignorePaths": ["**/node_modules/**", "**/bin/**", "**/obj/**"]
}
@@ -120,3 +120,15 @@ public interface IDigestService
{ {
Task SendDigestAsync(Guid userId, CancellationToken ct = default); Task SendDigestAsync(Guid userId, CancellationToken ct = default);
} }
/// <summary>System feature flags (fail-closed: unknown key = disabled).</summary>
public interface IFeatureFlags
{
Task<bool> IsEnabledAsync(string key, CancellationToken ct = default);
}
/// <summary>Policy gate for AI features: system flag AND the user's opt-in.</summary>
public interface IAiGate
{
Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
}
@@ -0,0 +1,42 @@
using InboxIntel.Domain.Common;
namespace InboxIntel.Domain.Entities;
/// <summary>
/// System-wide feature flag (docs/discovery/multi-provider/04). The admin master switches:
/// a disabled flag turns its feature off for EVERYONE regardless of user preferences.
/// Reads are fail-closed — an unknown key counts as disabled.
/// </summary>
public class FeatureFlag : AuditableEntity
{
/// <summary>Stable key, e.g. "ai.enabled", "provider.google".</summary>
public string Key { get; set; } = string.Empty;
public bool Enabled { get; set; }
/// <summary>True = a user preference may turn the feature OFF for themselves
/// (never on beyond the flag); false = system-only switch.</summary>
public bool UserOverridable { get; set; }
public string? Description { get; set; }
}
/// <summary>
/// Per-user preferences (docs/discovery/multi-provider/04). One row per user, created
/// lazily; absent row = defaults. AiOptIn defaults true so enabling the ai.enabled flag
/// behaves exactly like today until a user opts out.
/// </summary>
public class UserSetting : AuditableEntity
{
public Guid UserId { get; set; }
public User? User { get; set; }
/// <summary>"system" | "light" | "dark".</summary>
public string Theme { get; set; } = "dark";
/// <summary>Master per-user AI opt-in (effective only while ai.enabled is on).</summary>
public bool AiOptIn { get; set; } = true;
/// <summary>Free-form UI preferences (layout, density, notifications) as JSON.</summary>
public string? PreferencesJson { get; set; }
}
@@ -0,0 +1,104 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Ai;
/// <summary>
/// Fills <c>Email.Embedding</c> (pgvector) for semantic search, in small background batches
/// so interactive requests are never starved (per docs/discovery/06: embeddings are the
/// small always-on model; the batch pause keeps VRAM/CPU pressure low). Exits immediately
/// when the embedding provider is unavailable (AI disabled / Ollama down) — semantic search
/// simply stays dormant and lexical search is unaffected.
/// </summary>
public class EmbeddingBackfillWorker : BackgroundService
{
private const int BatchSize = 32;
private static readonly TimeSpan BatchPause = TimeSpan.FromSeconds(2);
private static readonly TimeSpan IdleRescan = TimeSpan.FromMinutes(15);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<EmbeddingBackfillWorker> _logger;
public EmbeddingBackfillWorker(IServiceScopeFactory scopeFactory, ILogger<EmbeddingBackfillWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Provider availability is fixed by configuration for the process lifetime.
using (var probe = _scopeFactory.CreateScope())
{
if (!probe.ServiceProvider.GetRequiredService<IEmbeddingProvider>().IsAvailable)
{
_logger.LogDebug("EmbeddingBackfillWorker idle: no embedding provider (AI disabled).");
return;
}
}
_logger.LogInformation("EmbeddingBackfillWorker started (batch {Batch}, pause {Pause}s)",
BatchSize, BatchPause.TotalSeconds);
while (!stoppingToken.IsCancellationRequested)
{
int processed;
try
{
processed = await ProcessBatchAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
// Ollama hiccups must never crash the host; back off and retry.
_logger.LogWarning(ex, "Embedding batch failed; retrying after idle pause.");
processed = 0;
}
await Task.Delay(processed > 0 ? BatchPause : IdleRescan, stoppingToken);
}
}
/// <summary>Embeds one batch. Public-ish (internal) for direct testing.</summary>
internal async Task<int> ProcessBatchAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var embeddings = scope.ServiceProvider.GetRequiredService<IEmbeddingProvider>();
var batch = await db.Emails
.Where(e => e.Embedding == null)
.OrderByDescending(e => e.SentAtUtc) // newest mail becomes searchable first
.Take(BatchSize)
.ToListAsync(ct);
if (batch.Count == 0) return 0;
// Subject + snippet is the semantic core; bodies are noisy (signatures, quoting)
// and slow to embed. Truncate defensively to keep well inside the model context.
var texts = batch
.Select(e => Truncate($"{e.Subject}\n{e.Snippet ?? e.BodyText}", 2000))
.ToList();
var vectors = await embeddings.EmbedBatchAsync(texts, ct);
if (vectors.Count != batch.Count)
{
_logger.LogWarning("Embedding batch returned {Got} vectors for {Want} emails; skipping batch.",
vectors.Count, batch.Count);
return 0;
}
for (var i = 0; i < batch.Count; i++)
{
if (vectors[i].Length == 0) continue; // provider soft-failure for one item
batch[i].Embedding = new Pgvector.Vector(vectors[i]);
}
await db.SaveChangesAsync(ct);
_logger.LogDebug("Embedded {Count} emails", batch.Count);
return batch.Count;
}
private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
}
@@ -92,6 +92,14 @@ public static class DependencyInjection
break; break;
} }
services.AddScoped<IAiService, AiService>(); services.AddScoped<IAiService, AiService>();
// Feature flags + AI policy gate (docs/discovery/multi-provider/04). Cached 15s,
// fail-closed. Admin toggle surface arrives with the multi-provider admin phase.
services.AddMemoryCache();
services.AddScoped<IFeatureFlags, Features.FeatureFlagService>();
services.AddScoped<IAiGate, Features.AiGate>();
// Semantic search: fills Email.Embedding in the background; no-ops when the
// embedding provider is unavailable (AI disabled), so lexical search is unaffected.
services.AddHostedService<EmbeddingBackfillWorker>();
// Background worker (daily incremental sync + aggregate refresh) // Background worker (daily incremental sync + aggregate refresh)
services.AddHostedService<GmailSyncWorker>(); services.AddHostedService<GmailSyncWorker>();
@@ -0,0 +1,72 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace InboxIntel.Infrastructure.Features;
/// <summary>
/// Flag evaluation (docs/discovery/multi-provider/04). DB-backed with a short cache so an
/// admin toggle takes effect within seconds and per-request reads stay free.
/// FAIL-CLOSED: unknown keys and read errors evaluate to disabled.
/// </summary>
public class FeatureFlagService : IFeatureFlags
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
private readonly AppDbContext _db;
private readonly IMemoryCache _cache;
public FeatureFlagService(AppDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<bool> IsEnabledAsync(string key, CancellationToken ct = default)
{
try
{
var flags = await _cache.GetOrCreateAsync("feature-flags", async e =>
{
e.AbsoluteExpirationRelativeToNow = CacheTtl;
return await _db.FeatureFlags.AsNoTracking()
.ToDictionaryAsync(f => f.Key, f => f.Enabled, ct);
});
return flags is not null && flags.TryGetValue(key, out var enabled) && enabled;
}
catch
{
return false; // fail closed
}
}
}
/// <summary>
/// The AI gate (the audit/design requirement that AI is governed by a FLAG, not only user
/// settings): effective AI = ai.enabled (admin, global) AND the user's opt-in (default true,
/// only consulted while the flag is on). Callers still check provider availability
/// (IAiService.IsEnabled / IEmbeddingProvider.IsAvailable) — this gate is policy, not plumbing.
/// </summary>
public class AiGate : IAiGate
{
public const string MasterFlag = "ai.enabled";
private readonly IFeatureFlags _flags;
private readonly AppDbContext _db;
public AiGate(IFeatureFlags flags, AppDbContext db)
{
_flags = flags;
_db = db;
}
public async Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default)
{
if (!await _flags.IsEnabledAsync(MasterFlag, ct)) return false;
// Absent settings row = default opt-in true.
var optIn = await _db.UserSettings.AsNoTracking()
.Where(s => s.UserId == userId)
.Select(s => (bool?)s.AiOptIn)
.FirstOrDefaultAsync(ct);
return optIn ?? true;
}
}
@@ -32,4 +32,7 @@
<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>
@@ -0,0 +1,831 @@
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
using Pgvector;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260702152850_FeatureFlagsAndUserSettings")]
partial class FeatureFlagsAndUserSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Day")
.HasColumnType("date");
b.Property<string>("HourHistogramJson")
.HasColumnType("text");
b.Property<int>("NewsletterCount")
.HasColumnType("integer");
b.Property<int>("TotalReceived")
.HasColumnType("integer");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("TotalUnread")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("WithAttachments")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "Day")
.IsUnique();
b.ToTable("analytics_aggregates", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("GmailAttachmentId")
.HasColumnType("text");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EmailId");
b.HasIndex("UserId", "MimeType");
b.ToTable("attachments", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Vector>("Embedding")
.HasColumnType("vector(768)");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("HasAttachments")
.HasColumnType("boolean");
b.Property<bool>("HasListUnsubscribe")
.HasColumnType("boolean");
b.Property<bool>("IsImportant")
.HasColumnType("boolean");
b.Property<bool>("IsInInbox")
.HasColumnType("boolean");
b.Property<bool>("IsStarred")
.HasColumnType("boolean");
b.Property<bool>("IsTrashed")
.HasColumnType("boolean");
b.Property<bool>("IsUnread")
.HasColumnType("boolean");
b.Property<string>("ListUnsubscribeRaw")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SizeEstimateBytes")
.HasColumnType("bigint");
b.Property<string>("Snippet")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("SupportsOneClickUnsubscribe")
.HasColumnType("boolean");
b.Property<Guid>("ThreadId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Embedding");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
b.HasIndex("SearchVector");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
b.HasIndex("SenderId");
b.HasIndex("ThreadId");
b.HasIndex("UserId", "Category");
b.HasIndex("UserId", "GmailMessageId")
.IsUnique();
b.HasIndex("UserId", "IsInInbox");
b.HasIndex("UserId", "IsUnread");
b.HasIndex("UserId", "SenderId");
b.HasIndex("UserId", "SentAtUtc");
b.ToTable("emails", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<Guid>("LabelId")
.HasColumnType("uuid");
b.HasKey("EmailId", "LabelId");
b.HasIndex("LabelId");
b.ToTable("email_labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UserOverridable")
.HasColumnType("boolean");
b.HasKey("Key");
b.ToTable("feature_flags", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ColorHex")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailLabelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailLabelId")
.IsUnique();
b.ToTable("labels", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("IsBulkSender")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Name");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("UserId", "Name")
.IsUnique();
b.ToTable("domains", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FirstMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailThreadId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<string>("Snippet")
.HasColumnType("text");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "GmailThreadId")
.IsUnique();
b.ToTable("threads", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("DomainId")
.HasColumnType("uuid");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("HasUnsubscribe")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastReceivedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("UnreadCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Address");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Address"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Address"), new[] { "gin_trgm_ops" });
b.HasIndex("DisplayName");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("DisplayName"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("DisplayName"), new[] { "gin_trgm_ops" });
b.HasIndex("DomainId");
b.HasIndex("UserId", "Address")
.IsUnique();
b.HasIndex("UserId", "EmailCount");
b.ToTable("senders", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("LastHistoryId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("LastSyncType")
.HasColumnType("integer");
b.Property<int>("MessagesProcessed")
.HasColumnType("integer");
b.Property<string>("ResumePageToken")
.HasColumnType("text");
b.Property<DateTimeOffset?>("StartedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalMessagesEstimate")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("sync_states", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("LastAttemptUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Method")
.HasColumnType("integer");
b.Property<string>("ResultMessage")
.HasColumnType("text");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UnsubscribeTarget")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SenderId");
b.HasIndex("UserId", "SenderId")
.IsUnique();
b.ToTable("unsubscribe_items", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<byte[]>("EncryptedRefreshToken")
.HasColumnType("bytea");
b.Property<string>("GoogleSubjectId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PictureUrl")
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("GoogleSubjectId")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("AiOptIn")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreferencesJson")
.HasColumnType("text");
b.Property<string>("Theme")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId");
b.ToTable("user_settings", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("H")
.HasColumnType("integer");
b.Property<string>("SettingsJson")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.Property<int>("W")
.HasColumnType("integer");
b.Property<string>("WidgetKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("X")
.HasColumnType("integer");
b.Property<int>("Y")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId", "WidgetKey")
.IsUnique();
b.ToTable("widget_layouts", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("Attachments")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany("Emails")
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
.WithMany("Emails")
.HasForeignKey("ThreadId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("Emails")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
b.Navigation("Thread");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
.WithMany("EmailLabels")
.HasForeignKey("EmailId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
.WithMany("EmailLabels")
.HasForeignKey("LabelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Email");
b.Navigation("Label");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
.WithMany("Senders")
.HasForeignKey("DomainId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Domain");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
{
b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sender");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", "User")
.WithOne()
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", null)
.WithMany("WidgetLayouts")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
{
b.Navigation("Attachments");
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Navigation("EmailLabels");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
{
b.Navigation("Senders");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
{
b.Navigation("Emails");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
{
b.Navigation("Emails");
b.Navigation("WidgetLayouts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class FeatureFlagsAndUserSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "feature_flags",
columns: table => new
{
Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
UserOverridable = table.Column<bool>(type: "boolean", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_feature_flags", x => x.Key);
});
migrationBuilder.CreateTable(
name: "user_settings",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Theme = table.Column<string>(type: "text", nullable: false),
AiOptIn = table.Column<bool>(type: "boolean", nullable: false),
PreferencesJson = table.Column<string>(type: "text", nullable: true),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_user_settings", x => x.UserId);
table.ForeignKey(
name: "FK_user_settings_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
// Behaviour-preserving defaults (docs/discovery/multi-provider/04): ai.enabled on
// (AI availability still requires Ai:Mode + provider), Google on, others off.
migrationBuilder.Sql("""
INSERT INTO feature_flags ("Key", "Enabled", "UserOverridable", "Description", "CreatedAtUtc", "UpdatedAtUtc")
VALUES
('ai.enabled', TRUE, TRUE, 'Master AI switch: off hides AI for everyone', NOW(), NOW()),
('provider.google', TRUE, FALSE, 'Google/Gmail provider', NOW(), NOW()),
('provider.microsoft', FALSE, FALSE, 'Microsoft/Outlook provider (future)', NOW(), NOW()),
('provider.imap', FALSE, FALSE, 'IMAP provider (future)', NOW(), NOW())
ON CONFLICT ("Key") DO NOTHING;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "feature_flags");
migrationBuilder.DropTable(
name: "user_settings");
}
}
}
@@ -19,7 +19,7 @@ namespace InboxIntel.Infrastructure.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.4") .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
@@ -243,6 +243,32 @@ namespace InboxIntel.Infrastructure.Migrations
b.ToTable("email_labels", (string)null); b.ToTable("email_labels", (string)null);
}); });
modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("UserOverridable")
.HasColumnType("boolean");
b.HasKey("Key");
b.ToTable("feature_flags", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -591,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations
b.ToTable("users", (string)null); b.ToTable("users", (string)null);
}); });
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("AiOptIn")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PreferencesJson")
.HasColumnType("text");
b.Property<string>("Theme")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId");
b.ToTable("user_settings", (string)null);
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -717,6 +769,17 @@ namespace InboxIntel.Infrastructure.Migrations
b.Navigation("Sender"); b.Navigation("Sender");
}); });
modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
{
b.HasOne("InboxIntel.Domain.Entities.User", "User")
.WithOne()
.HasForeignKey("InboxIntel.Domain.Entities.UserSetting", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{ {
b.HasOne("InboxIntel.Domain.Entities.User", null) b.HasOne("InboxIntel.Domain.Entities.User", null)
@@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext
public DbSet<Email> Emails => Set<Email>(); public DbSet<Email> Emails => Set<Email>();
public DbSet<MailThread> Threads => Set<MailThread>(); public DbSet<MailThread> Threads => Set<MailThread>();
public DbSet<Sender> Senders => Set<Sender>(); public DbSet<Sender> Senders => Set<Sender>();
public DbSet<FeatureFlag> FeatureFlags => Set<FeatureFlag>();
public DbSet<UserSetting> UserSettings => Set<UserSetting>();
public DbSet<MailDomain> Domains => Set<MailDomain>(); public DbSet<MailDomain> Domains => Set<MailDomain>();
public DbSet<Attachment> Attachments => Set<Attachment>(); public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<Label> Labels => Set<Label>(); public DbSet<Label> Labels => Set<Label>();
@@ -47,20 +49,36 @@ public class AppDbContext : DbContext, IAppDbContext
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied // its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered // uniformly to all user-scoped entities so EF sees no filtered/unfiltered
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers). // navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Email>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Sender>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<MailThread>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<MailDomain>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Attachment>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<Label>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email // AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
// entity; without a matching filter EF warns on boot and joins could surface rows whose // entity; without a matching filter EF warns on boot and joins could surface rows whose
// parent is filtered out. Filter via the Email navigation so the pair is consistent. // parent is filtered out. Filter via the Email navigation so the pair is consistent.
modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId); modelBuilder.Entity<EmailLabel>().HasQueryFilter("Tenant", el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<SyncState>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<UserSetting>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// Feature flags are system-wide (no tenant filter). Key is the natural PK.
modelBuilder.Entity<FeatureFlag>(b =>
{
b.ToTable("feature_flags");
b.HasKey(f => f.Key);
b.Property(f => f.Key).HasMaxLength(128);
});
modelBuilder.Entity<UserSetting>(b =>
{
b.ToTable("user_settings");
b.HasKey(x => x.UserId);
// 1:1 with User sharing the PK — prevents a shadow UserId1 FK column.
b.HasOne(x => x.User).WithOne().HasForeignKey<UserSetting>(x => x.UserId);
});
// PostgreSQL full-text search: generated tsvector over subject + body with a // PostgreSQL full-text search: generated tsvector over subject + body with a
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A' // GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
@@ -0,0 +1,95 @@
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Ai;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Pgvector.EntityFrameworkCore;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// Semantic-search backfill: the worker embeds emails lacking an Embedding and persists the
/// vectors. Runs against live Postgres (pgvector) since the Embedding column is ignored under
/// the InMemory provider; the CI db-tests job provides the database. Uses a deterministic fake
/// embedding provider — real-Ollama integration is verified separately (endpoint contract:
/// /api/embeddings, 768 dims).
/// </summary>
[Trait("Category", "LiveDb")]
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
public class EmbeddingBackfillTests
{
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId => Guid.Empty; // worker scope sees all rows
public bool IsAuthenticated => false;
}
private sealed class FakeEmbeddings : IEmbeddingProvider
{
public bool IsAvailable => true;
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
=> Task.FromResult(Vec(text));
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<float[]>>(texts.Select(Vec).ToList());
private static float[] Vec(string text)
{
var v = new float[768];
v[0] = text.Length; // deterministic, content-dependent
return v;
}
}
[Fact]
public async Task Worker_embeds_pending_emails_and_persists_vectors()
{
if (Conn is null) return; // soft-skip outside the live-db CI job
var uid = Guid.NewGuid();
var opts = new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(Conn!, o => o.UseVector()).Options;
var services = new ServiceCollection();
services.AddScoped<ICurrentUser, FakeCurrentUser>();
services.AddScoped(_ => new AppDbContext(opts, new FakeCurrentUser()));
services.AddScoped<IEmbeddingProvider, FakeEmbeddings>();
using var sp = services.BuildServiceProvider();
using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
{
await seed.Database.MigrateAsync();
seed.Users.Add(new User { Id = uid, GoogleSubjectId = "g" + uid, Email = uid + "@t.t" });
var dom = new MailDomain { UserId = uid, Name = "t.t" };
var snd = new Sender { UserId = uid, Address = "a@t.t", Domain = dom };
var thr = new MailThread { UserId = uid, GmailThreadId = "th" + uid };
seed.AddRange(dom, snd, thr);
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e1" + uid, Subject = "hello world", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
seed.Emails.Add(new Email { UserId = uid, GmailMessageId = "e2" + uid, Subject = "quarterly invoice", Sender = snd, Thread = thr, SentAtUtc = DateTimeOffset.UtcNow });
await seed.SaveChangesAsync();
}
try
{
var worker = new EmbeddingBackfillWorker(
sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<EmbeddingBackfillWorker>.Instance);
var processed = await worker.ProcessBatchAsync(CancellationToken.None);
processed.Should().BeGreaterThanOrEqualTo(2);
using var check = new AppDbContext(opts, new FakeCurrentUser());
var mine = await check.Emails.Where(e => e.UserId == uid).ToListAsync();
mine.Should().OnlyContain(e => e.Embedding != null);
mine.First().Embedding!.ToArray().Length.Should().Be(768);
}
finally
{
using var c = new AppDbContext(opts, new FakeCurrentUser());
await c.Emails.Where(e => e.UserId == uid).ExecuteDeleteAsync();
await c.Threads.Where(t => t.UserId == uid).ExecuteDeleteAsync();
await c.Senders.Where(s => s.UserId == uid).ExecuteDeleteAsync();
await c.Domains.Where(d => d.UserId == uid).ExecuteDeleteAsync();
await c.Users.Where(u => u.Id == uid).ExecuteDeleteAsync();
}
}
}
@@ -0,0 +1,76 @@
using FluentAssertions;
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Infrastructure.Features;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Xunit;
namespace InboxIntel.IntegrationTests;
/// <summary>
/// Feature-flag + AI-gate contracts (docs/discovery/multi-provider/04):
/// flags are FAIL-CLOSED, and the admin master switch (ai.enabled) beats any user opt-in.
/// </summary>
public class FeatureFlagTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid UserId { get; set; }
public bool IsAuthenticated => UserId != Guid.Empty;
}
private static AppDbContext NewDb(string name) =>
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
private static FeatureFlagService Flags(AppDbContext db) =>
new(db, new MemoryCache(new MemoryCacheOptions()));
[Fact]
public async Task Unknown_flag_is_disabled_fail_closed()
{
using var db = NewDb(nameof(Unknown_flag_is_disabled_fail_closed));
(await Flags(db).IsEnabledAsync("does.not.exist")).Should().BeFalse();
}
[Fact]
public async Task Enabled_flag_reads_true_disabled_reads_false()
{
using var db = NewDb(nameof(Enabled_flag_reads_true_disabled_reads_false));
db.FeatureFlags.AddRange(
new FeatureFlag { Key = "on.flag", Enabled = true },
new FeatureFlag { Key = "off.flag", Enabled = false });
await db.SaveChangesAsync();
var flags = Flags(db);
(await flags.IsEnabledAsync("on.flag")).Should().BeTrue();
(await flags.IsEnabledAsync("off.flag")).Should().BeFalse();
}
[Fact]
public async Task Ai_gate_master_flag_off_beats_user_opt_in()
{
using var db = NewDb(nameof(Ai_gate_master_flag_off_beats_user_opt_in));
var user = Guid.NewGuid();
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = false });
db.UserSettings.Add(new UserSetting { UserId = user, AiOptIn = true });
await db.SaveChangesAsync();
(await new AiGate(Flags(db), db).IsAiEnabledForUserAsync(user)).Should().BeFalse();
}
[Fact]
public async Task Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out()
{
using var db = NewDb(nameof(Ai_gate_flag_on_defaults_to_opted_in_but_respects_opt_out));
var optedOut = Guid.NewGuid();
var noRow = Guid.NewGuid();
db.FeatureFlags.Add(new FeatureFlag { Key = AiGate.MasterFlag, Enabled = true });
db.UserSettings.Add(new UserSetting { UserId = optedOut, AiOptIn = false });
await db.SaveChangesAsync();
var gate = new AiGate(Flags(db), db);
(await gate.IsAiEnabledForUserAsync(noRow)).Should().BeTrue("no settings row = default opt-in");
(await gate.IsAiEnabledForUserAsync(optedOut)).Should().BeFalse("explicit opt-out wins while the flag is on");
}
}
@@ -21,6 +21,7 @@ namespace InboxIntel.IntegrationTests;
/// InMemory test run is unaffected. /// InMemory test run is unaffected.
/// </summary> /// </summary>
[Trait("Category", "LiveDb")] [Trait("Category", "LiveDb")]
[Collection("LiveDb")] // serialise LiveDb classes: concurrent MigrateAsync on a fresh DB races
public class LiveDbSearchTests public class LiveDbSearchTests
{ {
private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION"); private static string? Conn => Environment.GetEnvironmentVariable("LIVEDB_CONNECTION");