<?php
// domain-inspector.php — single file UI + API (Vanilla PHP + Vue 2 via CDN)
// Herd-compatible. PHP 8+ recommended.

// ---------- Utility ----------
function json_response($data, $code = 200)
{
    http_response_code($code);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    exit;
}

function is_valid_domain($d)
{
    return (bool) preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $d);
}

function q($name, $type)
{
    try {
        return dns_get_record($name, $type) ?: [];
    } catch (Throwable $e) {
        return [];
    }
}

function qptr($ip)
{
    try {
        // First try using gethostbyaddr to get the hostname
        $hostname = @gethostbyaddr($ip);

        // If gethostbyaddr failed (returns the IP back), try direct PTR lookup
        if ($hostname === $ip || $hostname === false) {
            // For IPv4, reverse the octets and add .in-addr.arpa
            if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
                $octets = explode('.', $ip);
                $reversed = implode('.', array_reverse($octets));
                $ptr_domain = $reversed . '.in-addr.arpa';

                $result = @dns_get_record($ptr_domain, DNS_PTR);
                return $result ?: [];
            }
            // For IPv6, we'd need more complex logic
            return [];
        }

        // If gethostbyaddr worked, still try to get the full PTR record
        $result = @dns_get_record($hostname, DNS_PTR);
        if (!$result) {
            // If no PTR record found but gethostbyaddr worked, create a mock result
            return [['target' => $hostname, 'class' => 'IN', 'type' => 'PTR']];
        }

        return $result;
    } catch (Throwable $e) {
        return [];
    }
}

function qtxt($name)
{
    $rows = q($name, DNS_TXT);
    $vals = [];
    foreach ($rows as $r) {
        if (isset($r['txt'])) {
            $vals[] = $r['txt'];
        }
    }
    return $vals;
}

function parse_spf($txtRows)
{
    $spfRaw = null;
    foreach (($txtRows ?? []) as $t) {
        $val = is_array($t) && isset($t['txt']) ? $t['txt'] : (is_string($t) ? $t : null);
        if ($val && stripos($val, 'v=spf1') === 0) {
            $spfRaw = $val;
            break;
        }
    }
    if (!$spfRaw) {
        return null;
    }

    $lookup_est = preg_match_all('/\b(include:|a|mx|ptr|exists:|redirect=|exp=|ip4:|ip6:)/i', $spfRaw);
    $permissive = (strpos($spfRaw, '+all') !== false) || ((strpos($spfRaw, ' -all') === false) && (strpos($spfRaw, ' ~all') === false));
    return ['raw' => $spfRaw, 'lookup_estimate' => $lookup_est, 'permissive' => $permissive];
}

function parse_dmarc($dmarcTxt)
{
    $rec = null;
    foreach (($dmarcTxt ?? []) as $v) {
        if (is_string($v) && stripos($v, 'v=DMARC1') === 0) {
            $rec = $v;
            break;
        }
    }
    if (!$rec) {
        return null;
    }

    $parts = [];
    foreach (explode(';', $rec) as $p) {
        $p = trim($p);
        if ($p && strpos($p, '=') !== false) {
            [$k, $v] = array_map('trim', explode('=', $p, 2));
            $parts[strtolower($k)] = $v;
        }
    }
    $p = strtolower($parts['p'] ?? 'none');
    $grade = $p === 'reject' ? 'enforced' : ($p === 'quarantine' ? 'partial' : 'none');
    return [
        'raw' => $rec,
        'policy' => $p,
        'aspf' => $parts['aspf'] ?? null,
        'adkim' => $parts['adkim'] ?? null,
        'rua' => $parts['rua'] ?? null,
        'ruf' => $parts['ruf'] ?? null,
        'pct' => $parts['pct'] ?? '100',
        'grade' => $grade,
    ];
}

function rdap_lookup($domain)
{
    $ctx = stream_context_create([
        'http' => ['timeout' => 10, 'user_agent' => 'Domain Inspector/1.0'],
        'https' => ['timeout' => 10, 'user_agent' => 'Domain Inspector/1.0']
    ]);

    // Try rdap.org first
    $urls = [
        'https://rdap.org/domain/' . $domain,
    ];

    // Add TLD-specific RDAP servers for common TLDs
    $tld = strtolower(substr($domain, strrpos($domain, '.') + 1));
    $tldServers = [
        'com' => 'https://rdap.verisign.com/com/v1/domain/',
        'net' => 'https://rdap.verisign.com/net/v1/domain/',
        'org' => 'https://rdap.publicinterestregistry.org/rdap/domain/',
        // New Zealand (use SRS RDAP for nz and common 2nd-levels)
        'nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'co.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'org.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'net.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'govt.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'ac.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'school.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'mil.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'parliament.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'health.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'iwi.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        'kiwi.nz' => 'https://rdap.srs.net.nz/rdap/domain/',
        // Australia (use auDA RDAP for .au and common 2nd-levels)
        'au' => 'https://rdap.auda.org.au/domain/',
        'com.au' => 'https://rdap.auda.org.au/domain/',
        'net.au' => 'https://rdap.auda.org.au/domain/',
        'org.au' => 'https://rdap.auda.org.au/domain/',
        'edu.au' => 'https://rdap.auda.org.au/domain/',
        'gov.au' => 'https://rdap.auda.org.au/domain/',
        'asn.au' => 'https://rdap.auda.org.au/domain/',
        'id.au' => 'https://rdap.auda.org.au/domain/',
    ];

    // Attempt to match the most specific public-suffix (last 3, then last 2, then last 1 label)
    $labels = explode('.', $domain);
    $n = count($labels);
    $candidates = [];
    if ($n >= 3) {
        $candidates[] = strtolower($labels[$n - 3] . '.' . $labels[$n - 2] . '.' . $labels[$n - 1]);
    }
    if ($n >= 2) {
        $candidates[] = strtolower($labels[$n - 2] . '.' . $labels[$n - 1]);
    }
    if ($n >= 1) {
        $candidates[] = strtolower($labels[$n - 1]);
    }

    $matchedKey = null;
    foreach ($candidates as $cand) {
        if (isset($tldServers[$cand])) {
            $matchedKey = $cand;
            break;
        }
    }

    if ($matchedKey) {
        array_unshift($urls, $tldServers[$matchedKey] . $domain);
    }

    if (isset($tldServers[$tld])) {
        array_unshift($urls, $tldServers[$tld] . $domain);
    }

    $lastError = null;
    foreach ($urls as $url) {
        $raw = @file_get_contents($url, false, $ctx);
        if ($raw !== false) {
            $json = @json_decode($raw, true);
            if ($json && !isset($json['errorCode'])) {
                return $json;
            }
            $lastError = $json['title'] ?? 'Invalid JSON response';
        } else {
            $lastError = error_get_last()['message'] ?? 'Connection failed';
        }
    }

    // If RDAP failed, try WHOIS as fallback
    $whois = whois_lookup($domain);
    if (!isset($whois['error'])) {
        return [
            'source' => 'whois',
            'ldhName' => $domain,
            'nameservers' => array_map(function ($ns) {
                return ['ldhName' => $ns];
            }, $whois['nameservers']),
            'entities' => $whois['registrar'] ? [['roles' => ['registrar'], 'handle' => $whois['registrar']]] : [],
            'events' => array_filter([
                $whois['creation_date'] ? ['eventAction' => 'registration', 'eventDate' => $whois['creation_date']] : null,
                $whois['expiry_date'] ? ['eventAction' => 'expiration', 'eventDate' => $whois['expiry_date']] : null,
            ]),
            'status' => $whois['status'],
            'whois_raw' => $whois['raw'],
        ];
    }

    return ['error' => 'unavailable', 'rdap_details' => $lastError, 'whois_details' => $whois['error'] ?? null];
}

function whois_lookup($domain)
{
    // Determine the appropriate WHOIS server
    $tld = strtolower(substr($domain, strrpos($domain, '.') + 1));

    // Handle multi-level TLDs
    $domainParts = explode('.', $domain);
    $n = count($domainParts);
    $effectiveTld = $tld;
    if ($n >= 2) {
        $secondLevel = $domainParts[$n - 2];
        $topLevel = $domainParts[$n - 1];
        if (in_array($topLevel . '.' . $secondLevel, ['nz.co', 'nz.org', 'nz.net', 'au.com', 'au.net', 'au.org'])) {
            $effectiveTld = $secondLevel . '.' . $topLevel;
        }
    }

    $whoisServers = [
        'com' => 'whois.verisign-grs.com',
        'net' => 'whois.verisign-grs.com',
        'org' => 'whois.pir.org',
        'nz' => 'whois.srs.net.nz',
        'co.nz' => 'whois.srs.net.nz',
        'org.nz' => 'whois.srs.net.nz',
        'net.nz' => 'whois.srs.net.nz',
        'ac.nz' => 'whois.srs.net.nz',
        'govt.nz' => 'whois.srs.net.nz',
        'au' => 'whois.auda.org.au',
        'com.au' => 'whois.auda.org.au',
        'net.au' => 'whois.auda.org.au',
        'org.au' => 'whois.auda.org.au',
        'edu.au' => 'whois.auda.org.au',
        'gov.au' => 'whois.auda.org.au',
    ];

    $server = $whoisServers[$effectiveTld] ?? null;
    if (!$server) {
        return ['error' => 'No WHOIS server available for TLD'];
    }

    // Perform WHOIS lookup
    $fp = @fsockopen($server, 43, $errno, $errstr, 10);
    if (!$fp) {
        return ['error' => "Connection failed: $errstr ($errno)"];
    }

    fputs($fp, $domain . "\r\n");
    $response = '';
    while (!feof($fp)) {
        $response .= fgets($fp, 1024);
    }
    fclose($fp);

    if (empty($response)) {
        return ['error' => 'Empty response from WHOIS server'];
    }

    // Parse basic info from WHOIS response
    $parsed = parse_whois_response($response, $effectiveTld);
    $parsed['raw'] = $response;
    $parsed['server'] = $server;

    return $parsed;
}

function parse_whois_response($response, $tld)
{
    $lines = explode("\n", $response);
    $data = [
        'registrar' => null,
        'creation_date' => null,
        'expiry_date' => null,
        'nameservers' => [],
        'status' => [],
        'registrant' => null,
    ];

    foreach ($lines as $line) {
        $line = trim($line);
        if (empty($line) || $line[0] === '%' || $line[0] === '#')
            continue;

        if (strpos($line, ':') === false)
            continue;
        list($key, $value) = array_map('trim', explode(':', $line, 2));
        $key = strtolower($key);

        // Common patterns across different WHOIS formats
        if (in_array($key, ['registrar', 'sponsoring registrar'])) {
            $data['registrar'] = $value;
        } elseif (in_array($key, ['creation date', 'created', 'domain_dateregistered', 'registered'])) {
            $data['creation_date'] = $value;
        } elseif (in_array($key, ['expiry date', 'expires', 'domain_datebilleduntil', 'expiration date'])) {
            $data['expiry_date'] = $value;
        } elseif (in_array($key, ['name server', 'nserver', 'nameserver', 'ns'])) {
            if (!in_array($value, $data['nameservers'])) {
                $data['nameservers'][] = $value;
            }
        } elseif (in_array($key, ['status', 'domain status'])) {
            if (!in_array($value, $data['status'])) {
                $data['status'][] = $value;
            }
        } elseif (in_array($key, ['registrant', 'registrant name', 'registrant_contact_name'])) {
            $data['registrant'] = $value;
        }
    }

    return $data;
}

// ---------- API endpoints ----------
if (isset($_GET['api'])) {
    $api = $_GET['api'];
    if ($api === 'lookup') {
        $domain = strtolower(trim($_GET['domain'] ?? ''));
        if (!is_valid_domain($domain)) {
            json_response(['error' => 'Invalid domain'], 422);
        }

        $dns = [
            'A' => q($domain, DNS_A),
            'AAAA' => q($domain, DNS_AAAA),
            'MX' => q($domain, DNS_MX),
            'NS' => q($domain, DNS_NS),
            'SOA' => q($domain, DNS_SOA),
            // DNS_CAA may not exist on very old PHP builds; guard with defined()
            'CAA' => defined('DNS_CAA') ? q($domain, DNS_CAA) : [],
            'TXT' => q($domain, DNS_TXT),
            'CNAME_www' => q('www.' . $domain, DNS_CNAME),
            'DMARC' => qtxt('_dmarc.' . $domain),
        ];

        // Add PTR lookups for A records
        if (!empty($dns['A'])) {
            foreach ($dns['A'] as &$aRecord) {
                if (isset($aRecord['ip'])) {
                    $ip = $aRecord['ip'];

                    // Try gethostbyaddr first
                    $hostname = @gethostbyaddr($ip);

                    // Try PTR lookup
                    $ptr = qptr($ip);

                    $aRecord['ptr'] = !empty($ptr) ? $ptr[0]['target'] ?? null : null;

                    // Add debug info (remove this in production)
                    $aRecord['ptr_debug'] = [
                        'gethostbyaddr' => $hostname !== $ip ? $hostname : 'failed',
                        'ptr_records' => $ptr
                    ];
                }
            }
        }

        // Add PTR lookups for AAAA records too
        if (!empty($dns['AAAA'])) {
            foreach ($dns['AAAA'] as &$aaaaRecord) {
                if (isset($aaaaRecord['ipv6'])) {
                    $ip = $aaaaRecord['ipv6'];

                    // Try gethostbyaddr first
                    $hostname = @gethostbyaddr($ip);

                    // Try PTR lookup
                    $ptr = qptr($ip);

                    $aaaaRecord['ptr'] = !empty($ptr) ? $ptr[0]['target'] ?? null : null;

                    // Add debug info (remove this in production)
                    $aaaaRecord['ptr_debug'] = [
                        'gethostbyaddr' => $hostname !== $ip ? $hostname : 'failed',
                        'ptr_records' => $ptr
                    ];
                }
            }
        }

        $spf = parse_spf($dns['TXT']);
        $dmarc = parse_dmarc($dns['DMARC']);
        $issues = [];
        if (empty($dns['MX'])) {
            $issues[] = 'No MX records (inbound mail will fail)';
        }

        if (!$dmarc) {
            $issues[] = 'No DMARC record';
        }

        if ($spf && $spf['permissive']) {
            $issues[] = 'SPF is too permissive (+all or missing -all/~all)';
        }

        if ($spf && $spf['lookup_estimate'] > 10) {
            $issues[] = 'SPF may exceed 10 DNS-lookup limit';
        }

        $rdap = rdap_lookup($domain);

        json_response([
            'domain' => $domain,
            'rdap' => $rdap,
            'dns' => $dns,
            'spf' => $spf,
            'dmarc' => $dmarc,
            'issues' => $issues,
        ]);
    }
    if ($api === 'dkim' && ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST') {
        $body = json_decode(file_get_contents('php://input'), true) ?: [];
        $domain = strtolower(trim($body['domain'] ?? ''));
        $selectors = array_values(array_filter(array_map('trim', $body['selectors'] ?? [])));
        if (!is_valid_domain($domain)) {
            json_response(['error' => 'Invalid domain'], 422);
        }

        $out = [];
        foreach ($selectors as $s) {
            $out[$s] = qtxt("{$s}._domainkey." . $domain);
        }
        json_response(['domain' => $domain, 'dkim' => $out]);
    }
    json_response(['error' => 'Unknown endpoint'], 404);
}

// ---------- UI (served when no ?api=...) ----------
?><!doctype html>
<html>

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Domain Inspector</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link rel="preconnect" href="https://unpkg.com">
</head>

<body class="bg-gray-50 font-sans">
    <div id="app" class="max-w-4xl mx-auto p-6">
        <h1 class="text-3xl font-bold text-gray-900 mb-6">Domain Inspector (Vanilla PHP)</h1>
        <div class="flex gap-2 mb-6">
            <input v-model="domain" @keyup.enter="run" placeholder="example.com"
                class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
            <button @click="run" :disabled="loading"
                class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
                Lookup
            </button>
        </div>
        <div v-if="error" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-6">{{ error }}
        </div>
        <p v-if="loading" class="text-gray-600">Loading…</p>

        <div v-if="data" class="space-y-6">
            <div class="bg-white border border-gray-200 rounded-lg p-6">
                <h3 class="text-xl font-semibold text-gray-900 mb-4">Summary</h3>
                <ul class="space-y-2">
                    <li v-for="(issue,i) in data.issues" :key="i" class="text-red-600 flex items-center">
                        <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
                            <path fill-rule="evenodd"
                                d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
                                clip-rule="evenodd" />
                        </svg>
                        {{ issue }}
                    </li>
                    <li v-if="!data.issues.length" class="text-green-600 flex items-center">
                        <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
                            <path fill-rule="evenodd"
                                d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                                clip-rule="evenodd" />
                        </svg>
                        No critical issues detected
                    </li>
                </ul>
            </div>

            <div class="grid md:grid-cols-2 gap-6">
                <div class="bg-white border border-gray-200 rounded-lg p-6">
                    <h3 class="text-xl font-semibold text-gray-900 mb-4">Ownership (RDAP)</h3>
                    <pre class="bg-gray-50 p-4 rounded-md overflow-auto text-sm">{{ rdapSummary }}</pre>
                </div>
                <div class="bg-white border border-gray-200 rounded-lg p-6">
                    <h3 class="text-xl font-semibold text-gray-900 mb-4">Email Readiness</h3>
                    <div class="space-y-3">
                        <div>
                            <span class="font-semibold">MX:</span>
                            <template v-if="(data.dns.MX||[]).length">
                                <ul class="mt-2 space-y-1">
                                    <li v-for="(mx, i) in (data.dns.MX || [])" :key="i"
                                        class="flex items-center text-green-600">
                                        <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
                                            <path fill-rule="evenodd"
                                                d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
                                                clip-rule="evenodd" />
                                        </svg>
                                        {{ mx.target }}
                                    </li>
                                </ul>
                            </template>
                            <span v-else class="text-red-600">missing</span>
                        </div>
                        <div>
                            <span class="font-semibold">SPF:</span>
                            <span class="ml-2" :class="data.spf ? 'text-green-600' : 'text-red-600'">
                                {{ data.spf ? data.spf.raw : 'missing' }}
                            </span>
                        </div>
                        <div>
                            <span class="font-semibold">DMARC:</span>
                            <span class="ml-2" :class="data.dmarc ? 'text-green-600' : 'text-red-600'">
                                {{ data.dmarc ? data.dmarc.raw : 'missing' }}
                            </span>
                        </div>
                    </div>
                </div>
            </div>

            <div class="bg-white border border-gray-200 rounded-lg p-6">
                <h3 class="text-xl font-semibold text-gray-900 mb-4">DNS Records</h3>
                <details open>
                    <summary class="cursor-pointer text-blue-600 hover:text-blue-800 mb-4">A / AAAA / CNAME / MX / NS /
                        SOA / TXT / CAA</summary>
                    <pre class="bg-gray-50 p-4 rounded-md overflow-auto text-sm">{{ data.dns }}</pre>
                </details>
            </div>

            <div class="bg-white border border-gray-200 rounded-lg p-6">
                <h3 class="text-xl font-semibold text-gray-900 mb-4">Probe DKIM Selectors</h3>
                <div class="flex gap-2 mb-4">
                    <input v-model="dkimInput" placeholder="google, default, selector1"
                        class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
                    <button @click="probeDkim"
                        class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
                        Probe
                    </button>
                </div>
                <div v-if="probeError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md mb-4">{{
                    probeError }}</div>
                <p v-if="probing" class="text-gray-600 mb-4">Probing…</p>
                <pre v-if="dkim" class="bg-gray-50 p-4 rounded-md overflow-auto text-sm">{{ dkim }}</pre>
            </div>

            <div class="bg-white border border-gray-200 rounded-lg p-6">
                <button @click="exportJson" class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
                    Export JSON
                </button>
            </div>

            <div class="bg-white border border-gray-200 rounded-lg p-6">
                <h3 class="text-xl font-semibold text-gray-900 mb-4">Raw Response</h3>
                <pre class="bg-gray-50 p-4 rounded-md overflow-auto text-sm">{{ data }}</pre>
            </div>
        </div>
    </div>

    <script src="https://unpkg.com/vue@2.7.16/dist/vue.js"></script>
    <script>
        new Vue({
            el: '#app',
            data() { return { domain: '', loading: false, error: '', data: null, dkimInput: 'google, default, selector1, selector2', dkim: null, probing: false, probeError: '' } },
            computed: {
                rdapSummary() {
                    if (!this.data || !this.data.rdap) return ''
                    const j = this.data.rdap
                    const out = {
                        objectClassName: j.objectClassName,
                        handle: j.handle,
                        ldhName: j.ldhName,
                        status: j.status,
                        nameservers: (j.nameservers || []).map(n => {
                            // Handle different nameserver formats
                            if (typeof n === 'string') return n;
                            return n.ldhName || n.name || n.hostName || n.objectClassName || JSON.stringify(n);
                        }),
                        registrar: ((j.entities || []).find(e => (e.roles || []).includes('registrar')) || {}).vcardArray,
                        events: j.events
                    }
                    return JSON.stringify(out, null, 2)
                }
            },
            methods: {
                async run() {
                    this.error = ''; this.loading = true; this.data = null; this.dkim = null;
                    try {
                        const r = await fetch(`?api=lookup&domain=${encodeURIComponent(this.domain)}`)
                        const clone = r.clone();
                        const bodyText = await clone.text().catch(() => null);
                        let body = null;
                        try { body = bodyText ? JSON.parse(bodyText) : null; } catch (e) { body = bodyText; }

                        if (!r.ok) {
                            console.log('API response', { ok: r.ok, status: r.status, statusText: r.statusText, body });
                            throw new Error((body && (body.error || body.message)) || bodyText || `HTTP ${r.status}`);
                        }
                        this.data = await r.json()
                    } catch (e) { this.error = e.message }
                    finally { this.loading = false }
                },
                async probeDkim() {
                    this.probing = true; this.probeError = ''; this.dkim = null
                    try {
                        const selectors = this.dkimInput.split(',').map(s => s.trim()).filter(Boolean)
                        const r = await fetch(`?api=dkim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: this.domain, selectors }) })
                        this.dkim = await r.json()
                    } catch (e) { this.probeError = e.message }
                    finally { this.probing = false }
                },
                exportJson() {
                    const blob = new Blob([JSON.stringify(this.data, null, 2)], { type: 'application/json' })
                    const url = URL.createObjectURL(blob)
                    const a = Object.assign(document.createElement('a'), { href: url, download: `${this.domain}-lookup.json` })
                    a.click(); URL.revokeObjectURL(url)
                }
            }
        })
    </script>
</body>

</html>