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