Files
ROLAC/docs/superpowers/plans/2026-06-23-notification-service-email-line.md
T
Chris Chen 583408032d Add implementation plan for Email + Line notification service
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>
2026-06-23 18:56:04 -07:00

68 KiB
Raw Blame History

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 under API/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 the sub-claim quirk + "system" fallback).
  • Run all build/test commands from repo root E:\VSProject\ROLAC.

File Structure

Create:

  • API/ROLAC.API/Services/Notifications/NotificationOptions.csSmtpOptions, 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.cs under API/ROLAC.API.Tests/Services/Notifications/.

Modify:

  • API/ROLAC.API/ROLAC.API.csproj — add MailKit package.
  • API/ROLAC.API/Data/AppDbContext.cs — DbSets + OnModelCreating config.
  • API/ROLAC.API/Program.cs — DI registrations.
  • API/ROLAC.API/appsettings.jsonSmtp + Line placeholder 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 fake ISmtpDispatcher.

  • 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 in LineSignature (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:

  1. 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.
  2. Line: Create a Line Official Account + Messaging API channel; copy Channel access token and Channel secret into the Line config (secrets store, not committed).
  3. nginx: Route https://<domain>/api/line/webhook to 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 56; Line service + binding/groups (§4.2, §4.5) → Tasks 79; 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 the NotificationChannels/NotificationStatuses/NotificationTargetTypes constants.
  • No placeholders: every code step contains complete, compilable code.