diff --git a/API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs b/API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs new file mode 100644 index 0000000..da6d0dd --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs @@ -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 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(); + 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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task 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: "

Body

"); + + 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(), "Hi", "

Body

")); + + 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(), + new[] { "bad@example.com", "good@example.com" }, + "Hi", "

Body

")); + + 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)); + } +} diff --git a/API/ROLAC.API/Services/Notifications/EmailService.cs b/API/ROLAC.API/Services/Notifications/EmailService.cs new file mode 100644 index 0000000..5141715 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/EmailService.cs @@ -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; + +/// +/// 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]"; +} diff --git a/API/ROLAC.API/Services/Notifications/IEmailService.cs b/API/ROLAC.API/Services/Notifications/IEmailService.cs new file mode 100644 index 0000000..88ef311 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/IEmailService.cs @@ -0,0 +1,6 @@ +namespace ROLAC.API.Services.Notifications; + +public interface IEmailService +{ + Task SendAsync(EmailMessage message, CancellationToken ct = default); +}