diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs
index 60b4170..3a84c90 100644
--- a/src/InboxIntel.Application/Abstractions/IServices.cs
+++ b/src/InboxIntel.Application/Abstractions/IServices.cs
@@ -120,3 +120,15 @@ public interface IDigestService
{
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
}
+
+/// System feature flags (fail-closed: unknown key = disabled).
+public interface IFeatureFlags
+{
+ Task IsEnabledAsync(string key, CancellationToken ct = default);
+}
+
+/// Policy gate for AI features: system flag AND the user's opt-in.
+public interface IAiGate
+{
+ Task IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
+}
diff --git a/src/InboxIntel.Domain/Entities/FeatureFlag.cs b/src/InboxIntel.Domain/Entities/FeatureFlag.cs
new file mode 100644
index 0000000..2e7ea8a
--- /dev/null
+++ b/src/InboxIntel.Domain/Entities/FeatureFlag.cs
@@ -0,0 +1,42 @@
+using InboxIntel.Domain.Common;
+
+namespace InboxIntel.Domain.Entities;
+
+///
+/// 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.
+///
+public class FeatureFlag : AuditableEntity
+{
+ /// Stable key, e.g. "ai.enabled", "provider.google".
+ public string Key { get; set; } = string.Empty;
+
+ public bool Enabled { get; set; }
+
+ /// True = a user preference may turn the feature OFF for themselves
+ /// (never on beyond the flag); false = system-only switch.
+ public bool UserOverridable { get; set; }
+
+ public string? Description { get; set; }
+}
+
+///
+/// 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.
+///
+public class UserSetting : AuditableEntity
+{
+ public Guid UserId { get; set; }
+ public User? User { get; set; }
+
+ /// "system" | "light" | "dark".
+ public string Theme { get; set; } = "dark";
+
+ /// Master per-user AI opt-in (effective only while ai.enabled is on).
+ public bool AiOptIn { get; set; } = true;
+
+ /// Free-form UI preferences (layout, density, notifications) as JSON.
+ public string? PreferencesJson { get; set; }
+}
diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs
index 9bd0be3..2724714 100644
--- a/src/InboxIntel.Infrastructure/DependencyInjection.cs
+++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs
@@ -92,6 +92,11 @@ public static class DependencyInjection
break;
}
services.AddScoped();
+ // 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();
+ services.AddScoped();
// 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();
diff --git a/src/InboxIntel.Infrastructure/Features/FeatureFlagService.cs b/src/InboxIntel.Infrastructure/Features/FeatureFlagService.cs
new file mode 100644
index 0000000..4a91382
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Features/FeatureFlagService.cs
@@ -0,0 +1,72 @@
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace InboxIntel.Infrastructure.Features;
+
+///
+/// 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.
+///
+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 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
+ }
+ }
+}
+
+///
+/// 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.
+///
+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 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;
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs
new file mode 100644
index 0000000..a2d7e12
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.Designer.cs
@@ -0,0 +1,831 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Day")
+ .HasColumnType("date");
+
+ b.Property("HourHistogramJson")
+ .HasColumnType("text");
+
+ b.Property("NewsletterCount")
+ .HasColumnType("integer");
+
+ b.Property("TotalReceived")
+ .HasColumnType("integer");
+
+ b.Property("TotalSizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("TotalUnread")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailId")
+ .HasColumnType("uuid");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("GmailAttachmentId")
+ .HasColumnType("text");
+
+ b.Property("MimeType")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BodyText")
+ .HasColumnType("text");
+
+ b.Property("Category")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Embedding")
+ .HasColumnType("vector(768)");
+
+ b.Property("GmailMessageId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("HasAttachments")
+ .HasColumnType("boolean");
+
+ b.Property("HasListUnsubscribe")
+ .HasColumnType("boolean");
+
+ b.Property("IsImportant")
+ .HasColumnType("boolean");
+
+ b.Property("IsInInbox")
+ .HasColumnType("boolean");
+
+ b.Property("IsStarred")
+ .HasColumnType("boolean");
+
+ b.Property("IsTrashed")
+ .HasColumnType("boolean");
+
+ b.Property("IsUnread")
+ .HasColumnType("boolean");
+
+ b.Property("ListUnsubscribeRaw")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("ReceivedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SearchVector")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("tsvector")
+ .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("SentAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SizeEstimateBytes")
+ .HasColumnType("bigint");
+
+ b.Property("Snippet")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Subject")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("SupportsOneClickUnsubscribe")
+ .HasColumnType("boolean");
+
+ b.Property("ThreadId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("EmailId")
+ .HasColumnType("uuid");
+
+ b.Property("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("Key")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserOverridable")
+ .HasColumnType("boolean");
+
+ b.HasKey("Key");
+
+ b.ToTable("feature_flags", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ColorHex")
+ .HasColumnType("text");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GmailLabelId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailCount")
+ .HasColumnType("integer");
+
+ b.Property("IsBulkSender")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstMessageUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GmailThreadId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("LastMessageUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MessageCount")
+ .HasColumnType("integer");
+
+ b.Property("Snippet")
+ .HasColumnType("text");
+
+ b.Property("Subject")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("DomainId")
+ .HasColumnType("uuid");
+
+ b.Property("EmailCount")
+ .HasColumnType("integer");
+
+ b.Property("HasUnsubscribe")
+ .HasColumnType("boolean");
+
+ b.Property("LastReceivedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalSizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("UnreadCount")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastError")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("LastHistoryId")
+ .HasColumnType("text");
+
+ b.Property("LastSuccessfulSyncUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSyncType")
+ .HasColumnType("integer");
+
+ b.Property("MessagesProcessed")
+ .HasColumnType("integer");
+
+ b.Property("ResumePageToken")
+ .HasColumnType("text");
+
+ b.Property("StartedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TotalMessagesEstimate")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Confidence")
+ .HasColumnType("double precision");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailCount")
+ .HasColumnType("integer");
+
+ b.Property("LastAttemptUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Method")
+ .HasColumnType("integer");
+
+ b.Property("ResultMessage")
+ .HasColumnType("text");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UnsubscribeTarget")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccessTokenExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DigestEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("EncryptedRefreshToken")
+ .HasColumnType("bytea");
+
+ b.Property("GoogleSubjectId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("LastDigestSentUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastLoginUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PictureUrl")
+ .HasColumnType("text");
+
+ b.Property("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("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("AiOptIn")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PreferencesJson")
+ .HasColumnType("text");
+
+ b.Property("Theme")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("UserId");
+
+ b.ToTable("user_settings", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("H")
+ .HasColumnType("integer");
+
+ b.Property("SettingsJson")
+ .HasColumnType("text");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("Visible")
+ .HasColumnType("boolean");
+
+ b.Property("W")
+ .HasColumnType("integer");
+
+ b.Property("WidgetKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("X")
+ .HasColumnType("integer");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs
new file mode 100644
index 0000000..013fee7
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Migrations/20260702152850_FeatureFlagsAndUserSettings.cs
@@ -0,0 +1,75 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace InboxIntel.Infrastructure.Migrations
+{
+ ///
+ public partial class FeatureFlagsAndUserSettings : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "feature_flags",
+ columns: table => new
+ {
+ Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ Enabled = table.Column(type: "boolean", nullable: false),
+ UserOverridable = table.Column(type: "boolean", nullable: false),
+ Description = table.Column(type: "text", nullable: true),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAtUtc = table.Column(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(type: "uuid", nullable: false),
+ Theme = table.Column(type: "text", nullable: false),
+ AiOptIn = table.Column(type: "boolean", nullable: false),
+ PreferencesJson = table.Column(type: "text", nullable: true),
+ CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedAtUtc = table.Column(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;
+ """);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "feature_flags");
+
+ migrationBuilder.DropTable(
+ name: "user_settings");
+ }
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
index 41db34c..c0bb4e9 100644
--- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
@@ -19,7 +19,7 @@ namespace InboxIntel.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "8.0.4")
+ .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
@@ -243,6 +243,32 @@ namespace InboxIntel.Infrastructure.Migrations
b.ToTable("email_labels", (string)null);
});
+ modelBuilder.Entity("InboxIntel.Domain.Entities.FeatureFlag", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserOverridable")
+ .HasColumnType("boolean");
+
+ b.HasKey("Key");
+
+ b.ToTable("feature_flags", (string)null);
+ });
+
modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
{
b.Property("Id")
@@ -591,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations
b.ToTable("users", (string)null);
});
+ modelBuilder.Entity("InboxIntel.Domain.Entities.UserSetting", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("AiOptIn")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PreferencesJson")
+ .HasColumnType("text");
+
+ b.Property("Theme")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("UserId");
+
+ b.ToTable("user_settings", (string)null);
+ });
+
modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
{
b.Property("Id")
@@ -717,6 +769,17 @@ namespace InboxIntel.Infrastructure.Migrations
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)
diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
index d09a08b..7a33a96 100644
--- a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
+++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
@@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext
public DbSet Emails => Set();
public DbSet Threads => Set();
public DbSet Senders => Set();
+ public DbSet FeatureFlags => Set();
+ public DbSet UserSettings => Set();
public DbSet Domains => Set();
public DbSet Attachments => Set();
public DbSet