技術資料

PHPでDMARC集計レポート(XML)を解析する:SimpleXMLでSPF・DKIMの結果を配列化

作成日:2026.09.21

Googleなどのメール受信サービスから届くDMARC集計レポート(XML)をPHPのSimpleXMLで読み込み、report_metadata、policy_published、複数のrecordから送信元IP、件数、disposition、SPF・DKIMの結果を配列へ変換する方法を解説します。欠損項目やXML解析エラー、名前空間、UNIXタイムスタンプの日時変換も扱い、後続の画面表示や保存処理へ渡せる構造を作ります。

以前、GmailのDMARCレポート(XML形式)の読み方ガイドで、DMARCの設定方法や集計レポートに含まれる項目を紹介しました。

今回はその続きとして、保存済みのDMARC集計レポートをPHPで読み込みます。SimpleXMLを使って、レポートのメタデータ、公開されているポリシー、複数のrecordに含まれる送信元IPやSPF・DKIMの結果を、アプリケーションから参照しやすい配列へ変換します。

メールボックスから添付ファイルを取得したり、ZIPを展開したりする処理は扱いません。すでにXMLファイルとして保存されているDMARCレポートを入力にします。CSV出力も行わず、後続処理へ渡せる構造化データを作るところまでを確認します。

前提

今回の前提は次のとおりです。

  • PHP 8.3
  • SimpleXML、libxml拡張
  • ローカルに保存したDMARC集計レポートXML
  • コマンド実行はPowerShell

Googleから取得したDMARC集計レポートを参考にしていますが、記事内のサンプルは匿名化しています。ドメイン名にはexample.test、送信元IPには文書用に予約されたIPアドレスを使います。また、複数のrecordを処理する例にするため、2件のレコードを含めています。

DMARC集計レポートのXMLには、レポートを作成した組織や対象期間を示すreport_metadata、公開されているポリシーを示すpolicy_published、認証結果をまとめた複数のrecordが含まれます。

サンプルXMLを用意する

まず、次の内容をsample-dmarc.xmlとして保存します。実際のレポートを使う場合は、ドメイン、メールアドレス、送信元IP、レポートIDなどをそのまま公開しないようにしてください。

<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
    <version>1.0</version>
    <report_metadata>
        <org_name>receiver.example.test</org_name>
        <email>dmarc@example.test</email>
        <extra_contact_info>https://support.example.test/dmarc</extra_contact_info>
        <report_id>report-example-20250101-001</report_id>
        <date_range>
            <begin>1735689600</begin>
            <end>1735775999</end>
        </date_range>
    </report_metadata>
    <policy_published>
        <domain>example.test</domain>
        <adkim>r</adkim>
        <aspf>r</aspf>
        <p>none</p>
        <sp>none</sp>
        <pct>100</pct>
    </policy_published>
    <record>
        <row>
            <source_ip>192.0.2.1</source_ip>
            <count>3</count>
            <policy_evaluated>
                <disposition>none</disposition>
                <dkim>pass</dkim>
                <spf>fail</spf>
            </policy_evaluated>
        </row>
        <identifiers>
            <header_from>example.test</header_from>
        </identifiers>
        <auth_results>
            <dkim>
                <domain>example.test</domain>
                <result>pass</result>
                <selector>selector1</selector>
            </dkim>
            <spf>
                <domain>mail.example.test</domain>
                <result>pass</result>
            </spf>
        </auth_results>
    </record>
    <record>
        <row>
            <source_ip>198.51.100.2</source_ip>
            <count>1</count>
            <policy_evaluated>
                <disposition>quarantine</disposition>
                <dkim>fail</dkim>
                <spf>pass</spf>
            </policy_evaluated>
        </row>
        <identifiers>
            <header_from>example.test</header_from>
        </identifiers>
        <auth_results>
            <dkim>
                <domain>example.test</domain>
                <result>fail</result>
                <selector>selector1</selector>
            </dkim>
            <dkim>
                <domain>partner.example.test</domain>
                <result>pass</result>
                <selector>selector2</selector>
            </dkim>
            <spf>
                <domain>mail.example.test</domain>
                <result>pass</result>
            </spf>
        </auth_results>
    </record>
</feedback>

このサンプルでは、最初のrecordはSPFの評価がfailですが、auth_resultsのSPF結果はpassになっています。policy_evaluatedはDMARCポリシーの評価結果、auth_resultsは認証処理の詳細なので、別の情報として保持します。

2件目のrecordにはDKIM結果を2件入れています。実際のレポートでは、複数の署名結果を保持できるように、DKIMは最初の値だけでなく配列として扱います。

SimpleXMLでXMLを読み込む

SimpleXMLのsimplexml_load_file()は、XMLファイルをSimpleXMLElementオブジェクトとして読み込みます。読み込みに失敗した場合はfalseが返るため、戻り値は=== falseで確認します。

XMLの解析エラーを後から確認できるように、libxml_use_internal_errors(true)を使います。エラーを取得したら、後続の処理へ影響しないようにlibxml_clear_errors()でクリアします。

function loadDmarcXml(string $path): SimpleXMLElement
{
    if (!is_file($path)) {
        throw new InvalidArgumentException('XMLファイルが見つかりません。');
    }

    $previous = libxml_use_internal_errors(true);
    libxml_clear_errors();

    try {
        $xml = simplexml_load_file($path);
        $errors = libxml_get_errors();
    } finally {
        libxml_clear_errors();
        libxml_use_internal_errors($previous);
    }

    if ($xml === false) {
        $messages = array_map(
            static fn (LibXMLError $error): string => trim($error->message),
            $errors
        );

        $detail = $messages === [] ? '詳細不明' : implode('; ', $messages);

        throw new RuntimeException(
            'DMARCレポートのXMLを解析できませんでした: ' . $detail
        );
    }

    return $xml;
}

解析に失敗した場合は、入力ファイルが空でないか、XMLとして整形式になっているか、文字コード宣言と実際の保存形式が一致しているかを確認します。

この関数はファイルの存在確認とXML解析だけを担当します。メールの添付ファイルを受信したり、外部URLからXMLを取得したりする処理は分けておくと、通信エラーとXML解析エラーを切り分けやすくなります。

要素の値を安全に取り出す

SimpleXMLの要素は、文字列や整数として使う前に明示的にキャストします。項目が存在しない場合や、空要素の場合はnullとして扱う関数を用意します。

function optionalText(?SimpleXMLElement $parent, string $name): ?string
{
    if ($parent === null || !isset($parent->{$name})) {
        return null;
    }

    $value = trim((string) $parent->{$name});

    return $value === '' ? null : $value;
}

function optionalInteger(?SimpleXMLElement $parent, string $name): ?int
{
    $value = optionalText($parent, $name);

    if ($value === null || filter_var($value, FILTER_VALIDATE_INT) === false) {
        return null;
    }

    return (int) $value;
}

function unixTimestampToUtc(?string $value): ?DateTimeImmutable
{
    if ($value === null || filter_var($value, FILTER_VALIDATE_INT) === false) {
        return null;
    }

    $date = DateTimeImmutable::createFromFormat('!U', $value);

    return $date === false
        ? null
        : $date->setTimezone(new DateTimeZone('UTC'));
}

DMARCのdate_rangeはUNIXタイムスタンプで表現されます。ここでは、読み込んだ直後にUTCのDateTimeImmutableへ変換します。表示時のタイムゾーンへ変換したい場合は、利用する画面や保存先の要件に応じて変更してください。

レポートのメタデータを取得する

まず、レポートを作成した組織、レポートID、対象期間を取り出します。

$xml = loadDmarcXml(__DIR__ . '/sample-dmarc.xml');

$metadata = isset($xml->report_metadata)
    ? $xml->report_metadata
    : null;
$dateRange = $metadata !== null && isset($metadata->date_range)
    ? $metadata->date_range
    : null;

$metadataResult = [
    'version' => optionalText($xml, 'version'),
    'orgName' => optionalText($metadata, 'org_name'),
    'email' => optionalText($metadata, 'email'),
    'extraContactInfo' => optionalText($metadata, 'extra_contact_info'),
    'reportId' => optionalText($metadata, 'report_id'),
    'dateRange' => [
        'begin' => unixTimestampToUtc(optionalText($dateRange, 'begin')),
        'end' => unixTimestampToUtc(optionalText($dateRange, 'end')),
    ],
];

要素が存在しない場合でも処理を止めず、項目ごとにnullを返すようにしています。ただし、レポートIDなどアプリケーション上必須とする項目がある場合は、nullを許容せず、入力エラーとして扱う設計に変更してください。

日時を確認する場合は、次のようにDateTimeImmutableをフォーマットできます。

$begin = $metadataResult['dateRange']['begin'];

if ($begin instanceof DateTimeImmutable) {
    echo $begin->format('Y-m-d H:i:s T'), PHP_EOL;
}
// 2025-01-01 00:00:00 UTC

公開されているDMARCポリシーを取得する

policy_publishedには、対象ドメインや適用するポリシーが入っています。pctは文字列として読み込まれるため、数値として扱う場合は整数へ変換します。

$publishedPolicy = isset($xml->policy_published)
    ? $xml->policy_published
    : null;

$policyResult = [
    'domain' => optionalText($publishedPolicy, 'domain'),
    'adkim' => optionalText($publishedPolicy, 'adkim'),
    'aspf' => optionalText($publishedPolicy, 'aspf'),
    'policy' => optionalText($publishedPolicy, 'p'),
    'subdomainPolicy' => optionalText($publishedPolicy, 'sp'),
    'nonexistentSubdomainPolicy' => optionalText($publishedPolicy, 'np'),
    'percentage' => optionalInteger($publishedPolicy, 'pct'),
];

ここで取得するポリシーは、レポートに記録された公開情報です。個々のメールに対して実際に行われた評価結果は、各recordpolicy_evaluatedから取得します。

複数のrecordを配列へ変換する

DMARC集計レポートの実装で重要なのは、recordが1件だけとは限らないことです。送信元IPや認証結果ごとに複数のrecordが入るため、すべてをforeachで処理します。

function parseRecords(SimpleXMLElement $xml): array
{
    $records = [];

    foreach ($xml->record as $record) {
        $row = isset($record->row) ? $record->row : null;
        $policyEvaluated = $row !== null
            && isset($row->policy_evaluated)
            ? $row->policy_evaluated
            : null;
        $identifiers = isset($record->identifiers)
            ? $record->identifiers
            : null;
        $authResults = isset($record->auth_results)
            ? $record->auth_results
            : null;

        $dkimResults = [];
        if ($authResults !== null) {
            foreach ($authResults->dkim as $dkim) {
                $dkimResults[] = [
                    'domain' => optionalText($dkim, 'domain'),
                    'result' => optionalText($dkim, 'result'),
                    'selector' => optionalText($dkim, 'selector'),
                ];
            }
        }

        $spfResults = [];
        if ($authResults !== null) {
            foreach ($authResults->spf as $spf) {
                $spfResults[] = [
                    'domain' => optionalText($spf, 'domain'),
                    'result' => optionalText($spf, 'result'),
                ];
            }
        }

        $records[] = [
            'sourceIp' => optionalText($row, 'source_ip'),
            'count' => optionalInteger($row, 'count'),
            'policyEvaluated' => [
                'disposition' => optionalText($policyEvaluated, 'disposition'),
                'dkim' => optionalText($policyEvaluated, 'dkim'),
                'spf' => optionalText($policyEvaluated, 'spf'),
            ],
            'identifiers' => [
                'headerFrom' => optionalText($identifiers, 'header_from'),
            ],
            'authResults' => [
                'dkim' => $dkimResults,
                'spf' => $spfResults,
            ],
        ];
    }

    return $records;
}

この処理では、DKIMとSPFの認証結果を常に配列へ入れています。通常は1件しかない場合でも、複数の署名結果や将来の形式差を扱いやすくなります。

policyEvaluatedauthResultsは似た名前の値を持ちますが、同じものではありません。前者はDMARCポリシーの評価結果、後者はDKIMやSPFそのものの認証結果です。どちらか一方だけを残すと、後から原因を確認しにくくなるため、用途を分けて保存します。

レポート全体を一つの構造へまとめる

ここまでの関数を組み合わせると、レポート全体を次のような連想配列として扱えます。

$result = [
    'metadata' => $metadataResult,
    'policyPublished' => $policyResult,
    'records' => parseRecords($xml),
];

echo 'レポートID: ', $result['metadata']['reportId'], PHP_EOL;
echo '対象ドメイン: ', $result['policyPublished']['domain'], PHP_EOL;
echo 'record件数: ', count($result['records']), PHP_EOL;

foreach ($result['records'] as $record) {
    echo $record['sourceIp'], ': ', $record['count'], '件', PHP_EOL;
}

実行結果は次のようになります。

レポートID: report-example-20250101-001
対象ドメイン: example.test
record件数: 2
192.0.2.1: 3件
198.51.100.2: 1件

この時点で、画面表示、データベース保存、集計処理、CSV出力などへ渡せるデータになっています。今回の記事では、利用先ごとの処理は実装しません。配列の代わりにDTOなどのオブジェクトを採用したい場合も、XMLから値を取り出す処理と、利用側の処理を分けておけば置き換えやすくなります。

名前空間付きXMLを扱う場合

今回のサンプルには名前空間を付けていませんが、XMLによってはルート要素や子要素に名前空間が付いている場合があります。その場合、通常の$xml->recordでは要素を取得できないことがあります。

名前空間URIを確認し、registerXPathNamespace()でXPath用の接頭辞を登録してから検索します。

$namespaces = $xml->getDocNamespaces(true);
$defaultNamespace = $namespaces[''] ?? null;

if ($defaultNamespace !== null) {
    $xml->registerXPathNamespace('dmarc', $defaultNamespace);
    $records = $xml->xpath('/dmarc:feedback/dmarc:record') ?: [];
} else {
    $records = iterator_to_array($xml->record);
}

foreach ($records as $record) {
    $sourceIp = optionalText(
        isset($record->row) ? $record->row : null,
        'source_ip'
    );

    echo $sourceIp, PHP_EOL;
}

名前空間の有無で取得方法が変わるため、実際のレポートを使う場合は、まずルート要素と名前空間を確認してから実装します。名前空間のURIを文字列として決め打ちするのではなく、対象XMLの仕様や実データを確認してください。

解析エラーと欠損項目を分けて扱う

DMARCレポートを処理するときは、少なくとも次の状態を区別します。

  • ファイルが存在しない:入力ファイルの配置や取得処理を確認する
  • XMLを解析できない:XMLの破損、文字コード、形式変更を確認する
  • XMLは正常だがrecordがない:該当データなしとして扱う
  • 特定の項目だけ存在しない:項目単位でnullや空配列として扱う
  • 認証結果がfail:XML解析エラーではなく、認証結果として扱う

例えば、simplexml_load_file()が成功した後にrecordが0件でも、それはXMLの解析失敗とは限りません。エラーとして処理するか、空のレポートとして記録するかは、利用するアプリケーションの要件で決めます。

また、DMARCレポートの値をそのまま画面へ出力する場合は、HTMLエスケープを行います。ログへ記録するときも、メールアドレスや送信元IPなどの情報を必要以上に残さないようにします。

動作を確認する

PHPのバージョンとSimpleXML拡張が有効になっていることを確認します。PowerShellでは次のように実行できます。

php -v
php -m | Select-String "SimpleXML|libxml"

ここまでのコードをdmarc.phpへ保存し、同じディレクトリにsample-dmarc.xmlを置いて実行します。

php dmarc.php

正常系だけでなく、次の入力でも確認します。

  • XMLファイルが存在しない
  • 閉じタグが不足した不正なXML
  • recordが複数ある
  • DKIM結果が複数ある
  • countsource_ipが欠損している
  • recordが0件である
  • 名前空間付きのXMLである

特に、認証結果のfailをXML解析エラーと混同しないことが重要です。XMLを正しく読み込めていても、認証結果としてfailneutralなどが返る場合があります。

まとめ

DMARC集計レポートのXMLは、PHPのSimpleXMLを使って階層構造のまま読み込めます。

  • report_metadataからレポートの組織、ID、対象期間を取得する
  • policy_publishedから公開されているDMARCポリシーを取得する
  • 複数のrecordを繰り返し処理し、送信元や認証結果を配列へ変換する
  • DKIMやSPFの結果は、複数件を保持できる構造にする
  • 解析失敗、該当データなし、項目欠損、認証失敗を分けて扱う

今回作成した配列を使えば、画面表示、データベース保存、集計、CSV出力などへつなげられます。どの項目を保存し、どのように表示するかは、DMARCレポートを利用するアプリケーションの目的に合わせて決めるとよいかと思います。

参考資料

この記事を書いた人

※上が私です。

奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。

これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。

【制作会社・企業様向けサポート】
  • 専任エンジニアのいない企業様に対するシステム面の不安を解消
  • 柔軟な契約形態や短納期での対応により、急なニーズにも迅速にサポート
  • システムの企画段階から運用まで、ワンストップでのサービスを提供

Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。

ぜひ、プロフィールWeb制作会社様向け業務案内一般企業様向け業務案内もご覧くださいね。

PHPのcURLで外部API連携を実装する:HTTPメソッド・認証・エラー処理を共通化

2026.09.20

PHPのcURL拡張を使い、GET・POST・PUT・PATCH・DELETEのHTTPリクエストを送信する方法を解説します。APIキーやBearerトークンの扱い、タイムアウト、通信エラーとHTTPエラーの分離、JSONレスポンスの解析までを共通関数にまとめます。

PHP

Laravel 13のNamed Rate Limiterでレート制限を実装する:IP・ユーザー・入力値ごとの設定

2026.09.19

LaravelのNamed Rate Limiterを使い、無制限に繰り返されたくない処理へレート制限を追加する方法を解説します。RateLimiter::for()とthrottleミドルウェアの基本から、IPアドレス・ユーザーID・入力値による制限、複数の制限、429レスポンス、ログイン処理への応用、Redis利用時の注意点まで整理します。

Laravel PHP

Laravel SocialiteでGoogleログインを実装する|OAuth設定から初回登録まで

2026.09.18

Laravel Socialiteを使ったGoogleログインを、OAuthクライアントの設定からコールバック、Laravelユーザーの登録・ログインまで解説します。Googleアカウントは変更されるメールアドレスではなくsubで識別し、初回登録時は検証済みメールアドレスを確認します。既存アカウントへ自動連携しない設計や、セッション管理、Socialite Fakeで確認するテスト項目も紹介します。

Laravel OAuth PHP

Laravel Pintの使い方:PHPコードの書式を整えてCIで検査する

2026.09.15

Laravel Pintを使ってPHPコードの書式を整える方法を紹介します。ローカルでの自動修正、CIで書式違反だけを検査する--test、対象範囲の指定、pint.jsonの設定、Bladeファイルを扱う際の注意点を整理し、Pintを導入する判断基準も説明します。

CI/CD Laravel PHP

阿部辰也へのお仕事の依頼・お問い合わせ

軽いご相談もお気軽にどうぞ!

個人情報の取り扱いについて *必須 プライバシーポリシーをご確認いただき、同意いただける場合は「同意する」にチェックをしてください。

keyboard_double_arrow_up
TOP