跳至主要内容

郵件模組

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——設定怎麼存歸消費端,框架只宣告「我需要什麼」
MimeMessageBuilderFluent 組信(HTML、附件、CC/BCC),send() 直接寄出

分工原則:寄信的注入 Mailer 介面(不需要知道部署怎麼配郵件);維運與具名解析注入 RoutingMailer 具體型別named() / evict() / current());組裝收斂在應用的 mail 組態(參考實作為 app-tenant-serverMailConfig)。

寄信(消費端)

基本用法

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 開卻沒填收件信箱IllegalStateExceptionWARN,redirect 不生效

防火牆行為

情境行為
防火牆停用所有郵件正常發送
防火牆啟用 + 無白名單全擋——拋 MailBlockedException(大聲失敗,不靜默丟棄)
部分收件者在白名單過濾後發送(只發給白名單收件者)
收件者全不在白名單MailBlockedException

收件者改寄(Redirect)

測試或展示時把寄給真實收件者的郵件統一攔到指定信箱。與防火牆正交:redirect 啟用時優先生效並使防火牆短路(改寄目標即唯一收件者)。

項目行為
收件者TO/CC/BCC 全部收合成單一 TO = 指定信箱,CC/BCC 清空
主旨前綴標註原收件者,如 [REDIRECTED→alice@customer.com] 原主旨
標頭X-Original-To 記錄原收件者,便於程式化辨識
紀錄每次改寄發出一筆 WARN log
適用範圍MimeMessageSimpleMailMessage 兩條送信路徑皆生效

特殊訊息(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):

  1. 建立 OAuth 2.0 用戶端 ID
  2. 啟用 Gmail API
  3. 設定 scope: https://www.googleapis.com/auth/gmail.send

Office365 (Azure Portal):

  1. 註冊應用程式
  2. 新增 API 權限: https://outlook.office365.com/.default
  3. 授予管理員同意

動態設定(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,對隔離取向天然中性。

解析順序與快取

  1. provider 給得出當前範圍的 spec → 依 spec 建構(套同一份外寄政策)、以 id 快取
  2. 給不出(或根本沒有 provider)→ 系統預設 Mailer
  3. 兩者皆無 → 寄信時拋 MailUnavailableException
  • revision 指紋自動重建:消費端只要保證「內容變 → revision 變」(lastModifiedDate / @Version 皆可),不需要在異動後清快取——那是典型「忘了就靜默用舊憑證寄信」的陷阱。
  • 具名解析不 fallbacknamed("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 組裝,以及固定的 MailEndpointMailHealthIndicatormail: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_passwordoauth2_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-keyMAIL_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),不論郵件走哪條配置路徑,防火牆/改寄/特殊訊息一律生效。

最佳實踐

  1. 寄信的消費端一律注入 Mailer 介面——不要注入 DirectMailerRoutingMailer 具體型別,除非需要具名解析或維運操作。部署怎麼配郵件(靜態單一設定 vs 動態多設定)對消費端透明。
  2. 政策集中一處建構——MailOutboundPolicy 在應用的 mail 組態建一顆 bean,所有 Mailer 建構共用;不要在多處各自拼政策。
  3. 環境分離靠設定值,不靠程式分支——dev 開防火牆+debug、test 白名單或 redirect、prod 關防火牆,全由外部 conf 的 app.mail.* 決定,程式碼不變。
  4. 動態設定的 revision 用稽核欄位——lastModifiedDate 天然滿足「內容變 → revision 變」,不需要自己維護版本號。
  5. 附件用 try-with-resources,並自行限制附件大小(如 10MB)。

下一步