はじめに
Spring Boot などのフレームワークなしで Java のメール処理を実装する機会は意外と多い。 バッチツール、CLI ユーティリティ、組み込み用途など、軽量に動かしたいケースだ。
この記事では void main を起点に、以下を純粋な Java+最小限の依存だけで実装する。
- POP3 / IMAP でのメール受信
- 件名・送信者による振り分け
- 添付ファイルの解析と指定ディレクトリへの格納
環境・依存関係
依存ライブラリは Jakarta Mail(旧 JavaMail) だけ。JDK は 17 以上を想定する。
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.1</version>
</dependency>
設定値は .properties ファイルから読み込む。コードへの直書きを避けることが最低限のセキュリティ対策だ。
mail.host=mail.example.com
mail.port.pop3=995
mail.port.imap=993
mail.user=user@example.com
mail.password=secret
mail.protocol=imap # imap or pop3
mail.save.dir=/var/data/attachments
エントリポイント:void main からスタート
プロトコルの切り替えは mail.protocol プロパティ一行で完結する。
main はオーケストレーターとして薄く保ち、実処理はメソッドに委譲する。
public class MailFetcher {
public static void main(String[] args) throws Exception {
Properties config = loadConfig("mail.properties");
String protocol = config.getProperty("mail.protocol", "imap");
String saveDir = config.getProperty("mail.save.dir", "./attachments");
List<Message> messages = "pop3".equalsIgnoreCase(protocol)
? fetchViaPop3(config)
: fetchViaImap(config);
for (Message message : messages) {
processMessage(message, saveDir);
}
}
private static Properties loadConfig(String filename) throws Exception {
Properties props = new Properties();
try (InputStream in = new FileInputStream(filename)) {
props.load(in);
}
return props;
}
}
POP3 でのメール受信
POP3 はシンプルな取得専用プロトコル。サーバー上のメールを一括ダウンロードし、 基本的に削除して使う運用に向いている。
private static List<Message> fetchViaPop3(Properties config) throws Exception {
Properties sessionProps = new Properties();
sessionProps.put("mail.pop3s.host", config.getProperty("mail.host"));
sessionProps.put("mail.pop3s.port", config.getProperty("mail.port.pop3", "995"));
sessionProps.put("mail.pop3s.ssl.enable", "true");
Session session = Session.getInstance(sessionProps);
Store store = session.getStore("pop3s");
store.connect(
config.getProperty("mail.host"),
config.getProperty("mail.user"),
config.getProperty("mail.password")
);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
// 全メッセージ取得
Message[] messages = inbox.getMessages();
// POP3 は取得後にサーバーから削除するのが一般的
for (Message m : messages) {
m.setFlag(Flags.Flag.DELETED, true);
}
List<Message> result = Arrays.asList(messages);
inbox.close(true); // expunge=true で削除を実行
store.close();
return result;
}
⚠️ close(true) のタイミングに注意
DELETED フラグを立てた後に inbox.close(true) を呼ぶと、サーバーから即座に削除される。メッセージの内容を処理するのは close の前であること。Jakarta Mail の Message はフォルダが閉じると内容にアクセスできなくなる。
IMAP でのメール受信
IMAP はサーバー上でフォルダ管理ができるプロトコル。 「未読のみ取得」「処理済みフォルダへ移動」といった運用が可能で、業務ツールでは IMAP が適している。
private static List<Message> fetchViaImap(Properties config) throws Exception {
Properties sessionProps = new Properties();
sessionProps.put("mail.imaps.host", config.getProperty("mail.host"));
sessionProps.put("mail.imaps.port", config.getProperty("mail.port.imap", "993"));
sessionProps.put("mail.imaps.ssl.enable", "true");
Session session = Session.getInstance(sessionProps);
Store store = session.getStore("imaps");
store.connect(
config.getProperty("mail.host"),
config.getProperty("mail.user"),
config.getProperty("mail.password")
);
Folder inbox = store.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
// 未読メッセージのみ取得
FlagTerm unseenFlag = new FlagTerm(new Flags(Flags.Flag.SEEN), false);
Message[] messages = inbox.search(unseenFlag);
// 取得したら既読にする(重複処理防止)
for (Message m : messages) {
m.setFlag(Flags.Flag.SEEN, true);
}
// 処理済みフォルダへ移動(任意)
Folder processed = store.getFolder("Processed");
if (!processed.exists()) {
processed.create(Folder.HOLDS_MESSAGES);
}
inbox.copyMessages(messages, processed);
List<Message> result = new ArrayList<>(Arrays.asList(messages));
inbox.close(false);
store.close();
return result;
}
💡 FlagTerm で未読絞り込み
FlagTerm(new Flags(Flags.Flag.SEEN), false) は「SEEN フラグが false(=未読)なメッセージ」を意味する。SearchTerm のサブクラスは他にも SubjectTerm(件名)・FromTerm(送信者)などがあり、組み合わせてサーバー側で絞れる。
POP3 vs IMAP — 使い分けの指針
| POP3 | IMAP | |
|---|---|---|
| サーバー上の保持 | 削除が基本 | フォルダ管理できる |
| 未読のみ取得 | 不可(全件) | 可能(SearchTerm) |
| 複数クライアント共存 | 不向き | 向いている |
| フォルダ操作 | INBOX のみ | 任意のフォルダ |
| 主な用途 | シンプルな取得バッチ | 業務ツール・振り分け処理 |
件名・送信者による振り分け
processMessage の中で件名や送信者を見て保存先ディレクトリを切り替える。
まずは if 文で直感的に書き、条件が増えてきたら Predicate +ルールリストに切り出す。
private static void processMessage(Message message, String baseDir) throws Exception {
String subject = message.getSubject() != null ? message.getSubject() : "";
String from = message.getFrom() != null
? ((InternetAddress) message.getFrom()[0]).getAddress()
: "";
System.out.printf("処理中: [%s] from=%s%n", subject, from);
String subDir = resolveDirectory(subject, from);
String savePath = baseDir + "/" + subDir;
saveAttachments(message, savePath);
}
/**
* 件名・送信者から保存先サブディレクトリを決定する。
* 条件はここに集約して追加していく。
*/
private static String resolveDirectory(String subject, String from) {
if (subject.contains("請求書")) return "invoices";
if (from.endsWith("@partner.example.com")) return "partner";
if (subject.startsWith("[REPORT]")) return "reports";
return "others";
}
条件が多くなってきたら、ルールをデータとして持つ方式が保守しやすい。
record MailRule(
Predicate<String> subjectMatcher,
Predicate<String> fromMatcher,
String directory
) {}
private static final List<MailRule> RULES = List.of(
new MailRule(s -> s.contains("請求書"), f -> true, "invoices"),
new MailRule(s -> true, f -> f.endsWith("@partner.example.com"), "partner"),
new MailRule(s -> s.startsWith("[REPORT]"), f -> true, "reports")
);
private static String resolveDirectoryByRules(String subject, String from) {
return RULES.stream()
.filter(r -> r.subjectMatcher().test(subject) && r.fromMatcher().test(from))
.findFirst()
.map(MailRule::directory)
.orElse("others");
}
✅ record を使う
Java 16 以降で使える record はルール定義に最適。イミュータブルで equals / toString も自動生成される。設定ファイルやDBからルールを動的にロードする拡張も容易になる。
添付ファイルの解析と格納
メールのボディは MimeMultipart の入れ子構造になっているため、再帰的に探索する必要がある。
ファイル名のデコード・サニタイズと重複ファイル名対策を忘れずに実装しよう。
private static void saveAttachments(Message message, String saveDir) throws Exception {
Path dir = Path.of(saveDir);
Files.createDirectories(dir);
Object content = message.getContent();
if (content instanceof MimeMultipart multipart) {
extractParts(multipart, dir);
}
// content が String の場合は添付なし(テキストのみのメール)
}
private static void extractParts(MimeMultipart multipart, Path dir) throws Exception {
for (int i = 0; i < multipart.getCount(); i++) {
BodyPart part = multipart.getBodyPart(i);
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
// ① ファイル名を RFC2047 デコード
String fileName = MimeUtility.decodeText(part.getFileName());
// ② パストラバーサル対策
fileName = sanitizeFileName(fileName);
// ③ 重複時はサフィックスを付与
Path dest = resolveUniqueDestination(dir, fileName);
try (InputStream in = part.getInputStream()) {
Files.copy(in, dest);
}
System.out.println(" 保存: " + dest);
} else if (part.getContent() instanceof MimeMultipart nested) {
// ネストされた multipart を再帰的に処理
extractParts(nested, dir);
}
}
}
/** パストラバーサル対策(/ \\ : * ? " < > | を _ に置換) */
private static String sanitizeFileName(String name) {
return name.replaceAll("[/\\\\:*?\"<>|]", "_");
}
/** 同名ファイルが存在する場合は _1, _2 ... を付ける */
private static Path resolveUniqueDestination(Path dir, String fileName) {
Path dest = dir.resolve(fileName);
if (!Files.exists(dest)) return dest;
String base = fileName.contains(".")
? fileName.substring(0, fileName.lastIndexOf('.'))
: fileName;
String ext = fileName.contains(".")
? fileName.substring(fileName.lastIndexOf('.'))
: "";
int count = 1;
while (Files.exists(dest)) {
dest = dir.resolve(base + "_" + count++ + ext);
}
return dest;
}
⚠️ MimeUtility.decodeText() は必須
日本語ファイル名は =?UTF-8?B?...?= のような RFC2047 エンコードで届く場合がある。decodeText() を通さないと文字化けした名前でファイルが作られる。また getFileName() が null を返すケースも考慮しておこう。
動作フローの全体像
void main
├─ 設定ファイル読み込み(mail.properties)
└─ プロトコル分岐
├─ POP3: 全件取得 → DELETED フラグ → close(true) で expunge
└─ IMAP: 未読取得 → SEEN フラグ → Processed フォルダへ移動
│
└─ 各メッセージを処理
├─ 件名・送信者で保存先ディレクトリを決定
└─ MimeMultipart を再帰探索
└─ 添付ファイルを デコード → サニタイズ → 保存
まとめ・次のステップ
この記事のポイントを整理する。
| テーマ | 要点 |
|---|---|
| POP3 vs IMAP | シンプルな取得バッチなら POP3、フォルダ管理・未読絞り込みが必要なら IMAP |
| 振り分けロジック | resolveDirectory に集約。条件が増えたら Predicate + record のルールリストへ移行 |
| 添付解析 | MimeMultipart は再帰構造。MimeUtility.decodeText() でファイル名をデコードし、パストラバーサル対策も忘れずに |
| 重複防止 | IMAP では SEEN フラグ + Processed フォルダ移動で二重処理を防ぐ |
✅ 次のステップとして
・ScheduledExecutorService で定期実行化(cron なしで JVM だけで動かす)
・処理結果を DB に記録して「処理済み」管理を厳密にする
・受信内容をもとに別の API を呼び出す後続処理へ連携する
動作確認環境: Java 17 / jakarta.mail 2.0.1