12 TDD tasks: MailKit package, entities + migration, email service (SMTP seam), Line message channel + signature verify, Line notification service (send/binding/ groups), webhook + admin controllers, DI + config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 KiB
Notification Service (Email + Line) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build an API-only notification capability so backend code can send emails (SMTP) and Line messages (push to bound members/groups), with a Line webhook for binding, all audited in one NotificationLog table.
Architecture: Two peer services — IEmailService (MailKit/SMTP) and ILineNotificationService (Line push + webhook-driven binding/groups) — share a single NotificationLog. Email sends sit behind an ISmtpDispatcher seam so service logic is unit-testable without a real SMTP server; Line REST sits behind IMessageChannel so it is testable with a mock HttpMessageHandler. An anonymous, HMAC-verified LineWebhookController drives binding/group registration; an admin NotificationsController exposes binding-code generation, group management, history, and manual send (the only way to fire sends before a UI exists).
Tech Stack: .NET 8, EF Core 8 (Npgsql, in-memory for tests), MailKit/MimeKit, xUnit + Moq. Build/test with -c Release and EF tooling with --configuration Release (Visual Studio locks bin/Debug).
Conventions to follow:
- New code lives under
API/ROLAC.API/. Tests underAPI/ROLAC.API.Tests/. - Namespaces: services/records →
ROLAC.API.Services.Notifications; entities →ROLAC.API.Entities.Notifications; controllers →ROLAC.API.Controllers. - Acting user id uses
CurrentUserAccessor.UserIdOrSystem(handles thesub-claim quirk +"system"fallback). - Run all build/test commands from repo root
E:\VSProject\ROLAC.
File Structure
Create:
API/ROLAC.API/Services/Notifications/NotificationOptions.cs—SmtpOptions,LineOptions.API/ROLAC.API/Services/Notifications/NotificationModels.cs— shared records + constant classes.API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs— SMTP seam +OutboundEmail.API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs— MailKit implementation.API/ROLAC.API/Services/Notifications/IEmailService.cs/EmailService.cs.API/ROLAC.API/Services/Notifications/IMessageChannel.cs— Line channel abstraction +MessageSendResult.API/ROLAC.API/Services/Notifications/LineMessageChannel.cs— Line REST client.API/ROLAC.API/Services/Notifications/LineSignature.cs— HMAC-SHA256 verify helper.API/ROLAC.API/Services/Notifications/ILineNotificationService.cs/LineNotificationService.cs.API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs,LineBindingCode.cs,MessagingGroup.cs,NotificationLog.cs.API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs— webhook payload DTOs.API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs— admin controller request DTOs.API/ROLAC.API/Controllers/LineWebhookController.cs,NotificationsController.cs.- Tests:
LineSignatureTests.cs,EmailServiceTests.cs,LineNotificationServiceTests.cs,LineMessageChannelTests.csunderAPI/ROLAC.API.Tests/Services/Notifications/.
Modify:
API/ROLAC.API/ROLAC.API.csproj— add MailKit package.API/ROLAC.API/Data/AppDbContext.cs— DbSets +OnModelCreatingconfig.API/ROLAC.API/Program.cs— DI registrations.API/ROLAC.API/appsettings.json—Smtp+Lineplaceholder sections.
Task 1: Add MailKit package + config option classes
Files:
-
Modify:
API/ROLAC.API/ROLAC.API.csproj -
Create:
API/ROLAC.API/Services/Notifications/NotificationOptions.cs -
Step 1: Add the MailKit package reference
In API/ROLAC.API/ROLAC.API.csproj, add inside the existing <ItemGroup> (alphabetical-ish, near the other Microsoft refs):
<PackageReference Include="MailKit" Version="4.8.0" />
- Step 2: Restore to confirm the package resolves
Run: dotnet restore API/ROLAC.API/ROLAC.API.csproj
Expected: Restore succeeds, MailKit + MimeKit downloaded.
- Step 3: Create the options classes
Create API/ROLAC.API/Services/Notifications/NotificationOptions.cs:
namespace ROLAC.API.Services.Notifications;
/// <summary>SMTP transport settings (bound from the "Smtp" config section).</summary>
public sealed class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true; // true → STARTTLS
public string User { get; set; } = "";
public string Password { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
}
/// <summary>Line Messaging API settings (bound from the "Line" config section).</summary>
public sealed class LineOptions
{
public string ChannelAccessToken { get; set; } = "";
public string ChannelSecret { get; set; } = "";
}
- Step 4: Build to confirm it compiles
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded.
- Step 5: Commit
git add API/ROLAC.API/ROLAC.API.csproj API/ROLAC.API/Services/Notifications/NotificationOptions.cs
git commit -m "Add MailKit package and notification option classes"
Task 2: Shared models, records, and constants
Files:
-
Create:
API/ROLAC.API/Services/Notifications/NotificationModels.cs -
Step 1: Create the shared models file
Create API/ROLAC.API/Services/Notifications/NotificationModels.cs:
namespace ROLAC.API.Services.Notifications;
/// <summary>Canonical channel discriminators stored in NotificationLog.Channel.</summary>
public static class NotificationChannels
{
public const string Email = "email";
public const string Line = "line";
}
/// <summary>Canonical target-type discriminators stored in NotificationLog.TargetType.</summary>
public static class NotificationTargetTypes
{
public const string Email = "email";
public const string User = "user";
public const string Group = "group";
}
/// <summary>Canonical send statuses stored in NotificationLog.Status.</summary>
public static class NotificationStatuses
{
public const string Sent = "sent";
public const string Failed = "failed";
}
/// <summary>One failed delivery within a send batch.</summary>
public sealed record NotificationFailure(string Target, string Error);
/// <summary>Aggregated outcome of a send call.</summary>
public sealed record NotificationResult(
int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures)
{
public static NotificationResult Empty { get; } =
new(0, 0, Array.Empty<NotificationFailure>());
}
/// <summary>A file attached to an outbound email.</summary>
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
/// <summary>
/// A request to send one email to a set of members (resolved via Member.Email) and/or raw
/// addresses. The caller supplies the final HTML body — no templating in this phase.
/// </summary>
public sealed record EmailMessage(
IReadOnlyList<int> MemberIds,
IReadOnlyList<string> Addresses,
string Subject,
string HtmlBody,
IReadOnlyList<EmailAttachment>? Attachments = null,
string? SentByUserId = null);
- Step 2: Build to confirm it compiles
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded.
- Step 3: Commit
git add API/ROLAC.API/Services/Notifications/NotificationModels.cs
git commit -m "Add shared notification models, records, and constants"
Task 3: Entities + DbContext wiring + migration
Files:
-
Create:
API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs,LineBindingCode.cs,MessagingGroup.cs,NotificationLog.cs -
Modify:
API/ROLAC.API/Data/AppDbContext.cs -
Step 1: Create
MemberChannelBinding
Create API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs:
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>
/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future
/// channels don't require changes to Member.
/// </summary>
public class MemberChannelBinding
{
public int Id { get; set; }
public int MemberId { get; set; }
public Member? Member { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line userId
public DateTime BoundAt { get; set; }
}
- Step 2: Create
LineBindingCode
Create API/ROLAC.API/Entities/Notifications/LineBindingCode.cs:
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>A short-lived code a member types to the Line bot to complete account binding.</summary>
public class LineBindingCode
{
public int Id { get; set; }
public string Code { get; set; } = null!;
public int MemberId { get; set; }
public Member? Member { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? ConsumedAt { get; set; } // null = unused
}
- Step 3: Create
MessagingGroup
Create API/ROLAC.API/Entities/Notifications/MessagingGroup.cs:
namespace ROLAC.API.Entities.Notifications;
/// <summary>A Line group the bot was added to. Named by an admin after the join event.</summary>
public class MessagingGroup
{
public int Id { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line groupId
public string? Name { get; set; }
public bool IsActive { get; set; } = true;
public DateTime RegisteredAt { get; set; }
}
- Step 4: Create
NotificationLog
Create API/ROLAC.API/Entities/Notifications/NotificationLog.cs:
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>An append-only audit row for every email or Line send (success or failure).</summary>
public class NotificationLog
{
public long Id { get; set; }
public string Channel { get; set; } = null!; // "email" | "line"
public string TargetType { get; set; } = null!; // "email" | "user" | "group"
public string TargetExternalId { get; set; } = null!; // email address OR Line id
public string? Subject { get; set; } // email only
public int? MemberId { get; set; }
public Member? Member { get; set; }
public int? MessagingGroupId { get; set; }
public MessagingGroup? MessagingGroup { get; set; }
public string Body { get; set; } = null!;
public string Status { get; set; } = null!; // "sent" | "failed"
public string? Error { get; set; }
public string SentByUserId { get; set; } = null!;
public DateTime SentAt { get; set; }
}
- Step 5: Add DbSets to
AppDbContext
In API/ROLAC.API/Data/AppDbContext.cs, add the using and DbSets. After the existing using ROLAC.API.Entities; line add:
using ROLAC.API.Entities.Notifications;
After the public DbSet<RolePermission> RolePermissions => Set<RolePermission>(); line, add:
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
- Step 6: Add entity configuration in
OnModelCreating
In API/ROLAC.API/Data/AppDbContext.cs, immediately before the LogModelConfiguration.Configure(builder); line at the end of OnModelCreating, add:
// ── Notifications (email + Line) ─────────────────────────────────────
builder.Entity<MemberChannelBinding>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<LineBindingCode>(entity =>
{
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
entity.HasIndex(e => e.Code);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessagingGroup>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(200);
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
});
builder.Entity<NotificationLog>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
entity.Property(e => e.Subject).HasMaxLength(300);
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.SentAt);
entity.HasIndex(e => e.Channel);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.MessagingGroup).WithMany()
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
});
- Step 7: Build to confirm the model compiles
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded.
- Step 8: Create the EF migration
Run: dotnet ef migrations add AddNotifications --project API/ROLAC.API/ROLAC.API.csproj --configuration Release
Expected: A new migration appears under API/ROLAC.API/Migrations/ creating the four tables. (The app applies it on startup via MigrateAsync; do not run database update manually.)
- Step 9: Commit
git add API/ROLAC.API/Entities/Notifications API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations
git commit -m "Add notification entities, DbContext config, and migration"
Task 4: LineSignature HMAC helper (TDD)
Files:
-
Create:
API/ROLAC.API/Services/Notifications/LineSignature.cs -
Test:
API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs -
Step 1: Write the failing test
Create API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs:
using System.Security.Cryptography;
using System.Text;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineSignatureTests
{
private const string Secret = "test-channel-secret";
private static string Sign(string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
}
[Fact]
public void IsValid_ReturnsTrue_ForMatchingSignature()
{
var body = """{"events":[]}""";
var signature = Sign(body);
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
Assert.True(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForTamperedBody()
{
var signature = Sign("""{"events":[]}""");
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
Assert.False(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
{
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
Assert.False(LineSignature.IsValid(Secret, body, null));
Assert.False(LineSignature.IsValid(Secret, body, ""));
}
}
- Step 2: Run the test to verify it fails
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineSignatureTests"
Expected: FAIL — LineSignature does not exist (compile error).
- Step 3: Implement
LineSignature
Create API/ROLAC.API/Services/Notifications/LineSignature.cs:
using System.Security.Cryptography;
using System.Text;
namespace ROLAC.API.Services.Notifications;
/// <summary>Verifies the X-Line-Signature header (HMAC-SHA256 of the raw body, base64).</summary>
public static class LineSignature
{
public static bool IsValid(string channelSecret, byte[] rawBody, string? signatureHeader)
{
if (string.IsNullOrEmpty(signatureHeader)) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(channelSecret));
var expected = Convert.ToBase64String(hmac.ComputeHash(rawBody));
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signatureHeader));
}
}
- Step 4: Run the test to verify it passes
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineSignatureTests"
Expected: PASS (3 tests).
- Step 5: Commit
git add API/ROLAC.API/Services/Notifications/LineSignature.cs API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs
git commit -m "Add Line webhook signature verification helper"
Task 5: SMTP dispatcher seam + MailKit implementation
Files:
- Create:
API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs,MailKitSmtpDispatcher.cs
No unit test for
MailKitSmtpDispatcher— it is the thin transport boundary (needs a real SMTP server).EmailService(Task 6) is tested against a fakeISmtpDispatcher.
- Step 1: Create the dispatcher seam
Create API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs:
namespace ROLAC.API.Services.Notifications;
/// <summary>One outbound email envelope handed to the SMTP transport.</summary>
public sealed record OutboundEmail(
string ToAddress,
string Subject,
string HtmlBody,
IReadOnlyList<EmailAttachment> Attachments);
/// <summary>Thin seam over the actual MailKit send so EmailService stays unit-testable.</summary>
public interface ISmtpDispatcher
{
Task SendAsync(OutboundEmail email, CancellationToken ct = default);
}
- Step 2: Implement
MailKitSmtpDispatcher
Create API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs:
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
namespace ROLAC.API.Services.Notifications;
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
{
private readonly SmtpOptions _options;
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
message.To.Add(MailboxAddress.Parse(email.ToAddress));
message.Subject = email.Subject;
var builder = new BodyBuilder { HtmlBody = email.HtmlBody };
foreach (var attachment in email.Attachments)
{
builder.Attachments.Add(
attachment.FileName, attachment.Content, ContentType.Parse(attachment.ContentType));
}
message.Body = builder.ToMessageBody();
using var client = new SmtpClient();
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
if (!string.IsNullOrEmpty(_options.User))
await client.AuthenticateAsync(_options.User, _options.Password, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
}
- Step 3: Build to confirm it compiles
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded.
- Step 4: Commit
git add API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs
git commit -m "Add SMTP dispatcher seam and MailKit implementation"
Task 6: EmailService (TDD)
Files:
-
Create:
API/ROLAC.API/Services/Notifications/IEmailService.cs,EmailService.cs -
Test:
API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs -
Step 1: Create the interface
Create API/ROLAC.API/Services/Notifications/IEmailService.cs:
namespace ROLAC.API.Services.Notifications;
public interface IEmailService
{
Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
}
- Step 2: Write the failing test
Create API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs:
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));
}
}
- Step 3: Run the test to verify it fails
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~EmailServiceTests"
Expected: FAIL — EmailService does not exist (compile error).
- Step 4: Implement
EmailService
Create API/ROLAC.API/Services/Notifications/EmailService.cs:
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(m => message.MemberIds.Contains(m.Id) && m.Email != null && m.Email != "")
.Select(m => new { m.Id, m.Email })
.ToListAsync(ct);
foreach (var m in members)
if (seen.Add(m.Email!))
resolved.Add((m.Email!, m.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]";
}
- Step 5: Run the test to verify it passes
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~EmailServiceTests"
Expected: PASS (3 tests).
- Step 6: Commit
git add API/ROLAC.API/Services/Notifications/IEmailService.cs API/ROLAC.API/Services/Notifications/EmailService.cs API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs
git commit -m "Add EmailService with recipient resolution and logging"
Task 7: IMessageChannel + LineMessageChannel (TDD)
Files:
-
Create:
API/ROLAC.API/Services/Notifications/IMessageChannel.cs,LineMessageChannel.cs -
Test:
API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs -
Step 1: Create the interface
Create API/ROLAC.API/Services/Notifications/IMessageChannel.cs:
namespace ROLAC.API.Services.Notifications;
/// <summary>Result of one Line REST call.</summary>
public sealed record MessageSendResult(bool Success, string? Error);
/// <summary>Abstraction over a chat channel's send/reply (Line today; future channels later).</summary>
public interface IMessageChannel
{
Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default);
Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default);
Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default);
}
- Step 2: Write the failing test
Create API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs:
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests
{
// Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public string ResponseBody { get; set; } = "{}";
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
private static LineMessageChannel BuildChannel(CapturingHandler handler)
{
var http = new HttpClient(handler);
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
return new LineMessageChannel(http, options);
}
[Fact]
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.True(result.Success);
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
}
[Fact]
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
await channel.ReplyAsync("RTOKEN", "hi back");
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
}
[Fact]
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
{
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.False(result.Success);
Assert.Contains("429", result.Error);
}
}
- Step 3: Run the test to verify it fails
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineMessageChannelTests"
Expected: FAIL — LineMessageChannel does not exist (compile error).
- Step 4: Implement
LineMessageChannel
Create API/ROLAC.API/Services/Notifications/LineMessageChannel.cs:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Notifications;
/// <summary>Sends text messages and replies via the Line Messaging API REST endpoints.</summary>
public sealed class LineMessageChannel : IMessageChannel
{
private const string PushUrl = "https://api.line.me/v2/bot/message/push";
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
private readonly HttpClient _http;
private readonly LineOptions _options;
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
{
_http = http;
_options = options.Value;
}
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> PostAsync(ReplyUrl, new { replyToken, messages = new[] { new { type = "text", text } } }, ct);
private async Task<MessageSendResult> PostAsync(string url, object payload, CancellationToken ct)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
using var response = await _http.SendAsync(request, ct);
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
var body = await response.Content.ReadAsStringAsync(ct);
return new MessageSendResult(false, $"{(int)response.StatusCode}: {body}");
}
catch (Exception ex)
{
return new MessageSendResult(false, ex.Message);
}
}
}
- Step 5: Run the test to verify it passes
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineMessageChannelTests"
Expected: PASS (3 tests).
- Step 6: Commit
git add API/ROLAC.API/Services/Notifications/IMessageChannel.cs API/ROLAC.API/Services/Notifications/LineMessageChannel.cs API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs
git commit -m "Add IMessageChannel and Line REST implementation"
Task 8: LineNotificationService (TDD)
Files:
-
Create:
API/ROLAC.API/Services/Notifications/ILineNotificationService.cs,LineNotificationService.cs -
Test:
API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs -
Step 1: Create the interface
Create API/ROLAC.API/Services/Notifications/ILineNotificationService.cs:
namespace ROLAC.API.Services.Notifications;
/// <summary>Outcome of a webhook-driven binding attempt.</summary>
public sealed record LineBindingResult(bool Success, string Message, int? MemberId);
/// <summary>
/// Line-specific notification operations: outbound push to bound members/groups, plus the
/// webhook-driven binding-code generation/consumption and group registration.
/// </summary>
public interface ILineNotificationService
{
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default);
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default);
Task RegisterGroupAsync(string externalId, CancellationToken ct = default);
Task DeactivateGroupAsync(string externalId, CancellationToken ct = default);
}
- Step 2: Write the failing test
Create API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs:
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.Entities.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineNotificationServiceTests
{
// Records pushes; can be told to fail every call.
private sealed class FakeMessageChannel : IMessageChannel
{
public List<(string Target, string Text)> UserPushes { get; } = new();
public List<(string Target, string Text)> GroupPushes { get; } = new();
public bool Fail { get; set; }
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
{
UserPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
{
GroupPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> Task.FromResult(new MessageSendResult(true, null));
}
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)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var stored = await db.LineBindingCodes.SingleAsync();
Assert.Equal(code, stored.Code);
Assert.Null(stored.ConsumedAt);
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
}
[Fact]
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var result = await service.TryBindMemberAsync("U999", code);
Assert.True(result.Success);
Assert.Equal(memberId, result.MemberId);
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U999", binding.ExternalId);
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
}
[Fact]
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.LineBindingCodes.Add(new LineBindingCode
{
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel());
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
}
[Fact]
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U-NEW", binding.ExternalId);
}
[Fact]
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
{
using var db = BuildDb();
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.RegisterGroupAsync("G1");
await service.RegisterGroupAsync("G1"); // second call must not duplicate
Assert.Equal(1, await db.MessagingGroups.CountAsync());
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
await service.DeactivateGroupAsync("G1");
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
}
[Fact]
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
db.MessagingGroups.AddRange(activeGroup, deadGroup);
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId },
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
Assert.Equal(2, result.SentCount); // member + active group only
Assert.Single(channel.UserPushes);
Assert.Single(channel.GroupPushes); // inactive group skipped
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
- Step 3: Run the test to verify it fails
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineNotificationServiceTests"
Expected: FAIL — LineNotificationService does not exist (compile error).
- Step 4: Implement
LineNotificationService
Create API/ROLAC.API/Services/Notifications/LineNotificationService.cs:
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities.Notifications;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Line outbound push + webhook-driven binding/group operations. All sends write a
/// NotificationLog row; binding consumes a short-lived, single-use code.
/// </summary>
public sealed class LineNotificationService : ILineNotificationService
{
private const string Channel = NotificationChannels.Line;
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
private const int CodeLength = 6;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15);
private readonly AppDbContext _db;
private readonly IMessageChannel _channel;
public LineNotificationService(AppDbContext db, IMessageChannel channel)
{
_db = db;
_channel = channel;
}
public async Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default)
{
var failures = new List<NotificationFailure>();
var sentCount = 0;
var bindings = await _db.MemberChannelBindings
.Where(b => b.Channel == Channel && memberIds.Contains(b.MemberId))
.ToListAsync(ct);
foreach (var binding in bindings)
{
var result = await _channel.PushToUserAsync(binding.ExternalId, body, ct);
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.User, binding.ExternalId,
body, sentByUserId, result, memberId: binding.MemberId));
if (result.Success) sentCount++;
else failures.Add(new NotificationFailure($"member:{binding.MemberId}", result.Error ?? "unknown"));
}
var groups = await _db.MessagingGroups
.Where(g => g.Channel == Channel && g.IsActive && groupIds.Contains(g.Id))
.ToListAsync(ct);
foreach (var group in groups)
{
var result = await _channel.PushToGroupAsync(group.ExternalId, body, ct);
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.Group, group.ExternalId,
body, sentByUserId, result, groupId: group.Id));
if (result.Success) sentCount++;
else failures.Add(new NotificationFailure($"group:{group.Id}", result.Error ?? "unknown"));
}
await _db.SaveChangesAsync(ct);
return new NotificationResult(sentCount, failures.Count, failures);
}
public async Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default)
{
var code = GenerateCode();
_db.LineBindingCodes.Add(new LineBindingCode
{
Code = code,
MemberId = memberId,
ExpiresAt = DateTime.UtcNow.Add(CodeLifetime),
});
await _db.SaveChangesAsync(ct);
return code;
}
public async Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default)
{
var normalized = code.Trim().ToUpperInvariant();
var now = DateTime.UtcNow;
var bindingCode = await _db.LineBindingCodes
.FirstOrDefaultAsync(c => c.Code == normalized && c.ConsumedAt == null && c.ExpiresAt > now, ct);
if (bindingCode is null)
return new LineBindingResult(false, "綁定碼無效或已過期。", null);
bindingCode.ConsumedAt = now;
var existing = await _db.MemberChannelBindings
.FirstOrDefaultAsync(b => b.Channel == Channel && b.MemberId == bindingCode.MemberId, ct);
if (existing is null)
{
_db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = bindingCode.MemberId,
Channel = Channel,
ExternalId = externalId,
BoundAt = now,
});
}
else
{
existing.ExternalId = externalId;
existing.BoundAt = now;
}
await _db.SaveChangesAsync(ct);
return new LineBindingResult(true, "綁定成功!", bindingCode.MemberId);
}
public async Task RegisterGroupAsync(string externalId, CancellationToken ct = default)
{
var group = await _db.MessagingGroups
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
if (group is null)
{
_db.MessagingGroups.Add(new MessagingGroup
{
Channel = Channel,
ExternalId = externalId,
IsActive = true,
RegisteredAt = DateTime.UtcNow,
});
}
else
{
group.IsActive = true;
}
await _db.SaveChangesAsync(ct);
}
public async Task DeactivateGroupAsync(string externalId, CancellationToken ct = default)
{
var group = await _db.MessagingGroups
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
if (group is not null)
{
group.IsActive = false;
await _db.SaveChangesAsync(ct);
}
}
private static NotificationLog BuildLog(string targetType, string externalId, string body,
string sentBy, MessageSendResult result, int? memberId = null, int? groupId = null) => new()
{
Channel = Channel,
TargetType = targetType,
TargetExternalId = externalId,
MemberId = memberId,
MessagingGroupId = groupId,
Body = body,
Status = result.Success ? NotificationStatuses.Sent : NotificationStatuses.Failed,
Error = result.Error,
SentByUserId = sentBy,
SentAt = DateTime.UtcNow,
};
private static string GenerateCode()
{
var bytes = RandomNumberGenerator.GetBytes(CodeLength);
var chars = new char[CodeLength];
for (var i = 0; i < CodeLength; i++)
chars[i] = CodeAlphabet[bytes[i] % CodeAlphabet.Length];
return new string(chars);
}
}
- Step 5: Run the test to verify it passes
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineNotificationServiceTests"
Expected: PASS (7 tests).
- Step 6: Commit
git add API/ROLAC.API/Services/Notifications/ILineNotificationService.cs API/ROLAC.API/Services/Notifications/LineNotificationService.cs API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs
git commit -m "Add LineNotificationService with send, binding, and group ops"
Task 9: Webhook DTOs + LineWebhookController
Files:
- Create:
API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs,API/ROLAC.API/Controllers/LineWebhookController.cs
No unit test for the controller — its DB/binding logic lives in
LineNotificationService(already tested in Task 8) and signature verification inLineSignature(Task 4). The controller is thin wiring.
- Step 1: Create the webhook DTOs
Create API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs:
namespace ROLAC.API.DTOs.Notifications;
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
public sealed class LineWebhookPayload
{
public List<LineWebhookEvent>? Events { get; set; }
}
public sealed class LineWebhookEvent
{
public string? Type { get; set; } // follow | message | join | leave | ...
public string? ReplyToken { get; set; }
public LineWebhookSource? Source { get; set; }
public LineWebhookMessage? Message { get; set; }
}
public sealed class LineWebhookSource
{
public string? Type { get; set; } // user | group | room
public string? UserId { get; set; }
public string? GroupId { get; set; }
}
public sealed class LineWebhookMessage
{
public string? Type { get; set; } // text | image | ...
public string? Text { get; set; }
}
- Step 2: Implement
LineWebhookController
Create API/ROLAC.API/Controllers/LineWebhookController.cs:
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
/// returns 400 only on signature failure.
/// </summary>
[ApiController]
[Route("api/line")]
[AllowAnonymous]
public sealed class LineWebhookController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly LineOptions _options;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
{
_line = line;
_channel = channel;
_options = options.Value;
}
[HttpPost("webhook")]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
if (payload?.Events is not null)
foreach (var evt in payload.Events)
await DispatchAsync(evt, ct);
return Ok();
}
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
{
switch (evt.Type)
{
case "follow":
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
break;
case "message":
if (evt.Message?.Type == "text"
&& evt.Source?.UserId is { } userId
&& evt.Message.Text is { } text)
{
var result = await _line.TryBindMemberAsync(userId, text, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
}
break;
case "join":
if (evt.Source?.GroupId is { } joinGroupId)
{
await _line.RegisterGroupAsync(joinGroupId, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
}
break;
case "leave":
if (evt.Source?.GroupId is { } leaveGroupId)
await _line.DeactivateGroupAsync(leaveGroupId, ct);
break;
}
}
}
- Step 3: Build to confirm it compiles
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded.
- Step 4: Commit
git add API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs API/ROLAC.API/Controllers/LineWebhookController.cs
git commit -m "Add Line webhook controller with signature verification and dispatch"
Task 10: Admin request DTOs + NotificationsController
Files:
-
Create:
API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs,API/ROLAC.API/Controllers/NotificationsController.cs -
Step 1: Create the request DTOs
Create API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs:
namespace ROLAC.API.DTOs.Notifications;
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
- Step 2: Implement
NotificationsController
Create API/ROLAC.API/Controllers/NotificationsController.cs:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
/// management, send history, and manual send — the manual send endpoints are the only way to fire
/// a message before a UI exists; programmatic callers use the services directly.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public sealed class NotificationsController : ControllerBase
{
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly AppDbContext _db;
private readonly CurrentUserAccessor _currentUser;
public NotificationsController(
IEmailService email, ILineNotificationService line,
AppDbContext db, CurrentUserAccessor currentUser)
{
_email = email;
_line = line;
_db = db;
_currentUser = currentUser;
}
[HttpPost("members/{id:int}/line-binding-code")]
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
=> Ok(await _db.MessagingGroups
.OrderBy(g => g.Id)
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
.ToListAsync(ct));
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
{
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
if (group is null) return NotFound();
group.Name = request.Name;
group.IsActive = request.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var size = Math.Clamp(pageSize, 1, 200);
var skip = (Math.Max(page, 1) - 1) * size;
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
var total = await query.CountAsync(ct);
var items = await query
.Skip(skip).Take(size)
.Select(l => new
{
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
l.Status, l.Error, l.SentByUserId, l.SentAt,
})
.ToListAsync(ct);
return Ok(new { total, items });
}
[HttpPost("send-line")]
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
=> Ok(await _line.SendLineAsync(
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
_currentUser.UserIdOrSystem, ct));
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
=> Ok(await _email.SendAsync(new EmailMessage(
MemberIds: request.MemberIds ?? [],
Addresses: request.Addresses ?? [],
Subject: request.Subject,
HtmlBody: request.HtmlBody,
Attachments: null,
SentByUserId: _currentUser.UserIdOrSystem), ct));
}
- Step 3: Build to confirm it compiles
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded.
- Step 4: Commit
git add API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs API/ROLAC.API/Controllers/NotificationsController.cs
git commit -m "Add admin NotificationsController for binding, groups, history, and send"
Task 11: DI registration + config + full verification
Files:
-
Modify:
API/ROLAC.API/Program.cs,API/ROLAC.API/appsettings.json -
Step 1: Register services in
Program.cs
In API/ROLAC.API/Program.cs, in the "Application services" region (after the builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>(); line), add:
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
ROLAC.API.Services.Notifications.EmailService>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationService,
ROLAC.API.Services.Notifications.LineNotificationService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
ROLAC.API.Services.Notifications.LineMessageChannel>();
- Step 2: Add config sections to
appsettings.json
In API/ROLAC.API/appsettings.json, add these two top-level sections (placeholders only — real secrets go in user-secrets / appsettings.Development.json / environment variables, never committed):
"Smtp": {
"Host": "",
"Port": 587,
"UseSsl": true,
"User": "",
"Password": "",
"FromAddress": "noreply@rolac.org",
"FromName": "River of Life Christian Church"
},
"Line": {
"ChannelAccessToken": "",
"ChannelSecret": ""
}
- Step 3: Build the full API project
Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release
Expected: Build succeeded, 0 errors.
- Step 4: Run the entire test suite
Run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
Expected: PASS — all existing tests plus the new notification tests (LineSignature 3, EmailService 3, LineMessageChannel 3, LineNotificationService 7).
- Step 5: Commit
git add API/ROLAC.API/Program.cs API/ROLAC.API/appsettings.json
git commit -m "Register notification services and add SMTP/Line config sections"
Task 12: Final review & docs touch-up
Files:
-
Modify:
docs/NOTIFICATIONS.md(optional cross-link) -
Step 1: Confirm no secrets were committed
Run: git log --oneline -12 and git grep -nI "ChannelAccessToken\|Password" -- API/ROLAC.API/appsettings.json
Expected: appsettings.json shows only empty-string placeholders; no real tokens/passwords anywhere in the diff.
- Step 2: Add a pointer in
docs/NOTIFICATIONS.md
At the top of API/ROLAC.API notifications context, append a line under the Email section of docs/NOTIFICATIONS.md noting the implemented design:
> **實作狀態 (2026-06-23):** Email (SMTP/MailKit) + Line 已於 API 端實作,見
> [docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md](superpowers/specs/2026-06-23-notification-service-email-line-design.md)。
- Step 3: Commit
git add docs/NOTIFICATIONS.md
git commit -m "Cross-link implemented notification design in NOTIFICATIONS.md"
Operator Setup (non-code, before this works in production)
These are environment steps the engineer cannot do in code — flag them to the church admin:
- SMTP: Obtain mailbox host / port / user / password / from-address (M365, Google Workspace, or host SMTP). Set them in user-secrets or
appsettings.Development.json(dev) and environment variables (prod). Configure SPF/DKIM on the sending domain for deliverability. - Line: Create a Line Official Account + Messaging API channel; copy Channel access token and Channel secret into the
Lineconfig (secrets store, not committed). - nginx: Route
https://<domain>/api/line/webhookto the API; enter that URL as the webhook in the Line console and enable webhooks.
Self-Review Notes (completed by plan author)
- Spec coverage: Email service (§4.1) → Tasks 5–6; Line service + binding/groups (§4.2, §4.5) → Tasks 7–9;
IMessageChannel/Line REST (§4.4) → Task 7; signature verify (§4.5) → Task 4; admin endpoints (§4.6) → Task 10; data model (§3) → Task 3; config/DI (§5) → Task 11; error handling (§6) → covered in service implementations + webhook; tests (§7) → Tasks 4, 6, 7, 8; out-of-scope items (§10) deliberately omitted. - Type consistency:
NotificationResult,NotificationFailure,EmailMessage,MessageSendResult,LineBindingResult, and the constant classes are defined once (Tasks 2, 7, 8) and referenced consistently. Channel/status/target-type string literals always go through theNotificationChannels/NotificationStatuses/NotificationTargetTypesconstants. - No placeholders: every code step contains complete, compilable code.