企業級應用常需整合郵件系統 (自動化工單、帳單解析、安全審計)。本文基於 Jakarta Mail (原 JavaMail) 完整示範 POP3/IMAP 連接、郵件結構樹遞歸解析、文本/HTML/附件提取、大附件流式存儲、OAuth2 現代認證 (Gmail/Outlook) 與連接池化最佳實踐。
com.sun.mail
jakarta.mail
2.0.1
com.google.auth
google-auth-library-oauth2-http
1.19.0
@ConfigurationProperties(prefix = "mail")
public class MailConfig {
private String host = "imap.gmail.com";
private int port = 993;
private String protocol = "imaps"; // pop3s / imaps
private String username;
private String password; // 應用專用密碼或 OAuth2 token
private boolean sslEnable = true;
private int connectionTimeout = 30000;
private int readTimeout = 60000;
// getter/setter 省略
}
@Service
public class MailReaderService {
private final MailConfig config;
private final Session session;
public MailReaderService(MailConfig config) {
this.config = config;
Properties props = new Properties();
props.put("mail.store.protocol", config.getProtocol());
props.put("mail." + config.getProtocol() + ".host", config.getHost());
props.put("mail." + config.getProtocol() + ".port", config.getPort());
props.put("mail." + config.getProtocol() + ".ssl.enable", config.isSslEnable());
props.put("mail." + config.getProtocol() + ".connectiontimeout", config.getConnectionTimeout());
props.put("mail." + config.getProtocol() + ".timeout", config.getReadTimeout());
// IMAP 特有:支援 IDLE 推送、部分獲取
if ("imaps".equals(config.getProtocol())) {
props.put("mail.imap.partialfetch", true);
props.put("mail.imap.fetchsize", 1024 * 1024); // 1MB 分塊獲取
}
this.session = Session.getInstance(props);
}
public List fetchUnread(int maxCount) throws MessagingException {
try (Store store = session.getStore(config.getProtocol())) {
store.connect(config.getHost(), config.getPort(), config.getUsername(), config.getPassword());
try (Folder folder = store.getFolder("INBOX")) {
folder.open(Folder.READ_WRITE); // 需標記已讀
// 搜尋未讀郵件
FlagTerm unreadTerm = new FlagTerm(new Flags(Flags.Flag.SEEN), false);
Message[] messages = folder.search(unreadTerm);
return Arrays.stream(messages)
.limit(maxCount)
.map(this::parseMessage)
.collect(Collectors.toList());
}
}
}
private EmailMessage parseMessage(Message msg) throws MessagingException, IOException {
EmailMessage email = new EmailMessage();
email.setMessageId(msg.getMessageID());
email.setSubject(MimeUtility.decodeText(msg.getSubject()));
email.setFrom(Arrays.stream(msg.getFrom()).map(Object::toString).collect(Collectors.joining(", ")));
email.setSentDate(msg.getSentDate());
email.setFlags(Arrays.stream(msg.getFlags().getSystemFlags()).map(Flags.Flag::toString).collect(Collectors.toSet()));
// 遞歸解析多部分結構
parsePart(msg, email);
return email;
}
private void parsePart(Part part, EmailMessage email) throws MessagingException, IOException {
String contentType = part.getContentType();
String disposition = part.getDisposition();
if (part.isMimeType("text/plain")) {
email.setTextBody((String) part.getContent());
} else if (part.isMimeType("text/html")) {
email.setHtmlBody((String) part.getContent());
} else if (Part.ATTACHMENT.equalsIgnoreCase(disposition) || part.getFileName() != null) {
// 附件處理
String fileName = MimeUtility.decodeText(part.getFileName());
try (InputStream is = part.getInputStream()) {
// 大附件直接流式寫入磁碟/對象存儲,避免載入記憶體
Path tempFile = Files.createTempFile("mail_attach_", "_" + fileName);
Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING);
email.addAttachment(new Attachment(fileName, part.getContentType(), tempFile.toFile()));
}
} else if (contentType != null && contentType.startsWith("multipart/")) {
// 遞歸處理 multipart/mixed, multipart/alternative, multipart/related
Multipart mp = (Multipart) part.getContent();
for (int i = 0; i < mp.getCount(); i++) {
parsePart(mp.getBodyPart(i), email);
}
}
}
}
public class OAuth2Authenticator {
private static final String GMAIL_TOKEN_URL = "https://oauth2.googleapis.com/token";
public static String getAccessToken(String clientId, String clientSecret, String refreshToken) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(GMAIL_TOKEN_URL))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(
"client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8) +
"&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8) +
"&refresh_token=" + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8) +
"&grant_type=refresh_token"))
.build();
String resp = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString()).body();
// 解析 JSON 取 access_token
return JsonParser.parseString(resp).getAsJsonObject().get("access_token").getAsString();
}
// 連接時使用 XOAUTH2
public static void connectWithOAuth2(Store store, String email, String accessToken) throws MessagingException {
store.connect(email, accessToken); // Jakarta Mail 2.0+ 支援 XOAUTH2
// 或手動 SASL: props.put("mail.imap.auth.mechanisms", "XOAUTH2");
}
}
@Data @Builder
public class EmailMessage {
private String messageId;
private String subject;
private String from;
private String textBody;
private String htmlBody;
private Date sentDate;
private Set flags;
private List attachments = new ArrayList<>();
}
@Data @AllArgsConstructor
public class Attachment {
private String fileName;
private String contentType;
private File file; // 或 byte[] / InputStream / S3 路徑
}
| 優化點 | 配置/代碼 | 效果 |
|---|---|---|
| 連接池 | Apache Commons Pool2 包裝 Store,maxTotal=20 | 避免頻繁建立 SSL 連接開銷 |
| 增量同步 | IMAP UIDNEXT/UIDVALIDITY 記錄同步游標 | 僅抓取新郵件,避免全量掃描 |
| 大附件流式存儲 | Files.copy(is, path) 而非 getContent() | 防止 Heap OOM |
| 異步處理 | @Async + 線程池解析郵件 | 不阻塞 API 響應 |
| 錯誤重試 | Resilience4j Retry (指數退避) | 應對網路抖動、郵件伺服器暫時不可用 |
msg.getContent() 直接強轉 String → 可能是 Multipart 拋 ClassCastException,必須 isMimeType() 判斷MimeUtility.decodeText() → 中文主題/附件名亂碼mail.imap.fetchsize 分塊獲取大郵件folder.expunge() 清理已刪除郵件Java 讀取郵件的核心難點在於**多部分 MIME 結構遞歸解析**、**大附件流式處理**與**現代 OAuth2 認證遷移**。掌握 Jakarta Mail API 的 Part 樹狀模型、IMAP 部分獲取與 IDLE 推送機制,配合連接池化與異步解析,即可構建企業級郵件整合中台。