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>
14 KiB
Notification Service (Email + Line) — 設計文件
- 日期:2026-06-23
- 狀態:已核可(待實作計畫)
- 作者:Chris Chen + Claude
- 專案:ROLAC(River Of Life Christian Church 教會管理系統)
- 前置文件:2026-06-20-line-notifications-design.md(Line 模組)、../../NOTIFICATIONS.md(早期多通道願景)
1. 背景與目標
需要一個後端通知能力,讓後端程式碼可以決定透過 Email 或 Line 發送訊息:
- 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 省額度)
NotificationsController(admin [Authorize])──► 綁定碼 / 群組 / 歷史 / 手動發送
- 兩個對等服務,後端依用途自行呼叫。
- 兩者皆寫
NotificationLog(Channel欄位區分email/line)。 - Line 對內接收(webhook)與綁定流程沿用核可 spec。
3. 資料模型(新實體)
Line 三表沿用核可 spec;NotificationLog 調整為同時服務兩通道。命名與軟刪除慣例對齊既有實體。
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)。
ScheduledNotification(Phase 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);
流程
- 解析
MemberIds→Member.Email(跳過 null/空白),與Addresses去重合併。 - 逐一以 MailKit 發送(subject + HTML + 附件)。
- 每位收件人寫一筆
NotificationLog(channel=email、sent/failed)。 - 回傳
NotificationResult彙總;單一收件人失敗不丟例外,記錄後續行。 - 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;每筆寫 NotificationLog(channel=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 REST;
HttpClient經IHttpClientFactory;token 讀 config。回傳成功/失敗供上層寫 log。
4.5 LineWebhookController(路由 /api/line/webhook,匿名)
- 讀原始 body,以
Line:ChannelSecret計算 HMAC-SHA256,與X-Line-Signature比對;不符回 400。 - 快速回 200,事件分派:
- follow:記 userId;reply 綁定說明(請輸入綁定碼)。
- message(文字):比對有效
LineBindingCode→ 建MemberChannelBinding、標記 code 已用、reply 成功;無對應碼則 reply 說明。重複綁定 → 更新既有 binding。 - join:建
MessagingGroup(Name 待命名);reply 提示後台命名。 - leave:對應
MessagingGroup設IsActive=false。
4.6 NotificationsController(admin [Authorize])
本階段無前端,這些端點供管理員(Swagger)操作與測試發送:
POST /api/notifications/members/{id}/line-binding-code— 產生綁定碼。GET /api/notifications/groups/PUT .../groups/{id}— 群組列表 / 改名 / 啟停用。GET /api/notifications/history—NotificationLog分頁(Email + Line)。POST /api/notifications/send-line— 手動發 Line(body + memberIds[] + groupIds[])。POST /api/notifications/send-email— 手動發 Email(subject + 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 DI(Program.cs)
builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp"))、Configure<LineOptions>(...)。AddScoped<IEmailService, EmailService>()。AddScoped<ISmtpDispatcher, MailKitSmtpDispatcher>()(MailKit seam)。AddScoped<ILineNotificationService, LineNotificationService>()。AddScoped<IMessageChannel, LineMessageChannel>()+AddHttpClient(Line REST)。- Webhook 為 server-to-server,CORS 不受影響。
6. 錯誤處理
- Webhook 簽章不符 → 400 並記錄;合法事件一律快速回 200(避免 Line 重送)。
- Email 發送失敗(無效地址、SMTP 拒絕)→ 該筆
NotificationLogfailed+ error;連線/驗證層級錯誤額外寫 SystemLog;不因單筆失敗中斷整批。 - Line 推播失敗(封鎖、id 失效、額度用罄)→
NotificationLogfailed+ error,回報彙總。 - 綁定碼:過期 / 已使用 / 不存在 → reply 友善說明;重複綁定 → 更新既有 binding。
- 發送者歸屬:JWT sub claim 用
?? "sub"fallback;背景觸發填 "system"。
7. 測試(xUnit,-c Release / --output 建置)
- Email:收件人解析(
MemberIds→Email、跳過 null/空白、與Addresses去重);附件對應;單筆失敗不中斷整批;log 寫入與彙總(ISmtpDispatchermock)。 - Line 簽章:HMAC-SHA256 正/負案例。
- 綁定碼:有效 / 過期 / 已使用 / 不存在;重複綁定更新。
- 收件人解析:未綁定會友、停用群組被略過。
- Line payload 組裝:mock
HttpMessageHandler。 - Webhook 分派:follow / message / join / leave 樣本 payload。
- 彙總與 log:成功 / 部分失敗。
8. 分階段範圍
Phase 1(本次實作,僅 API 端)
- Email:
IEmailService+ MailKit SMTP + 附件 +NotificationLog。 - Line:webhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 API/Swagger。
- 兩服務共用
NotificationLog。
Phase 2
排程定時發送:ScheduledNotification + 背景 worker(BackgroundService 或 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 token 與 Channel secret;nginx 將
/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。