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,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);
}
}