0163165beb
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>
70 lines
2.6 KiB
C#
70 lines
2.6 KiB
C#
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); }
|
|
}
|
|
}
|
|
}
|