Add EmailService with recipient resolution and logging

TDD: IEmailService interface, EmailService resolves member emails + raw addresses (case-insensitive dedup), sends via ISmtpDispatcher, writes a NotificationLog per recipient (sent/failed), and never aborts the batch on a single failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-23 19:11:13 -07:00
parent 444cc70b56
commit 0ddb34dd20
3 changed files with 220 additions and 0 deletions
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// 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.
/// </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<NotificationResult> 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<EmailAttachment>();
var failures = new List<NotificationFailure>();
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<IReadOnlyList<(string Address, int? MemberId)>> ResolveRecipientsAsync(
EmailMessage message, CancellationToken ct)
{
var resolved = new List<(string Address, int? MemberId)>();
var seen = new HashSet<string>(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]";
}
@@ -0,0 +1,6 @@
namespace ROLAC.API.Services.Notifications;
public interface IEmailService
{
Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
}