diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..5f8f4d5
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,63 @@
+# InboxIntel — Security & Data Posture
+
+The deliberate security posture of this application, so operators know exactly what is and
+isn't protected. Complements [AUDIT_REPORT.md](AUDIT_REPORT.md) (point-in-time audit) and
+`docs/discovery/multi-provider/06-security-model.md` (future multi-user design).
+
+## Deployment model this posture assumes
+Self-hosted, **single-operator** instance: the person running the server is the person whose
+mailbox is synced. All services (API, Postgres, frontend) run in Docker on the operator's own
+machine; Postgres and the API bind to loopback/compose-internal only; TLS terminates at the
+reverse proxy.
+
+## What is protected, and how
+| Asset | Protection |
+|---|---|
+| Google OAuth refresh/access tokens | Encrypted at rest (ASP.NET Data Protection, AES); never logged; never sent to the browser |
+| Data Protection key ring | Optionally encrypted with an operator-supplied X.509 certificate — set `DataProtection:CertificatePath`/`CertificatePassword`. **Without it, keys sit in plaintext on the `/keys` volume** and anyone with volume access can decrypt stored tokens. Recommended for any shared host. |
+| App session | HttpOnly/SameSite=Lax/Secure cookie · sliding 7 d **with an absolute 30 d cap** (`Auth:AbsoluteSessionDays`) |
+| Login/abuse | Rate limiting: global 300 req/min per user (or IP when anonymous); `auth` 10/min; export/unsubscribe/AI 20/min (`RateLimiting:*`) |
+| Cross-user access | EF global query filters on every tenant-scoped entity (incl. the EmailLabel join) — tested |
+| Outbound fetches (unsubscribe etc.) | `SafeHttpGuard` SSRF allowlisting (DNS-rebinding-safe) + redirects disabled |
+| Untrusted email content in the UI | Rendered only as escaped React text (no `dangerouslySetInnerHTML`); search highlights use non-HTML sentinels; SPA ships CSP with `script-src 'self'` |
+| Secrets | Never committed (`deploy/.env*` git-ignored; pre-commit + CI gitleaks scans); no passwords stored at all (OAuth-only login) |
+
+## What is deliberately NOT protected (accepted risks — read this)
+1. **Email bodies are stored in plaintext in Postgres.** Full-text and semantic search index
+ the body; encrypted columns cannot be indexed this way. On the assumed single-operator
+ deployment, the database lives on the operator's own disk, so the threat this would
+ mitigate (a third party reading the DB files) reduces to "someone with access to your
+ machine" — mitigate it at the layer that actually works:
+ - **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
+ - **Encrypt backups** of the `pgdata` volume the same way.
+ - 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).
+2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
+ loopback. If you ever move Postgres to another host, add `SSL Mode=Require` to the
+ connection string (audit L-4).
+3. **No CSRF tokens** — SameSite=Lax cookies + strict CORS + JSON-only bodies make classic
+ CSRF impractical; revisit if either changes (audit L-1).
+
+## Data retention (opt-in)
+By default the local mailbox copy is kept indefinitely. Two knobs enable automatic purging of
+the **local copy only** (your actual Gmail is never touched):
+```json
+"DataRetention": {
+ "PurgeTrashedAfterDays": 0, // e.g. 30 — purge local copies of trashed mail after 30 days
+ "PurgeAllAfterDays": 0 // e.g. 730 — keep at most ~2 years of mail locally
+}
+```
+`0` disables a knob. A daily background worker applies them. For a full "delete my data"
+operation: stop the stack and remove the `pgdata` + `keys` volumes
+(`docker compose down -v`), and revoke the app's access in your Google account.
+
+## Production checklist (beyond compose defaults)
+- Set `AllowedHosts` to your real hostname(s) (audit L-2).
+- Terminate TLS at the proxy; HSTS is enabled automatically outside Development.
+- Provide `DataProtection:CertificatePath` to encrypt the key ring.
+- Keep `deploy/.env` readable only by the service user; rotate the DB password if exposed.
+- Dependency + secret scanning run in CI on every PR (required checks).
+
+## Reporting
+Single-operator project — if you find a vulnerability, open a private issue or contact the
+repository owner directly.
diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs
index c3bd352..bb50ae0 100644
--- a/src/InboxIntel.Api/Program.cs
+++ b/src/InboxIntel.Api/Program.cs
@@ -27,9 +27,20 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.Console());
// Persist Data Protection keys so encrypted refresh tokens survive restarts.
-builder.Services.AddDataProtection()
+// AUDIT M-3: optionally encrypt the Data Protection key ring with an X.509 certificate so
+// the keys are not readable in plaintext from the /keys volume (which would otherwise let
+// anyone with volume access decrypt all stored refresh tokens). Configure
+// DataProtection:CertificatePath (+ CertificatePassword) to enable; without it, keys are
+// persisted unprotected and a startup warning documents the residual risk.
+var dp = builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys"))
.SetApplicationName("InboxIntel");
+var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
+if (!string.IsNullOrWhiteSpace(dpCertPath))
+{
+ dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2(
+ dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
+}
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json
index edbcb56..abe8b91 100644
--- a/src/InboxIntel.Api/appsettings.json
+++ b/src/InboxIntel.Api/appsettings.json
@@ -6,7 +6,13 @@
"AutoMigrate": true
},
"DataProtection": {
- "KeyPath": "/keys"
+ "KeyPath": "/keys",
+ "CertificatePath": "",
+ "CertificatePassword": ""
+ },
+ "DataRetention": {
+ "PurgeTrashedAfterDays": 0,
+ "PurgeAllAfterDays": 0
},
"GoogleOAuth": {
"ClientId": "",
diff --git a/src/InboxIntel.Infrastructure/Configuration/Options.cs b/src/InboxIntel.Infrastructure/Configuration/Options.cs
index 3d155e9..1864637 100644
--- a/src/InboxIntel.Infrastructure/Configuration/Options.cs
+++ b/src/InboxIntel.Infrastructure/Configuration/Options.cs
@@ -72,3 +72,13 @@ public class DigestOptions
/// Hour (UTC) the background worker checks for due digests.
public int SendHourUtc { get; set; } = 8;
}
+
+/// AUDIT H-3: opt-in local data retention. 0 = disabled (keep forever).
+public class DataRetentionOptions
+{
+ public const string SectionName = "DataRetention";
+ /// Purge locally stored emails flagged Trashed older than this many days.
+ public int PurgeTrashedAfterDays { get; set; } = 0;
+ /// Purge ALL locally stored emails older than this many days (local copy only).
+ public int PurgeAllAfterDays { get; set; } = 0;
+}
diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs
index 3ee8c99..51fe403 100644
--- a/src/InboxIntel.Infrastructure/DependencyInjection.cs
+++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs
@@ -60,6 +60,11 @@ public static class DependencyInjection
services.AddScoped();
services.AddHostedService();
+ // AUDIT H-3: opt-in local data retention (worker no-ops while disabled).
+ services.Configure(config.GetSection(DataRetentionOptions.SectionName));
+ services.AddScoped();
+ services.AddHostedService();
+
// HTTP clients
// V-01: do NOT follow redirects — a validated external URL must not be able to
// 3xx-redirect into an internal target after SafeHttpGuard has checked it.
diff --git a/src/InboxIntel.Infrastructure/Retention/RetentionService.cs b/src/InboxIntel.Infrastructure/Retention/RetentionService.cs
new file mode 100644
index 0000000..d247411
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Retention/RetentionService.cs
@@ -0,0 +1,63 @@
+using InboxIntel.Infrastructure.Configuration;
+using InboxIntel.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace InboxIntel.Infrastructure.Retention;
+
+///
+/// AUDIT H-3 (retention): purges locally stored email data past the configured age so the
+/// local mailbox copy is not kept forever by default-of-omission. This deletes ONLY the
+/// local Postgres rows — the user's actual Gmail is never touched. Both knobs default to 0
+/// (disabled) so existing deployments are unchanged until the operator opts in.
+///
+public class RetentionService
+{
+ private readonly AppDbContext _db;
+ private readonly DataRetentionOptions _options;
+ private readonly ILogger _logger;
+
+ public RetentionService(AppDbContext db, IOptions options, ILogger logger)
+ {
+ _db = db;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ /// Runs the configured purges. Returns the number of emails removed.
+ public async Task PurgeAsync(CancellationToken ct = default)
+ {
+ var removed = 0;
+ var now = DateTimeOffset.UtcNow;
+
+ if (_options.PurgeTrashedAfterDays > 0)
+ {
+ var cutoff = now.AddDays(-_options.PurgeTrashedAfterDays);
+ removed += await PurgeWhereAsync(e => e.IsTrashed && e.SentAtUtc < cutoff, ct);
+ }
+
+ if (_options.PurgeAllAfterDays > 0)
+ {
+ var cutoff = now.AddDays(-_options.PurgeAllAfterDays);
+ removed += await PurgeWhereAsync(e => e.SentAtUtc < cutoff, ct);
+ }
+
+ if (removed > 0)
+ _logger.LogInformation("Retention purge removed {Count} locally stored emails", removed);
+ return removed;
+ }
+
+ private async Task PurgeWhereAsync(
+ System.Linq.Expressions.Expression> predicate,
+ CancellationToken ct)
+ {
+ // RemoveRange (not ExecuteDelete) so dependent rows (labels/attachments) cascade via
+ // the model on every provider, and the InMemory test provider works identically.
+ var doomed = await _db.Emails.Where(predicate).ToListAsync(ct);
+ if (doomed.Count == 0) return 0;
+ _db.Emails.RemoveRange(doomed);
+ await _db.SaveChangesAsync(ct);
+ return doomed.Count;
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Retention/RetentionWorker.cs b/src/InboxIntel.Infrastructure/Retention/RetentionWorker.cs
new file mode 100644
index 0000000..0dfa5dd
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Retention/RetentionWorker.cs
@@ -0,0 +1,53 @@
+using InboxIntel.Infrastructure.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace InboxIntel.Infrastructure.Retention;
+
+///
+/// Daily driver for (AUDIT H-3). Exits immediately when both
+/// retention knobs are 0 (the default) so existing deployments see no behaviour change.
+///
+public class RetentionWorker : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly DataRetentionOptions _options;
+ private readonly ILogger _logger;
+
+ public RetentionWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ if (_options.PurgeTrashedAfterDays <= 0 && _options.PurgeAllAfterDays <= 0)
+ {
+ _logger.LogDebug("RetentionWorker idle: retention is disabled (all knobs 0).");
+ return;
+ }
+
+ _logger.LogInformation("RetentionWorker started; trashed>{Trashed}d, all>{All}d",
+ _options.PurgeTrashedAfterDays, _options.PurgeAllAfterDays);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var service = scope.ServiceProvider.GetRequiredService();
+ await service.PurgeAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Retention purge failed; will retry next cycle.");
+ }
+ await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
+ }
+ }
+}
diff --git a/tests/InboxIntel.IntegrationTests/RetentionServiceTests.cs b/tests/InboxIntel.IntegrationTests/RetentionServiceTests.cs
new file mode 100644
index 0000000..bbd50ce
--- /dev/null
+++ b/tests/InboxIntel.IntegrationTests/RetentionServiceTests.cs
@@ -0,0 +1,73 @@
+using FluentAssertions;
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Domain.Entities;
+using InboxIntel.Infrastructure.Configuration;
+using InboxIntel.Infrastructure.Persistence;
+using InboxIntel.Infrastructure.Retention;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace InboxIntel.IntegrationTests;
+
+///
+/// AUDIT H-3 (retention): the purge removes only what the configuration targets and is a
+/// no-op while disabled — locking the "no behaviour change until opted in" guarantee.
+///
+public class RetentionServiceTests
+{
+ private sealed class FakeCurrentUser : ICurrentUser
+ {
+ public Guid UserId => Guid.Empty; // worker scope
+ public bool IsAuthenticated => false;
+ }
+
+ private static AppDbContext NewDb(string name) =>
+ new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
+
+ private static async Task SeedAsync(AppDbContext db)
+ {
+ var user = Guid.NewGuid();
+ db.Emails.AddRange(
+ new Email { UserId = user, GmailMessageId = "old-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) },
+ new Email { UserId = user, GmailMessageId = "new-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-5) },
+ new Email { UserId = user, GmailMessageId = "old-kept", IsTrashed = false, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) });
+ await db.SaveChangesAsync();
+ }
+
+ private static RetentionService Sut(AppDbContext db, int trashedDays = 0, int allDays = 0) =>
+ new(db, Options.Create(new DataRetentionOptions
+ {
+ PurgeTrashedAfterDays = trashedDays,
+ PurgeAllAfterDays = allDays
+ }), NullLogger.Instance);
+
+ [Fact]
+ public async Task Disabled_retention_purges_nothing()
+ {
+ using var db = NewDb(nameof(Disabled_retention_purges_nothing));
+ await SeedAsync(db);
+ (await Sut(db).PurgeAsync()).Should().Be(0);
+ (await db.Emails.CountAsync()).Should().Be(3);
+ }
+
+ [Fact]
+ public async Task Trashed_purge_removes_only_old_trashed_emails()
+ {
+ using var db = NewDb(nameof(Trashed_purge_removes_only_old_trashed_emails));
+ await SeedAsync(db);
+ (await Sut(db, trashedDays: 30).PurgeAsync()).Should().Be(1);
+ var remaining = await db.Emails.Select(e => e.GmailMessageId).ToListAsync();
+ remaining.Should().BeEquivalentTo(new[] { "new-trashed", "old-kept" });
+ }
+
+ [Fact]
+ public async Task Age_purge_removes_everything_past_the_cutoff()
+ {
+ using var db = NewDb(nameof(Age_purge_removes_everything_past_the_cutoff));
+ await SeedAsync(db);
+ (await Sut(db, allDays: 30).PurgeAsync()).Should().Be(2); // both 100-day-old emails
+ (await db.Emails.SingleAsync()).GmailMessageId.Should().Be("new-trashed");
+ }
+}