Compare commits

..

4 Commits

Author SHA1 Message Date
cesnimda c6a8302997 chore: strip UTF-8 BOM from EF-generated migration files
CI / backend (pull_request) Successful in 57s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 55s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 56s
Security / sast (pull_request) Successful in 40s
The .editorconfig charset=utf-8 rule (added with the CI format gate) flags the BOM
that 'dotnet ef migrations' writes into generated files, failing the format check on
develop itself and thus on every PR. De-BOM all tracked source files so the gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:27:02 +02:00
cesnimda 3333e19452 perf(search): keyset pagination (browse path) (#36)
CI / backend (push) Successful in 54s
CI / frontend (push) Successful in 12s
CI / format (push) Failing after 51s
CI / db-tests (push) Successful in 57s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 59s
Security / sast (push) Successful in 40s
CI / backend (pull_request) Successful in 55s
CI / frontend (pull_request) Successful in 15s
CI / format (pull_request) Failing after 54s
CI / db-tests (pull_request) Successful in 52s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 56s
Security / sast (pull_request) Successful in 37s
2026-07-04 16:01:39 +02:00
cesnimda 1e8feeef71 ci(security): Semgrep SAST (#32)
CI / backend (push) Successful in 1m1s
CI / frontend (push) Successful in 16s
CI / format (push) Successful in 51s
CI / db-tests (push) Successful in 55s
Deploy Staging / deploy (push) Successful in 20s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m6s
Security / sast (push) Successful in 41s
CI / backend (pull_request) Successful in 56s
CI / frontend (pull_request) Successful in 13s
CI / format (pull_request) Successful in 51s
CI / db-tests (pull_request) Successful in 56s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 57s
Security / sast (pull_request) Successful in 38s
2026-07-04 16:01:34 +02:00
cesnimda 074d39d9a2 feat(platform): feature flags + user settings foundation (#35)
CI / backend (push) Successful in 1m4s
CI / frontend (push) Successful in 18s
CI / format (push) Successful in 1m1s
CI / db-tests (push) Successful in 1m4s
Deploy Staging / deploy (push) Successful in 37s
CI / backend (pull_request) Successful in 1m7s
CI / frontend (pull_request) Successful in 18s
CI / format (pull_request) Successful in 59s
CI / db-tests (pull_request) Successful in 1m5s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m4s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m4s
2026-07-02 18:04:14 +02:00
17 changed files with 1221 additions and 9 deletions
+18
View File
@@ -52,3 +52,21 @@ jobs:
# (Vite/PostCSS/etc.) shouldn't block a merge.
working-directory: frontend
run: npm audit --omit=dev --audit-level=high
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
# don't look for. Advisory at first (not a required check); promote once tuned.
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The runner image lacks pip, and a semgrep job-container lacks the node that
# actions/checkout needs — so install pip via apt on the standard image.
- name: Install semgrep
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
pipx install semgrep
- name: Semgrep scan
run: |
export PATH="$HOME/.local/bin:$PATH"
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
@@ -120,3 +120,15 @@ public interface IDigestService
{
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
}
/// <summary>System feature flags (fail-closed: unknown key = disabled).</summary>
public interface IFeatureFlags
{
Task<bool> IsEnabledAsync(string key, CancellationToken ct = default);
}
/// <summary>Policy gate for AI features: system flag AND the user's opt-in.</summary>
public interface IAiGate
{
Task<bool> IsAiEnabledForUserAsync(Guid userId, CancellationToken ct = default);
}
@@ -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; }
}
@@ -92,6 +92,11 @@ public static class DependencyInjection
break;
}
services.AddScoped<IAiService, AiService>();
// Feature flags + AI policy gate (docs/discovery/multi-provider/04). Cached 15s,
// fail-closed. Admin toggle surface arrives with the multi-provider admin phase.
services.AddMemoryCache();
services.AddScoped<IFeatureFlags, Features.FeatureFlagService>();
services.AddScoped<IAiGate, Features.AiGate>();
// Semantic search: fills Email.Embedding in the background; no-ops when the
// embedding provider is unavailable (AI disabled), so lexical search is unaffected.
services.AddHostedService<EmbeddingBackfillWorker>();
@@ -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;
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -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");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -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<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")
@@ -591,6 +617,32 @@ namespace InboxIntel.Infrastructure.Migrations
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")
@@ -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)
@@ -29,6 +29,8 @@ public class AppDbContext : DbContext, IAppDbContext
public DbSet<Email> Emails => Set<Email>();
public DbSet<MailThread> Threads => Set<MailThread>();
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<Attachment> Attachments => Set<Attachment>();
public DbSet<Label> Labels => Set<Label>();
@@ -61,6 +63,22 @@ public class AppDbContext : DbContext, IAppDbContext
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", 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
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
@@ -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");
}
}