---
title: C10 api → 客戶 SMTP
eyebrow: Guidant AI 資安檢視總報告 · 連線清單
h1: C10 api → 客戶郵件伺服器（SMTP，依設定）
lede: 系統寄出每一封信走的那條線——通知信、登入驗證碼、新帳號初始密碼信、設定頁的「寄測試信」。目的地是客戶管理員在設定頁填的郵件伺服器；系統帶著存著的郵件帳密去連。這一頁回答：這條線**怎麼連**、**哪些功能走它**、它帶來**什麼威脅、駭客怎麼打**、我們**要怎麼防、目前做到哪**。
chips:
  - { text: "依設定", kind: plain }
  - { text: "目的地由客戶填", kind: warn }
  - { text: "威脅 4 種", kind: accent }
  - { text: "掃描命中 4 條", kind: plain }
---

> **這一頁怎麼來的**：[DFD Level 0](../DFD/dfd-level0.html#c10) 把產品運作時的連線編成 C01～C25；[STRIDE 六頁](../STRIDE/S-spoofing.html)每一條問題都標了發生在哪幾條線。這裡以**連線**為單位，把這條線的特性、掃描在這條線上抓到的攻擊手法、以及由此該有的防線整理成一頁。個別問題修了沒不在這頁講，請點條目連回 STRIDE 頁。

## 一、連線圖

```{.mermaid cap="C10 — api 到客戶郵件伺服器。設定全系統只有一份（存最上層），寄信時 api 讀出存著的帳密去連設定頁填的主機。紅色是這條線上「對方由人填、內容由人寫」的兩個入口：主機位址由客戶管理員自填，信件內容夾著使用者自填的暱稱與專案名。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
flowchart LR
    ADM(["👤 客戶管理員<br/>（總部層級）"])
    USR(["👤 任一登入使用者"])
    subgraph MAIN["🏠 主系統"]
        direction TB
        CFG[("SMTP 設定<br/>全系統一份，存最上層<br/>主機・埠・帳號・密碼・加密・驗憑證")]
        API["guidant-api<br/>組信件・讀設定・寄出"]
        LOG[("app.log<br/>寄信失敗紀錄")]
        CFG --> API
        API -. "失敗時" .-> LOG
    end
    SMTP(["📮 客戶郵件伺服器<br/>（位址由設定頁填）"])
    MITM(["🕵 站在路上的人"])
    ADM -- "C02 · 改設定／寄測試信" --> CFG
    USR -- "C02 · 暱稱、專案名進信件" --> API
    API == "C10 · SMTP + STARTTLS（可選）<br/>帳密＋信件內容" ==> SMTP
    MITM -. "不驗憑證時可假扮" .-> SMTP
    classDef ext fill:#FBFCFC,stroke:#9AA8AA,stroke-dasharray:3 2
    classDef open fill:#FDECEC,stroke:#C0392B
    class ADM,USR,SMTP,MITM ext
    class CFG open
```

## 二、這條線怎麼連

| 項目 | 現況 |
|---|---|
| **誰 → 誰** | `guidant-api` → 客戶自己的郵件伺服器。主機位址、埠、帳號、密碼全由客戶在「郵件設定」頁填 |
| **協定／埠** | SMTP，埠由設定決定（出廠預設 25、加密關）。加密只有一種做法：先明文連上再升級成加密（STARTTLS，設定頁的「TLS」開關）。**沒有「一連上就是加密」（:465 那種）的路**——填 465 配 TLS 開關實際連不上，見「DFD 對照」 |
| **傳什麼** | 郵件帳號密碼（登入郵件伺服器用）＋每一封信的全文：通知信、登入一次性驗證碼、新帳號初始密碼、測試信 |
| **怎麼驗對方身分** | **預設不驗**。設定頁有「驗證伺服器憑證」開關與「信任憑證」欄位，勾了才會確認對方真的是那台伺服器（`build_ssl_context()`：要求憑證、比對主機名、貼了 CA 就只信那一份）；沒勾、或升級前存的舊設定沒有這個鍵，一律走 Python 內建的 `starttls()`，**它預設什麼都不驗**。前端新建設定時這個開關**預設是關的**（CM-2239 決策者裁定） |
| **誰能改目的地** | 寫入要總部層級（`COMPANY_WIDE_CONFIG_GROUPS` 含 `SMTP`，軸⑦ `require_tenant_hq`）；子公司管理員改不動。**但「寄測試信」帶的是畫面上那份設定**——操作者可以不存檔就把主機改成別台去試寄 |
| **設定存在哪** | 全系統只有一份，存最上層租戶（ROOT）那一列；子公司觸發的通知信也用這份。密碼存明文 JSON（`secret` 欄）在 `system_configs` 表 |
| **失敗時** | 記伺服器位址、埠、錯誤類別進 `app.log`，**不記設定物件**（程式註解明寫「以下 log 絕不可帶 self.config」） |
| **何時存在** | 依設定。設定沒開（`enable: false`）時通知信直接略過不寄、不報錯 |

依據：套件 `jedi_notification/infra/smtp_mail/smtp_mail_adapter.py`（連線與 TLS 邏輯）、`app/dto/smtp_email_config_dto.py`（`tls_verify` 缺鍵＝不驗）、主專案 `app/notification/service/notification_service.py`／`test_mail_service.py`、`common/constant/company_wide_config.py`（寫入守門）、套件 `jedi_system_core/migrations/003-system-configs-seed.sql`（出廠預設 port 25、tls false）、前端 `src/views/smtp-config/SmtpConfigForm.vue`（`tls_verify: false` 預設）。

**DFD 對照**：DFD 寫「SMTP :587（STARTTLS）或 465」。實查程式只有 `smtplib.SMTP` ＋ `starttls()` 一條路、沒有 `SMTP_SSL`，所以 :465（一連上就加密）**實際不支援**；出廠預設是 :25 無加密。已回寫本卡。

## 三、哪些功能會走這條線

四群，差別在「信裡裝的東西有多值錢」與「誰能觸發」：

| 群 | 功能 | 對威脅的意義 |
|---|---|---|
| **① 登入與帳號的信** | 雙因子驗證碼（email OTP）、新帳號初始密碼信、忘記密碼（jedi-iam 透過 `send_mail_notification` 寄） | 信裡裝的是**接管帳號的材料**。這條線被攔，等於帳號被接管 |
| **② 流程通知信** | 批次完成任務彙總、批次指派任務、專案啟動（`workflow_execution_service`、`project_service`、`job_batch_complete_service`） | 信件是 HTML，內容夾著使用者自填的暱稱與專案名稱——**別人寫的字會進到收信人的信箱裡** |
| **③ 寄測試信** | 郵件設定頁按鈕（套件 `POST /mail/test` → `test_mail_service.send_test_mail()`） | 帶的是**畫面上**那份設定，不是存著的——操作者可以指定任何主機去連 |
| **④ 授權到期通知** | `license_expiry_notification_service` 透過同一支寄信 | 同群 ② |

## 四、這條線會帶來什麼威脅、駭客怎麼打

掃描在這條線上抓到 4 條問題（[M16-1](../STRIDE/I-information-disclosure.html#m16-1)、[M16-2](../STRIDE/I-information-disclosure.html#m16-2)、[M16-3](../STRIDE/I-information-disclosure.html#m16-3)、[M06-27](../STRIDE/S-spoofing.html#m06-27)），歸納起來是**四種攻擊手法**。每種寫駭客實際的步驟與得手什麼，條目是實例。

| # | 風險 | 威脅 | 駭客怎麼打 | 得手什麼 | STRIDE | 實例 |
|---|---|---|---|---|---|---|
| T1 | 🟡 中 | **把存著的密碼騙到自己的主機** | ① 有郵件設定權限的人打開設定頁；② 把伺服器位址改成自己控制的機器、密碼欄留空不動；③ 按「寄測試信」；④ 系統拿存著的真密碼去連他的機器，帳密到手；⑤ 用這組帳密以公司名義對外寄信，通過寄件人驗證、收件人看不出是假的 | 公司郵件伺服器的帳號密碼；可冒名寄出通過驗證的釣魚信 | [I 資料外洩](../STRIDE/I-information-disclosure.html) | [M16-1 寄測試信把存著的郵件密碼送到操作者指定的主機](../STRIDE/I-information-disclosure.html#m16-1) 🟡 |
| T2 | 🟡 中 | **站在路上假扮郵件伺服器** | ① 攻擊者站在我們與郵件伺服器之間（同網段、或控制了中間設備／DNS）；② 假扮成郵件伺服器，拿一張自己簽的憑證；③ 系統沒勾「驗證憑證」，照單全收、下一行就把帳密送過來；④ 之後每一封信都落到他手上——含登入驗證碼、初始密碼 | 郵件帳密，以及每一封信的內容（含接管帳號的材料） | [I 資料外洩](../STRIDE/I-information-disclosure.html)、[S 冒充身分](../STRIDE/S-spoofing.html) | [M16-3 連郵件伺服器有加密但不認人](../STRIDE/I-information-disclosure.html#m16-3) 🟡 |
| T3 | 🟡 中 | **寄信失敗把密碼寫進紀錄** | ① 不用打——郵件伺服器暫時連不上，或管理員打錯一次密碼；② 系統把「寄信失敗」連同整組設定（含密碼）寫進紀錄檔；③ 任何看得到紀錄的人、或拿到回廠診斷包的人，搜尋就得到郵件帳密 | 公司郵件伺服器的帳號密碼 | [I 資料外洩](../STRIDE/I-information-disclosure.html) | [M16-2 寄信失敗把整組郵件設定含密碼寫進紀錄](../STRIDE/I-information-disclosure.html#m16-2) 🟡 |
| T4 | ⚪ 低 | **用系統的寄件身分發釣魚信** | ① 任一登入帳號把暱稱改成一段網頁語法，例如一個寫著「請重新登入」的連結；② 他去批次完成任務或啟動專案；③ 系統寄出的通知信裡出現一個看起來像系統發的連結；④ 收信的同事點了就上當 | 用系統寄件身分發釣魚信 | [S 冒充身分](../STRIDE/S-spoofing.html) | [M06-27 通知信把暱稱原樣塞進信件，可放釣魚連結](../STRIDE/S-spoofing.html#m06-27) ⚪ |

**四種手法的共同點**：T1、T2 都利用「**目的地由人填、對方身分不驗**」——系統帶著存著的祕密出門，卻不確認門外站的是誰；T3 是「**連不上時把祕密抖出來**」；T4 是「**信件是我們寄的，內容卻是別人寫的**」。這三個特性是這條線的本質：寄信這件事天生要把帳密交給對方、要把使用者的字放進信裡，程式修好了特性還在，下次加一種新通知信或一個新的「測試」按鈕，同樣的路就再開一次。

## 五、我們要怎麼防、目前做到哪

對應四種威脅與這條線的本質，防線分六條。D1～D4 直接對應掃描抓到的四種手法；**標 ◇ 的（D5、D6）是依這條線的特性補的標準防線，掃描範圍沒涵蓋、沒有實例**。「目前」欄寫程式與設定裡**實際有的機制**；⚠️ 表示目前只靠慣例或只守到局部，沒有機制保證。

| # | 防線 | 擋哪種威脅 | 目前做到哪 |
|---|---|---|---|
| D1 | **存著的密碼只能跟著存著的主機走**。「測試連線」類功能若主機位址與存著的不同，必須要求重新輸入密碼，不得沿用存著的那組 | T1 | ✅ `test_mail_service.send_test_mail()`：密碼欄沒改時比對送來的主機與埠跟存著的是否相同，不同回 400 `MAIL_TEST_SERVER_CHANGED_REQUIRES_PASSWORD`。⚠️ 權限面裁定不修（M16-1）：能進設定頁的人仍可以用自己的密碼對任何主機試寄；守門只擋「偷存著的密碼」這一招 |
| D2 | **加密連線必須驗對方身分**。選了加密就要確認憑證與主機名；自簽憑證的客戶改貼信任憑證，不是關掉驗證 | T2 | ✅ 機制有：`tls_verify` ＋ `tls_ca_cert` 兩個欄位，勾了走 `build_ssl_context()`（`CERT_REQUIRED` ＋ `check_hostname`）。⚠️ **預設不勾**：前端新建設定 `tls_verify: false`、升級前舊設定缺鍵一律當不驗（為了升級當天不斷信）。等於客戶不主動勾就是 T2 的狀態，而設定頁沒有風險提示（CM-2239 拿掉了） |
| D3 | **失敗紀錄只記位址與錯誤類別，不記設定物件**。帶密碼的物件永遠不進 log，不依賴共用遮罩救（遮罩只認特定格式） | T3 | ✅ 兩個 `except` 分支改成只記 `host`／`port`／錯誤類別，程式註解明寫「絕不可帶 self.config」；診斷包那端的遮罩同批補齊（CM-2212）。⚠️ 靠逐處 review，沒有 lint 或測試攔「把 pydantic 物件格式化進 log」的新寫法 |
| D4 | **使用者的字進 HTML 信件前一律跳脫**。暱稱、專案名、任何自填欄位放進信件範本前先 `html.escape()` | T4 | ✅ 三種流程通知信的暱稱與專案名已跳脫（CM-2213）。⚠️ 跳脫寫在各寄信點，沒有共用的「組信件」工具統一做；新加一種通知信要記得。登入類的信（OTP、初始密碼）沒放使用者自填欄位，不受影響 |
| D5 ◇ | **密碼不以明文落庫**。郵件密碼比照 AI 金鑰與 Drive 密鑰，加密後才寫進 `system_configs` | 讀得到設定表的人（DB 備份、診斷包、能查 DB 的維運） | ✅ 讀取 API 有遮：套件 `mask_secret_value()` 對 `SMTP` 群組刪掉 `secret` 再回，寫入時沒改密碼從既有列補回。⚠️ **落庫是明文**：AI 金鑰（`AI_PROVIDER_ENCRYPTION_KEY`）與 Drive 密鑰（Fernet）有加密，SMTP 的 `secret` 欄沒有——能讀 DB 或備份的人直接拿到郵件密碼 |
| D6 ◇ | **設定頁對「不驗憑證」有明顯提示**，或預設改成驗、讓客戶主動關 | T2 的前提 | ⚠️ 決策者 2026-10-01 裁定預設不勾且拿掉提示（CM-2239），理由是自簽憑證客戶升級體驗。這是有意識的取捨，不是漏做；記在這裡是因為它直接決定 T2 在客戶現場成不成立 |

**最便宜的一步**：D5。加密落庫那一件在 AI 金鑰那組已經寫好（`encrypt_api_key`／`decrypt_api_key`），遮罩與補回 SMTP 本來就有，只差在寫入前加密、讀出時解密兩處；掃描 24 個模組頁沒有任何一條點名它，因為掃描看的是「誰能讀到 API 回應」，不是「誰能讀到資料庫」。

## 六、依這些防線，掃描還沒看過的地方

- **所有「測試連線」類按鈕的完整清單**（D1）：郵件、LDAP、日誌轉送、Discord、Telegram、儲存、AI 服務——每一支都要過「主機改了就要重填密碼」這條，目前只確認過郵件與 LDAP。
- **登入類信件的內容**（D2 的後果面）：OTP 驗證碼與初始密碼信走這條線，T2 成立時等於帳號接管。該評估驗證碼效期與一次性、初始密碼是否強制首次登入改掉，讓信被攔的損害有上限。
- **`system_configs` 表裡還有哪些明文祕密**（D5）：SMTP 之外，`NOTIFY_CONFIG`（Discord webhook、Telegram bot token）、`ISSUE_INTEGRATE_CONFIG`（GitLab／GitHub token）、`THIRD_PARTY_LOGIN`（LDAP 服務帳號密碼）都是讀取有遮、落庫明文。

---

*依據：STRIDE 六頁信任邊界連線標記（CM-2403／2404 驗收後版本）、套件 `jedi_notification/infra/smtp_mail/smtp_mail_adapter.py`、`jedi_notification/app/dto/smtp_email_config_dto.py`、主專案 `app/notification/service/`、`common/constant/company_wide_config.py`、`app/system_config/service/guarded_system_config_service.py`（確認 SMTP 不在加密群組）、前端 `SmtpConfigForm.vue`、DFD Level 0。*
