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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 22:11:39 +02:00
parent 9bd3799fbc
commit 0163165beb
17 changed files with 1113 additions and 0 deletions
@@ -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);
/// <summary>User-level preferences. All reads/writes are scoped to the authenticated UserId.</summary>
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<IActionResult> 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<IActionResult> 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));
}
/// <summary>Sends a digest immediately, for testing/preview.</summary>
[HttpPost("digest/send-now")]
public async Task<IActionResult> SendNow(CancellationToken ct)
{
await _digest.SendDigestAsync(UserId, ct);
return NoContent();
}
}
+14
View File
@@ -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" ]
},
@@ -73,3 +73,16 @@ public interface IExportService
{
Task<(byte[] Content, string ContentType, string FileName)> ExportReportAsync(Guid userId, ExportFormat format, CancellationToken ct = default);
}
/// <summary>SMTP-backed email delivery, used for digest notifications. No-ops if SMTP is not configured.</summary>
public interface IEmailSender
{
bool IsEnabled { get; }
Task SendAsync(string toAddress, string subject, string htmlBody, CancellationToken ct = default);
}
/// <summary>Builds and sends the periodic inbox digest for opted-in users.</summary>
public interface IDigestService
{
Task SendDigestAsync(Guid userId, CancellationToken ct = default);
}
+5
View File
@@ -26,6 +26,11 @@ public class User : AuditableEntity
public DateTimeOffset? LastLoginUtc { get; set; }
/// <summary>Whether this user receives periodic inbox digest emails.</summary>
public bool DigestEnabled { get; set; } = false;
public DateTimeOffset? LastDigestSentUtc { get; set; }
public ICollection<Email> Emails { get; set; } = new List<Email>();
public ICollection<WidgetLayout> WidgetLayouts { get; set; } = new List<WidgetLayout>();
}
@@ -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";
/// <summary>How often a digest is sent to each opted-in user.</summary>
public int FrequencyDays { get; set; } = 7;
/// <summary>Hour (UTC) the background worker checks for due digests.</summary>
public int SendHourUtc { get; set; } = 8;
}
@@ -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<GoogleOAuthOptions>(config.GetSection(GoogleOAuthOptions.SectionName));
services.Configure<GmailSyncOptions>(config.GetSection(GmailSyncOptions.SectionName));
services.Configure<AiOptions>(config.GetSection(AiOptions.SectionName));
services.Configure<SmtpOptions>(config.GetSection(SmtpOptions.SectionName));
services.Configure<DigestOptions>(config.GetSection(DigestOptions.SectionName));
// Security
services.AddSingleton<ITokenProtector, DataProtectionTokenProtector>();
@@ -51,6 +54,11 @@ public static class DependencyInjection
services.AddScoped<IUnsubscribeService, UnsubscribeService>();
services.AddScoped<IExportService, ExportService>();
// Notifications (digest emails)
services.AddScoped<IEmailSender, SmtpEmailSender>();
services.AddScoped<IDigestService, DigestService>();
services.AddHostedService<DigestWorker>();
// HTTP clients
services.AddHttpClient("unsubscribe", c => c.Timeout = TimeSpan.FromSeconds(15));
services.AddHttpClient("ollama");
@@ -19,6 +19,7 @@
<PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="MailKit" Version="4.13.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
@@ -0,0 +1,739 @@
// <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;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260630200946_AddUserDigestFields")]
partial class AddUserDigestFields
{
/// <inheritdoc />
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<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<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("to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", 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("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.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("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("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<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.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.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,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserDigestFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DigestEnabled",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "LastDigestSentUtc",
table: "users",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DigestEnabled",
table: "users");
migrationBuilder.DropColumn(
name: "LastDigestSentUtc",
table: "users");
}
}
}
@@ -520,6 +520,9 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
@@ -536,6 +539,9 @@ namespace InboxIntel.Infrastructure.Migrations
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
@@ -0,0 +1,65 @@
using System.Text;
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Notifications;
/// <summary>Builds an HTML inbox-summary email and sends it via <see cref="IEmailSender"/>.</summary>
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("<div style=\"font-family:sans-serif;max-width:480px\">");
sb.Append("<h2>Your inbox this week</h2>");
sb.Append($"<p><strong>{d.Health.Score}/100</strong> inbox health (grade {d.Health.Grade})</p>");
sb.Append($"<p>{d.UnreadEmails:N0} unread of {d.TotalEmails:N0} total emails</p>");
if (d.Health.SafeToUnsubscribeCount > 0)
sb.Append($"<p>{d.Health.SafeToUnsubscribeCount} senders are safe to unsubscribe from.</p>");
if (d.TopSenders.Count > 0)
{
sb.Append("<h3>Top senders</h3><ul>");
foreach (var s in d.TopSenders.Take(5))
sb.Append($"<li>{System.Net.WebUtility.HtmlEncode(s.DisplayName ?? s.Address)} — {s.EmailCount:N0} emails</li>");
sb.Append("</ul>");
}
if (d.Health.Recommendations.Count > 0)
{
sb.Append("<h3>Recommendations</h3><ul>");
foreach (var r in d.Health.Recommendations)
sb.Append($"<li>{System.Net.WebUtility.HtmlEncode(r)}</li>");
sb.Append("</ul>");
}
sb.Append("</div>");
return sb.ToString();
}
}
@@ -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;
/// <summary>
/// Hourly tick that sends the inbox digest to opted-in users once their
/// configured frequency has elapsed. Mirrors <see cref="Sync.GmailSyncWorker"/>.
/// </summary>
public class DigestWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly DigestOptions _options;
private readonly ILogger<DigestWorker> _logger;
public DigestWorker(IServiceScopeFactory scopeFactory, IOptions<DigestOptions> options, ILogger<DigestWorker> 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<AppDbContext>();
var digest = scope.ServiceProvider.GetRequiredService<IDigestService>();
var emailSender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
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); }
}
}
}
@@ -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<SmtpEmailSender> _logger;
public SmtpEmailSender(IOptions<SmtpOptions> options, ILogger<SmtpEmailSender> 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);
}
}