線稿索引所有 spec最後更新 2026-07-03
specs/emails.md

Email 寄送

一句話:平台用單一 Resend 帳號代所有租戶寄交易信 —— 租戶零設定即可寄(平台共用 domain + Reply-To 導回租戶信箱);綁定自有站台 domain 的租戶可啟用「以自家 domain 寄件」(DNS 驗證),寄件 domain 即綁定 domain、不可自由輸入,租戶只能調 sender local-part(預設 info)。憑證永遠是平台的,無 BYOK

1. 目標 / 問題

本系統只寄交易信(註冊歡迎、付款成功、訂單確認、同意書提醒、請款核可、忘記密碼),無行銷信。量小(小旅行社每日數十單級)、spam 風險低。

1.1 決策:內建服務,無 BYOK

ECPay 憑證Email 憑證
歸屬判準錢進租戶的特店帳戶 → 憑證必須是租戶的寄信是平台代為履行的服務職責 → 憑證歸平台
結論per-tenant(KMS 加密存租戶 DB,fail-closed)平台級 RESEND_API_KEY(env,fail-open 降級)

理由:

  • 客群非技術(取代 BV + Google Sheet 的小旅行社)。要求租戶註冊 Resend、綁卡、驗 domain、貼 key 會卡死 onboarding。
  • 成本平台吸收:交易信量級下 Resend 費用 trivial,攤進訂閱費。
  • 後台不出現任何 provider 憑證欄位:settings 郵件 tab 不顯示 API key(連遮罩唯讀展示也不要,避免暗示租戶要自備)。BYO key 未來真有大租戶要求 billing 隔離時,照抄 ECPay 的 per-tenant KMS 憑證模式即可 —— 不預建。

1.2 兩層寄件身分

  1. 預設(零設定):從平台共用 domain 寄(PLATFORM_EMAIL_DOMAIN,如 mail.toko.tw)。From 顯示名稱 = 租戶 company_nameReply-To = 租戶 email_reply_to —— 客戶回信直達租戶信箱,租戶不需動任何 DNS。
  2. 自有 domain(可選升級):前提是租戶已綁定自有站台 domain(control_db.tenant_domain,平台 CLI 管理)。寄件 domain 就是綁定的 primary 自有 domain —— 租戶按「啟用」(不輸入 domain)→ 平台經 Resend Domains API 註冊該 domain → 租戶到自己的 DNS 加驗證 record → 通過後改從 info@<綁定domain> 寄。租戶唯一可調的是 sender local-part(預設 info)。

2. 資料模型

2.1 既有

  • outgoing_emails@packages/core/src/schema/business.ts,per-tenant queue):status(queued/sending/sent/failed)、attemptsclaimed_at(lease 回收)、provider_message_id;flush 時以 row id 作 Resend idempotencyKey 防重寄。
  • tenant_settings.email_reply_to:回覆地址(兩層身分都適用,有填才帶)。
  • tenant_settings.email_from退場。自由輸入完整寄件地址的模型已被「domain 派生 + local-part」取代;該欄目前無任何讀取方(service.ts 直讀 env),實作 resolver 時移除 UI 輸入並棄用欄位。

2.2 新增(tenant_settings@packages/core/src/schema/business.ts

欄位型別用途
sending_domaintext null啟用自有寄件時的 domain 快照(= 啟用當下租戶的 primary 自有站台 domain)。由 server 派生,非自由輸入 —— 租戶任何輸入路徑都碰不到這個值
resend_domain_idtext null平台 Resend 帳號下該 domain 的資源 id(DNS records / 驗證狀態都經它查)
sending_domain_statustext null最近一次同步的 Resend 驗證狀態(pending / verified / failed…),display + 解析快取用
sender_local_parttext null寄件人 local-part,未填視為 info。僅允許安全字元(^[a-z0-9][a-z0-9._-]*$),server 端驗證

2.3 平台層 env

env用途
RESEND_API_KEY平台唯一寄信憑證(所有租戶共用;unset 時 fallback enqueue,dev/test/CI 不寄真信)
PLATFORM_EMAIL_DOMAIN共用寄件 domain(預設身分的 From domain;local-part 固定 notify
PLATFORM_ROOT_DOMAIN平台根網域 —— 判定「自有 domain」時排除 *.<root> 平台 subdomain;未設則僅排除 *.run.app / localhost
EMAIL_FROM退場 —— 寄件身分改由 per-tenant resolver 派生(過渡期作最終 fallback,resolveSenderIdentitysource: 'env'

3. 邏輯 / 存取層

3.1 寄件身分解析 resolveSenderIdentity(db)

兩條既有寄送路徑(即時 sendTransactional、queue flushForTenant)共用同一個 resolver,取代現在直讀 process.env.EMAIL_FROM

sending_domain 存在且 status = verified
  → from = `${company_name} <${sender_local_part ?? 'info'}@${sending_domain}>`
否則
  → from = `${company_name} <notify@${PLATFORM_EMAIL_DOMAIN}>`
(兩者皆:reply_to = email_reply_to,有填才帶)
  • 防偽冒是結構性的:From 的 domain 只可能是「平台 domain」或「平台替租戶綁定的站台 domain」,租戶沒有任何輸入路徑能指定其他 domain;local-part 受字元白名單限制。
  • fail-open 降級:domain 驗證失效(DNS record 被移除、Resend 標 failed)→ 自動退回平台身分,信照寄。對比 ECPay 的 fail-closed —— 付款配置錯誤要擋下,寄信中斷則是更大的事故。
  • 60s per-dbName cache(仿 resolveEcpayCredentials):flush 迴圈逐封寄送不重複查 settings;狀態變更最多延遲一分鐘生效。

3.2 自有 domain 寄件生命週期

(未啟用)─ enable ─▶ pending ──verify ok──▶ verified ──Resend 標失效──▶ failed ─┐
    ▲                  │  ▲                                                      │
    └──── disable ─────┘  └──────────── 重新驗證(re-sync)◀─────────────────────┘
  1. enableenableCustomDomainSendingAction() —— 無 domain 參數。Server 端取該租戶 control_db.tenant_domain 的 primary 自有 domain(平台發的 *.<平台根網域> subdomain 不算自有,無自有 domain 時整個功能不可用)→ Resend Domains API 在平台帳號下註冊該 domain → 存 sending_domain 快照 + resend_domain_id、status pending
  2. 顯示 DNS records:從 Resend API 以 resend_domain_id 即時取(DKIM + return-path record;Resend 的 record 設計在子網域/selector 上,與租戶 root domain 既有郵件系統的 SPF 不衝突),不快照進 DB(Resend 為準,避免 stale 指示)。
  3. verify / re-syncverifySendingDomainAction() → 觸發 Resend verify + 回寫 sending_domain_status。驗證是非同步的(DNS 傳播),UI 提供手動「重新檢查」;不建輪詢 cron(YAGNI,租戶按按鈕即可)。
  4. disable:清 sending_domain / resend_domain_id / sending_domain_status + 刪 Resend 端 domain 資源。立即回到平台身分寄送。
  5. 綁定 domain 變更(平台 CLI set-domain 改了 primary domain):sending_domain 快照與現綁定不一致 → resolver 照快照運作不中斷(DNS 驗證仍有效),UI 顯示警示並提供「以新 domain 重新啟用」(= disable + enable)。

3.3 queue / flush(既有,不變)

enqueue(enqueueEmail / enqueueEmailOn)→ POST /api/jobs/flush-emails(token-gated,Cloud Scheduler)→ flushQueuedAsSystem 逐 active 租戶 flushForTenant(conditional UPDATE claim 防雙寄、lease 回收、idempotencyKey 防重送、attempts ≥ 4 封頂 failed)。本 spec 只動其中的 from: 來源。

4. UI 與權限

/admin/settings 郵件 tab(settings §4):

  • 不顯示任何 provider 憑證(無 Resend API key 欄位,已自頁面移除)。
  • email_reply_to 編輯(updateTenantSettingsAction);email_from 自由輸入欄隨 resolver 實作移除。
  • 「寄件身分」區塊(依狀態):
    • 無自有綁定 domain → 顯示目前身分 公司名 <notify@平台domain> + 說明(綁定自有網域後可啟用自家 domain 寄件;綁定由平台管理)。
    • 有綁定 domain、未啟用 → 「啟用以 <綁定domain> 寄件」按鈕(無輸入框)。
    • pending → 待設定 DNS record 表(host / type / value,從 Resend 即時取)+「重新檢查」鈕 + 狀態 badge。
    • verified → 綠 badge + sender local-part 輸入(預設 info)+ 生效寄件人即時預覽 公司名 <info@domain> + 停用鈕。
    • failed → 警示 badge(已自動退回平台身分寄送)+ DNS record 表 + 重新檢查。
  • 權限:所有 action 沿用 settings 整頁既有的 role.manage 閘。
  • Audit:tenant_settings.sending_domain.enable / .verify / .disableaudit_log

5. 開放議題

  • bounce / complaint 回饋:Resend webhook → 標記 outgoing_emails 終態 + 會員 email 健康度(hard bounce 後抑制重寄)。上線後看量再決定。
  • 平台共用 domain 鄰居信譽:交易信 only 下風險低;若未來開放行銷信,必須拆獨立 domain(或強制自有 domain)再開放。
  • PLATFORM_EMAIL_DOMAIN 選址:與租戶站台 domain 體系(Cloudflare DNS scaffold)共用 zone 還是獨立 zone,買 domain 時一併定案。
  • 綁定 domain 變更警示 UI:平台 CLI set-domain 改 primary 後,sending_domain 快照與現綁定不一致 —— resolver 照快照運作不中斷(DNS 驗證仍有效),但 settings 頁尚未顯示「以新 domain 重新啟用」警示;有第二租戶換 domain 的真實情境再補。
  • BYO provider key:明確不做、不留欄位;真有大租戶要求 billing 隔離時再沿 ECPay KMS 模式評估。

6. 罐頭訊息範本(message_templates

§1–§5 講的是平台代寄的交易信(Resend)。本節描述另一條、與寄送管線分離的罐頭訊息治理:後台 /admin/message-templates 集中管理「對齊 SOP 各階段要傳的罐頭 / 系統訊息」的文案與啟用狀態。實作在 src/app/(admin)/admin/message-templates/page.tsx,repo packages/core/src/comms/message-templates-repo.ts

6.1 模型

  • 通道(channel)line(官方 LINE)/ email(走 §1–§5 的 Resend 寄送)。
  • SOP 階段分類:每則範本綁一個階段,對齊出團 SOP 流程。
  • 內容含 merge 變數:自由文字 + {{變數}} 佔位,寄送時以訂單 / 客戶資料代入。
  • 啟用狀態(isActive):草稿 / 暫停 / 啟用中。
  • 提供範本 CRUD(列表 + 通道 filter + 新增 / 編輯 / 啟停)。
  • 權限message_template.read 進站(op + admin 可讀,文案治理);message_template.manage 在各 server action 再強制。

6.2 未定義 / 未實作邊界

  • merge 變數白名單驗證未實作):目前是「帶 merge 變數的自由文字」,{{變數}} 的白名單驗證尚待產品定義。
  • SOP 情境 → 範本 → 真寄整合 機制已落地(email 通道)enqueueTemplatedEmail(db, { scenarioKey, toEmail, vars, isMultiParty? })packages/core/src/comms/send-templated.ts)以 scenarioKey 查啟用中、key 相符的 email 範本 → 代入 merge 變數(renderMergeTemplatecomms/merge.ts,缺變數原樣保留、不靜默清空)→ 經 outgoing_emails queue 真寄(templateKey custom_message 通用外殼)。fail-open:查無啟用範本回 SKIPPED_NO_TEMPLATE 不拋。這是可重用機制:spec §6.2 只定義框架、未列具體情境清單與事件點,故只落地「查範本→渲染→enqueue」管線,呼叫端在自己的事件點傳入 scenarioKey,不寫死荒野特定情境。官方 LINE 通道的真寄整合仍未做。
  • 官方 LINE 推播 / 群組訊息串接未實作):屬未來(現階段只治理文案 + email 真寄,不推播 LINE)。

Changelog

版本日期變更Commit
32026-06-12實作對齊:resolveSenderIdentitypackages/core/src/emails/sender-identity.ts)+ 生命週期(sending-domain.ts,enable/sync/disable + isCustomTenantDomain)落地;env 表補 PLATFORM_ROOT_DOMAIN;「綁定 domain 變更警示」UI 移開放議題(快照照常運作不中斷)
22026-06-12修正:移除 BYOK 唯讀欄位(settings 郵件 tab 不顯示任何 provider 憑證,code 同步移除);sending_domain 改為綁定站台 domain 派生快照(非自由輸入,enable 無參數);租戶僅可調 sender_local_part(預設 info,字元白名單);email_from 自由輸入退場;防偽冒由驗證規則升級為結構性不可能
12026-06-12初版:內建服務決策(不 BYO)、兩層寄件身分、自有 domain DNS 驗證流程、防偽冒不變式、fail-open 降級
42026-07-01新增 §6 罐頭訊息範本(message_templates):line/email 通道 + SOP 階段 + merge 變數的文案治理,與交易信寄送管線分離;明標變數白名單 / SOP 真寄整合 / 官方 LINE 推播為待定義·未實作邊界(page 內 Alert 已自揭)。順手移除檔尾誤植的 XML 標籤。
52026-07-03§6.2「SOP 情境 → 範本 → 真寄整合」機制落地(email 通道):enqueueTemplatedEmailcomms/send-templated.ts)依 scenarioKey 查啟用範本 → renderMergeTemplatecomms/merge.ts,缺變數保留原樣)代入 → 經 outgoing_emails queue 真寄(custom_message 外殼);fail-open。只落地可重用管線、不寫死情境(spec 只定框架)。merge 白名單驗證 / LINE 真寄仍未做。