はじめに

Spring Boot などのフレームワークなしで Java のメール処理を実装する機会は意外と多い。 バッチツール、CLI ユーティリティ、組み込み用途など、軽量に動かしたいケースだ。

この記事では void main を起点に、以下を純粋な Java+最小限の依存だけで実装する。

  • POP3 / IMAP でのメール受信
  • 件名・送信者による振り分け
  • 添付ファイルの解析と指定ディレクトリへの格納

環境・依存関係

依存ライブラリは Jakarta Mail(旧 JavaMail) だけ。JDK は 17 以上を想定する。

XML — pom.xml(Maven)
<dependency>
    <groupId>com.sun.mail</groupId>
    <artifactId>jakarta.mail</artifactId>
    <version>2.0.1</version>
</dependency>

設定値は .properties ファイルから読み込む。コードへの直書きを避けることが最低限のセキュリティ対策だ。

Properties — mail.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 はオーケストレーターとして薄く保ち、実処理はメソッドに委譲する。

Java — MailFetcher.java(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 はシンプルな取得専用プロトコル。サーバー上のメールを一括ダウンロードし、 基本的に削除して使う運用に向いている。

Java — fetchViaPop3()
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 が適している。

Java — fetchViaImap()
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 +ルールリストに切り出す。

Java — processMessage() / resolveDirectory()
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";
}

条件が多くなってきたら、ルールをデータとして持つ方式が保守しやすい。

Java — Predicate +ルールリスト(発展例)
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 の入れ子構造になっているため、再帰的に探索する必要がある。 ファイル名のデコード・サニタイズと重複ファイル名対策を忘れずに実装しよう。

Java — saveAttachments() / extractParts()
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 に集約。条件が増えたら Predicaterecord のルールリストへ移行
添付解析 MimeMultipart は再帰構造。MimeUtility.decodeText() でファイル名をデコードし、パストラバーサル対策も忘れずに
重複防止 IMAP では SEEN フラグ + Processed フォルダ移動で二重処理を防ぐ

次のステップとして

ScheduledExecutorService で定期実行化(cron なしで JVM だけで動かす)
・処理結果を DB に記録して「処理済み」管理を厳密にする
・受信内容をもとに別の API を呼び出す後続処理へ連携する

動作確認環境: Java 17 / jakarta.mail 2.0.1