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

294 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notification Service (Email + Line) — 設計文件
- **日期**2026-06-23
- **狀態**:已核可(待實作計畫)
- **作者**Chris Chen + Claude
- **專案**ROLACRiver Of Life Christian Church 教會管理系統)
- **前置文件**[2026-06-20-line-notifications-design.md](2026-06-20-line-notifications-design.md)Line 模組)、[../../NOTIFICATIONS.md](../../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 省額度)
NotificationsControlleradmin [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
```csharp
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. 解析 `MemberIds``Member.Email`(跳過 null/空白),與 `Addresses` 去重合併。
2. 逐一以 MailKit 發送(subject + HTML + 附件)。
3. 每位收件人寫一筆 `NotificationLog``channel=email``sent`/`failed`)。
4. 回傳 `NotificationResult` 彙總;**單一收件人失敗不丟例外**,記錄後續行。
5. SMTP 連線/驗證層級錯誤 → 寫 SystemLog,並把該批標記 failed。
**MailKit seam**:實際 `SmtpClient.Connect/Authenticate/Send` 封裝在薄介面(例如 `ISmtpDispatcher`)後,使 `EmailService` 的收件人解析、附件對應、log 寫入可單元測試(不需真實 SMTP server)。
### 4.2 `ILineNotificationService`(沿用核可 spec
```csharp
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 共用回傳型別
```csharp
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`,匿名)
1. 讀**原始 body**,以 `Line:ChannelSecret` 計算 HMAC-SHA256,與 `X-Line-Signature` 比對;不符回 400。
2. 快速回 200,事件分派:
- **follow**:記 userIdreply 綁定說明(請輸入綁定碼)。
- **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` — 手動發 Linebody + memberIds[] + groupIds[])。
- `POST /api/notifications/send-email` — 手動發 Emailsubject + htmlBody + memberIds[] + addresses[],附件本階段以後端程式為主要用途,端點先支援無附件)。
> 註:真正的程式化發送(收據、歡迎信等)由其他後端程式直接呼叫 `IEmailService` / `ILineNotificationService`;上述 send 端點主要供無 UI 階段的手動測試與管理員臨時發送。
---
## 5. 設定(config)與 DI
### 5.1 設定區段(secrets 走 user-secrets / 環境變數,勿入版控)
```jsonc
"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](../../../API/ROLAC.API/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-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 端)
- Email`IEmailService` + MailKit SMTP + 附件 + `NotificationLog`
- Linewebhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 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。