現代郵件多為 Multipart 結構:multipart/alternative (純文本/HTML 替代) 嵌套 multipart/related (內嵌圖片) 及 multipart/mixed (附件)。本文深入剖析 MIME 結構樹,提供健壯的遞迴解析代碼,正確提取 HTML 正文、內嵌圖片 CID 對應、附件儲存,解決「只取第一層 Part 導致內容遺失」的常見陷阱。
| MIME Type | 用途 |
|---|---|
| text/plain | 純文本正文 |
| text/html | HTML 格式正文 |
| multipart/alternative | 同一內容的多種表現形式 (擇一顯示) |
| multipart/related | 相關資源聚合 (HTML + 內嵌圖片) |
| multipart/mixed | 混合內容 (正文 + 附件) |
Message (multipart/mixed) ├─ Part 0: multipart/related │ ├─ Part 0.0: multipart/alternative │ │ ├─ Part 0.0.0: text/plain │ │ └─ Part 0.0.1: text/html (含) │ └─ Part 0.1: image/png (Content-ID:
) └─ Part 1: application/pdf (附件, Content-Disposition: attachment)
核心洞見:Message.getContent() 回傳頂層 Multipart,第一層 Part 常非葉節點,必須遞迴遍歷至葉節點 才能判斷實際內容類型。
public class MailContentParser {
public static String parseMessage(Message msg, boolean preferHtml)
throws MessagingException, IOException {
Object content = msg.getContent();
if (content instanceof Multipart) {
return parseMultipart((Multipart) content, preferHtml);
} else if (content instanceof String) {
return (String) content; // 簡單郵件
}
return "";
}
private static String parseMultipart(Multipart mp, boolean preferHtml)
throws MessagingException, IOException {
StringBuilder result = new StringBuilder();
for (int i = 0; i < mp.getCount(); i++) {
BodyPart part = mp.getBodyPart(i);
result.append(parsePart(part, preferHtml));
}
return result.toString();
}
private static String parsePart(Part part, boolean preferHtml)
throws MessagingException, IOException {
String contentType = part.getContentType();
if (contentType == null) return "";
// 1. 處理 multipart/alternative: 根據偏好選擇 HTML 或 Plain
if (part.isMimeType("multipart/alternative")) {
Multipart mp = (Multipart) part.getContent();
String html = "", plain = "";
for (int i = 0; i < mp.getCount(); i++) {
BodyPart bp = mp.getBodyPart(i);
if (bp.isMimeType("text/html")) html = parsePart(bp, preferHtml);
else if (bp.isMimeType("text/plain")) plain = parsePart(bp, preferHtml);
}
return preferHtml ? (html.isEmpty() ? plain : html) : (plain.isEmpty() ? html : plain);
}
// 2. 處理其他 multipart (related/mixed): 遞迴遍歷所有子部分
if (part.isMimeType("multipart/*")) {
return parseMultipart((Multipart) part.getContent(), preferHtml);
}
// 3. 葉節點: 直接內容
if (part.isMimeType("text/html")) {
return "" + part.getContent() + ""; // 實際應處理 CID 圖片替換
}
if (part.isMimeType("text/plain")) {
return "" + part.getContent() + "
";
}
// 4. 附件處理 (Content-Disposition: attachment)
String disposition = part.getDisposition();
if (Part.ATTACHMENT.equalsIgnoreCase(disposition) || part.getFileName() != null) {
String fileName = part.getFileName();
// 儲存附件邏輯: saveAttachment(fileName, part.getInputStream());
return "[附件: " + fileName + "]";
}
// 5. 內嵌資源 (inline, 如圖片) - 實際應建立 CID->資源映射供 HTML 替換
if (Part.INLINE.equalsIgnoreCase(disposition)) {
return "[內嵌資源: " + part.getContentType() + "]";
}
return "[未知部分: " + contentType + "]";
}
// 附件儲存輔助方法
private static void saveAttachment(String fileName, InputStream in) throws IOException {
File file = new File(fileName);
for (int i = 0; file.exists(); i++) file = new File(fileName + "_" + i);
try (FileOutputStream fos = new FileOutputStream(file);
BufferedOutputStream bos = new BufferedOutputStream(fos);
BufferedInputStream bis = new BufferedInputStream(in)) {
byte[] buf = new byte[4096];
int len;
while ((len = bis.read(buf)) != -1) bos.write(buf, 0, len);
}
}
}
HTML 正文中 <img src="cid:part1.01@host"> 引用內嵌圖片。完整解析需兩階段:
Part.INLINE 部分,建立 Map (Key: Content-ID 去除 <><>)cid:xxx 替換為 data:image/...;base64,... 或暫存檔案路徑MimeUtility.decodeText() 處理主題、附件檔名 (RFC 2047/2231)getContent() 載入巨大附件,考慮流式處理Folder.READ_WRITE 開啟並 setFlag(Flags.Flag.SEEN, true) 才會標記已讀