diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 8329a08..909777a 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -148,4 +148,10 @@ export const ExportApi = {
reportUrl: (format) => `/api/v1/export/report?format=${format}`
};
+export const SettingsApi = {
+ getDigest: () => api.get('/settings/digest').then((r) => r.data),
+ setDigest: (enabled) => api.put('/settings/digest', enabled).then((r) => r.data),
+ sendDigestNow: () => api.post('/settings/digest/send-now'),
+};
+
export default api;
diff --git a/frontend/src/components/DigestToggle.jsx b/frontend/src/components/DigestToggle.jsx
new file mode 100644
index 0000000..dd9a6b1
--- /dev/null
+++ b/frontend/src/components/DigestToggle.jsx
@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react';
+import { SettingsApi } from '../api/client.js';
+
+export default function DigestToggle() {
+ const [enabled, setEnabled] = useState(null);
+
+ useEffect(() => {
+ SettingsApi.getDigest().then((d) => setEnabled(d.enabled)).catch(() => setEnabled(false));
+ }, []);
+
+ const toggle = async () => {
+ const next = !enabled;
+ setEnabled(next);
+ try { await SettingsApi.setDigest(next); } catch { setEnabled(!next); }
+ };
+
+ if (enabled === null) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index 8436985..bbcac11 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -4,6 +4,7 @@ import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js';
import Logo from './Logo.jsx';
import DevBanner from './DevBanner.jsx';
import SyncStatus from './SyncStatus.jsx';
+import DigestToggle from './DigestToggle.jsx';
import useSavedSearches from '../hooks/useSavedSearches.js';
// ββ Folder definitions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -255,6 +256,7 @@ export default function Layout() {
+
{user?.email}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 8132871..1402b43 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -412,3 +412,5 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
.saved-search-save-btn:hover { color: var(--text); border-color: var(--accent); }
.email-row--focused { outline: 1px solid var(--accent); outline-offset: -1px; }
+
+.digest-toggle { font-size: 16px; padding: 4px 8px; }
diff --git a/src/InboxIntel.Api/Controllers/SettingsController.cs b/src/InboxIntel.Api/Controllers/SettingsController.cs
new file mode 100644
index 0000000..125207a
--- /dev/null
+++ b/src/InboxIntel.Api/Controllers/SettingsController.cs
@@ -0,0 +1,47 @@
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace InboxIntel.Api.Controllers;
+
+public record DigestSettingsDto(bool Enabled, DateTimeOffset? LastSentUtc);
+
+/// User-level preferences. All reads/writes are scoped to the authenticated UserId.
+public class SettingsController : ApiControllerBase
+{
+ private readonly AppDbContext _db;
+ private readonly IDigestService _digest;
+
+ public SettingsController(AppDbContext db, IDigestService digest)
+ {
+ _db = db;
+ _digest = digest;
+ }
+
+ [HttpGet("digest")]
+ public async Task GetDigest(CancellationToken ct)
+ {
+ var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == UserId, ct);
+ if (user is null) return NotFound();
+ return Ok(new DigestSettingsDto(user.DigestEnabled, user.LastDigestSentUtc));
+ }
+
+ [HttpPut("digest")]
+ public async Task SetDigest([FromBody] bool enabled, CancellationToken ct)
+ {
+ var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == UserId, ct);
+ if (user is null) return NotFound();
+ user.DigestEnabled = enabled;
+ await _db.SaveChangesAsync(ct);
+ return Ok(new DigestSettingsDto(user.DigestEnabled, user.LastDigestSentUtc));
+ }
+
+ /// Sends a digest immediately, for testing/preview.
+ [HttpPost("digest/send-now")]
+ public async Task SendNow(CancellationToken ct)
+ {
+ await _digest.SendDigestAsync(UserId, ct);
+ return NoContent();
+ }
+}
diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json
index da36c5c..0ebdc56 100644
--- a/src/InboxIntel.Api/appsettings.json
+++ b/src/InboxIntel.Api/appsettings.json
@@ -37,6 +37,20 @@
"OpenAiApiKey": "",
"OpenAiModel": "gpt-4o-mini"
},
+ "Smtp": {
+ "Enabled": false,
+ "Host": "",
+ "Port": 587,
+ "UseSsl": true,
+ "User": "",
+ "Password": "",
+ "FromAddress": "",
+ "FromName": "InboxIntel"
+ },
+ "Digest": {
+ "FrequencyDays": 7,
+ "SendHourUtc": 8
+ },
"Cors": {
"Origins": [ "http://localhost:5173" ]
},
diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs
index 86db84f..658e9cb 100644
--- a/src/InboxIntel.Application/Abstractions/IServices.cs
+++ b/src/InboxIntel.Application/Abstractions/IServices.cs
@@ -73,3 +73,16 @@ public interface IExportService
{
Task<(byte[] Content, string ContentType, string FileName)> ExportReportAsync(Guid userId, ExportFormat format, CancellationToken ct = default);
}
+
+/// SMTP-backed email delivery, used for digest notifications. No-ops if SMTP is not configured.
+public interface IEmailSender
+{
+ bool IsEnabled { get; }
+ Task SendAsync(string toAddress, string subject, string htmlBody, CancellationToken ct = default);
+}
+
+/// Builds and sends the periodic inbox digest for opted-in users.
+public interface IDigestService
+{
+ Task SendDigestAsync(Guid userId, CancellationToken ct = default);
+}
diff --git a/src/InboxIntel.Domain/Entities/User.cs b/src/InboxIntel.Domain/Entities/User.cs
index adc9bf6..a813afb 100644
--- a/src/InboxIntel.Domain/Entities/User.cs
+++ b/src/InboxIntel.Domain/Entities/User.cs
@@ -26,6 +26,11 @@ public class User : AuditableEntity
public DateTimeOffset? LastLoginUtc { get; set; }
+ /// Whether this user receives periodic inbox digest emails.
+ public bool DigestEnabled { get; set; } = false;
+
+ public DateTimeOffset? LastDigestSentUtc { get; set; }
+
public ICollection Emails { get; set; } = new List();
public ICollection WidgetLayouts { get; set; } = new List();
}
diff --git a/src/InboxIntel.Infrastructure/Configuration/Options.cs b/src/InboxIntel.Infrastructure/Configuration/Options.cs
index dd7b430..77786e2 100644
--- a/src/InboxIntel.Infrastructure/Configuration/Options.cs
+++ b/src/InboxIntel.Infrastructure/Configuration/Options.cs
@@ -48,3 +48,25 @@ public class AiOptions
public string OpenAiApiKey { get; set; } = string.Empty;
public string OpenAiModel { get; set; } = "gpt-4o-mini";
}
+
+public class SmtpOptions
+{
+ public const string SectionName = "Smtp";
+ public bool Enabled { get; set; } = false;
+ public string Host { get; set; } = string.Empty;
+ public int Port { get; set; } = 587;
+ public bool UseSsl { get; set; } = true;
+ public string User { get; set; } = string.Empty;
+ public string Password { get; set; } = string.Empty;
+ public string FromAddress { get; set; } = string.Empty;
+ public string FromName { get; set; } = "InboxIntel";
+}
+
+public class DigestOptions
+{
+ public const string SectionName = "Digest";
+ /// How often a digest is sent to each opted-in user.
+ public int FrequencyDays { get; set; } = 7;
+ /// Hour (UTC) the background worker checks for due digests.
+ public int SendHourUtc { get; set; } = 8;
+}
diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs
index 3898679..f52e281 100644
--- a/src/InboxIntel.Infrastructure/DependencyInjection.cs
+++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs
@@ -6,6 +6,7 @@ using InboxIntel.Infrastructure.Cleanup;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Export;
using InboxIntel.Infrastructure.Gmail;
+using InboxIntel.Infrastructure.Notifications;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Search;
using InboxIntel.Infrastructure.Security;
@@ -30,6 +31,8 @@ public static class DependencyInjection
services.Configure(config.GetSection(GoogleOAuthOptions.SectionName));
services.Configure(config.GetSection(GmailSyncOptions.SectionName));
services.Configure(config.GetSection(AiOptions.SectionName));
+ services.Configure(config.GetSection(SmtpOptions.SectionName));
+ services.Configure(config.GetSection(DigestOptions.SectionName));
// Security
services.AddSingleton();
@@ -51,6 +54,11 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
+ // Notifications (digest emails)
+ services.AddScoped();
+ services.AddScoped();
+ services.AddHostedService();
+
// HTTP clients
services.AddHttpClient("unsubscribe", c => c.Timeout = TimeSpan.FromSeconds(15));
services.AddHttpClient("ollama");
diff --git a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj
index ef824fa..01875f8 100644
--- a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj
+++ b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs
new file mode 100644
index 0000000..1a812aa
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs
@@ -0,0 +1,739 @@
+ο»Ώ//
+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;
+
+#nullable disable
+
+namespace InboxIntel.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260630200946_AddUserDigestFields")]
+ partial class AddUserDigestFields
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ 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("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("to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", 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("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.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("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("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("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.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.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/20260630200946_AddUserDigestFields.cs b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs
new file mode 100644
index 0000000..7c6a216
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs
@@ -0,0 +1,40 @@
+ο»Ώusing System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace InboxIntel.Infrastructure.Migrations
+{
+ ///
+ public partial class AddUserDigestFields : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "DigestEnabled",
+ table: "users",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "LastDigestSentUtc",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "DigestEnabled",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "LastDigestSentUtc",
+ table: "users");
+ }
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
index e295ba7..90a704b 100644
--- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
@@ -520,6 +520,9 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
+ b.Property("DigestEnabled")
+ .HasColumnType("boolean");
+
b.Property("DisplayName")
.HasColumnType("text");
@@ -536,6 +539,9 @@ namespace InboxIntel.Infrastructure.Migrations
.HasMaxLength(64)
.HasColumnType("character varying(64)");
+ b.Property("LastDigestSentUtc")
+ .HasColumnType("timestamp with time zone");
+
b.Property("LastLoginUtc")
.HasColumnType("timestamp with time zone");
diff --git a/src/InboxIntel.Infrastructure/Notifications/DigestService.cs b/src/InboxIntel.Infrastructure/Notifications/DigestService.cs
new file mode 100644
index 0000000..b0b2d58
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Notifications/DigestService.cs
@@ -0,0 +1,65 @@
+using System.Text;
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace InboxIntel.Infrastructure.Notifications;
+
+/// Builds an HTML inbox-summary email and sends it via .
+public class DigestService : IDigestService
+{
+ private readonly AppDbContext _db;
+ private readonly IAnalyticsService _analytics;
+ private readonly IEmailSender _emailSender;
+
+ public DigestService(AppDbContext db, IAnalyticsService analytics, IEmailSender emailSender)
+ {
+ _db = db;
+ _analytics = analytics;
+ _emailSender = emailSender;
+ }
+
+ public async Task SendDigestAsync(Guid userId, CancellationToken ct = default)
+ {
+ var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
+ if (user is null || string.IsNullOrWhiteSpace(user.Email)) return;
+
+ var dashboard = await _analytics.GetDashboardAsync(userId, ct);
+ var html = BuildHtml(dashboard);
+
+ await _emailSender.SendAsync(user.Email, "Your InboxIntel digest", html, ct);
+
+ user.LastDigestSentUtc = DateTimeOffset.UtcNow;
+ await _db.SaveChangesAsync(ct);
+ }
+
+ private static string BuildHtml(Application.DTOs.DashboardSummaryDto d)
+ {
+ var sb = new StringBuilder();
+ sb.Append("");
+ sb.Append("
Your inbox this week
");
+ sb.Append($"
{d.Health.Score}/100 inbox health (grade {d.Health.Grade})
");
+ sb.Append($"
{d.UnreadEmails:N0} unread of {d.TotalEmails:N0} total emails
");
+ if (d.Health.SafeToUnsubscribeCount > 0)
+ sb.Append($"
{d.Health.SafeToUnsubscribeCount} senders are safe to unsubscribe from.
");
+
+ if (d.TopSenders.Count > 0)
+ {
+ sb.Append("
Top senders
");
+ foreach (var s in d.TopSenders.Take(5))
+ sb.Append($"- {System.Net.WebUtility.HtmlEncode(s.DisplayName ?? s.Address)} β {s.EmailCount:N0} emails
");
+ sb.Append("
");
+ }
+
+ if (d.Health.Recommendations.Count > 0)
+ {
+ sb.Append("
Recommendations
");
+ foreach (var r in d.Health.Recommendations)
+ sb.Append($"- {System.Net.WebUtility.HtmlEncode(r)}
");
+ sb.Append("
");
+ }
+
+ sb.Append("
");
+ return sb.ToString();
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Notifications/DigestWorker.cs b/src/InboxIntel.Infrastructure/Notifications/DigestWorker.cs
new file mode 100644
index 0000000..6065680
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Notifications/DigestWorker.cs
@@ -0,0 +1,69 @@
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Infrastructure.Configuration;
+using InboxIntel.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace InboxIntel.Infrastructure.Notifications;
+
+///
+/// Hourly tick that sends the inbox digest to opted-in users once their
+/// configured frequency has elapsed. Mirrors .
+///
+public class DigestWorker : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly DigestOptions _options;
+ private readonly ILogger _logger;
+
+ public DigestWorker(IServiceScopeFactory scopeFactory, IOptions options, ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("DigestWorker started; send hour = {Hour}:00 UTC, frequency = {Days}d", _options.SendHourUtc, _options.FrequencyDays);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (DateTimeOffset.UtcNow.Hour == _options.SendHourUtc)
+ await SendDueDigestsAsync(stoppingToken);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "DigestWorker tick failed");
+ }
+
+ await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
+ }
+ }
+
+ private async Task SendDueDigestsAsync(CancellationToken ct)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var digest = scope.ServiceProvider.GetRequiredService();
+ var emailSender = scope.ServiceProvider.GetRequiredService();
+ if (!emailSender.IsEnabled) return;
+
+ var cutoff = DateTimeOffset.UtcNow.AddDays(-_options.FrequencyDays);
+ var dueUserIds = await db.Users
+ .Where(u => u.DigestEnabled && (u.LastDigestSentUtc == null || u.LastDigestSentUtc <= cutoff))
+ .Select(u => u.Id)
+ .ToListAsync(ct);
+
+ foreach (var userId in dueUserIds)
+ {
+ try { await digest.SendDigestAsync(userId, ct); }
+ catch (Exception ex) { _logger.LogError(ex, "Digest send failed for user {UserId}", userId); }
+ }
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Notifications/SmtpEmailSender.cs b/src/InboxIntel.Infrastructure/Notifications/SmtpEmailSender.cs
new file mode 100644
index 0000000..2003a75
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Notifications/SmtpEmailSender.cs
@@ -0,0 +1,46 @@
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Infrastructure.Configuration;
+using MailKit.Net.Smtp;
+using MailKit.Security;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using MimeKit;
+
+namespace InboxIntel.Infrastructure.Notifications;
+
+public class SmtpEmailSender : IEmailSender
+{
+ private readonly SmtpOptions _options;
+ private readonly ILogger _logger;
+
+ public SmtpEmailSender(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public bool IsEnabled => _options.Enabled && !string.IsNullOrWhiteSpace(_options.Host);
+
+ public async Task SendAsync(string toAddress, string subject, string htmlBody, CancellationToken ct = default)
+ {
+ if (!IsEnabled)
+ {
+ _logger.LogInformation("SMTP not configured; skipping email \"{Subject}\" to {To}", subject, toAddress);
+ return;
+ }
+
+ var message = new MimeMessage();
+ message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
+ message.To.Add(MailboxAddress.Parse(toAddress));
+ message.Subject = subject;
+ message.Body = new BodyBuilder { HtmlBody = htmlBody }.ToMessageBody();
+
+ using var client = new SmtpClient();
+ var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.None;
+ await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
+ if (!string.IsNullOrEmpty(_options.User))
+ await client.AuthenticateAsync(_options.User, _options.Password, ct);
+ await client.SendAsync(message, ct);
+ await client.DisconnectAsync(true, ct);
+ }
+}