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:
@@ -0,0 +1,112 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class EmailServiceTests
|
||||
{
|
||||
// Records every email it is asked to send; can be told to throw for a given address.
|
||||
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
|
||||
{
|
||||
public List<OutboundEmail> Sent { get; } = new();
|
||||
public string? FailForAddress { get; set; }
|
||||
|
||||
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||
{
|
||||
if (email.ToAddress == FailForAddress)
|
||||
throw new InvalidOperationException("smtp rejected");
|
||||
Sent.Add(email);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new CurrentUserAccessor(mock.Object);
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
|
||||
{
|
||||
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
return member.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db, "member@example.com");
|
||||
var dispatcher = new FakeSmtpDispatcher();
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var message = new EmailMessage(
|
||||
MemberIds: new[] { memberId },
|
||||
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
|
||||
Subject: "Hi", HtmlBody: "<p>Body</p>");
|
||||
|
||||
var result = await service.SendAsync(message);
|
||||
|
||||
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
|
||||
Assert.Equal(0, result.FailedCount);
|
||||
Assert.Equal(2, dispatcher.Sent.Count);
|
||||
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SkipsMembersWithNoEmail()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db, null);
|
||||
var dispatcher = new FakeSmtpDispatcher();
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var result = await service.SendAsync(new EmailMessage(
|
||||
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Empty(dispatcher.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var result = await service.SendAsync(new EmailMessage(
|
||||
Array.Empty<int>(),
|
||||
new[] { "bad@example.com", "good@example.com" },
|
||||
"Hi", "<p>Body</p>"));
|
||||
|
||||
Assert.Equal(1, result.SentCount);
|
||||
Assert.Equal(1, result.FailedCount);
|
||||
Assert.Single(result.Failures);
|
||||
Assert.Equal("bad@example.com", result.Failures[0].Target);
|
||||
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user