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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" ]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user