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

- **日期**：2026-06-23
- **狀態**：已核可（待實作計畫）
- **作者**：Chris Chen + Claude
- **專案**：ROLAC（River 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 省額度）
  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）

```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**：記 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 / 環境變數，勿入版控）

```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-server，CORS 不受影響。

---

## 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`。
- 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。
