Ver código fonte

adding two mail tools, bimi and delivery

Josef Straßl 3 dias atrás
pai
commit
71232a52f2
2 arquivos alterados com 852 adições e 0 exclusões
  1. 442 0
      bimi-check.php
  2. 410 0
      mail-delivery-check.php

+ 442 - 0
bimi-check.php

@@ -0,0 +1,442 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Looks up a BIMI record (default._bimi.domain), parses its tags, fetches and
+ * validates the referenced SVG logo, and checks that DMARC is at enforcement —
+ * the precondition mailbox providers require before they will show a BIMI logo.
+ */
+class BimiChecker
+{
+    /** Hard cap on how much of the logo/certificate we download. */
+    private const MAX_FETCH_BYTES = 256 * 1024;
+
+    /** BIMI logos must be SVG Tiny Portable/Secure and stay under this size. */
+    private const MAX_SVG_BYTES = 32 * 1024;
+
+    public function check(string $domain, string $selector): array
+    {
+        $fqdn = $selector . '._bimi.' . $domain;
+        $report = [
+            'domain'   => $domain,
+            'selector' => $selector,
+            'fqdn'     => $fqdn,
+            'record'   => null,
+            'tags'     => [],
+            'logo'     => null,
+            'dmarc'    => self::checkDmarc($domain),
+            'errors'   => [],
+            'warnings' => [],
+            'valid'    => false,
+        ];
+
+        $record = self::getTxtRecord($fqdn);
+        if ($record === null) {
+            $report['errors'][] = 'No BIMI TXT record found at ' . $fqdn . '.';
+            return $report;
+        }
+        $report['record'] = $record;
+
+        $tags = self::parseTags($record);
+        $report['tags'] = $tags;
+
+        $version = strtoupper($tags['v'] ?? '');
+        if ($version === '') {
+            $report['errors'][] = 'Missing v= tag — a BIMI record must start with v=BIMI1.';
+        } elseif ($version !== 'BIMI1') {
+            $report['errors'][] = 'Unexpected v= tag: "' . ($tags['v'] ?? '') . '" (expected "BIMI1").';
+        }
+
+        $logoUrl = trim($tags['l'] ?? '');
+        if ($logoUrl === '') {
+            // An empty l= is a valid "opt out / decline" signal, but usually a mistake.
+            $report['warnings'][] = 'l= tag is empty — this domain declines to publish a logo.';
+        } elseif (!self::isHttpsUrl($logoUrl)) {
+            $report['errors'][] = 'l= must be an https:// URL. Got: ' . $logoUrl;
+        } else {
+            $report['logo'] = self::inspectLogo($logoUrl);
+            foreach ($report['logo']['errors'] as $e) {
+                $report['errors'][] = $e;
+            }
+            foreach ($report['logo']['warnings'] as $w) {
+                $report['warnings'][] = $w;
+            }
+        }
+
+        $authUrl = trim($tags['a'] ?? '');
+        if ($authUrl === '') {
+            $report['warnings'][] = 'No a= tag — Gmail and Apple Mail require a Verified Mark Certificate (VMC/CMC) to show the logo.';
+        } elseif (!self::isHttpsUrl($authUrl)) {
+            $report['errors'][] = 'a= must be an https:// URL pointing to a PEM certificate. Got: ' . $authUrl;
+        }
+
+        // BIMI only renders when DMARC is enforced. Fold that into the verdict.
+        $dmarc = $report['dmarc'];
+        if (!$dmarc['found']) {
+            $report['errors'][] = 'No DMARC record found — BIMI requires an enforced DMARC policy.';
+        } elseif (!$dmarc['enforced']) {
+            $report['errors'][] = 'DMARC policy is p=' . ($dmarc['policy'] ?? 'none')
+                . ' — BIMI requires p=quarantine or p=reject.';
+        } elseif ($dmarc['pct'] !== null && $dmarc['pct'] < 100) {
+            $report['warnings'][] = 'DMARC pct=' . $dmarc['pct'] . ' — BIMI needs pct=100 (or no pct tag) to apply to all mail.';
+        }
+
+        $report['valid'] = empty($report['errors']);
+        return $report;
+    }
+
+    /** @return array<string,string> */
+    private static function parseTags(string $record): array
+    {
+        $tags = [];
+        foreach (explode(';', $record) as $part) {
+            $part = trim($part);
+            if ($part === '' || !str_contains($part, '=')) {
+                continue;
+            }
+            [$key, $value] = explode('=', $part, 2);
+            $tags[strtolower(trim($key))] = trim($value);
+        }
+        return $tags;
+    }
+
+    /** Downloads the SVG logo and checks the SVG Tiny PS constraints BIMI imposes. */
+    private static function inspectLogo(string $url): array
+    {
+        $logo = [
+            'url'      => $url,
+            'bytes'    => null,
+            'mime'     => null,
+            'title'    => null,
+            'errors'   => [],
+            'warnings' => [],
+        ];
+
+        $fetch = self::fetch($url);
+        if ($fetch['error'] !== null) {
+            $logo['errors'][] = 'Could not fetch logo (' . $url . '): ' . $fetch['error'];
+            return $logo;
+        }
+
+        $body = $fetch['body'];
+        $logo['bytes'] = strlen($body);
+        $logo['mime']  = $fetch['mime'];
+
+        if ($logo['bytes'] > self::MAX_SVG_BYTES) {
+            $logo['errors'][] = sprintf(
+                'Logo is %.1f KB — BIMI requires the SVG to be under 32 KB.',
+                $logo['bytes'] / 1024
+            );
+        }
+
+        if (!str_contains($body, '<svg')) {
+            $logo['errors'][] = 'Logo does not look like an SVG file (no <svg> element found).';
+            return $logo;
+        }
+
+        // Pull the root <svg> attributes to check the required Tiny PS profile.
+        if (preg_match('/<svg\b[^>]*>/is', $body, $m)) {
+            $svgTag = $m[0];
+            if (!preg_match('/baseProfile\s*=\s*["\']tiny-ps["\']/i', $svgTag)) {
+                $logo['errors'][] = 'SVG is missing baseProfile="tiny-ps" — BIMI requires the SVG Tiny Portable/Secure profile.';
+            }
+            if (!preg_match('/version\s*=\s*["\']1\.2["\']/i', $svgTag)) {
+                $logo['warnings'][] = 'SVG root should declare version="1.2" for the Tiny profile.';
+            }
+            if (preg_match('/\b(x|y|width|height)\s*=/i', $svgTag)) {
+                $logo['warnings'][] = 'SVG root should not have x/y/width/height — use a square viewBox instead.';
+            }
+            if (!preg_match('/viewBox\s*=\s*["\']\s*0\s+0\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*["\']/i', $svgTag, $vb)) {
+                $logo['warnings'][] = 'SVG should have a square viewBox starting at "0 0".';
+            } elseif (abs((float) $vb[1] - (float) $vb[2]) > 0.01) {
+                $logo['warnings'][] = 'viewBox is not square (' . $vb[1] . '×' . $vb[2] . ') — BIMI logos must be square.';
+            }
+        }
+
+        // Forbidden constructs in the Tiny PS profile.
+        if (preg_match('/<script\b/i', $body)) {
+            $logo['errors'][] = 'SVG contains a <script> element — scripts are forbidden in BIMI logos.';
+        }
+        if (preg_match('/<(a|image|use|foreignObject)\b/i', $body, $m)) {
+            $logo['errors'][] = 'SVG contains a <' . strtolower($m[1]) . '> element, which is not allowed in the Tiny PS profile.';
+        }
+        if (preg_match('/xlink:href|(?<![a-z])href\s*=/i', $body)) {
+            $logo['warnings'][] = 'SVG references external content (href) — external references are not permitted.';
+        }
+        if (preg_match('/<(animate|animateTransform|animateMotion|set)\b/i', $body)) {
+            $logo['warnings'][] = 'SVG contains animation elements — animation is not allowed.';
+        }
+
+        if (preg_match('/<title>(.*?)<\/title>/is', $body, $m)) {
+            $logo['title'] = trim(html_entity_decode(strip_tags($m[1])));
+        } else {
+            $logo['warnings'][] = 'SVG has no <title> element — recommended so the logo has an accessible name.';
+        }
+
+        return $logo;
+    }
+
+    /** Reads the domain's DMARC record and decides whether it is at enforcement. */
+    private static function checkDmarc(string $domain): array
+    {
+        $record = self::getTxtRecord('_dmarc.' . $domain);
+        $out = ['found' => false, 'record' => null, 'policy' => null, 'pct' => null, 'enforced' => false];
+        if ($record === null || stripos($record, 'v=DMARC1') === false) {
+            return $out;
+        }
+        $out['found']  = true;
+        $out['record'] = $record;
+
+        $tags = self::parseTags($record);
+        $out['policy'] = strtolower($tags['p'] ?? 'none');
+        if (isset($tags['pct']) && is_numeric($tags['pct'])) {
+            $out['pct'] = (int) $tags['pct'];
+        }
+        $out['enforced'] = in_array($out['policy'], ['quarantine', 'reject'], true);
+        return $out;
+    }
+
+    private static function isHttpsUrl(string $url): bool
+    {
+        return (bool) preg_match('#^https://[^\s/]+#i', $url);
+    }
+
+    private static function fetch(string $url): array
+    {
+        $ctx = stream_context_create([
+            'http' => [
+                'method'        => 'GET',
+                'timeout'       => 8,
+                'user_agent'    => 'medowar-bimi-check/1.0',
+                'max_redirects' => 3,
+                'ignore_errors' => true,
+            ],
+            'ssl' => [
+                'verify_peer'      => true,
+                'verify_peer_name' => true,
+            ],
+        ]);
+
+        $stream = @fopen($url, 'rb', false, $ctx);
+        if ($stream === false) {
+            return ['body' => '', 'mime' => null, 'error' => 'connection failed or TLS error'];
+        }
+
+        $meta   = stream_get_meta_data($stream);
+        $status = self::statusFromHeaders($meta['wrapper_data'] ?? []);
+        $mime   = self::headerValue($meta['wrapper_data'] ?? [], 'content-type');
+
+        $body = @stream_get_contents($stream, self::MAX_FETCH_BYTES);
+        fclose($stream);
+
+        if ($status !== null && $status >= 400) {
+            return ['body' => '', 'mime' => $mime, 'error' => 'HTTP ' . $status];
+        }
+        if ($body === false || $body === '') {
+            return ['body' => '', 'mime' => $mime, 'error' => 'empty response'];
+        }
+        return ['body' => $body, 'mime' => $mime, 'error' => null];
+    }
+
+    /** @param array<int,string> $headers */
+    private static function statusFromHeaders(array $headers): ?int
+    {
+        foreach ($headers as $h) {
+            if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
+                $status = (int) $m[1];
+            }
+        }
+        return $status ?? null;
+    }
+
+    /** @param array<int,string> $headers */
+    private static function headerValue(array $headers, string $name): ?string
+    {
+        foreach ($headers as $h) {
+            if (stripos($h, $name . ':') === 0) {
+                return trim(substr($h, strlen($name) + 1));
+            }
+        }
+        return null;
+    }
+
+    private static function getTxtRecord(string $fqdn): ?string
+    {
+        foreach ((@dns_get_record($fqdn, DNS_TXT) ?: []) as $r) {
+            $txt = $r['txt'] ?? (isset($r['entries']) ? implode('', $r['entries']) : '');
+            if ($txt !== '') {
+                return $txt;
+            }
+        }
+        return null;
+    }
+}
+
+$domain   = trim((string) ($_GET['domain'] ?? ''));
+$selector = trim((string) ($_GET['selector'] ?? ''));
+$report   = null;
+$inputError = null;
+
+if ($selector === '') {
+    $selector = 'default';
+}
+
+if ($domain !== '') {
+    // Accept a bare hostname; strip scheme/path if a URL was pasted.
+    $domain = preg_replace('#^\w+://#', '', $domain);
+    $domain = explode('/', $domain)[0];
+    $domain = strtolower(trim($domain));
+
+    if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
+        $inputError = 'Please enter a valid domain name (e.g. example.com).';
+    } elseif (!preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9._-]{0,127})?$/', $selector)) {
+        $inputError = 'Please enter a valid selector (letters, digits, dot, dash, underscore).';
+    } else {
+        $report = (new BimiChecker())->check($domain, $selector);
+    }
+}
+
+$h = 'htmlspecialchars';
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>BIMI Record Checker</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1000px;
+            margin: 40px auto;
+            padding: 0 20px;
+            background: #f5f5f5;
+            color: #333;
+        }
+        h1 { color: #333; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
+        input[type=text] {
+            flex: 1; min-width: 180px; padding: 10px; font-size: 15px;
+            border: 1px solid #ccc; border-radius: 5px;
+        }
+        input[name=selector] { flex: 0 0 200px; min-width: 140px; }
+        .btn {
+            display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .warning {
+            background: #fff8e1; color: #e65100; padding: 10px; border-radius: 5px;
+            margin: 10px 0; border-left: 5px solid #ff9800;
+        }
+        .verdict {
+            display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
+            font-size: 17px; font-weight: 600; margin: 20px 0 10px;
+        }
+        .verdict.ok   { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
+        .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
+        .record {
+            background: #263238; color: #aed581; padding: 8px 12px; border-radius: 4px;
+            font-family: monospace; font-size: 13px; word-break: break-all; margin: 10px 0;
+        }
+        .logo-preview {
+            display: flex; align-items: center; gap: 16px; background: white; padding: 14px;
+            border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin: 12px 0;
+        }
+        .logo-preview img {
+            width: 64px; height: 64px; border-radius: 50%; border: 1px solid #eee;
+            background: #fafafa; object-fit: contain;
+        }
+        .logo-preview .meta { font-size: 13px; color: #555; }
+        table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
+        th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
+        th { background: #fafafa; color: #555; font-weight: 600; }
+        td.mono, .mono { font-family: monospace; word-break: break-all; }
+        h2 { color: #333; margin-top: 30px; }
+        .empty { text-align: center; color: #999; padding: 40px; }
+    </style>
+</head>
+<body>
+    <h1>🎨 BIMI Record Checker</h1>
+
+    <div class="info">
+        Looks up the <strong>BIMI</strong> TXT record for a domain
+        (<code>selector._bimi.domain</code>, default selector <code>default</code>),
+        parses its tags, fetches the SVG logo to validate the SVG&nbsp;Tiny&nbsp;PS profile,
+        and confirms <strong>DMARC</strong> is enforced — the precondition for a logo to display.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com" value="<?= $h($domain) ?>" autofocus>
+        <input type="text" name="selector" placeholder="selector (default)" value="<?= $h($report ? $report['selector'] : ($_GET['selector'] ?? '')) ?>">
+        <button type="submit" class="btn">🔍 Check BIMI</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= $h($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($report !== null): ?>
+        <?php if ($report['valid']): ?>
+            <div class="verdict ok">✅ <?= $h($report['fqdn']) ?> — valid, displayable BIMI record.</div>
+        <?php else: ?>
+            <div class="verdict fail">❌ <?= $h($report['fqdn']) ?> — BIMI record is missing or will not display.</div>
+        <?php endif; ?>
+
+        <?php foreach ($report['errors'] as $e): ?>
+            <div class="error">❌ <?= $h($e) ?></div>
+        <?php endforeach; ?>
+        <?php foreach ($report['warnings'] as $w): ?>
+            <div class="warning">⚠️ <?= $h($w) ?></div>
+        <?php endforeach; ?>
+
+        <?php if ($report['record'] !== null): ?>
+            <h2>BIMI record</h2>
+            <div class="record"><?= $h($report['record']) ?></div>
+
+            <?php if ($report['logo'] !== null && $report['logo']['bytes'] !== null): ?>
+                <div class="logo-preview">
+                    <img src="<?= $h($report['logo']['url']) ?>" alt="BIMI logo" loading="lazy">
+                    <div class="meta">
+                        <strong><?= $h($report['logo']['title'] ?? 'Logo') ?></strong><br>
+                        <?= number_format($report['logo']['bytes'] / 1024, 1) ?> KB
+                        <?= $report['logo']['mime'] ? '· ' . $h($report['logo']['mime']) : '' ?><br>
+                        <span class="mono"><?= $h($report['logo']['url']) ?></span>
+                    </div>
+                </div>
+            <?php endif; ?>
+
+            <table>
+                <thead><tr><th>Tag</th><th>Meaning</th><th>Value</th></tr></thead>
+                <tbody>
+                    <?php
+                    $meanings = ['v' => 'Version', 'l' => 'Logo URL (SVG)', 'a' => 'Authority (VMC/CMC)'];
+                    foreach ($report['tags'] as $tag => $value):
+                    ?>
+                        <tr>
+                            <td class="mono"><?= $h($tag) ?></td>
+                            <td><?= $h($meanings[$tag] ?? '—') ?></td>
+                            <td class="mono"><?= $value === '' ? '<em>(empty)</em>' : $h($value) ?></td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+
+        <h2>DMARC prerequisite</h2>
+        <?php if ($report['dmarc']['found']): ?>
+            <div class="record"><?= $h($report['dmarc']['record']) ?></div>
+            <p style="font-size:14px;color:#555;">
+                Policy <strong>p=<?= $h($report['dmarc']['policy'] ?? 'none') ?></strong><?php
+                    if ($report['dmarc']['pct'] !== null) echo ', pct=' . (int) $report['dmarc']['pct'];
+                ?> —
+                <?= $report['dmarc']['enforced'] ? 'meets the BIMI enforcement requirement.' : 'not enforced; BIMI will not display.' ?>
+            </p>
+        <?php else: ?>
+            <div class="empty">No DMARC record found at _dmarc.<?= $h($report['domain']) ?>.</div>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>

+ 410 - 0
mail-delivery-check.php

@@ -0,0 +1,410 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Works out where a sending mail server (MTA) would actually try to deliver mail
+ * for a domain, following RFC 5321 §5.1 routing rules:
+ *
+ *   1. Look up the recipient domain's MX records and sort them by preference
+ *      (lowest number = tried first). Equal preferences are chosen at random.
+ *   2. Resolve each MX target to its A / AAAA addresses — those are the hosts a
+ *      sender opens an SMTP connection to, in preference order.
+ *   3. Null MX (RFC 7505): a single "0 ." record means the domain accepts no mail.
+ *   4. Implicit MX (RFC 5321 §5.1): with no MX records, the domain's own A / AAAA
+ *      records are used as an implicit MX at preference 0.
+ *
+ * The result is the ordered list of servers/IPs a sender would attempt, enriched
+ * with ASN / country / company for each address.
+ */
+class DeliveryResolver
+{
+    /** @var string[] Human-readable notes / warnings about the routing. */
+    public array $warnings = [];
+
+    /** @var array<int,array{pref:int,host:string,implicit:bool,cname:?string,addresses:array<int,array{ip:string,version:string}>,error:?string}> */
+    public array $targets = [];
+
+    public bool $nullMx = false;   // RFC 7505 — domain explicitly refuses mail
+    public bool $hasMx   = false;  // at least one usable MX record was found
+    public ?string $error = null;
+
+    public function resolve(string $domain): void
+    {
+        $mx = @dns_get_record($domain, DNS_MX) ?: [];
+
+        // RFC 7505 Null MX: exactly one record, preference 0, target "." (root).
+        if (count($mx) === 1
+            && (int) ($mx[0]['pri'] ?? -1) === 0
+            && rtrim((string) ($mx[0]['target'] ?? ''), '.') === '') {
+            $this->nullMx = true;
+            $this->warnings[] = 'This domain publishes a Null MX record (RFC 7505: "0 .") — '
+                . 'it explicitly does not accept email. Senders should bounce immediately.';
+            return;
+        }
+
+        if (!empty($mx)) {
+            $this->hasMx = true;
+            // Sort by preference ascending; senders try the lowest number first.
+            usort($mx, static fn($a, $b) => ($a['pri'] ?? 0) <=> ($b['pri'] ?? 0));
+
+            $prefs = array_map(static fn($r) => (int) ($r['pri'] ?? 0), $mx);
+            if (count($prefs) !== count(array_unique($prefs))) {
+                $this->warnings[] = 'Several MX records share the same preference. A sender picks '
+                    . 'between equal-preference hosts at random, so the exact host order can vary per delivery.';
+            }
+
+            foreach ($mx as $r) {
+                $host = rtrim((string) ($r['target'] ?? ''), '.');
+                $this->targets[] = $this->buildTarget((int) ($r['pri'] ?? 0), $host, false);
+            }
+            return;
+        }
+
+        // No MX record → RFC 5321 implicit MX: try the domain's own A / AAAA.
+        $implicit = $this->buildTarget(0, $domain, true);
+        if (empty($implicit['addresses'])) {
+            $this->error = 'No MX records and no A/AAAA records for the domain — '
+                . 'there is nowhere to deliver mail. Senders will return a bounce.';
+            return;
+        }
+        $this->warnings[] = 'No MX records found. Under RFC 5321 the domain\'s own address (A/AAAA) '
+            . 'is used as an implicit MX at preference 0.';
+        $this->targets[] = $implicit;
+    }
+
+    /**
+     * Resolves one MX target to its addresses and flags common misconfigurations
+     * (a CNAME where a hostname is required, or a target that does not resolve).
+     */
+    private function buildTarget(int $pref, string $host, bool $implicit): array
+    {
+        $target = [
+            'pref'      => $pref,
+            'host'      => $host,
+            'implicit'  => $implicit,
+            'cname'     => null,
+            'addresses' => [],
+            'error'     => null,
+        ];
+
+        if ($host === '') {
+            $target['error'] = 'Empty MX target.';
+            return $target;
+        }
+
+        // RFC 2181 §10.3 / RFC 5321 §5.1: an MX target must be a hostname with
+        // address records, never a CNAME. Flag it, but still follow the chain.
+        $cname = @dns_get_record($host, DNS_CNAME) ?: [];
+        foreach ($cname as $c) {
+            if (($c['host'] ?? '') === $host && !empty($c['target'])) {
+                $target['cname'] = rtrim((string) $c['target'], '.');
+                if (!$implicit) {
+                    $this->warnings[] = sprintf(
+                        'MX target "%s" is a CNAME pointing to "%s". RFC 2181 forbids this; some '
+                        . 'senders reject such records. It should be an A/AAAA hostname.',
+                        $host,
+                        $target['cname']
+                    );
+                }
+                break;
+            }
+        }
+
+        foreach (@dns_get_record($host, DNS_A) ?: [] as $r) {
+            if (!empty($r['ip'])) {
+                $target['addresses'][] = ['ip' => $r['ip'], 'version' => 'IPv4'];
+            }
+        }
+        foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $r) {
+            if (!empty($r['ipv6'])) {
+                $target['addresses'][] = ['ip' => $r['ipv6'], 'version' => 'IPv6'];
+            }
+        }
+
+        if (empty($target['addresses'])) {
+            $target['error'] = $target['cname'] !== null
+                ? 'Target is a CNAME and did not resolve to any address.'
+                : 'MX host has no A/AAAA records — a sender cannot connect to it.';
+        }
+
+        return $target;
+    }
+
+    /** @return string[] Every unique IP across all targets, for batch enrichment. */
+    public function allIps(): array
+    {
+        $ips = [];
+        foreach ($this->targets as $t) {
+            foreach ($t['addresses'] as $a) {
+                $ips[] = $a['ip'];
+            }
+        }
+        return array_values(array_unique($ips));
+    }
+}
+
+/**
+ * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint.
+ * @param string[] $ips
+ * @return array<string,array>
+ */
+function lookupIpInfo(array $ips): array
+{
+    $out = [];
+    $ips = array_values(array_unique(array_filter($ips)));
+    if (empty($ips)) {
+        return $out;
+    }
+    $fields = 'query,status,message,country,countryCode,as,asname,isp,org,reverse';
+    foreach (array_chunk($ips, 100) as $chunk) {
+        $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk);
+        $ch = curl_init('http://ip-api.com/batch');
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => json_encode($payload),
+            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT => 20,
+        ]);
+        $resp = curl_exec($ch);
+        curl_close($ch);
+        foreach ((json_decode((string) $resp, true) ?: []) as $item) {
+            if (isset($item['query'])) {
+                $out[$item['query']] = $item;
+            }
+        }
+    }
+    return $out;
+}
+
+$domain = trim((string) ($_GET['domain'] ?? ''));
+$resolver = null;
+$ipInfo = [];
+$inputError = null;
+
+if ($domain !== '') {
+    // Accept a bare hostname; strip scheme/path if a URL was pasted, and an
+    // email address if someone enters user@example.com.
+    $domain = preg_replace('#^\w+://#', '', $domain);
+    $domain = explode('/', $domain)[0];
+    if (str_contains($domain, '@')) {
+        $domain = substr($domain, strrpos($domain, '@') + 1);
+    }
+    $domain = strtolower(trim($domain));
+
+    if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
+        $inputError = 'Please enter a valid domain name (e.g. example.com) or an email address.';
+    } else {
+        $resolver = new DeliveryResolver();
+        $resolver->resolve($domain);
+        $ipInfo = lookupIpInfo($resolver->allIps());
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Mail Delivery Route Checker</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1100px;
+            margin: 40px auto;
+            padding: 0 20px;
+            background: #f5f5f5;
+            color: #333;
+        }
+        h1 { color: #333; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
+        input[type=text] {
+            flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
+            border: 1px solid #ccc; border-radius: 5px;
+        }
+        .btn {
+            display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .warning {
+            background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
+            margin: 10px 0; border-left: 5px solid #ff9800;
+        }
+        .verdict {
+            display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
+            font-size: 17px; font-weight: 600; margin: 16px 0;
+        }
+        .verdict.ok   { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
+        .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
+        h2 { color: #333; margin-top: 30px; }
+        .target {
+            background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+            margin: 12px 0; padding: 14px 16px; border-left: 5px solid #2196F3;
+        }
+        .target.dead { border-left-color: #e53935; opacity: 0.85; }
+        .target-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
+        .pref {
+            display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 30px;
+            background: #2196F3; color: white; border-radius: 50%; font-weight: 700; font-size: 14px; padding: 0 6px;
+        }
+        .target.dead .pref { background: #e53935; }
+        .mxhost { font-family: monospace; font-size: 16px; font-weight: 600; word-break: break-all; }
+        .tag {
+            display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700;
+        }
+        .tag.implicit { background: #ede7f6; color: #5e35b1; }
+        .tag.cname { background: #fff3e0; color: #e65100; }
+        .tag.first { background: #e8f5e9; color: #2e7d32; }
+        .tag-err { color: #c62828; font-size: 13px; margin-top: 6px; }
+        table { width: 100%; border-collapse: collapse; margin-top: 10px; }
+        th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
+        th { color: #555; background: #fafafa; font-weight: 600; }
+        td.ip { font-family: monospace; }
+        .ver { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 700; }
+        .ver.IPv4 { background: #e3f2fd; color: #1565c0; }
+        .ver.IPv6 { background: #f3e5f5; color: #6a1b9a; }
+        .empty { text-align: center; color: #999; padding: 40px; }
+        .muted { color: #888; font-size: 12px; }
+        ol.flat { padding-left: 20px; }
+        ol.flat li { margin: 4px 0; font-size: 14px; }
+        ol.flat code { font-family: monospace; }
+    </style>
+</head>
+<body>
+    <h1>📮 Mail Delivery Route Checker</h1>
+
+    <div class="info">
+        Shows <strong>where a sending mail server would try to deliver</strong> email for a domain, following
+        <strong>RFC 5321</strong> routing: MX records sorted by <strong>preference</strong> (lowest first), each MX
+        resolved to its <strong>A / AAAA addresses</strong>, plus <strong>Null MX</strong> (RFC 7505) and
+        <strong>implicit MX</strong> fallback. Every address is enriched with its ASN, country and company.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com  (or user@example.com)"
+               value="<?= htmlspecialchars($domain) ?>" autofocus>
+        <button type="submit" class="btn">🔍 Trace delivery</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= htmlspecialchars($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($resolver !== null): ?>
+
+        <?php if ($resolver->nullMx): ?>
+            <div class="verdict fail">🚫 <?= htmlspecialchars($domain) ?> does not accept mail (Null MX).</div>
+        <?php elseif ($resolver->error !== null): ?>
+            <div class="verdict fail">❌ Mail cannot be delivered to <?= htmlspecialchars($domain) ?>.</div>
+        <?php else:
+            $reachable = array_filter($resolver->targets, static fn($t) => !empty($t['addresses']));
+        ?>
+            <?php if (!empty($reachable)): ?>
+                <div class="verdict ok">✅ Mail for <?= htmlspecialchars($domain) ?> would be delivered to
+                    <?= count($reachable) ?> reachable <?= $resolver->hasMx ? 'MX host' : 'implicit MX host' ?><?= count($reachable) === 1 ? '' : 's' ?>.</div>
+            <?php else: ?>
+                <div class="verdict fail">❌ MX records exist but none resolve to a usable address.</div>
+            <?php endif; ?>
+        <?php endif; ?>
+
+        <?php foreach ($resolver->warnings as $w): ?>
+            <div class="warning">⚠️ <?= htmlspecialchars($w) ?></div>
+        <?php endforeach; ?>
+
+        <?php if ($resolver->error !== null): ?>
+            <div class="error"><?= htmlspecialchars($resolver->error) ?></div>
+        <?php endif; ?>
+
+        <?php if (!empty($resolver->targets)): ?>
+            <h2>Delivery targets, in the order a sender tries them</h2>
+            <?php foreach ($resolver->targets as $i => $t): ?>
+                <div class="target <?= empty($t['addresses']) ? 'dead' : '' ?>">
+                    <div class="target-head">
+                        <span class="pref" title="MX preference"><?= (int) $t['pref'] ?></span>
+                        <span class="mxhost"><?= htmlspecialchars($t['host']) ?></span>
+                        <?php if ($i === 0 && !empty($t['addresses'])): ?>
+                            <span class="tag first">tried first</span>
+                        <?php endif; ?>
+                        <?php if ($t['implicit']): ?>
+                            <span class="tag implicit">implicit MX (A/AAAA)</span>
+                        <?php endif; ?>
+                        <?php if ($t['cname'] !== null): ?>
+                            <span class="tag cname">CNAME → <?= htmlspecialchars($t['cname']) ?></span>
+                        <?php endif; ?>
+                    </div>
+
+                    <?php if ($t['error'] !== null): ?>
+                        <div class="tag-err">⚠️ <?= htmlspecialchars($t['error']) ?></div>
+                    <?php else: ?>
+                        <table>
+                            <thead>
+                                <tr>
+                                    <th style="width:60px;">#</th>
+                                    <th>IP address</th>
+                                    <th>Type</th>
+                                    <th>PTR (reverse)</th>
+                                    <th>ASN</th>
+                                    <th>Company / ISP</th>
+                                    <th>Country</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                <?php foreach ($t['addresses'] as $j => $a): ?>
+                                    <?php
+                                        $info = $ipInfo[$a['ip']] ?? null;
+                                        $ok = $info && ($info['status'] ?? '') === 'success';
+                                        $asn = $ok ? ($info['as'] ?: '—') : '—';
+                                        $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
+                                        $country = $ok ? trim(($info['country'] ?? '') . ' (' . ($info['countryCode'] ?? '') . ')', ' ()') : '';
+                                        $ptr = $ok ? ($info['reverse'] ?? '') : '';
+                                    ?>
+                                    <tr>
+                                        <td><?= $j + 1 ?></td>
+                                        <td class="ip"><?= htmlspecialchars($a['ip']) ?></td>
+                                        <td><span class="ver <?= $a['version'] ?>"><?= $a['version'] ?></span></td>
+                                        <td class="ip"><?= htmlspecialchars($ptr ?: '—') ?></td>
+                                        <td><?= htmlspecialchars($asn) ?></td>
+                                        <td><?= htmlspecialchars($company ?: '—') ?></td>
+                                        <td><?= htmlspecialchars($country ?: '—') ?></td>
+                                    </tr>
+                                <?php endforeach; ?>
+                            </tbody>
+                        </table>
+                    <?php endif; ?>
+                </div>
+            <?php endforeach; ?>
+
+            <?php
+                // Flat "connection attempt" order across every reachable address.
+                $flat = [];
+                foreach ($resolver->targets as $t) {
+                    foreach ($t['addresses'] as $a) {
+                        $flat[] = ['pref' => $t['pref'], 'host' => $t['host'], 'ip' => $a['ip']];
+                    }
+                }
+            ?>
+            <?php if (!empty($flat)): ?>
+                <h2>Connection attempt order</h2>
+                <p class="muted">A sender opens an SMTP connection to these addresses in turn, moving on only when one is unreachable or defers.</p>
+                <ol class="flat">
+                    <?php foreach ($flat as $f): ?>
+                        <li><code><?= htmlspecialchars($f['host']) ?></code> [pref <?= (int) $f['pref'] ?>] → <code><?= htmlspecialchars($f['ip']) ?></code></li>
+                    <?php endforeach; ?>
+                </ol>
+                <p class="muted">
+                    Note: equal-preference MX hosts, and the choice between IPv4/IPv6 per host, are ultimately up to
+                    the sending MTA — so the exact order can differ between deliveries.
+                </p>
+            <?php endif; ?>
+
+        <?php elseif (!$resolver->nullMx && $resolver->error === null): ?>
+            <div class="empty">No delivery targets were found.</div>
+        <?php endif; ?>
+
+        <p class="muted" style="margin-top:20px;">IP intelligence via ip-api.com (free tier).</p>
+    <?php endif; ?>
+</body>
+</html>