Java 發送郵件標準 API 為 **Jakarta Mail** (原 `javax.mail`,現 `jakarta.mail`),支援 SMTP/IMAP/POP3、SSL/TLS/STARTTLS、MIME 多部分郵件 (HTML+純文字+附件+內嵌圖片)。本文從核心 API 手寫發送器、工廠模式封裝、觀察者模式業務解耦、Spring Boot 整合、生產環境調優 (連線池、重試、監控) 全方位實戰指南。
<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>
package com.example.mail;
import jakarta.mail.*;
import jakarta.mail.internet.*;
import jakarta.activation.*;
import java.util.*;
import java.io.*;
public class SimpleMailSender {
private final Session session;
private final String from;
private final String fromName;
public SimpleMailSender(String host, int port, String username, String password,
String from, String fromName, boolean ssl, boolean starttls) {
this.from = from;
this.fromName = fromName;
Properties props = new Properties();
props.put("mail.smtp.host", host);
props.put("mail.smtp.port", port);
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.connectiontimeout", 5000);
props.put("mail.smtp.timeout", 10000);
props.put("mail.smtp.writetimeout", 10000);
if (ssl) {
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.socketFactory.port", port);
} else if (starttls) {
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
}
// 連線池設定 (Jakarta Mail 內建簡易池)
props.put("mail.smtp.pool.enable", "true");
props.put("mail.smtp.pool.maxsize", "10");
this.session = Session.getInstance(props, new Authenticator() {
@Override protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
// 除錯模式
// this.session.setDebug(true);
}
/** 發送純文字郵件 */
public void sendText(String to, String subject, String text) throws MessagingException {
send(to, subject, text, null, null, null);
}
/** 發送 HTML 郵件 (含純文字備用) */
public void sendHtml(String to, String subject, String html, String textFallback) throws MessagingException {
send(to, subject, textFallback, html, null, null);
}
/** 發送帶附件郵件 */
public void sendWithAttachments(String to, String subject, String html, String text,
List attachments, List inlineImages)
throws MessagingException {
send(to, subject, text, html, attachments, inlineImages);
}
/** 核心發送邏輯 */
private void send(String to, String subject, String text, String html,
List attachments, List inlineImages)
throws MessagingException {
MimeMessage msg = new MimeMessage(session);
msg.setFrom(new InternetAddress(from, fromName, "UTF-8"));
msg.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
msg.setSubject(subject, "UTF-8");
msg.setSentDate(new Date());
// 建立多層 MIME 結構
Multipart root = new MimeMultipart("mixed"); // 最外層 mixed: 附件 + related/alternative
// 1. alternative: text/plain + text/html (郵件客戶端自動選擇顯示)
Multipart alternative = new MimeMultipart("alternative");
if (text != null) {
MimeBodyPart textPart = new MimeBodyPart();
textPart.setContent(text, "text/plain; charset=UTF-8");
alternative.addBodyPart(textPart);
}
if (html != null) {
MimeBodyPart htmlPart = new MimeBodyPart();
htmlPart.setContent(html, "text/html; charset=UTF-8");
alternative.addBodyPart(htmlPart);
}
// 將 alternative 包入 root
MimeBodyPart altWrapper = new MimeBodyPart();
altWrapper.setContent(alternative);
root.addBodyPart(altWrapper);
// 2. related: HTML 內嵌圖片 (cid:)
if (inlineImages != null && !inlineImages.isEmpty()) {
Multipart related = new MimeMultipart("related");
// 將 alternative 移入 related
related.addBodyPart(altWrapper); // 需重新組裝,這裡簡化:假設 html 使用 cid
for (InlineImage img : inlineImages) {
MimeBodyPart imgPart = new MimeBodyPart();
imgPart.setDataHandler(new DataHandler(new ByteArrayDataSource(img.content, img.mimeType)));
imgPart.setHeader("Content-ID", "<" + img.cid + ">"); //
imgPart.setDisposition(MimeBodyPart.INLINE);
related.addBodyPart(imgPart);
}
// 替換 root 中的 alternative 為 related
root.removeBodyPart(altWrapper);
MimeBodyPart relWrapper = new MimeBodyPart();
relWrapper.setContent(related);
root.addBodyPart(relWrapper);
}
// 3. 附件
if (attachments != null) {
for (Attachment att : attachments) {
MimeBodyPart attPart = new MimeBodyPart();
attPart.setDataHandler(new DataHandler(new ByteArrayDataSource(att.content, att.mimeType)));
attPart.setFileName(MimeUtility.encodeText(att.filename, "UTF-8", "B")); // RFC 2047 編碼中文檔名
attPart.setDisposition(MimeBodyPart.ATTACHMENT);
root.addBodyPart(attPart);
}
}
msg.setContent(root);
Transport.send(msg);
}
/** 附件資料結構 */
public static class Attachment {
public final String filename;
public final String mimeType;
public final byte[] content;
public Attachment(String filename, String mimeType, byte[] content) {
this.filename = filename; this.mimeType = mimeType; this.content = content;
}
public static Attachment fromFile(File file) throws IOException {
return new Attachment(file.getName(),
Files.probeContentType(file.toPath()),
Files.readAllBytes(file.toPath()));
}
}
/** 內嵌圖片資料結構 */
public static class InlineImage {
public final String cid; // 不含 <>
public final String mimeType;
public final byte[] content;
public InlineImage(String cid, String mimeType, byte[] content) {
this.cid = cid; this.mimeType = mimeType; this.content = content;
}
}
}
package com.example.mail;
public class MailSenderFactory {
private static volatile SimpleMailSender instance;
public static SimpleMailSender getInstance() {
if (instance == null) {
synchronized (MailSenderFactory.class) {
if (instance == null) {
// 從配置檔/環境變數/配置中心讀取
String host = Config.get("mail.smtp.host", "smtp.gmail.com");
int port = Config.getInt("mail.smtp.port", 587);
String user = Config.get("mail.username");
String pass = Config.get("mail.password");
String from = Config.get("mail.from");
String name = Config.get("mail.from.name", "系統通知");
boolean ssl = Config.getBool("mail.ssl", false);
boolean tls = Config.getBool("mail.starttls", true);
instance = new SimpleMailSender(host, port, user, pass, from, name, ssl, tls);
}
}
}
return instance;
}
}
// 事件定義
public class PriceDropEvent {
private final Product product;
private final double oldPrice;
private final double newPrice;
// getter...
}
// 監聽器
@Component
public class PriceDropMailListener implements ApplicationListener {
private final SimpleMailSender mailSender = MailSenderFactory.getInstance();
private final List recipients = Arrays.asList("user1@example.com", "user2@example.com");
@Override
public void onApplicationEvent(PriceDropEvent event) {
Product p = event.getProduct();
double drop = event.getOldPrice() - event.getNewPrice();
if (drop <= 0) return;
String subject = "🔥 降價提醒:" + p.getName();
String html = String.format("""
%s
原價:%.2f 元 → 現價:%.2f 元 (降幅 %.2f 元)
""", p.getName(), event.getOldPrice(), event.getNewPrice(), drop, p.getDetailUrl());
recipients.parallelStream().forEach(to -> {
try {
mailSender.sendHtml(to, subject, html, "純文字備用內容...");
} catch (MessagingException e) {
log.error("發送失敗: {}", to, e);
// 可送入重試佇列 / 死信隊列
}
});
}
}
// 發布事件 (Service 層)
@Service
public class ProductService {
@Autowired ApplicationEventPublisher publisher;
public void updatePrice(Long id, double newPrice) {
Product p = repo.findById(id).orElseThrow();
double old = p.getPrice();
p.setPrice(newPrice);
repo.save(p);
if (newPrice < old) {
publisher.publishEvent(new PriceDropEvent(p, old, newPrice));
}
}
}
spring:
mail:
host: smtp.gmail.com
port: 587
username: your@gmail.com
password: ${MAIL_PASSWORD} # 環境變數/密碼管理器
protocol: smtp
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 10000
writetimeout: 10000
pool:
enable: true
maxsize: 20
default-encoding: UTF-8
# 開發環境輸出到日誌而非實際發送
# test-connection: true
@Service
@RequiredArgsConstructor
public class NotificationService {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine; // Thymeleaf/FreeMarker
public void sendWelcome(User user) {
MimeMessage msg = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(msg, true, "UTF-8");
helper.setTo(user.getEmail());
helper.setSubject("歡迎加入 " + appName);
// 模板渲染 HTML
Context ctx = new Context();
ctx.setVariable("user", user);
String html = templateEngine.process("mail/welcome", ctx);
helper.setText(html, true); // true = HTML
// 內嵌 Logo
ClassPathResource logo = new ClassPathResource("static/images/logo.png");
helper.addInline("logo", logo, "image/png"); // cid:logo
mailSender.send(msg);
}
}
| 維度 | 建議 |
|---|---|
| 連線管理 | 啟用 `mail.smtp.pool.enable=true`,設定 `maxsize` 避免連線洩漏;Spring Boot 自動配置 `JavaMailSender` 為單例。 |
| 重試機制 | 網路波動導致發送失敗:**不可同步重試** (阻塞執行緒)。改用 **非同步佇列**:發送請求寫入 Redis/RabbitMQ/Kafka → Worker 消費重試 (指數退避)、死信隊列告警。 |
| 異步發送 | `@Async` + `ThreadPoolTaskExecutor` (Spring Boot) 或 `CompletableFuture` (手寫版)。確保業務主流程不被郵件阻塞。 |
| 大量收件人 | **勿**放入單一 `TO`/`BCC` 超過 50-100 位 (SMTP 伺服器/反垃圾限制)。拆批發送、使用郵件列表服務 (Mailgun、SendGrid、Amazon SES) 或自建 Listserv。 |
| 信譽管理 | 配置 **SPF、DKIM、DMARC** DNS 記錄;使用專用發信域名/子域;監控退信率、投訴率、送達率;暖機新 IP。 |
| 模板引擎 | Thymeleaf/FreeMarker/Velocity 分離 HTML 模板,支援國際化、片段重用、條件渲染。 |
| 附件大小 | 單檔 < 10MB,總請求 < 25MB (Gmail/Outlook 限制)。大檔案上傳雲端儲存,郵件僅附下載連結 (簽名 URL、過期時間)。 |
| 測試 | 開發/測試環境使用 **MailHog / FakeSMTP / GreenMail** 攔截郵件,避免誤發真實用戶;單元測試 Mock `JavaMailSender`。 |
| 服務商 | Host | Port | 加密 | 備註 |
|---|---|---|---|---|
| Gmail | smtp.gmail.com | 587 | STARTTLS | 需開啟「低安全性應用程式存取」或使用應用程式密碼 |
| Outlook/Office 365 | smtp.office365.com | 587 | STARTTLS | 支援 OAuth2 現代驗證 |
| QQ 郵箱 | smtp.qq.com | 465/587 | SSL/STARTTLS | 需開啟 SMTP 服務生成授權碼 |
| 163/126 | smtp.163.com | 465/994 | SSL | 需開啟客戶端授權密碼 |
| Amazon SES | email-smtp.region.amazonaws.com | 587/25/2587 | STARTTLS | 需驗證域名/郵箱、申請移出沙箱 |
| SendGrid | smtp.sendgrid.net | 587 | STARTTLS | API Key 作為密碼 (用戶名 `apikey`) |