using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.Entities.Notifications; using ROLAC.API.Services.Logging; namespace ROLAC.API.Services.Notifications; /// /// Resolves recipients (member emails + raw addresses, deduped), sends each via the SMTP /// dispatcher, and writes a NotificationLog row per recipient. A single failure never aborts the /// batch — it is recorded and reported in the summary. /// public sealed class EmailService : IEmailService { private const int BodyLogMaxLength = 8000; private readonly AppDbContext _db; private readonly ISmtpDispatcher _dispatcher; private readonly CurrentUserAccessor _currentUser; public EmailService(AppDbContext db, ISmtpDispatcher dispatcher, CurrentUserAccessor currentUser) { _db = db; _dispatcher = dispatcher; _currentUser = currentUser; } public async Task SendAsync(EmailMessage message, CancellationToken ct = default) { var recipients = await ResolveRecipientsAsync(message, ct); if (recipients.Count == 0) return NotificationResult.Empty; var sentBy = message.SentByUserId ?? _currentUser.UserIdOrSystem; var attachments = message.Attachments ?? Array.Empty(); var failures = new List(); var sentCount = 0; foreach (var recipient in recipients) { var log = new NotificationLog { Channel = NotificationChannels.Email, TargetType = NotificationTargetTypes.Email, TargetExternalId = recipient.Address, Subject = message.Subject, MemberId = recipient.MemberId, Body = Truncate(message.HtmlBody), SentByUserId = sentBy, SentAt = DateTime.UtcNow, }; try { await _dispatcher.SendAsync( new OutboundEmail(recipient.Address, message.Subject, message.HtmlBody, attachments), ct); log.Status = NotificationStatuses.Sent; sentCount++; } catch (Exception ex) { log.Status = NotificationStatuses.Failed; log.Error = ex.Message; failures.Add(new NotificationFailure(recipient.Address, ex.Message)); } _db.NotificationLogs.Add(log); } await _db.SaveChangesAsync(ct); return new NotificationResult(sentCount, failures.Count, failures); } private async Task> ResolveRecipientsAsync( EmailMessage message, CancellationToken ct) { var resolved = new List<(string Address, int? MemberId)>(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); if (message.MemberIds.Count > 0) { var members = await _db.Members .Where(member => message.MemberIds.Contains(member.Id) && member.Email != null && member.Email != "") .Select(member => new { member.Id, member.Email }) .ToListAsync(ct); foreach (var member in members) if (seen.Add(member.Email!)) resolved.Add((member.Email!, member.Id)); } foreach (var address in message.Addresses) { var trimmed = address?.Trim(); if (!string.IsNullOrWhiteSpace(trimmed) && seen.Add(trimmed)) resolved.Add((trimmed, null)); } return resolved; } private static string Truncate(string body) => body.Length <= BodyLogMaxLength ? body : body[..BodyLogMaxLength] + "…[truncated]"; }