郵件模組
Package:
io.leandev.appfuse.mail.*
AppFuse Server 提供完整的郵件能力:SMTP 與 OAuth2 認證(Gmail、Office365)、環境層外寄安全政策(防火牆/改寄/特殊訊息)、以及「寄信當下依範圍解析設定」的動態路由。消費端只面向一個 Mailer 介面。
核心模型
| 型別 | 角色 |
|---|---|
Mailer(介面) | 消費端面向的寄信契約:compose()(from 已預填)、send(MimeMessage)、testConnection() |
DirectMailer | 綁定單一 JavaMailSender 的具體 Mailer;外寄政策與預設寄件者於建構時烘入 |
RoutingMailer | 寄信當下依 MailerSpecProvider 解析目標(範圍專屬設定 → 系統預設 fallback),revision 指紋快取;比照 Spring routing DataSource 模式 |
MailOutboundPolicy | 不可變的外寄安全政策(防火牆/白名單/debug/改寄/特殊訊息),builder().build() 內含 fail-fast 驗證 |
MailerSpec / MailerSpecProvider | 郵件設定契約與來源 SPI——設定怎麼存歸消費端,框架只宣告「我需要什麼」 |
MimeMessageBuilder | Fluent 組信(HTML、附件、CC/BCC),send() 直接寄出 |
分工原則:寄信的注入 Mailer 介面(不需要知道部署怎麼配郵件);維運與具名解析注入 RoutingMailer 具體型別(named() / evict() / current());組裝收斂在應用的 mail 組態(參考實作為 app-tenant-server 的 MailConfig)。
寄信(消費端)
基本用法
import io.leandev.appfuse.mail.Mailer;
@Service
@RequiredArgsConstructor
public class OrderNotificationService {
private final Mailer mailer; // 解析到 RoutingMailer(@Primary)
public void sendConfirmation(String to, String subject, String html) {
mailer.compose() // from 已預填(範圍專屬設定或系統預設)
.to(to)
.subject(subject)
.html(html)
.send();
}
}
compose()回傳的MimeMessageBuilder已預填寄件者;呼叫端可再以.from(...)覆寫。- 解析不到任何郵件設定(provider 給不出、也沒配系統預設)時,
compose()/send()拋MailUnavailableException。 - 「整個部署未配郵件」的合法狀態由消費端以
ObjectProvider<Mailer>接(無 bean)。
HTML + 附件
mailer.compose()
.to("recipient@example.com")
.to("another@example.com", "收件者名稱") // 可指定顯示名稱
.cc("cc@example.com")
.subject("含附件的 HTML 郵件")
.html("<h1>標題</h1><p>這是 <strong>HTML</strong> 內容</p>")
.attachment("report.pdf", "/path/to/report.pdf")
.attachment("data.xlsx", excelBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.send();
InputStream 附件
try (InputStream inputStream = new FileInputStream("document.pdf")) {
mailer.compose()
.to("recipient@example.com")
.subject("附件測試")
.text("請查閱附件")
.attachment(inputStream, "document.pdf", "application/pdf")
.send();
}
錯誤處理
try {
mailer.compose().to(to).subject(subject).html(html).send();
} catch (MailBlockedException e) {
// 政策封鎖:收件者全被防火牆擋下(見「外寄政策」)
} catch (MailUnavailableException e) {
// 無可解析的郵件設定
} catch (MailException e) {
log.error("郵件發送失敗", e);
}
外寄政策(MailOutboundPolicy)
防火牆/白名單/debug/改寄/特殊訊息回答的都是「這個環境能不能把信寄到真實收件者」,與「是誰在寄」無關——故它們是環境層政策、對所有 Mailer 一律生效,不是郵件設定資源的欄位。政策是 DirectMailer 的建構參數:不給政策就建不出 Mailer,「所有 Mailer 走同一段政策碼」由建構子擔保。
MailOutboundPolicy policy = MailOutboundPolicy.builder()
.firewall(true)
.allowedDomains("dev.example.com", "test.example.com")
.debug(true) // JavaMail debug(詳細 SMTP 交互日誌)
.redirectTo("qa-inbox@example.com") // 改寄(測試/展示用;空白=不啟用)
.noticeSubjectPrefix("[TEST] ") // 特殊訊息主旨前綴
.noticeHeader("X-Environment", "test")
.mailConfigured(true) // 「已明確配置郵件」訊號,決定 fail-fast 或 WARN
.build(); // 建構即驗證
建構即驗證(fail-fast)
「不誤發真實郵件」採 production-safe 預設 + fail-fast。build() 要求政策三擇一:
| 政策 | 效果 |
|---|---|
| redirect 生效 | 所有外寄郵件收合改寄到單一信箱,不觸及真實收件者(redirect 優先於防火牆短路,isFirewallActive() 回 false) |
| firewall 開 + 有白名單 | 只寄給白名單網域的收件者(測試環境放行外部客戶網域) |
| firewall 關 | 全寄真實收件者(僅正式環境;建構時 WARN 留痕) |
危險組合的處置依 mailConfigured:
| 組合 | 已配置郵件 | 未配置郵件 |
|---|---|---|
| firewall 開 + 白名單空(=全擋) | 拋 IllegalStateException(啟動失敗,迫使明確決策) | WARN,政策仍生效(寄不出去,不是誤寄) |
| redirect 開卻沒填收件信箱 | 拋 IllegalStateException | WARN,redirect 不生效 |
防火牆行為
| 情境 | 行為 |
|---|---|
| 防火牆停用 | 所有郵件正常發送 |
| 防火牆啟用 + 無白名單 | 全擋——拋 MailBlockedException(大聲失敗,不靜默丟棄) |
| 部分收件者在白名單 | 過濾後發送(只發給白名單收件者) |
| 收件者全不在白名單 | 拋 MailBlockedException |
收件者改寄(Redirect)
測試或展示時把寄給真實收件者的郵件統一攔到指定信箱。與防火牆正交:redirect 啟用時優先生效並使防火牆短路(改寄目標即唯一收件者)。
| 項目 | 行為 |
|---|---|
| 收件者 | TO/CC/BCC 全部收合成單一 TO = 指定信箱,CC/BCC 清空 |
| 主旨 | 前綴標註原收件者,如 [REDIRECTED→alice@customer.com] 原主旨 |
| 標頭 | 加 X-Original-To 記錄原收件者,便於程式化辨識 |
| 紀錄 | 每次改寄發出一筆 WARN log |
| 適用範圍 | MimeMessage 與 SimpleMailMessage 兩條送信路徑皆生效 |
特殊訊息(Notice)
送出前在 chokepoint 對每封信加主旨前綴與自訂 MIME 標頭,典型用途是標記非正式環境郵件(主旨 [TEST]、標頭 X-Environment: test 供閘道過濾)。主旨前綴對兩種訊息皆生效;自訂標頭僅 MimeMessage 生效。範圍專屬設定可覆寫主旨前綴(MailerSpec.noticeSubjectPrefix),其餘政策項不可覆寫。
建立 Mailer(組裝)
SMTP(帳密認證)
JavaMailSender sender = JavaMailSenderBuilder
.create("smtp.example.com", 587)
.authenticator(new BasicAuthenticator("user@example.com", "password"))
.property("mail.smtp.starttls.enable", "true")
.build();
Mailer mailer = new DirectMailer(sender, policy, "System <noreply@example.com>");
第三個參數是預設寄件者(Name <addr> 或純位址),compose() 據此預填 from。
Gmail OAuth2
OAuth2MailAuthenticator authenticator = OAuth2MailAuthenticatorBuilder.forGmail()
.clientId("your-client-id")
.clientSecret("your-client-secret")
.username("sender@gmail.com")
.build();
JavaMailSender sender = JavaMailSenderBuilder.forGmail(authenticator).build();
Mailer mailer = new DirectMailer(sender, policy, "sender@gmail.com");
Office365 OAuth2
OAuth2MailAuthenticator authenticator = OAuth2MailAuthenticatorBuilder
.forOffice365("your-tenant-id")
.clientId("your-client-id")
.clientSecret("your-client-secret")
.username("sender@yourcompany.onmicrosoft.com")
.build();
JavaMailSender sender = JavaMailSenderBuilder.forOffice365(authenticator).build();
Mailer mailer = new DirectMailer(sender, policy, "sender@yourcompany.onmicrosoft.com");
OAuth2 應用程式設定
Gmail (Google Cloud Console):
- 建立 OAuth 2.0 用戶端 ID
- 啟用 Gmail API
- 設定 scope:
https://www.googleapis.com/auth/gmail.send
Office365 (Azure Portal):
- 註冊應用程式
- 新增 API 權限:
https://outlook.office365.com/.default - 授予管理員同意
動態設定(RoutingMailer + MailerSpecProvider)
郵件設定住 DB、執行期可變(管理者在 UI 上改設定、多組具名設定、或 per-租戶設定)時,用 RoutingMailer:它在寄信當下解析該用哪個 Mailer,消費端仍只注入 Mailer 介面。
契約:框架宣告「它需要什麼」,不宣告「你怎麼存」
public record MailerSpec(
String id, // 快取鍵;設定識別,同一範圍內唯一
String revision, // 內容指紋;變更即觸發重建
Transport transport, // SMTP 或 OAUTH2
Smtp smtp, OAuth2 oauth2,
String fromAddress, String fromName,
String noticeSubjectPrefix) { }
@FunctionalInterface
public interface MailerSpecProvider {
Optional<MailerSpec> currentDefault(); // 當前範圍的預設設定
default Optional<MailerSpec> byName(String configName) { // 具名查找(可不實作)
return Optional.empty();
}
}
持久化形狀、表結構、租戶性、CRUD、授權全由消費端決定;框架不含任何 entity,對隔離取向天然中性。
解析順序與快取
- provider 給得出當前範圍的 spec → 依 spec 建構(套同一份外寄政策)、以
id快取 - 給不出(或根本沒有 provider)→ 系統預設 Mailer
- 兩者皆無 → 寄信時拋
MailUnavailableException
- revision 指紋自動重建:消費端只要保證「內容變 → revision 變」(
lastModifiedDate/@Version皆可),不需要在異動後清快取——那是典型「忘了就靜默用舊憑證寄信」的陷阱。 - 具名解析不 fallback:
named("support")找不到就回空,不悄悄用系統預設——指名了某個設定卻用別的,寄件身分會與預期不符。 - 「範圍」是 provider 的責任:範圍不明確時(如租戶模式下無
TenantContext)必須回空,不可任選一筆。
組裝範例(Spring 配置)
@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class MailConfig {
@Bean
@ConditionalOnMissingBean(MailOutboundPolicy.class)
public MailOutboundPolicy mailOutboundPolicy(MailProperties properties) {
var firewall = properties.getFirewall();
var redirect = properties.getRedirect();
return MailOutboundPolicy.builder()
.firewall(firewall.isEnabled())
.allowedDomains(firewall.getAllowedDomains().toArray(String[]::new))
.debug(properties.getDebug().isEnabled())
.redirect(redirect.isEnabled(), redirect.getTo())
.noticeSubjectPrefix(properties.getNotice().getSubjectPrefix())
.noticeHeaders(properties.getNotice().getHeaders())
.mailConfigured(properties.getType() != null)
.build();
}
@Bean("systemDefaultMailer")
@ConditionalOnBean(JavaMailSender.class)
@ConditionalOnMissingBean(name = "systemDefaultMailer")
public DirectMailer systemDefaultMailer(
JavaMailSender sender,
MailOutboundPolicy policy,
MailProperties properties) {
properties.validateSystemMailer();
return new DirectMailer(sender, policy, properties.buildFromAddress());
}
@Bean
@Primary
@ConditionalOnMissingBean(RoutingMailer.class)
public RoutingMailer mailer(
ObjectProvider<MailerSpecProvider> specProvider,
@Qualifier("systemDefaultMailer") ObjectProvider<DirectMailer> systemDefault,
MailOutboundPolicy policy) {
return new RoutingMailer(specProvider.getIfAvailable(), systemDefault.getIfAvailable(), policy);
}
}
完整實作分成兩層:feature/mail 提供上述 production-safe 組裝,以及固定的
MailEndpoint、MailHealthIndicator 與 mail:ops 維運授權;具體 MailSetting schema、
CRUD URL、provider adapter 與 mail_setting:* 權限位於附屬的業務層 mail reference
domain。兩個應用 server 各自作者化自己的 schema 與 provider:app-tenant-server
以 tenant discriminator 隔離設定;app-tenantless-server 使用 application scope 與全域
config_name 唯一。模組型別已表達隔離取向,不另設 runtime isolation 開關。
MailSetting 的 SMTP 密碼與 OAuth2 client secret 由應用層 persistence-encryption feature
保護。部署只需管理 app.persistence-encryption 的單一 root key ring;mail 固定使用
appfuse/mail/v1 purpose 經 HKDF 衍生專用 key,不另設 mail encryption key,也不直接使用
root key。密文 envelope 帶 key id,輪替期間可同時保留新舊 root key。完整設定與輪替流程見
持久化資料加密指南。
reference implementation 對 SMTP password 與 OAuth2 client secret 的 plaintext 上限皆為
1000 字元;POST 由 Bean Validation、PATCH 與直接 service 呼叫由 service boundary 同步把關。
Entity 的 smtp_password/oauth2_client_secret 保存的是會膨脹的密文,因此使用
Length.LONG32,不把 1000 字元 plaintext 契約錯誤映射成 VARCHAR(1000) ciphertext 容量。
MySQL 既有表請在升級 reference implementation 前執行:
ALTER TABLE mail_settings
MODIFY smtp_password LONGTEXT NULL,
MODIFY oauth2_client_secret LONGTEXT NULL;
具名設定與維運操作
@Service
@RequiredArgsConstructor
public class SupportMailService {
private final RoutingMailer routingMailer; // 注入具體型別
public void reply(String to, String subject, String text) {
routingMailer.named("support") // 不 fallback 到系統預設
.orElseThrow(() -> new IllegalStateException("Mail configuration not found: support"))
.compose().to(to).subject(subject).text(text).send();
}
}
// 維運操作
routingMailer.isAvailable(); // 當前範圍是否有可用郵件服務
routingMailer.current(); // Optional<Mailer>(健康檢查用)
routingMailer.cacheSize();
routingMailer.evict(specId); // 一般不需要——revision 變更自動重建
routingMailer.evictAll(); // 平台維運動作(如外部 SMTP 全面輪換)
測試與診斷
連接測試
Mailer.MailConnectionTestResult result = mailer.testConnection();
if (result.isSuccess()) {
System.out.println("連接成功: " + result.getHost() + ":" + result.getPort());
} else {
System.out.println("連接失敗: " + result.getMessage());
}
發送測試郵件(DirectMailer)
// 刻意繞過外寄政策(直接走底層 sender):平台維運驗證「線路通不通」的動作,
// 收件者由維運者顯式指定,不應被防火牆/改寄攔截
DirectMailer.MailSendTestResult result = directMailer.sendTestEmail(
"test@example.com", "郵件發送器測試");
發送器資訊(DirectMailer)
DirectMailer.MailSenderInfo info = directMailer.getMailSenderInfo();
System.out.println("Host: " + info.getHost());
System.out.println("Port: " + info.getPort());
System.out.println("防火牆啟用: " + info.isFirewallEnabled());
System.out.println("白名單數量: " + info.getAllowedDomainsCount());
參考實作的設定屬性(app.mail.*)
參考實作 app-tenant-server 把政策值外部化為 app.mail.*,經 /env-config 按環境產生:
MailSetting 的持久化 root key 不屬於 app.mail.*;它由獨立 Feature 擁有:
app:
persistence-encryption:
active-key-id: v1
keys:
v1: ${APP_PERSISTENCE_ENCRYPTION_KEY:}
舊的 app.mail.persistence-encryption-key/MAIL_SETTINGS_ENCRYPTION_KEY 已移除。reference
implementation 與目前下游在切換前尚未保存 credential,因此不提供 migration runner。若其他
採用端確實已有舊格式 ciphertext,不能只改設定名稱;必須持有舊 key,以專案的一次性離線工具
完成解密與重加密。
app:
mail:
type: smtp # gmail / office365 / smtp;不設=不建系統 Mailer
system-account: noreply@example.com
from-name: "System Notification"
smtp:
host: smtp.example.com
port: 587
username: noreply@example.com
password: ${SMTP_PASSWORD}
firewall:
enabled: true # production-safe 預設:開 + 空白名單=全擋 fail-fast
allowed-domains:
- dev.example.com
redirect:
enabled: false # 測試/展示才開;正式環境務必保持關閉
to: qa-inbox@example.com
notice:
subject-prefix: "[TEST] "
headers:
X-Environment: test
debug:
enabled: false
系統預設 Mailer 與範圍專屬 Mailer 走同一份政策(
MailOutboundPolicy),不論郵件走哪條配置路徑,防火牆/改寄/特殊訊息一律生效。
最佳實踐
- 寄信的消費端一律注入
Mailer介面——不要注入DirectMailer/RoutingMailer具體型別,除非需要具名解析或維運操作。部署怎麼配郵件(靜態單一設定 vs 動態多設定)對消費端透明。 - 政策集中一處建構——
MailOutboundPolicy在應用的 mail 組態建一顆 bean,所有 Mailer 建構共用;不要在多處各自拼政策。 - 環境分離靠設定值,不靠程式分支——dev 開防火牆+debug、test 白名單或 redirect、prod 關防火牆,全由外部 conf 的
app.mail.*決定,程式碼不變。 - 動態設定的 revision 用稽核欄位——
lastModifiedDate天然滿足「內容變 → revision 變」,不需要自己維護版本號。 - 附件用 try-with-resources,並自行限制附件大小(如 10MB)。
下一步
- OAuth2 模組 - 深入了解 OAuth2 認證機制
- Security 模組 - 安全性相關功能
- HTTP 模組 - HTTP 客戶端工具