【qmail運用】バウンスメール大量発生の原因調査と対策 – Pythonで解析する実践的アプローチ

技術メモ

メールサーバーの運用中に大量のバウンスメールが発生し、その原因調査と対策を行った事例を紹介する。本記事では、Python による解析スクリプトの実装から、qmail 特有のログ解析手法まで、実践的な技術内容を詳しく解説する。

背景

ある日、メールサーバーの postmaster 宛に大量のバウンスメール(配信失敗通知)が届いていることに気づいた。約2日間で4,500件以上のバウンスメールが蓄積されており、原因の特定と対策が急務となった。

調査環境

  • MTA: qmail
  • アンチウイルス: DrWeb
  • OS: CentOS / Rocky Linux
  • 解析ツール: Python 3

バウンスメールの構造を理解する

qmail のバウンスメール形式

qmail が生成するバウンスメールは以下の構造を持つ。

Return-Path: <#@[]>                    ← ダブルバウンスの特徴
Delivered-To: [email protected]
From: [email protected]
Subject: failure notice

Hi. This is the qmail-send program at example.com.
I tried to deliver a bounce message to this address, but the bounce bounced!

<[email protected]>:
192.0.2.1 does not like recipient.
Remote host said: 553 sorry, that domain isn't in my list of allowed rcpthosts (#5.7.1)
Giving up on 192.0.2.1.

--- Below this line is the original bounce.
(元のバウンスメールが続く)

ポイント:
Return-Path: <#@[]> はダブルバウンス(バウンスのバウンス)を示す
Return-Path: <> は通常のバウンス
– エラーコードは (#5.x.x) 形式で記載される

SMTP エラーコードの体系

5.x.x = 永続的エラー(再送しても失敗)
4.x.x = 一時的エラー(再送で成功の可能性あり)

5.1.x = 宛先アドレス関連
5.2.x = メールボックス関連
5.4.x = ネットワーク/ルーティング関連
5.7.x = セキュリティ/ポリシー関連

Pythonによるバウンスメール解析スクリプト

バウンスメール解析クラスの実装

Python の email モジュールを使用してバウンスメールをパースする。

#!/usr/bin/env python3
"""バウンスメール解析スクリプト"""

import re
import email
from email import policy
from pathlib import Path
from dataclasses import dataclass
from typing import Optional

@dataclass
class BounceInfo:
    """バウンスメール情報"""
    filename: str
    original_recipient: Optional[str] = None
    error_code: Optional[str] = None
    error_message: Optional[str] = None
    bounce_type: str = "unknown"
    is_double_bounce: bool = False

class BounceAnalyzer:
    # SMTPエラーコードとバウンスタイプのマッピング
    BOUNCE_PATTERNS = [
        (r"no mailbox here by that name.*\(#5\.1\.1\)", "mailbox_not_found", "5.1.1"),
        (r"couldn't find a mail exchanger.*\(#5\.4\.4\)", "no_mx_record", "5.4.4"),
        (r"domain isn't in my list of allowed rcpthosts.*\(#5\.7\.1\)", "relay_denied", "5.7.1"),
        (r"envelope sender is in my badmailfrom list.*\(#5\.7\.1\)", "sender_blocked", "5.7.1"),
    ]

    def analyze_file(self, filepath: Path) -> BounceInfo:
        with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
            content = f.read()

        msg = email.message_from_string(content, policy=policy.default)
        bounce = BounceInfo(filename=filepath.name)

        # 本文取得
        body = self._get_body(msg)

        # ダブルバウンス判定
        if "bounce bounced" in body.lower():
            bounce.is_double_bounce = True

        # エラー情報抽出
        self._extract_error_info(bounce, body)

        return bounce

    def _extract_error_info(self, bounce: BounceInfo, body: str):
        # Remote host said パターンでエラーメッセージ抽出
        remote_match = re.search(
            r'Remote host said:\s*(\d+)\s+(.+?)(?:\n|$)',
            body, re.IGNORECASE
        )
        if remote_match:
            bounce.error_message = remote_match.group(2).strip()
            # エラーコード抽出
            code_match = re.search(r'\(#(\d\.\d\.\d+)\)', bounce.error_message)
            if code_match:
                bounce.error_code = code_match.group(1)

        # バウンスタイプ分類
        for pattern, btype, code in self.BOUNCE_PATTERNS:
            if re.search(pattern, body.lower()):
                bounce.bounce_type = btype
                if not bounce.error_code:
                    bounce.error_code = code
                break

qmail ログ解析スクリプト

qmail のログは syslog 形式で出力され、独自のタイムスタンプ(UNIX時間)を持つ。

Jan 20 10:49:40 mailserver qmail: 1768873780.433367 info msg 67524490: bytes 2726 from <> qp 20850 uid 496

ログエントリの種類:

エントリ 意味
new msg XXXXX 新規メッセージ受信
info msg XXXXX: bytes N from <sender> qp N uid N メッセージ情報
starting delivery N: msg XXXXX to local/remote addr 配信開始
delivery N: success/failure/deferral: message 配信結果
bounce msg XXXXX qp N バウンス生成
end msg XXXXX メッセージ処理終了
triple bounce: discarding bounce/XXXXX トリプルバウンス破棄
#!/usr/bin/env python3
"""qmail ログ解析スクリプト"""

import re
from dataclasses import dataclass, field
from typing import Dict, List

@dataclass
class MessageSession:
    """メッセージセッション"""
    msg_id: int
    first_seen: float
    sender: str = ""
    deliveries: List = field(default_factory=list)
    bounced: bool = False
    is_triple_bounce: bool = False

class QmailLogParser:
    # syslog + qmail タイムスタンプのパターン
    SYSLOG_PATTERN = re.compile(
        r'^(\w+\s+\d+\s+[\d:]+)\s+(\S+)\s+(\S+):\s+(\d+\.\d+)\s+(.+)'
    )

    # イベントパターン
    EVENT_PATTERNS = {
        'new_msg': re.compile(r'^new msg (\d+)'),
        'info_msg': re.compile(r'^info msg (\d+): bytes (\d+) from <([^>]*)> qp (\d+) uid (\d+)'),
        'starting_delivery': re.compile(r'^starting delivery (\d+): msg (\d+) to (local|remote) (.+)'),
        'delivery_result': re.compile(r'^delivery (\d+): (success|failure|deferral): (.*)'),
        'bounce': re.compile(r'^bounce msg (\d+) qp (\d+)'),
        'triple_bounce': re.compile(r'^triple bounce: discarding bounce/(\d+)'),
    }

    def __init__(self, log_file: str):
        self.sessions: Dict[int, MessageSession] = {}

    def parse_line(self, line: str):
        match = self.SYSLOG_PATTERN.match(line)
        if not match:
            return None

        timestamp, hostname, program, qmail_ts, message = match.groups()

        # qmail 以外のログは除外
        if 'qmail' not in program:
            return None

        # イベントタイプを判定してセッションを更新
        for event_type, pattern in self.EVENT_PATTERNS.items():
            event_match = pattern.match(message)
            if event_match:
                self._process_event(event_type, event_match.groups(), float(qmail_ts))
                break

バウンスメールとログの紐付け

バウンスメールのファイル名にはタイムスタンプが含まれている。

1768790936.3292.mailserver,S=4071
↑ UNIXタイムスタンプ

これを qmail ログのタイムスタンプと照合することで紐付けが可能になる。

調査結果

4,521件のバウンスメールを分析した結果:

原因 件数 割合 エラーコード
Cronエラー 2,157 47.7% 5.7.1 (relay_denied)
アンチウイルス通知 1,490 33.0% 5.7.1 (relay_denied)
宛先不存在 473 10.5% 5.1.1
リレー拒否 272 6.0% 5.7.1
MXレコードなし 22 0.5% 5.4.4
その他 107 2.3%

qmail ログからの統計:

指標
総配信試行 34,423
配信成功 8,487 (24.7%)
配信失敗 25,936 (75.3%)
トリプルバウンス 6,111

原因と対策

原因1: Cron ジョブの出力がメール送信される(47.7%)

問題の詳細:

Linux の cron デーモンは、ジョブの標準出力・標準エラー出力を MAILTO で指定されたアドレス(デフォルトは実行ユーザー)にメール送信する。

# cron の動作フロー
1. ジョブ実行
2. 出力があれば sendmail 経由でメール送信
3. MAILTO 未設定の場合は root@hostname 宛
4. hostname が外部ドメインだとリレー拒否でバウンス

発見された問題パターン:

# パターン1: リダイレクトなし(出力がメールされる)
*/1 * * * * /path/to/script.sh

# パターン2: リダイレクト記法ミス
*/5 * * * * /path/to/script.sh > /dev/null 2&>1   # NG: 2&>1 は無効
*/5 * * * * /path/to/script.sh >./dev/null 2>&1   # NG: ./dev/null は相対パス
*/1 * * * * /path/to/script.sh > /dev/null 2>&>1  # NG: 2>&>1 は無効

# パターン3: 正しい記法
*/1 * * * * /path/to/script.sh > /dev/null 2>&1   # OK
*/1 * * * * /path/to/script.sh >/dev/null 2>&1    # OK(スペースなしも可)

リダイレクトの解説:

> /dev/null    # 標準出力を /dev/null へ
2>&1           # 標準エラー出力(2)を標準出力(1)と同じ場所へ
               # 結果: 両方とも /dev/null へ

# 順序が重要
command > /dev/null 2>&1  # OK: まず stdout を /dev/null に、次に stderr を stdout(=/dev/null) に
command 2>&1 > /dev/null  # NG: まず stderr を stdout(=端末) に、次に stdout を /dev/null に

対策:

# 方法1: 出力を破棄
*/1 * * * * /path/to/script.sh > /dev/null 2>&1

# 方法2: ログファイルに出力
*/1 * * * * /path/to/script.sh >> /var/log/myjob.log 2>&1

# 方法3: MAILTO を設定
MAILTO=""  # 空にするとメール送信しない
*/1 * * * * /path/to/script.sh

# 方法4: cronic を使用(エラー時のみ通知)
*/1 * * * * /usr/bin/cronic /path/to/script.sh

原因2: アンチウイルスソフトの通知メール(33.0%)

問題の詳細:

DrWeb がメールをスキャンできない場合(ライセンス制限等)、管理者に通知メールを送信する。

Subject: A message has not been checked due to license limitations

Dear Postmaster,

the message with following attributes has not been checked
due to the licenses limitation.

--- Dr.Web report ---
message's envelope (addresses) aren't present in license (protected e-mail's), skipped!

対策:

DrWeb の設定ファイルで通知をブロック:

# /etc/drweb/addresses.conf に追加
to "postmaster@"

または通知自体を無効化:

# /etc/drweb/drweb_qmail.conf
AdminNotify = no

設定変更後は再起動が必要:

service drweb restart

原因3: トリプルバウンスと postmaster 問題

トリプルバウンスとは:

1. 元のメール送信 A → B
   ↓ 失敗
2. バウンス生成 B → A (from: <>)
   ↓ 失敗
3. ダブルバウンス → postmaster (from: <#@[]>)
   ↓ 失敗
4. トリプルバウンス → 破棄
   "triple bounce: discarding bounce/XXXXX"

対策:

# qmail の rcpthosts に追加
echo "local-server.example.com" >> /var/qmail/control/rcpthosts

# postmaster エイリアスを設定
echo "[email protected]" > /var/qmail/alias/.qmail-postmaster

qmail 設定の確認ポイント

rcpthosts の確認

cat /var/qmail/control/rcpthosts

リレーを許可するドメインが列挙されている。内部サーバー間でメール転送する場合、相互に許可が必要だ。

badmailfrom の確認

cat /var/qmail/control/badmailfrom

拒否する送信元アドレスのリスト。アンチウイルスの daemon アドレスが誤って登録されていないか確認。

キューの確認

# キュー内のメッセージ数
/var/qmail/bin/qmail-qstat

# キューの詳細
/var/qmail/bin/qmail-qread

監視とアラート設定

バウンス監視スクリプト

#!/bin/bash
# /usr/local/bin/check_bounces.sh

THRESHOLD=100
COUNT=$(grep -c "triple bounce" /var/log/maillog)

if [ "$COUNT" -gt "$THRESHOLD" ]; then
    echo "Warning: $COUNT triple bounces detected" | mail -s "Bounce Alert" [email protected]
fi

ログローテーション設定

# /etc/logrotate.d/maillog
/var/log/maillog {
    daily
    rotate 30
    compress
    delaycompress
    missingok
    notifempty
}

ベストプラクティスまとめ

Cron ジョブ設定

項目 推奨設定
出力リダイレクト 必ず > /dev/null 2>&1 または適切なログファイルへ
MAILTO 必要に応じて設定、不要なら空に
記法 2>&1 を正確に(2&>1 は間違い)

qmail 設定

項目 確認ポイント
rcpthosts 内部サーバー間のリレー許可
postmaster 到達可能なアドレスを設定
badmailfrom 誤ったブロックがないか

監視

項目 推奨
バウンス数 日次で監視、閾値超えでアラート
トリプルバウンス 発生したら即調査
キューサイズ 異常な増加を監視

まとめ

今回の調査で、大量のバウンスメールの約80%が設定ミスに起因していることが判明した。

  • Cron の出力リダイレクト問題: 47.7%
  • アンチウイルスの通知設定問題: 33.0%

これらは適切な設定により完全に防止可能だ。

メールサーバーの運用では、配信エラーだけでなく、周辺システムからの通知メールの扱いも重要である。定期的なログ解析とバウンスメールの監視により、問題を早期に発見・対処することが大切だ。

付録: 主要な SMTP エラーコード一覧

コード カテゴリ 意味 対策
5.1.0 アドレス 送信者アドレスが無効 送信元設定を確認
5.1.1 アドレス 宛先メールボックスが存在しない 宛先アドレスを確認
5.1.2 アドレス 宛先ドメインが無効 DNS 設定を確認
5.2.1 メールボックス メールボックス無効(利用停止) アカウント状態を確認
5.2.2 メールボックス メールボックス容量超過 容量を確認・拡張
5.4.4 ネットワーク MX レコードが見つからない DNS 設定を確認
5.7.1 セキュリティ 配信拒否(ポリシー違反) リレー許可/認証設定を確認
5.7.26 セキュリティ メール認証失敗 SPF/DKIM/DMARC 設定を確認

コメント

タイトルとURLをコピーしました