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

【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) -&gt; 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 &quot;bounce bounced&quot; 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 &lt;([^&gt;]*)&gt; 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,15747.7%5.7.1 (relay_denied)
アンチウイルス通知1,49033.0%5.7.1 (relay_denied)
宛先不存在47310.5%5.1.1
リレー拒否2726.0%5.7.1
MXレコードなし220.5%5.4.4
その他1072.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 設定を確認