Files
ROLAC/docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md
T
Chris Chen ea0ea233a8 Add Email + Line notification service design spec
Phase 1 (API-only): IEmailService (MailKit/SMTP) + ILineNotificationService
(full approved Line module) as two peer services sharing NotificationLog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:46:44 -07:00

14 KiB
Raw Blame History

Notification Service (Email + Line) — 設計文件


1. 背景與目標

需要一個後端通知能力,讓後端程式碼可以決定透過 EmailLine 發送訊息:

  • Email:正式內容(年度奉獻收據含 PDF、歡迎信、密碼重設等)。每位會友 Member.Email 已存在,無需綁定。
  • Line:非正式提醒,介於 Email(太正式)與 SMS(不適合)之間;可發給個人或群組。需先綁定取得 Line userId

本次將 2026-06-20 已核可的 Line 模組完整實作,並新增 Email 作為第二通道,兩者共用 NotificationLog 稽核表。

決策摘要(brainstorming 結論)

決策 選擇
範圍 完整 Line 模組 Email 第二通道,一次完成
本階段平台 僅 API 端(不做 Angular 前端;Line spec 的 UI 延後)
Email 傳輸 SMTP(使用 MailKit / MimeKit
路由決策 後端程式碼決定每次發送走 Email 或 Line
Email 內容 Subject + HTML body + 可選附件;呼叫端自行組好最終 body(本階段不做範本引擎)
服務切分 兩個對等服務(非單一 facade):IEmailService + ILineNotificationService

為什麼 Email 不塞進 IMessageChannel

Line spec 的 IMessageChannel 是聊天形狀(PushToUserAsync(externalId, text) / ReplyAsync(replyToken, text)),而 Email 有 subject、HTML、附件、收件地址、無 reply token、無群組概念。強行共用會造成糟糕的抽象。故 Email 走獨立的 IEmailService


2. 架構總覽

                     後端程式碼(receipts / welcome / reminders ...
                       │ 直接呼叫對應服務(後端決定通道)
        ┌──────────────┴───────────────┐
        ▼                               ▼
  IEmailService                   ILineNotificationService
  (EmailService)                  (LineNotificationService)
   MailKit / SMTP                  收件人解析 + 稽核
   subject / HTML / 附件             │
        │                           ▼
        │                      IMessageChannel
        │                      (LineMessageChannel)  ← 沿用核可 spec
        │                       Push / Reply REST
        ▼                               │
        └──────────► NotificationLog ◄──┘   (共用稽核表,channel 區分)
                          ▲
                          │
  LineWebhookController(匿名、HMAC 驗簽)──► 綁定 + 群組註冊(多用 reply 省額度)
  NotificationsControlleradmin [Authorize])──► 綁定碼 / 群組 / 歷史 / 手動發送
  • 兩個對等服務,後端依用途自行呼叫。
  • 兩者皆寫 NotificationLogChannel 欄位區分 email / line)。
  • Line 對內接收(webhook)與綁定流程沿用核可 spec。

3. 資料模型(新實體)

Line 三表沿用核可 specNotificationLog 調整為同時服務兩通道。命名與軟刪除慣例對齊既有實體。

MemberChannelBinding

會友與通道帳號的綁定(獨立表,未來多通道不需改 Member)。

欄位 型別 說明
Id int PK
MemberId int FK → Member
Channel string "line"(未來 "wechat"/"webpush"
ExternalId string Line userId
BoundAt DateTime 綁定時間
  • 唯一索引:(MemberId, Channel)(Channel, ExternalId)

LineBindingCode

短效綁定碼(會友在 Line 輸入此碼以完成綁定)。

欄位 型別 說明
Id int PK
Code string 短碼(避免易混淆字元)
MemberId int FK → Member
ExpiresAt DateTime 過期時間(建議 15 分鐘)
ConsumedAt DateTime? 已使用時間(null = 未用)

MessagingGroup

bot 被拉進的 Line 群組。

欄位 型別 說明
Id int PK
Channel string "line"
ExternalId string Line groupId
Name string? 管理員命名(join 時先空白待命名)
IsActive bool leave 事件時設 false
RegisteredAt DateTime
  • 唯一索引:(Channel, ExternalId)

NotificationLog(共用:Email + Line

每筆發送的稽核記錄。

欄位 型別 說明
Id int PK
Channel string "line" / "email"
TargetType string "user" / "group" / "email"
TargetExternalId string Line id email 地址
Subject string? 新增Email 主旨(Line 為 null
MemberId int? FK 由會友解析時填
MessagingGroupId int? FK Line 群組發送時填
Body string 訊息內容(Email 存 HTML body,過長時截斷至合理上限,例如 8000 字元,並標註截斷)
Status string "sent" / "failed"
Error string? 失敗原因
SentByUserId string 發送者(JWT sub claim,使用 ?? "sub" fallback;背景觸發時填 "system"
SentAt DateTime
  • EF migration 以 -c Release / --output 建置(VS 鎖 bin/Debug)。

ScheduledNotificationPhase 2 預留,本階段不建表)

Body、收件人規格、RunAtUtc、Recurrence、Status。


4. 服務介面與流程

4.1 IEmailService(獨立,MailKit/SMTP

public interface IEmailService
{
    Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
}

public sealed record EmailMessage(
    IReadOnlyList<int>    MemberIds,        // 解析 Member.Email
    IReadOnlyList<string> Addresses,        // 直接指定的收件地址
    string                Subject,
    string                HtmlBody,         // 呼叫端組好的最終 HTML
    IReadOnlyList<EmailAttachment>? Attachments = null,
    string?               SentByUserId = null);

public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);

流程

  1. 解析 MemberIdsMember.Email(跳過 null/空白),與 Addresses 去重合併。
  2. 逐一以 MailKit 發送(subject + HTML + 附件)。
  3. 每位收件人寫一筆 NotificationLogchannel=emailsent/failed)。
  4. 回傳 NotificationResult 彙總;單一收件人失敗不丟例外,記錄後續行。
  5. SMTP 連線/驗證層級錯誤 → 寫 SystemLog,並把該批標記 failed。

MailKit seam:實際 SmtpClient.Connect/Authenticate/Send 封裝在薄介面(例如 ISmtpDispatcher)後,使 EmailService 的收件人解析、附件對應、log 寫入可單元測試(不需真實 SMTP server)。

4.2 ILineNotificationService(沿用核可 spec

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);
}

SendLineAsync 流程:解析收件人 → 已綁定會友 ExternalId + 啟用群組 ExternalId;逐一呼叫 IMessageChannel;每筆寫 NotificationLogchannel=line);回傳彙總。

4.3 共用回傳型別

public sealed record NotificationResult(
    int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures);

public sealed record NotificationFailure(string Target, string Error);

4.4 IMessageChannel / LineMessageChannel(沿用核可 spec

  • PushToUserAsync(externalId, text)PushToGroupAsync(externalId, text)ReplyAsync(replyToken, text)
  • 封裝 Line RESTHttpClientIHttpClientFactorytoken 讀 config。回傳成功/失敗供上層寫 log。

4.5 LineWebhookController(路由 /api/line/webhook,匿名)

  1. 原始 body,以 Line:ChannelSecret 計算 HMAC-SHA256,與 X-Line-Signature 比對;不符回 400。
  2. 快速回 200,事件分派:
    • follow:記 userId;reply 綁定說明(請輸入綁定碼)。
    • message(文字):比對有效 LineBindingCode → 建 MemberChannelBinding、標記 code 已用、reply 成功;無對應碼則 reply 說明。重複綁定 → 更新既有 binding。
    • join:建 MessagingGroup(Name 待命名);reply 提示後台命名。
    • leave:對應 MessagingGroupIsActive=false

4.6 NotificationsControlleradmin [Authorize]

本階段無前端,這些端點供管理員(Swagger)操作與測試發送:

  • POST /api/notifications/members/{id}/line-binding-code — 產生綁定碼。
  • GET /api/notifications/groups / PUT .../groups/{id} — 群組列表 / 改名 / 啟停用。
  • GET /api/notifications/historyNotificationLog 分頁(Email + Line)。
  • POST /api/notifications/send-line — 手動發 Linebody + memberIds[] + groupIds[])。
  • POST /api/notifications/send-email — 手動發 Emailsubject + htmlBody + memberIds[] + addresses[],附件本階段以後端程式為主要用途,端點先支援無附件)。

註:真正的程式化發送(收據、歡迎信等)由其他後端程式直接呼叫 IEmailService / ILineNotificationService;上述 send 端點主要供無 UI 階段的手動測試與管理員臨時發送。


5. 設定(config)與 DI

5.1 設定區段(secrets 走 user-secrets / 環境變數,勿入版控)

"Smtp": {
  "Host":        "smtp.example.com",
  "Port":        587,
  "UseSsl":      true,            // STARTTLS
  "User":        "${SMTP_USER}",
  "Password":    "${SMTP_PASSWORD}",
  "FromAddress": "noreply@rolac.org",
  "FromName":    "River of Life Christian Church"
},
"Line": {
  "ChannelAccessToken": "${LINE_CHANNEL_ACCESS_TOKEN}",
  "ChannelSecret":      "${LINE_CHANNEL_SECRET}"
}

5.2 DIProgram.cs

  • builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp"))Configure<LineOptions>(...)
  • AddScoped<IEmailService, EmailService>()
  • AddScoped<ISmtpDispatcher, MailKitSmtpDispatcher>()MailKit seam)。
  • AddScoped<ILineNotificationService, LineNotificationService>()
  • AddScoped<IMessageChannel, LineMessageChannel>() + AddHttpClientLine REST)。
  • Webhook 為 server-to-serverCORS 不受影響。

6. 錯誤處理

  • Webhook 簽章不符 → 400 並記錄;合法事件一律快速回 200(避免 Line 重送)。
  • Email 發送失敗(無效地址、SMTP 拒絕)→ 該筆 NotificationLog failed + error;連線/驗證層級錯誤額外寫 SystemLog;不因單筆失敗中斷整批。
  • Line 推播失敗(封鎖、id 失效、額度用罄)→ NotificationLog failed + error,回報彙總。
  • 綁定碼:過期 / 已使用 / 不存在 → reply 友善說明;重複綁定 → 更新既有 binding。
  • 發送者歸屬JWT sub claim 用 ?? "sub" fallback;背景觸發填 "system"。

7. 測試(xUnit-c Release / --output 建置)

  • Email:收件人解析(MemberIds→Email、跳過 null/空白、與 Addresses 去重);附件對應;單筆失敗不中斷整批;log 寫入與彙總(ISmtpDispatcher mock)。
  • Line 簽章HMAC-SHA256 正/負案例。
  • 綁定碼:有效 / 過期 / 已使用 / 不存在;重複綁定更新。
  • 收件人解析:未綁定會友、停用群組被略過。
  • Line payload 組裝mock HttpMessageHandler
  • Webhook 分派follow / message / join / leave 樣本 payload。
  • 彙總與 log:成功 / 部分失敗。

8. 分階段範圍

Phase 1(本次實作,僅 API 端)

  • EmailIEmailService + MailKit SMTP + 附件 + NotificationLog
  • Linewebhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 API/Swagger。
  • 兩服務共用 NotificationLog

Phase 2

排程定時發送:ScheduledNotification + 背景 workerBackgroundService 或 Hangfire/Quartz,實作計畫時決定)。

Phase 3

事件觸發(生日 / 活動報名確認 / 奉獻收據)整合既有模組;多通道擴充(PWA Web Push、WeChat);Email 範本引擎與雙語範本。


9. 使用者需先準備(非程式)

  • SMTP:取得寄件信箱主機 / port / 帳密 / 寄件地址(M365、Google Workspace 或主機 SMTP);設定寄件網域 SPF/DKIM 以利送達率。
  • Line:申請 Line Official Account + Messaging API channel,取得 Channel access tokenChannel secretnginx 將 /api/line/webhook 對外路由,於 Line 後台填入 webhook URL 並啟用。
  • 將 SMTP 與 Line secrets 放入 API 設定(user-secrets / 環境變數,勿入版控)。

10. 待解 / 未涵蓋(Out of scope

  • Angular 前端(撰寫頁、歷史頁、綁定面板、群組管理頁)。
  • 排程與事件觸發發送。
  • Email 範本引擎與雙語範本(本階段呼叫端自行組 HTML)。
  • 通知偏好設定矩陣(會友自選通道)。
  • 圖文訊息、多語 Line 訊息。
  • 推播額度自動監控與告警。
  • 原生 App 推播 / PWA Web Push / SMS。