一句話:平台用單一 Resend 帳號代所有租戶寄交易信 —— 租戶零設定即可寄(平台共用 domain + Reply-To 導回租戶信箱);綁定自有站台 domain 的租戶可啟用「以自家 domain 寄件」(DNS 驗證),寄件 domain 即綁定 domain、不可自由輸入,租戶只能調 sender local-part(預設
info)。憑證永遠是平台的,無 BYOK。
本系統只寄交易信(註冊歡迎、付款成功、訂單確認、同意書提醒、請款核可、忘記密碼),無行銷信。量小(小旅行社每日數十單級)、spam 風險低。
| ECPay 憑證 | Email 憑證 | |
|---|---|---|
| 歸屬判準 | 錢進租戶的特店帳戶 → 憑證必須是租戶的 | 寄信是平台代為履行的服務職責 → 憑證歸平台 |
| 結論 | per-tenant(KMS 加密存租戶 DB,fail-closed) | 平台級 RESEND_API_KEY(env,fail-open 降級) |
理由:
PLATFORM_EMAIL_DOMAIN,prod 為 mail.tokocloud.tw)。From 顯示名稱 = 租戶 company_name,Reply-To = 租戶 email_reply_to —— 客戶回信直達租戶信箱,租戶不需動任何 DNS。control_db.tenant_domain,平台 CLI 管理)。寄件 domain 就是綁定的 primary 自有 domain —— 租戶按「啟用」(不輸入 domain)→ 平台經 Resend Domains API 註冊該 domain → 租戶到自己的 DNS 加驗證 record → 通過後改從 info@<綁定domain> 寄。租戶唯一可調的是 sender local-part(預設 info)。outgoing_emails(寄信唯一真相;email_log 已 DROP 退場)(@packages/core/src/schema/business.ts,per-tenant):status(queued/sending/sent/failed;sending 是 flush claim 的暫態)、attempts、claimed_at(lease 回收)、provider_message_id,以及歸屬欄 order_id / user_id / kind(原 email_log 的職責併入,訂單 timeline / 會員信件直接查此表)。flush 時以 row id 作 Resend idempotencyKey 防重寄。
sendTransactional 真寄後補記一列 status='sent'|'failed'(attempts=1、best-effort 不擋主流程),與 queue 路徑同表同真相。outgoing_emails.status(排隊中 → 已寄出 / 失敗)——不再有「enqueue 當下就記 sent」的假已寄;從未送達的信不會顯示為已寄出。tenant_settings.email_reply_to:回覆地址(兩層身分都適用,有填才帶)。tenant_settings.email_from:已退場(欄位不存在)。寄件地址由「sending domain 派生 + sender_local_part」組合,無自由輸入欄。tenant_settings,@packages/core/src/schema/business.ts)| 欄位 | 型別 | 用途 |
|---|---|---|
sending_domain | text null | 啟用自有寄件時的 domain 快照(= 啟用當下租戶的 primary 自有站台 domain)。由 server 派生,非自由輸入 —— 租戶任何輸入路徑都碰不到這個值 |
resend_domain_id | text null | 平台 Resend 帳號下該 domain 的資源 id(DNS records / 驗證狀態都經它查) |
sending_domain_status | text null | 最近一次同步的 Resend 驗證狀態(pending / verified / failed…),display + 解析快取用 |
sender_local_part | text null | 寄件人 local-part,未填視為 info。僅允許安全字元(^[a-z0-9][a-z0-9._-]*$),server 端驗證 |
| 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,resolveSenderIdentity 的 source: 'env') |
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,有填才帶)
resolveEcpayCredentials):flush 迴圈逐封寄送不重複查 settings;狀態變更最多延遲一分鐘生效。(未啟用)─ enable ─▶ pending ──verify ok──▶ verified ──Resend 標失效──▶ failed ─┐
▲ │ ▲ │
└──── disable ─────┘ └──────────── 重新驗證(re-sync)◀─────────────────────┘
enableCustomDomainSendingAction() —— 無 domain 參數。Server 端取該租戶 control_db.tenant_domain 的 primary 自有 domain(平台發的 *.<平台根網域> subdomain 不算自有,無自有 domain 時整個功能不可用)→ Resend Domains API 在平台帳號下註冊該 domain → 存 sending_domain 快照 + resend_domain_id、status pending。resend_domain_id 即時取(DKIM + return-path record;Resend 的 record 設計在子網域/selector 上,與租戶 root domain 既有郵件系統的 SPF 不衝突),不快照進 DB(Resend 為準,避免 stale 指示)。verifySendingDomainAction() → 觸發 Resend verify + 回寫 sending_domain_status。驗證是非同步的(DNS 傳播),UI 提供手動「重新檢查」;不建輪詢 cron(YAGNI,租戶按按鈕即可)。sending_domain / resend_domain_id / sending_domain_status + 刪 Resend 端 domain 資源。立即回到平台身分寄送。set-domain 改了 primary domain):sending_domain 快照與現綁定不一致 → resolver 照快照運作不中斷(DNS 驗證仍有效),UI 顯示警示並提供「以新 domain 重新啟用」(= disable + enable)。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: 來源。
/admin/settings 郵件 tab(settings §4):
email_reply_to 編輯(updateTenantSettingsAction);email_from 自由輸入欄隨 resolver 實作移除。公司名 <notify@平台domain> + 說明(綁定自有網域後可啟用自家 domain 寄件;綁定由平台管理)。<綁定domain> 寄件」按鈕(無輸入框)。pending → 待設定 DNS record 表(host / type / value,從 Resend 即時取)+「重新檢查」鈕 + 狀態 badge。verified → 綠 badge + sender local-part 輸入(預設 info)+ 生效寄件人即時預覽 公司名 <info@domain> + 停用鈕。failed → 警示 badge(已自動退回平台身分寄送)+ DNS record 表 + 重新檢查。role.manage 閘。tenant_settings.sending_domain.enable / .verify / .disable 寫 audit_log。outgoing_emails 終態 + 會員 email 健康度(hard bounce 後抑制重寄)。上線後看量再決定。PLATFORM_EMAIL_DOMAIN 選址:與租戶站台 domain 體系(Cloudflare DNS scaffold)共用 zone 還是獨立 zone,買 domain 時一併定案。set-domain 改 primary 後,sending_domain 快照與現綁定不一致 —— resolver 照快照運作不中斷(DNS 驗證仍有效),但 settings 頁尚未顯示「以新 domain 重新啟用」警示;有第二租戶換 domain 的真實情境再補。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。
line(官方 LINE)/ email(走 §1–§5 的 Resend 寄送)。message_template_stages):每則範本綁一個階段,message_templates.stage FK → message_template_stages.name(unique 錨點,onUpdate: cascade / onDelete: restrict),目錄含 sort_order / is_active、有 CRUD——平台層不再寫死任何階段值(舊的九個荒野 SOP 中文值 DB CHECK 已撤,多租戶開放性:各租戶自定自己的 SOP 階段)。寫入時 repo 驗 stage 存在且啟用(STAGE_INVALID)。{{變數}} 佔位,寄送時以訂單 / 客戶資料代入。message_template.read 進站(op + admin 可讀,文案治理);message_template.manage 在各 server action 再強制。{{變數}} 的白名單驗證尚待產品定義。enqueueTemplatedEmail(db, { scenarioKey, toEmail, vars, isMultiParty? })(packages/core/src/comms/send-templated.ts)以 scenarioKey 查啟用中、key 相符的 email 範本 → 代入 merge 變數(renderMergeTemplate,comms/merge.ts,缺變數原樣保留、不靜默清空)→ 經 outgoing_emails queue 真寄(templateKey custom_message 通用外殼)。fail-open:查無啟用範本回 SKIPPED_NO_TEMPLATE 不拋。這是可重用機制:spec §6.2 只定義框架、未列具體情境清單與事件點,故只落地「查範本→渲染→enqueue」管線,呼叫端在自己的事件點傳入 scenarioKey,不寫死荒野特定情境。官方 LINE 通道的真寄整合仍未做。