JavaMail 是 Java 平台郵件收發標準 API。本文提供生產級封裝:簡單文本/HTML/附件/內嵌圖片郵件發送、異步模板發送、郵件服務器配置、異常處理、安全最佳實踐。
| 協議 | 用途 | 默認端口 | SSL/TLS 端口 |
|---|---|---|---|
| SMTP | 發送 | 25 | 465 (SSL), 587 (STARTTLS) |
| POP3 | 接收(下載) | 110 | 995 |
| IMAP | 接收(同步) | 143 | 993 |
# 163/126/yeah.net
host: smtp.163.com, port: 465/994 (SSL) 或 25/587 (STARTTLS)
# QQ 郵箱
host: smtp.qq.com, port: 465/587 (需授權碼)
# Gmail
host: smtp.gmail.com, port: 465/587 (需應用專用密碼)
# Outlook
host: smtp.office365.com, port: 587 (STARTTLS)
# 企業郵箱
咨詢 IT 部門獲取專用 SMTP 服務器
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
<!-- Spring Boot 整合 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
@Data
@ConfigurationProperties(prefix = "spring.mail")
public class MailProperties {
private String host;
private int port = 587;
private String username;
private String password;
private String protocol = "smtp";
private String defaultEncoding = "UTF-8";
private Properties properties = new Properties();
@PostConstruct
public void init() {
properties.put("mail.smtp.host", host);
properties.put("mail.smtp.port", port);
properties.put("mail.smtp.auth", "true");
properties.put("mail.transport.protocol", protocol);
properties.put("mail.smtp.starttls.enable", "true"); // STARTTLS
properties.put("mail.smtp.ssl.enable", "false"); // SSL 另行配置
properties.put("mail.debug", "false");
properties.put("mail.mime.charset", defaultEncoding);
}
// SSL 配置方法
public void enableSsl() {
properties.put("mail.smtp.ssl.enable", "true");
properties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
properties.put("mail.smtp.socketFactory.port", port);
}
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class EmailMessage {
private String[] to; // 收件人
private String[] cc; // 抄送
private String[] bcc; // 密送
private String subject; // 主題
private String text; // 純文本內容
private String html; // HTML 內容
private List attachments; // 附件
private List inlineResources; // 內嵌資源
private String from; // 發件人(可選,默認配置)
private String replyTo; // 回復地址
private Map headers; // 自定義頭部
@Data
@Builder
public static class Attachment {
private String filename;
private byte[] content;
private String contentType;
private File file; // 或直接文件
}
@Data
@Builder
public static class InlineResource {
private String contentId; // CID,HTML 中引用 cid:xxx
private byte[] content;
private String contentType;
private File file;
}
}
@Service
@Slf4j
public class MailService {
private final Session session;
private final String defaultFrom;
public MailService(MailProperties props) {
this.session = Session.getInstance(props.getProperties(),
new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(props.getUsername(), props.getPassword());
}
});
this.defaultFrom = props.getUsername();
}
// 同步發送入口
public void send(EmailMessage email) {
sendMail(buildMimeMessage(email));
}
// 異步發送
@Async("mailExecutor")
public CompletableFuture sendAsync(EmailMessage email) {
sendMail(buildMimeMessage(email));
return CompletableFuture.completedFuture(null);
}
// 模板發送
public void sendTemplate(String templateName, Map model, String... to) {
String html = templateEngine.process(templateName, model);
EmailMessage email = EmailMessage.builder()
.to(to)
.subject(model.get("subject").toString())
.html(html)
.build();
send(email);
}
private MimeMessage buildMimeMessage(EmailMessage email) {
try {
MimeMessage message = new MimeMessage(session);
// 發件人
message.setFrom(new InternetAddress(
StringUtils.hasText(email.getFrom()) ? email.getFrom() : defaultFrom));
// 收件人
setRecipients(message, Message.RecipientType.TO, email.getTo());
setRecipients(message, Message.RecipientType.CC, email.getCc());
setRecipients(message, Message.RecipientType.BCC, email.getBcc());
// 回復地址
if (StringUtils.hasText(email.getReplyTo())) {
message.setReplyTo(InternetAddress.parse(email.getReplyTo()));
}
// 主題
message.setSubject(email.getSubject(), "UTF-8");
// 自定義頭部
if (email.getHeaders() != null) {
email.getHeaders().forEach((k, v) -> {
try { message.addHeader(k, v); } catch (MessagingException ignored) {}
});
}
// 構建內容
Multipart multipart = buildContent(email);
message.setContent(multipart);
message.saveChanges(); // 重要:生成 Message-ID、Date 等頭部
return message;
} catch (MessagingException | UnsupportedEncodingException e) {
throw new MailBuildException("郵件構建失敗", e);
}
}
private void setRecipients(MimeMessage message, Message.RecipientType type, String[] addresses)
throws MessagingException {
if (addresses != null && addresses.length > 0) {
InternetAddress[] internetAddresses = Arrays.stream(addresses)
.filter(StringUtils::hasText)
.map(addr -> {
try { return new InternetAddress(addr); }
catch (Exception e) { throw new IllegalArgumentException("無效郵箱: " + addr, e); }
})
.toArray(InternetAddress[]::new);
message.setRecipients(type, internetAddresses);
}
}
private Multipart buildContent(EmailMessage email) throws MessagingException {
// 判斷內容類型選擇 multipart 子類型
boolean hasAttachments = email.getAttachments() != null && !email.getAttachments().isEmpty();
boolean hasInline = email.getInlineResources() != null && !email.getInlineResources().isEmpty();
boolean hasHtml = StringUtils.hasText(email.getHtml());
boolean hasText = StringUtils.hasText(email.getText());
// 策略:
// 1. 只有文本 → 直接 setText
// 2. 只有 HTML → mixed 或 related
// 3. 文本+HTML → alternative
// 4. 有內嵌資源 → related 包裝 alternative
// 5. 有附件 → mixed 包裝以上所有
if (!hasAttachments && !hasInline) {
// 簡單情況
if (hasHtml && hasText) {
MimeMultipart mp = new MimeMultipart("alternative");
addTextPart(mp, email.getText());
addHtmlPart(mp, email.getHtml());
return mp;
} else if (hasHtml) {
MimeMultipart mp = new MimeMultipart("related");
addHtmlPart(mp, email.getHtml());
return mp;
} else {
MimeMessage msg = new MimeMessage(session);
msg.setText(email.getText(), "UTF-8");
return new MimeMultipart(); // 不會走這裡
}
}
// 複雜情況:mixed 最外層
MimeMultipart mixed = new MimeMultipart("mixed");
// 內容主體:alternative (文本+HTML) 或 related (HTML+內嵌)
Multipart body;
if (hasHtml && hasText) {
body = new MimeMultipart("alternative");
addTextPart(body, email.getText());
addHtmlPart(body, email.getHtml());
} else if (hasHtml) {
body = new MimeMultipart("related");
addHtmlPart(body, email.getHtml());
} else {
body = new MimeMultipart("mixed");
addTextPart(body, email.getText());
}
// 內嵌資源加入 body (若 body 是 related)
if (hasInline && body instanceof MimeMultipart) {
MimeMultipart related = (MimeMultipart) body;
for (EmailMessage.InlineResource res : email.getInlineResources()) {
addInlinePart(related, res);
}
}
// 將 body 加入 mixed
MimeBodyPart bodyPart = new MimeBodyPart();
try {
bodyPart.setContent(body);
mixed.addBodyPart(bodyPart);
} catch (MessagingException e) {
throw new MailBuildException("內容組裝失敗", e);
}
// 附件加入 mixed
if (hasAttachments) {
for (EmailMessage.Attachment att : email.getAttachments()) {
addAttachmentPart(mixed, att);
}
}
return mixed;
}
private void addTextPart(Multipart mp, String text) throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
part.setContent(text, "text/plain;charset=UTF-8");
mp.addBodyPart(part);
}
private void addHtmlPart(Multipart mp, String html) throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
part.setContent(html, "text/html;charset=UTF-8");
mp.addBodyPart(part);
}
private void addInlinePart(Multipart mp, EmailMessage.InlineResource res) throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
if (res.getFile() != null) {
part.attachFile(res.getFile());
} else {
part.setContent(res.getContent(), res.getContentType());
}
part.setContentID("<" + res.getContentId() + ">"); // 標準 CID 格式
part.setDisposition(MimeBodyPart.INLINE);
mp.addBodyPart(part);
}
private void addAttachmentPart(Multipart mp, EmailMessage.Attachment att) throws MessagingException {
MimeBodyPart part = new MimeBodyPart();
if (att.getFile() != null) {
part.attachFile(att.getFile());
} else {
part.setContent(att.getContent(), att.getContentType());
}
// 解決中文附件名亂碼
String encodedName = MimeUtility.encodeText(att.getFilename(), "UTF-8", "B");
part.setFileName(encodedName);
part.setDisposition(MimeBodyPart.ATTACHMENT);
mp.addBodyPart(part);
}
private void sendMail(MimeMessage message) {
try {
Transport.send(message);
log.info("郵件發送成功: to={}, subject={}",
Arrays.toString(message.getRecipients(Message.RecipientType.TO)),
message.getSubject());
} catch (MessagingException e) {
log.error("郵件發送失敗: {}", message.getSubject(), e);
throw new MailSendException("郵件發送失敗", e);
}
}
}
spring:
mail:
host: smtp.163.com
port: 465
username: your_email@163.com
password: your_auth_code # 授權碼,非登錄密碼
protocol: smtp
default-encoding: UTF-8
properties:
mail:
smtp:
ssl:
enable: true
auth: true
starttls:
enable: false
socketFactory:
class: javax.net.ssl.SSLSocketFactory
port: 465
debug: false
# 異步線程池
task:
execution:
pool:
mail:
core-size: 5
max-size: 20
queue-capacity: 100
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("mailExecutor")
public ThreadPoolTaskExecutor mailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("mail-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@Autowired
private MailService mailService;
public void notifyAdmin(String message) {
EmailMessage email = EmailMessage.builder()
.to(new String[]{"admin@company.com"})
.subject("系統告警")
.text(message)
.build();
mailService.sendAsync(email);
}
public void sendWelcomeEmail(User user) {
String html = "歡迎 " + user.getName() + "!
" +
"您的帳號已激活,點擊登錄
" +
"
"; // 內嵌圖片
EmailMessage email = EmailMessage.builder()
.to(new String[]{user.getEmail()})
.subject("歡迎加入我們")
.html(html)
.inlineResources(List.of(
InlineResource.builder()
.contentId("logo")
.file(new File("classpath:static/images/logo.png"))
.contentType("image/png")
.build()
))
.build();
mailService.sendAsync(email);
}
public void sendReportWithAttachment(String to, File reportFile) {
EmailMessage email = EmailMessage.builder()
.to(new String[]{to})
.subject("月度報表")
.text("請查收附件中的月度報表。")
.attachments(List.of(
Attachment.builder()
.filename("月度報表_" + LocalDate.now() + ".pdf")
.file(reportFile)
.contentType("application/pdf")
.build()
))
.build();
mailService.sendAsync(email);
}
@Autowired
private TemplateEngine templateEngine; // Thymeleaf
public void sendOrderConfirmation(Order order) {
Context ctx = new Context();
ctx.setVariables(Map.of(
"order", order,
"customer", order.getCustomer(),
"items", order.getItems(),
"subject", "訂單確認 - " + order.getOrderNo()
));
mailService.sendTemplate("email/order-confirmation", ctx.getVariables(),
order.getCustomer().getEmail());
}
| 問題 | 原因 | 解決 |
|---|---|---|
| 發送超時 | 網絡/防火牆/端口 | 檢查 465/587 端口連通性;嘗試 STARTTLS (587) |
| 認證失敗 | 密碼錯誤/未開啟 SMTP | 使用授權碼/應用專用密碼;郵箱設置開啟 SMTP |
| 進垃圾箱 | 內容/域名信譽/SPF/DKIM | 配置 SPF/DKIM/DMARC;避免敏感詞;使用企業郵箱 |
| 中文附件名亂碼 | RFC 2231 編碼 | MimeUtility.encodeText(filename, "UTF-8", "B") |
| 大附件發送失敗 | 郵箱大小限制 (通常 20-50MB) | 分卷壓縮/雲存儲鏈接/斷點續傳 |
| 連接泄漏 | Transport 未關閉 | 使用 Transport.send() 靜態方法自動管理 |
| HTML 顯示異常 | CSS 不兼容/圖片引用失效 | 內聯 CSS、內嵌關鍵圖片、內聯關鍵樣式 |
@Timed("mail.send")
public void send(EmailMessage email) { ... }
// 關鍵指標
- mail.send.total: 發送總數
- mail.send.success: 成功數
- mail.send.failed: 失敗數
- mail.send.duration: 發送耗時
- mail.queue.size: 異步隊列積壓
- mail.connection.pool.usage: 連接池使用率
JavaMail 封裝核心在於:MimeMessage 正確構建(multipart 嵌套邏輯)、異步非阻塞發送、模板引擎集成、異常分類處理、安全合規。生產環境必備:異步線程池、重試機制、限流熔斷、審計日誌、退信監控。Spring Boot `spring-boot-starter-mail` 提供良好自動配置,建議在其基礎上構建業務級 `MailService`。