雖然即時通訊應用普及,但電子郵件仍是企業系統通知、帳單發送、密碼重設、行銷推播的核心基礎設施。JavaMail (現屬 Jakarta Mail,包名 `jakarta.mail`) 是 Java 平台發送/接收郵件的標準 API,支援 SMTP、IMAP、POP3、MULTIPART、S/MIME、DKIM 等協定。本文涵蓋從依賴引入、Session 設定、郵件發送/接收實作、SSL/TLS 加密、OAuth2 現代認證,到常見錯誤診斷與效能調優。
com.sun.mail
jakarta.mail
2.0.1
implementation("com.sun.mail:jakarta.mail:2.0.1")
com.sun.mail
javax.mail
1.6.2
import jakarta.mail.*;
import jakarta.mail.internet.*;
import java.util.Properties;
public class MailConfig {
public static Session createSmtpSession(String host, int port,
String username, String password,
boolean useSsl, boolean useStartTls) {
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", "30000");
props.put("mail.smtp.writetimeout", "10000");
if (useSsl) {
// SMTPS (隱式 SSL,port 465)
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.socketFactory.port", port);
props.put("mail.smtp.socketFactory.fallback", "false");
} else if (useStartTls) {
// STARTTLS (顯式 TLS,port 587)
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
}
// 信任所有憑證 (僅測試用,生產需正確信任鏈)
props.put("mail.smtp.ssl.trust", host);
props.put("mail.smtp.ssl.protocols", "TLSv1.2 TLSv1.3");
return Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
}
}
| 服務商 | SMTP Host | Port | 加密 | 備註 |
|---|---|---|---|---|
| Gmail | smtp.gmail.com | 587 | STARTTLS | 需開啟「低安全性應用程式存取」或使用應用程式密碼 |
| Outlook/Office365 | smtp.office365.com | 587 | STARTTLS | 支援 OAuth2 現代認證 |
| Yahoo | smtp.mail.yahoo.com | 465/587 | SSL/STARTTLS | 需應用程式密碼 |
| SendGrid | smtp.sendgrid.net | 587 | STARTTLS | 用戶名: apikey,密碼: API Key |
| Mailgun | smtp.mailgun.org | 587 | STARTTLS | 需驗證網域 |
| 企業內部 Exchange | 內部主機 | 25/587 | STARTTLS/無 | 可能需網域帳號認證 |
public static Session createImapSession(String host, int port,
String username, String password, boolean useSsl) {
Properties props = new Properties();
props.put("mail.imap.host", host);
props.put("mail.imap.port", port);
props.put("mail.imap.auth", "true");
props.put("mail.imap.connectiontimeout", "5000");
props.put("mail.imap.timeout", "30000");
if (useSsl) {
props.put("mail.imap.ssl.enable", "true");
props.put("mail.imap.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.imap.socketFactory.port", port);
props.put("mail.imap.socketFactory.fallback", "false");
}
return Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(username, password);
}
});
}
public void sendSimpleMail(Session session, String from, String to,
String subject, String text) throws MessagingException {
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress(from));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
message.setSubject(subject, "UTF-8");
message.setText(text, "UTF-8");
message.setSentDate(new Date());
Transport.send(message);
}
public void sendRichMail(Session session, String from, String to,
String subject, String htmlContent,
Map inlineImages, // CID -> 圖片資料
List attachments) throws MessagingException, IOException {
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress(from));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
message.setSubject(subject, "UTF-8");
// 根容器:mixed (附件在最外層)
MimeMultipart root = new MimeMultipart("mixed");
// 內容層:related (HTML + 內嵌圖片)
MimeMultipart related = new MimeMultipart("related");
// HTML 文字部分
MimeBodyPart htmlPart = new MimeBodyPart();
htmlPart.setContent(htmlContent, "text/html; charset=UTF-8");
related.addBodyPart(htmlPart);
// 內嵌圖片
for (Map.Entry entry : inlineImages.entrySet()) {
MimeBodyPart imgPart = new MimeBodyPart();
imgPart.setDataHandler(new DataHandler(new ByteArrayDataSource(entry.getValue(), "image/png")));
imgPart.setHeader("Content-ID", "<" + entry.getKey() + ">"); // CID 引用
imgPart.setDisposition(MimeBodyPart.INLINE);
related.addBodyPart(imgPart);
}
// 將 related 包入 root
MimeBodyPart contentWrapper = new MimeBodyPart();
contentWrapper.setContent(related);
root.addBodyPart(contentWrapper);
// 附件
for (Attachment att : attachments) {
MimeBodyPart attPart = new MimeBodyPart();
attPart.setDataHandler(new DataHandler(new ByteArrayDataSource(att.data(), att.mimeType())));
attPart.setFileName(MimeUtility.encodeText(att.filename(), "UTF-8", "B")); // RFC 2047 編碼
attPart.setDisposition(MimeBodyPart.ATTACHMENT);
root.addBodyPart(attPart);
}
message.setContent(root);
message.setSentDate(new Date());
message.saveChanges(); // 重要:生成 Message-ID 等標頭
Transport.send(message);
}
record Attachment(String filename, String mimeType, byte[] data) {}
public List fetchUnreadMails(Session session, String folderName)
throws MessagingException {
Store store = session.getStore("imap");
store.connect();
Folder folder = store.getFolder(folderName); // "INBOX"
folder.open(Folder.READ_WRITE); // 需 READ_WRITE 才能設定 Flags
// 搜尋未讀
Flags seen = new Flags(Flags.Flag.SEEN);
FlagTerm unseenTerm = new FlagTerm(seen, false);
Message[] messages = folder.search(unseenTerm);
// 取得前 50 封最新
List result = new ArrayList<>();
for (int i = messages.length - 1; i >= 0 && result.size() < 50; i--) {
result.add(messages[i]);
}
return result;
}
public class MailParser {
public static ParsedMail parse(Message msg) throws MessagingException, IOException {
ParsedMail mail = new ParsedMail();
mail.subject = MimeUtility.decodeText(msg.getSubject());
mail.from = parseAddresses(msg.getFrom());
mail.to = parseAddresses(msg.getRecipients(Message.RecipientType.TO));
mail.sentDate = msg.getSentDate();
mail.messageId = msg.getMessageID();
extractContent(msg, mail);
return mail;
}
private static void extractContent(Part part, ParsedMail mail) throws MessagingException, IOException {
String contentType = part.getContentType();
String disposition = part.getDisposition();
if (part.isMimeType("text/plain") && Part.ATTACHMENT != disposition) {
mail.textContent = (String) part.getContent();
} else if (part.isMimeType("text/html") && Part.ATTACHMENT != disposition) {
mail.htmlContent = (String) part.getContent();
} else if (part.isMimeType("multipart/*")) {
Multipart mp = (Multipart) part.getContent();
for (int i = 0; i < mp.getCount(); i++) {
extractContent(mp.getBodyPart(i), mail);
}
} else if (Part.ATTACHMENT.equalsIgnoreCase(disposition) ||
Part.INLINE.equalsIgnoreCase(disposition)) {
// 附件或內嵌圖片
String filename = MimeUtility.decodeText(part.getFileName());
byte[] data = IOUtils.toByteArray(part.getInputStream());
mail.attachments.add(new Attachment(filename, part.getContentType(), data,
Part.INLINE.equalsIgnoreCase(disposition)));
}
}
private static List parseAddresses(Address[] addrs) {
return Arrays.stream(addrs)
.map(a -> ((InternetAddress) a).getAddress())
.toList();
}
}
class ParsedMail {
String subject, textContent, htmlContent, messageId;
Date sentDate;
List from, to;
List attachments = new ArrayList<>();
}
record Attachment(String filename, String mimeType, byte[] data, boolean inline) {}
傳統密碼認證正逐步被淘汰,Google、Microsoft 強制要求 OAuth2。JavaMail 支援 `XOAUTH2` SASL 機制。
public static Session createGmailOAuth2Session(String accessToken, String email) {
Properties props = new Properties();
props.put("mail.smtp.host", "smtp.gmail.com");
props.put("mail.smtp.port", "587");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.sasl.enable", "true");
props.put("mail.smtp.sasl.mechanisms", "XOAUTH2");
return Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
// XOAUTH2 使用 access token 作為密碼,用戶名為 email
return new PasswordAuthentication(email, accessToken);
}
});
}
// 使用 Google OAuth2 取得 access token (需 Google Cloud 專案、OAuth 同意畫面)
建議直接使用 Microsoft Graph Java SDK 而非 JavaMail SMTP,功能更完整 (郵件、行事曆、聯絡人、OneDrive)。
| 錯誤訊息 | 原因 | 解決方案 |
|---|---|---|
| 535 5.7.8 Username and Password not accepted | 認證失敗 | 檢查帳號/密碼、啟用應用程式密碼、確認 OAuth2 token 有效 |
| 421 4.7.0 Try again later / Connection timed out | 連線逾時/服務商限流 | 增加 timeout、實作指數退避重試、檢查防火牆/Proxy |
| javax.net.ssl.SSLHandshakeException | 憑證不信任/協定不匹配 | 匯入 CA 憑證到 truststore、設定 `mail.smtp.ssl.protocols=TLSv1.2 TLSv1.3`、確認主機名稱驗證 |
| 550 5.7.1 Relaying denied / SPF/DKIM/DMARC fail | 發件網域聲譽/認證失敗 | 設定 SPF/DKIM/DMARC 記錄、使用信譽良好的發送服務 (SendGrid 等) |
| MessagingException: Could not connect to SMTP host | 網路不通/埠號錯誤 | telnet 測試連線、檢查安全群組/防火牆、確認 port 25/465/587 |
| ClassNotFoundException: jakarta.activation.DataSource | 缺少 JAF 相依 | 升級 jakarta.mail 2.0+ (內含) 或手動加入 jakarta.activation-api |
| OutOfMemoryError 發送大附件 | 整封郵件載入 Heap | 使用 `FileDataSource` 串流、分塊上傳、增加 `-Xmx` |
// JavaMail 預設不支援連線池,可使用 Apache Commons Pool 2 自行實作
// 或改用 Spring Boot Mail (內建連線池支援)
@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost("smtp.example.com");
sender.setPort(587);
sender.setUsername("user");
sender.setPassword("pass");
sender.setJavaMailProperties(props);
// Spring Boot 2.3+ 支援連線池
props.put("spring.mail.properties.mail.smtp.connectionpool", "true");
props.put("spring.mail.properties.mail.smtp.connectionpoolsize", "10");
return sender;
}
@Async("mailExecutor")
public CompletableFuture sendAsync(MimeMessagePreparator preparator) {
javaMailSender.send(preparator);
return CompletableFuture.completedFuture(null);
}
// 執行緒池設定
@Bean
public ThreadPoolTaskExecutor mailExecutor() {
ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor();
exec.setCorePoolSize(5);
exec.setMaxPoolSize(20);
exec.setQueueCapacity(100);
exec.setThreadNamePrefix("mail-sender-");
return exec;
}
JavaMail/Jakarta Mail 是成熟穩定的郵件 API,關鍵在於正確配置 Session Properties (SSL/TLS/認證/逾時)、妥善處理 MimeMultipart 結構、遷移至 OAuth2 現代認證、建立 非同步發送與連線池 架構、並落實 安全性與監控。現代 Spring Boot 專案建議直接使用 `spring-boot-starter-mail` 簡化配置,並整合 `Micrometer` 監控發送指標。對於大量行銷郵件,建議委託專業 ESP (SendGrid、Mailgun、Amazon SES) 發送,應用層僅負責組裝內容與呼叫 HTTP API。