|
@@ -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 Tiny 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>
|