From 0163165beb0874dadd6195aeb494aed3085b286f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 22:11:39 +0200 Subject: [PATCH] feat: notification digests via SMTP Adds a periodic inbox-summary email per opted-in user. SMTP is configured via appsettings (Smtp section); the digest is built from existing analytics (health score, top senders, recommendations) and sent by a new hourly DigestWorker, mirroring the GmailSyncWorker pattern. Frequency and send hour are configurable (Digest section). Backend: IEmailSender (SmtpEmailSender, MailKit), IDigestService, DigestWorker, new User.DigestEnabled/LastDigestSentUtc fields + migration, SettingsController for the per-user toggle and a send-now test endpoint, all scoped to the authenticated UserId. Frontend: SettingsApi + a bell/no-bell toggle button in the topbar. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/client.js | 6 + frontend/src/components/DigestToggle.jsx | 28 + frontend/src/components/Layout.jsx | 2 + frontend/src/styles.css | 2 + .../Controllers/SettingsController.cs | 47 ++ src/InboxIntel.Api/appsettings.json | 14 + .../Abstractions/IServices.cs | 13 + src/InboxIntel.Domain/Entities/User.cs | 5 + .../Configuration/Options.cs | 22 + .../DependencyInjection.cs | 8 + .../InboxIntel.Infrastructure.csproj | 1 + ...0630200946_AddUserDigestFields.Designer.cs | 739 ++++++++++++++++++ .../20260630200946_AddUserDigestFields.cs | 40 + .../Migrations/AppDbContextModelSnapshot.cs | 6 + .../Notifications/DigestService.cs | 65 ++ .../Notifications/DigestWorker.cs | 69 ++ .../Notifications/SmtpEmailSender.cs | 46 ++ 17 files changed, 1113 insertions(+) create mode 100644 frontend/src/components/DigestToggle.jsx create mode 100644 src/InboxIntel.Api/Controllers/SettingsController.cs create mode 100644 src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.Designer.cs create mode 100644 src/InboxIntel.Infrastructure/Migrations/20260630200946_AddUserDigestFields.cs create mode 100644 src/InboxIntel.Infrastructure/Notifications/DigestService.cs create mode 100644 src/InboxIntel.Infrastructure/Notifications/DigestWorker.cs create mode 100644 src/InboxIntel.Infrastructure/Notifications/SmtpEmailSender.cs 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); + } +}