Spring 對 JavaMail 的封裝極大簡化了郵件發送開發。結合 FreeMarker 模板引擎,能優雅地生成富文本 HTML 郵件。本文基於 Spring Boot 3.x / Spring 6.x,展示從配置到發送的完整流程,含附件、內嵌圖片、異步發送等進階場景。
<dependencies>
<!-- Spring Boot Mail Starter (含 JavaMail) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- FreeMarker 模板引擎 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<!-- Lombok 簡化代碼 (可選) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
# application.yml
spring:
mail:
host: smtp.qq.com
port: 465 # SSL 端口
username: your_email@qq.com
password: your_auth_code # 授權碼,非登錄密碼
protocol: smtp
default-encoding: UTF-8
properties:
mail:
smtp:
auth: true
ssl:
enable: true # 啟用 SSL
# required: true # 強制 SSL
socketFactory:
class: javax.net.ssl.SSLSocketFactory
fallback: false
debug: false # 生產關閉
freemarker:
template-loader-path: classpath:/templates/mail/
suffix: .ftl
charset: UTF-8
settings:
number_format: '#' # 避免數字顯示科學計數法
templates/mail/user-deleted.ftl<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: 'Microsoft YaHei', sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px; }
.header { background: #f8f9fa; padding: 15px; border-radius: 6px 6px 0 0; border-bottom: 1px solid #e0e0e0; }
.content { padding: 20px 15px; }
.detail { background: #f8f9fa; padding: 15px; border-radius: 6px; margin-top: 15px; font-size: 14px; }
.footer { margin-top: 20px; padding-top: 15px; border-top: 1px solid #e0e0e0; font-size: 12px; color: #999; text-align: center; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; background: #ff4d4f; color: white; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0; color: #1890ff;">用戶刪除通知</h2>
</div>
<div class="content">
<p>您好,系統檢測到用戶 ${user.name} <span class="badge">已刪除</span>。</p>
<div class="detail">
<p><strong>用戶 ID</strong>: ${user.id}</p>
<p><strong>郵箱</strong>: ${user.email}</p>
<p><strong>註冊時間</strong>: ${user.createdTime?string("yyyy-MM-dd HH:mm:ss")}</p>
<p><strong>操作人</strong>: ${operator}</p>
</div>
</div>
<div class="footer">
<p>此郵件由系統自動發送,請勿直接回覆。</p>
<p>${systemName} 運營團隊 <#if currentTime??>- ${currentTime?string("yyyy-MM-dd HH:mm:ss")}</#if></p>
</div>
</div>
</body>
</html>
package com.example.mail;
import freemarker.template.Configuration;
import freemarker.template.Template;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.mail.javamail.MimeMessagePreparator;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class MailService {
private final JavaMailSender mailSender;
private final Configuration freemarkerConfig;
/**
* 發送用戶刪除通知郵件 (含附件與內嵌圖片)
*/
@Async("mailExecutor") // 異步發送,需配置 ThreadPoolTaskExecutor Bean 名為 mailExecutor
public void sendUserDeletedNotification(User user, String operator) {
MimeMessagePreparator preparator = mimeMessage -> {
MimeMessageHelper helper = new MimeMessageHelper(
mimeMessage,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED, // 支援附件+內嵌資源
StandardCharsets.UTF_8.name()
);
helper.setFrom("your_email@qq.com");
helper.setTo(user.getEmail()); // 或管理員郵箱
helper.setSubject("【系統通知】用戶刪除:" + user.getName());
// 1. 準備模型數據
Map model = new HashMap<>();
model.put("user", user);
model.put("operator", operator);
model.put("systemName", "MyApp 管理後台");
model.put("currentTime", java.time.LocalDateTime.now());
// 2. 渲染 FreeMarker 模板
String htmlContent = FreeMarkerTemplateUtils.processTemplateIntoString(
freemarkerConfig.getTemplate("user-deleted.ftl"), model);
// 3. 設置 HTML 內容 (true 表示是 HTML)
helper.setText(htmlContent, true);
// 4. 添加附件 (如用戶導出數據 CSV)
helper.addAttachment("user-data-export.csv",
new ClassPathResource("static/exports/user-" + user.getId() + ".csv"));
// 5. 添加內嵌圖片 (HTML 中用 <img src="cid:logo"> 引用)
helper.addInline("logo", new ClassPathResource("static/images/logo.png"));
};
try {
mailSender.send(preparator);
log.info("用戶刪除通知郵件發送成功: userId={}, to={}", user.getId(), user.getEmail());
} catch (MailException e) {
log.error("郵件發送失敗: userId={}, error={}", user.getId(), e.getMessage(), e);
// 可拋出業務異常觸發重試/告警
}
}
/**
* 發送簡單文本郵件
*/
public void sendSimpleMail(String to, String subject, String text) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name());
helper.setFrom("your_email@qq.com");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(text, false); // 純文本
mailSender.send(message);
} catch (MessagingException e) {
throw new RuntimeException("簡單郵件發送失敗", e);
}
}
}
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("mailExecutor")
public Executor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("mail-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 拒絕策略:調用者執行
executor.initialize();
return executor;
}
}
package com.example.controller;
import com.example.mail.MailService;
import com.example.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final MailService mailService;
@DeleteMapping("/{id}")
public ResponseEntity deleteUser(@PathVariable Long id) {
// 1. 查找用戶 (模擬)
User user = userService.findById(id);
if (user == null) {
return ResponseEntity.notFound().build();
}
// 2. 執行刪除業務邏輯
userService.deleteById(id);
// 3. 異步發送通知郵件 (非阻塞)
mailService.sendUserDeletedNotification(user, "admin");
return ResponseEntity.noContent().build();
}
}
@Retryable(retryFor = {MailException.class}, maxAttempts = 3, backoff = @Backoff(delay = 2000))
public void sendWithRetry(MimeMessagePreparator preparator) {
mailSender.send(preparator);
}
需啟用 `@EnableRetry` 並引入 `spring-retry` 依賴。
FreeMarker 默認不轉義 `${var}`。若模型數據來自用戶輸入,務必手動轉義或配置全域轉義:
// 模板中手動轉義
${user.name?html}
// 或配置 Configuration
configuration.setOutputFormat(OutputFormat.HTML); // FreeMarker 2.3.24+
// 則 ${var} 自動 HTML 轉義,需原始輸出用 ${var?no_esc}
| 現象 | 可能原因 | 解決方案 |
|---|---|---|
| 535 Error: Authentication failed | 授權碼錯誤 / 未開啟 SMTP 服務 | 郵箱設定中生成專用授權碼,開啟 SMTP/IMAP 服務 |
| Could not connect to SMTP host | 端口/協議錯誤 / 防火牆攔截 | QQ 郵箱 SSL 用 465/587,確認 `ssl.enable=true` |
| 附件中文檔名亂碼 | 編碼問題 | 使用 `MimeUtility.encodeText(filename)` 或 Spring Boot 2.2+ 自動處理 |
| 模板找不到 | 路徑前綴/後綴配置錯誤 | 檢查 `spring.freemarker.template-loader-path` 對應 `classpath:/templates/mail/` |
Spring Boot + JavaMail + FreeMarker 組合提供了企業級郵件發送能力:
掌握此棧,讓系統通知、營銷郵件、報表訂閱等場景開發事半功倍。