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