Ver Fonte

adding more mail-tools

Josef Straßl há 4 semanas atrás
pai
commit
4a979d5f03
3 ficheiros alterados com 1186 adições e 0 exclusões
  1. 337 0
      dkim-check.php
  2. 423 0
      mta-sts-check.php
  3. 426 0
      send-test-mail.php

+ 337 - 0
dkim-check.php

@@ -0,0 +1,337 @@
+<?php
+declare(strict_types=1);
+
+/** Selectors tried when the user leaves the selector field blank. */
+const COMMON_SELECTORS = [
+    'default', 'selector1', 'selector2', 'google', 'k1', 'k2', 's1', 's2',
+    'dkim', 'mail', 'smtp', 'mx', 'pm', 'zoho', 'mandrill', 'sendgrid',
+    'amazonses', 'mailjet', 'mailgun',
+];
+
+/**
+ * Looks up a DKIM selector TXT record, parses its tags and validates the
+ * public key (RSA or Ed25519).
+ */
+class DkimChecker
+{
+    public function check(string $domain, string $selector): array
+    {
+        $fqdn = $selector . '._domainkey.' . $domain;
+        $report = [
+            'fqdn'     => $fqdn,
+            'selector' => $selector,
+            'record'   => null,
+            'tags'     => [],
+            'key'      => null,
+            'errors'   => [],
+            'warnings' => [],
+            'valid'    => false,
+        ];
+
+        $record = self::getTxtRecord($fqdn);
+        if ($record === null) {
+            $report['errors'][] = 'No TXT record found at ' . $fqdn;
+            return $report;
+        }
+        $report['record'] = $record;
+
+        $tags = self::parseTags($record);
+        $report['tags'] = $tags;
+
+        $version = $tags['v'] ?? null;
+        if ($version !== null && strtoupper($version) !== 'DKIM1') {
+            $report['errors'][] = 'Unexpected v= tag: "' . $version . '" (expected "DKIM1").';
+        }
+
+        $keyType = strtolower($tags['k'] ?? 'rsa');
+        if (!in_array($keyType, ['rsa', 'ed25519'], true)) {
+            $report['warnings'][] = 'Unknown key type k=' . $keyType . ' — treating as opaque.';
+        }
+
+        if (!isset($tags['p'])) {
+            $report['errors'][] = 'Missing p= tag — record is not a valid DKIM key record.';
+        } elseif ($tags['p'] === '') {
+            $report['errors'][] = 'Empty p= tag — this key has been revoked.';
+        } else {
+            $report['key'] = self::analyzeKey($tags['p'], $keyType);
+            if (!$report['key']['valid']) {
+                $report['errors'][] = 'Public key could not be parsed: ' . $report['key']['error'];
+            } elseif ($keyType === 'rsa' && $report['key']['bits'] !== null && $report['key']['bits'] < 1024) {
+                $report['warnings'][] = 'RSA key is only ' . $report['key']['bits'] . ' bits — below the recommended 1024-bit minimum.';
+            } elseif ($keyType === 'rsa' && $report['key']['bits'] !== null && $report['key']['bits'] < 2048) {
+                $report['warnings'][] = 'RSA key is ' . $report['key']['bits'] . ' bits — 2048 bits is recommended for new keys.';
+            }
+        }
+
+        if (($tags['t'] ?? '') !== '' && str_contains($tags['t'], 'y')) {
+            $report['warnings'][] = 'Testing mode is on (t=y) — receivers may not enforce this key\'s signatures.';
+        }
+
+        if (isset($tags['h'])) {
+            $algos = array_map('trim', explode(':', $tags['h']));
+            if (!array_intersect($algos, ['sha256'])) {
+                $report['warnings'][] = 'h= tag does not list sha256 — allowed hash algorithms: ' . $tags['h'];
+            }
+        }
+
+        $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;
+    }
+
+    private static function analyzeKey(string $p, string $keyType): array
+    {
+        $clean = preg_replace('/\s+/', '', $p) ?? '';
+        $der = base64_decode($clean, true);
+        if ($der === false) {
+            return ['valid' => false, 'error' => 'p= is not valid base64.', 'bits' => null, 'type' => $keyType];
+        }
+
+        if ($keyType === 'ed25519') {
+            if (strlen($der) !== 32) {
+                return [
+                    'valid' => false,
+                    'error' => sprintf('Ed25519 keys must be 32 raw bytes, got %d.', strlen($der)),
+                    'bits' => null,
+                    'type' => $keyType,
+                ];
+            }
+            return ['valid' => true, 'error' => null, 'bits' => 256, 'type' => $keyType];
+        }
+
+        // RSA (or unknown, treated as RSA-encoded SubjectPublicKeyInfo).
+        $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
+        $pubKey = @openssl_pkey_get_public($pem);
+        if ($pubKey === false) {
+            return ['valid' => false, 'error' => 'OpenSSL could not parse the public key.', 'bits' => null, 'type' => $keyType];
+        }
+        $details = openssl_pkey_get_details($pubKey);
+        return [
+            'valid' => true,
+            'error' => null,
+            'bits'  => $details['bits'] ?? null,
+            'type'  => $keyType,
+        ];
+    }
+
+    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;
+$scanResults = [];
+$inputError = null;
+
+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 ($selector !== '' && !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 {
+        $checker = new DkimChecker();
+        if ($selector !== '') {
+            $report = $checker->check($domain, $selector);
+        } else {
+            foreach (COMMON_SELECTORS as $candidate) {
+                $result = $checker->check($domain, $candidate);
+                if ($result['record'] !== null) {
+                    $scanResults[] = $result;
+                }
+            }
+        }
+    }
+}
+
+/** Renders a single DKIM report (verdict, checks, tag table). */
+function renderReport(array $report): void
+{
+    $h = 'htmlspecialchars';
+    $key = $report['key'];
+    ?>
+    <?php if ($report['valid']): ?>
+        <div class="verdict ok">✅ <?= $h($report['fqdn']) ?> — valid DKIM key record.</div>
+    <?php else: ?>
+        <div class="verdict fail">❌ <?= $h($report['fqdn']) ?> — invalid or unusable DKIM record.</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): ?>
+        <div class="record"><?= $h($report['record']) ?></div>
+
+        <div class="checks">
+            <?php
+            $renderCheck = function (bool $pass, string $label, string $detail, bool $criticalIfFail = true) use ($h) {
+                $cls = $pass ? 'pass' : ($criticalIfFail ? 'crit' : 'warn');
+                $ic  = $pass ? '✔️' : ($criticalIfFail ? '❌' : '⚠️');
+                printf(
+                    '<div class="check %s"><span class="ic">%s</span><div><strong>%s</strong><br>%s</div></div>',
+                    $cls, $ic, $h($label), $h($detail)
+                );
+            };
+            $renderCheck(($report['tags']['p'] ?? '') !== '', 'Public key present', ($report['tags']['p'] ?? '') !== '' ? 'p= tag is populated' : 'Key missing or revoked');
+            if ($key) {
+                $renderCheck($key['valid'], 'Key parses', $key['valid'] ? strtoupper($key['type']) . ($key['bits'] ? ', ' . $key['bits'] . ' bits' : '') : ($key['error'] ?? 'Parse error'));
+            }
+            $renderCheck(!str_contains($report['tags']['t'] ?? '', 'y'), 'Not in testing mode', str_contains($report['tags']['t'] ?? '', 'y') ? 't=y is set' : 'No testing flag', false);
+            ?>
+        </div>
+
+        <table>
+            <thead><tr><th>Tag</th><th>Meaning</th><th>Value</th></tr></thead>
+            <tbody>
+                <?php
+                $meanings = [
+                    'v' => 'Version', 'k' => 'Key type', 'p' => 'Public key (base64)',
+                    'h' => 'Hash algorithms', 't' => 'Flags', 's' => 'Service type',
+                    'n' => 'Notes', 'g' => 'Granularity',
+                ];
+                foreach ($report['tags'] as $tag => $value):
+                ?>
+                    <tr>
+                        <td class="mono"><?= $h($tag) ?></td>
+                        <td><?= $h($meanings[$tag] ?? '—') ?></td>
+                        <td class="mono" style="word-break: break-all;">
+                            <?= $tag === 'p' ? $h(substr($value, 0, 60) . (strlen($value) > 60 ? '…' : '')) : $h($value) ?>
+                        </td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    <?php endif; ?>
+    <?php
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>DKIM 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;
+        }
+        .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin: 12px 0; }
+        .check {
+            display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-radius: 5px;
+            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); font-size: 14px;
+        }
+        .check .ic { font-size: 18px; }
+        .check.pass { border-left: 4px solid #43a047; }
+        .check.warn { border-left: 4px solid #fb8c00; }
+        .check.crit { border-left: 4px solid #e53935; }
+        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; }
+        h2 { color: #333; margin-top: 30px; }
+        details.scan { margin: 14px 0; }
+        summary { cursor: pointer; font-weight: 600; color: #1976D2; }
+        .empty { text-align: center; color: #999; padding: 40px; }
+    </style>
+</head>
+<body>
+    <h1>✉️ DKIM Record Checker</h1>
+
+    <div class="info">
+        Looks up the <strong>DKIM</strong> TXT record for a domain + selector
+        (<code>selector._domainkey.domain</code>), parses its tags and validates the
+        public key. Leave the selector blank to scan a list of common selectors.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com" value="<?= htmlspecialchars($domain) ?>" autofocus>
+        <input type="text" name="selector" placeholder="selector (optional)" value="<?= htmlspecialchars($selector) ?>">
+        <button type="submit" class="btn">🔍 Check DKIM</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= htmlspecialchars($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($report !== null): ?>
+        <?php renderReport($report); ?>
+    <?php elseif ($domain !== '' && $selector === '' && !$inputError): ?>
+        <h2>Common selector scan</h2>
+        <?php if (empty($scanResults)): ?>
+            <div class="empty">No DKIM record found at any of the <?= count(COMMON_SELECTORS) ?> common selectors tried.<br>
+            If you know the selector, enter it above for a direct lookup.</div>
+        <?php else: ?>
+            <?php foreach ($scanResults as $result): ?>
+                <details class="scan" open>
+                    <summary><?= htmlspecialchars($result['selector']) ?></summary>
+                    <?php renderReport($result); ?>
+                </details>
+            <?php endforeach; ?>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>

+ 423 - 0
mta-sts-check.php

@@ -0,0 +1,423 @@
+<?php
+declare(strict_types=1);
+
+const POLICY_TIMEOUT   = 12;    // seconds for the HTTPS policy fetch
+const POLICY_MAX_BYTES = 65536; // RFC 8461: policies are small; cap the download
+
+/**
+ * Checks whether a domain publishes an MTA-STS policy (RFC 8461) and evaluates
+ * its consistency: the `_mta-sts` DNS TXT record, the HTTPS-hosted policy file
+ * at `mta-sts.<domain>/.well-known/mta-sts.txt`, and the accompanying TLS-RPT
+ * (`_smtp._tls`) reporting record.
+ */
+class MtaStsChecker
+{
+    /** @return array MTA-STS / TLS-RPT report for the given domain. */
+    public function check(string $domain): array
+    {
+        $report = [
+            'domain'    => $domain,
+            'supported' => false,      // TXT + fetchable, parseable policy
+            'enforced'  => false,      // policy mode is "enforce"
+            'errors'    => [],
+            'warnings'  => [],
+            'txt'       => null,       // the _mta-sts TXT record contents
+            'txt_id'    => null,
+            'policy_url'=> 'https://mta-sts.' . $domain . '/.well-known/mta-sts.txt',
+            'policy_raw'=> null,       // raw fetched policy body
+            'policy'    => null,       // parsed: version, mode, mx[], max_age
+            'http'      => null,       // http status / content-type of the fetch
+            'tlsrpt'    => null,       // the _smtp._tls TXT record contents
+        ];
+
+        // 1) The MTA-STS DNS record lives at _mta-sts.<domain> and signals a policy.
+        $this->checkTxtRecord($domain, $report);
+
+        // 2) The policy itself is served over HTTPS from the mta-sts. subdomain.
+        $this->fetchPolicy($report);
+
+        // 3) TLS-RPT is optional but strongly recommended alongside MTA-STS.
+        $this->checkTlsRpt($domain, $report);
+
+        $mode = $report['policy']['mode'] ?? null;
+        $report['supported'] = ($report['txt'] !== null && $report['policy'] !== null);
+        $report['enforced']  = ($report['supported'] && $mode === 'enforce');
+
+        return $report;
+    }
+
+    /** Looks up and validates the _mta-sts.<domain> TXT record. */
+    private function checkTxtRecord(string $domain, array &$report): void
+    {
+        $host    = '_mta-sts.' . $domain;
+        $records = @dns_get_record($host, DNS_TXT) ?: [];
+
+        $sts = [];
+        foreach ($records as $rec) {
+            $txt = $rec['txt'] ?? implode('', $rec['entries'] ?? []);
+            if (stripos($txt, 'v=STSv1') !== false) {
+                $sts[] = $txt;
+            }
+        }
+
+        if (!$sts) {
+            $report['errors'][] = 'No "v=STSv1" TXT record found at ' . $host . '.';
+            return;
+        }
+        if (count($sts) > 1) {
+            $report['warnings'][] = 'Multiple STS TXT records found at ' . $host
+                . ' — RFC 8461 requires exactly one.';
+        }
+
+        $txt = $sts[0];
+        $report['txt'] = $txt;
+
+        // Parse the semicolon-separated key=value fields; "id" is mandatory.
+        $fields = [];
+        foreach (explode(';', $txt) as $pair) {
+            if (str_contains($pair, '=')) {
+                [$k, $v] = explode('=', $pair, 2);
+                $fields[strtolower(trim($k))] = trim($v);
+            }
+        }
+        if (($fields['v'] ?? '') !== 'STSv1') {
+            $report['warnings'][] = 'TXT record does not start with "v=STSv1".';
+        }
+        if (empty($fields['id'])) {
+            $report['errors'][] = 'TXT record is missing the required "id" field.';
+        } elseif (!preg_match('/^[A-Za-z0-9]{1,32}$/', $fields['id'])) {
+            $report['warnings'][] = 'The "id" value should be 1–32 alphanumeric characters.';
+        }
+        $report['txt_id'] = $fields['id'] ?? null;
+    }
+
+    /** Fetches the HTTPS policy file and parses its key/value directives. */
+    private function fetchPolicy(array &$report): void
+    {
+        $url = $report['policy_url'];
+
+        // RFC 8461: the policy MUST be served over HTTPS with a valid certificate,
+        // and redirects MUST NOT be followed. We verify the chain explicitly.
+        $ctx = stream_context_create([
+            'http' => [
+                'method'        => 'GET',
+                'timeout'       => POLICY_TIMEOUT,
+                'follow_location'=> 0,
+                'ignore_errors' => true, // so we still see 4xx/5xx bodies + headers
+                'header'        => "User-Agent: mta-sts-check (tool.medowar.de)\r\n",
+            ],
+            'ssl' => [
+                'verify_peer'      => true,
+                'verify_peer_name' => true,
+                'SNI_enabled'      => true,
+            ],
+        ]);
+
+        $body = @file_get_contents($url, false, $ctx, 0, POLICY_MAX_BYTES);
+
+        // $http_response_header is populated by the HTTP wrapper on any response.
+        $status = null; $ctype = null;
+        foreach ($http_response_header ?? [] as $h) {
+            if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
+                $status = (int) $m[1]; // last one wins (in case of intermediates)
+            } elseif (stripos($h, 'Content-Type:') === 0) {
+                $ctype = trim(substr($h, strlen('Content-Type:')));
+            }
+        }
+        $report['http'] = ['status' => $status, 'content_type' => $ctype];
+
+        if ($body === false) {
+            $err = error_get_last()['message'] ?? '';
+            $err = trim(preg_replace('/\s+/', ' ', preg_replace('#^file_get_contents\([^)]*\):\s*#', '', $err)));
+            $report['errors'][] = 'Could not fetch the policy over HTTPS'
+                . ($err !== '' ? ': ' . $err : '. The mta-sts host or its certificate may be misconfigured.');
+            return;
+        }
+        if ($status !== null && $status !== 200) {
+            $report['errors'][] = 'Policy fetch returned HTTP ' . $status . ' (expected 200).';
+            return;
+        }
+        if ($ctype !== null && stripos($ctype, 'text/plain') === false) {
+            $report['warnings'][] = 'Policy Content-Type is "' . $ctype . '"; RFC 8461 requires "text/plain".';
+        }
+
+        $report['policy_raw'] = $body;
+        $this->parsePolicy($body, $report);
+    }
+
+    /** Parses the mta-sts.txt body into version/mode/mx[]/max_age and validates it. */
+    private function parsePolicy(string $body, array &$report): void
+    {
+        $policy = ['version' => null, 'mode' => null, 'mx' => [], 'max_age' => null];
+
+        foreach (preg_split('/\r\n|\r|\n/', $body) as $line) {
+            $line = trim($line);
+            if ($line === '' || !str_contains($line, ':')) {
+                continue;
+            }
+            [$key, $val] = explode(':', $line, 2);
+            $key = strtolower(trim($key));
+            $val = trim($val);
+            switch ($key) {
+                case 'version': $policy['version'] = $val; break;
+                case 'mode':    $policy['mode'] = strtolower($val); break;
+                case 'mx':      if ($val !== '') { $policy['mx'][] = $val; } break;
+                case 'max_age': $policy['max_age'] = (int) $val; break;
+            }
+        }
+
+        if ($policy['version'] !== 'STSv1') {
+            $report['errors'][] = 'Policy "version" is not "STSv1".';
+        }
+        if (!in_array($policy['mode'], ['enforce', 'testing', 'none'], true)) {
+            $report['errors'][] = 'Policy "mode" is missing or invalid (expected enforce, testing or none).';
+        }
+        if ($policy['mode'] !== 'none' && empty($policy['mx'])) {
+            $report['errors'][] = 'Policy declares no "mx" host patterns.';
+        }
+        if ($policy['max_age'] === null) {
+            $report['errors'][] = 'Policy is missing the required "max_age" field.';
+        } elseif ($policy['max_age'] < 86400) {
+            $report['warnings'][] = 'A "max_age" below 86400 (1 day) is unusually short; long-lived caching is the point of MTA-STS.';
+        } elseif ($policy['max_age'] > 31557600) {
+            $report['warnings'][] = 'A "max_age" above 31557600 (1 year) exceeds the RFC 8461 recommended maximum.';
+        }
+
+        // Cross-check: the TXT id should change whenever the policy changes, but
+        // a mode of "testing" means failures are reported, not enforced.
+        if ($policy['mode'] === 'testing') {
+            $report['warnings'][] = 'Policy mode is "testing" — TLS failures are reported but mail is still delivered.';
+        }
+        if ($policy['mode'] === 'none') {
+            $report['warnings'][] = 'Policy mode is "none" — this actively signals that any previous MTA-STS policy is withdrawn.';
+        }
+
+        $report['policy'] = $policy;
+    }
+
+    /** Looks up the optional TLS-RPT (_smtp._tls.<domain>) reporting record. */
+    private function checkTlsRpt(string $domain, array &$report): void
+    {
+        $host    = '_smtp._tls.' . $domain;
+        $records = @dns_get_record($host, DNS_TXT) ?: [];
+        foreach ($records as $rec) {
+            $txt = $rec['txt'] ?? implode('', $rec['entries'] ?? []);
+            if (stripos($txt, 'v=TLSRPTv1') !== false) {
+                $report['tlsrpt'] = $txt;
+                return;
+            }
+        }
+        $report['warnings'][] = 'No TLS-RPT (v=TLSRPTv1) record at ' . $host
+            . ' — you would not receive reports about TLS delivery failures.';
+    }
+}
+
+$domain     = trim((string) ($_GET['domain'] ?? ''));
+$report     = null;
+$inputError = null;
+
+if ($domain !== '') {
+    // Accept a bare domain, an email address, or a pasted URL — reduce to the domain.
+    $domain = preg_replace('#^\w+://#', '', $domain);
+    $domain = explode('/', $domain)[0];
+    if (str_contains($domain, '@')) {
+        $domain = substr($domain, strrpos($domain, '@') + 1);
+    }
+    $domain = strtolower(trim($domain, ". \t"));
+    if (str_contains($domain, ':')) {
+        $domain = explode(':', $domain)[0];
+    }
+
+    if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
+        $inputError = 'Please enter a valid domain (e.g. example.com).';
+    } else {
+        $report = (new MtaStsChecker())->check($domain);
+    }
+}
+
+function fmtMaxAge(?int $secs): string
+{
+    if ($secs === null) { return '—'; }
+    $days = $secs / 86400;
+    if ($days >= 1) {
+        return $secs . ' s (' . rtrim(rtrim(number_format($days, 1), '0'), '.') . ' days)';
+    }
+    return $secs . ' s';
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>MTA-STS 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; align-items: stretch; }
+        input[type=text] {
+            padding: 10px; font-size: 15px; border: 1px solid #ccc; border-radius: 5px; background: white; flex: 1; min-width: 220px;
+        }
+        .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: 6px 0; }
+        .warn  { background: #fff3e0; color: #e65100; padding: 10px; border-radius: 5px; margin: 6px 0; }
+        .verdict {
+            display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
+            font-size: 18px; font-weight: 600; margin: 16px 0;
+        }
+        .verdict.ok   { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
+        .verdict.part { background: #fff3e0; color: #e65100; border-left: 6px solid #fb8c00; }
+        .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
+        .checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; margin: 16px 0; }
+        .check {
+            display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-radius: 5px;
+            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); font-size: 14px;
+        }
+        .check .ic { font-size: 18px; }
+        .check.pass { border-left: 4px solid #43a047; }
+        .check.warn { border-left: 4px solid #fb8c00; }
+        .check.crit { border-left: 4px solid #e53935; }
+        h2 { color: #333; margin-top: 30px; }
+        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: 10px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
+        th { width: 180px; color: #555; background: #fafafa; font-weight: 600; }
+        td.mono, .mono { font-family: monospace; word-break: break-all; }
+        ul.mx { margin: 0; padding-left: 18px; }
+        ul.mx li { font-family: monospace; font-size: 13px; }
+        .pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
+        .pill.g { background: #e8f5e9; color: #2e7d32; }
+        .pill.r { background: #ffebee; color: #c62828; }
+        .pill.o { background: #fff3e0; color: #e65100; }
+        details { margin-top: 16px; }
+        details pre {
+            background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px;
+            font-size: 12px; overflow-x: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-all;
+        }
+        summary { cursor: pointer; font-weight: 600; color: #1976D2; }
+        a.url { color: #1976D2; word-break: break-all; }
+    </style>
+</head>
+<body>
+    <h1>📮 MTA-STS Checker</h1>
+
+    <div class="info">
+        Checks whether a domain supports <strong>MTA-STS</strong> (SMTP MTA Strict Transport Security,
+        <a href="https://www.rfc-editor.org/rfc/rfc8461" target="_blank" rel="noopener">RFC&nbsp;8461</a>).
+        It reads the <code>_mta-sts</code> DNS record, fetches the HTTPS policy at
+        <code>mta-sts.&lt;domain&gt;/.well-known/mta-sts.txt</code>, and looks for a
+        <strong>TLS-RPT</strong> reporting record. Nothing is sent — only public DNS and the policy file are read.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com"
+               value="<?= htmlspecialchars($domain) ?>" autofocus>
+        <button type="submit" class="btn">🔍 Check MTA-STS</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= htmlspecialchars($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($report !== null): ?>
+
+        <?php if ($report['enforced']): ?>
+            <div class="verdict ok">✅ MTA-STS is supported and set to <strong>enforce</strong> for <?= htmlspecialchars($report['domain']) ?>.</div>
+        <?php elseif ($report['supported']): ?>
+            <div class="verdict part">⚠️ MTA-STS is published (mode: <?= htmlspecialchars($report['policy']['mode'] ?? '?') ?>) but not enforcing.</div>
+        <?php else: ?>
+            <div class="verdict fail">❌ <?= htmlspecialchars($report['domain']) ?> does not have a working MTA-STS policy.</div>
+        <?php endif; ?>
+
+        <div class="checks">
+            <?php
+            $renderCheck = function (bool $pass, string $label, string $detail, bool $criticalIfFail = true) {
+                $cls = $pass ? 'pass' : ($criticalIfFail ? 'crit' : 'warn');
+                $ic  = $pass ? '✔️' : ($criticalIfFail ? '❌' : '⚠️');
+                printf(
+                    '<div class="check %s"><span class="ic">%s</span><div><strong>%s</strong><br>%s</div></div>',
+                    $cls, $ic, htmlspecialchars($label), htmlspecialchars($detail)
+                );
+            };
+            $renderCheck($report['txt'] !== null, 'DNS TXT record',
+                $report['txt'] !== null ? '_mta-sts record found (id=' . ($report['txt_id'] ?? '?') . ')' : 'No _mta-sts TXT record');
+            $renderCheck($report['policy'] !== null, 'HTTPS policy',
+                $report['policy'] !== null ? 'Fetched and parsed over HTTPS' : 'Policy missing or invalid');
+            $mode = $report['policy']['mode'] ?? null;
+            $renderCheck($mode === 'enforce', 'Enforcing',
+                $mode === 'enforce' ? 'mode: enforce' : ('mode: ' . ($mode ?? '—')), false);
+            $renderCheck($report['tlsrpt'] !== null, 'TLS-RPT reporting',
+                $report['tlsrpt'] !== null ? 'Reporting record present' : 'No _smtp._tls record', false);
+            ?>
+        </div>
+
+        <?php foreach ($report['errors'] as $e): ?>
+            <div class="error">❌ <?= htmlspecialchars($e) ?></div>
+        <?php endforeach; ?>
+        <?php foreach ($report['warnings'] as $w): ?>
+            <div class="warn">⚠️ <?= htmlspecialchars($w) ?></div>
+        <?php endforeach; ?>
+
+        <h2>DNS record</h2>
+        <table>
+            <tr><th>Host</th><td class="mono">_mta-sts.<?= htmlspecialchars($report['domain']) ?></td></tr>
+            <tr><th>TXT</th><td class="mono"><?= $report['txt'] !== null ? htmlspecialchars($report['txt']) : '—' ?></td></tr>
+            <tr><th>Policy id</th><td class="mono"><?= htmlspecialchars($report['txt_id'] ?? '—') ?></td></tr>
+        </table>
+
+        <h2>HTTPS policy</h2>
+        <table>
+            <tr><th>URL</th><td><a class="url" href="<?= htmlspecialchars($report['policy_url']) ?>" target="_blank" rel="noopener"><?= htmlspecialchars($report['policy_url']) ?></a></td></tr>
+            <tr><th>HTTP status</th><td class="mono"><?= $report['http']['status'] !== null ? (int) $report['http']['status'] : '—' ?></td></tr>
+            <tr><th>Content-Type</th><td class="mono"><?= htmlspecialchars($report['http']['content_type'] ?? '—') ?></td></tr>
+            <?php if ($report['policy'] !== null): $p = $report['policy']; ?>
+            <tr><th>Version</th><td class="mono"><?= htmlspecialchars($p['version'] ?? '—') ?></td></tr>
+            <tr>
+                <th>Mode</th>
+                <td><?php
+                    $mp = ['enforce' => 'g', 'testing' => 'o', 'none' => 'r'][$p['mode']] ?? 'r';
+                    echo '<span class="pill ' . $mp . '">' . htmlspecialchars($p['mode'] ?? '—') . '</span>';
+                ?></td>
+            </tr>
+            <tr>
+                <th>MX patterns</th>
+                <td>
+                    <?php if (!empty($p['mx'])): ?>
+                        <ul class="mx">
+                            <?php foreach ($p['mx'] as $mx): ?>
+                                <li><?= htmlspecialchars($mx) ?></li>
+                            <?php endforeach; ?>
+                        </ul>
+                    <?php else: ?>—<?php endif; ?>
+                </td>
+            </tr>
+            <tr><th>max_age</th><td class="mono"><?= htmlspecialchars(fmtMaxAge($p['max_age'])) ?></td></tr>
+            <?php endif; ?>
+        </table>
+
+        <h2>TLS-RPT</h2>
+        <table>
+            <tr><th>Host</th><td class="mono">_smtp._tls.<?= htmlspecialchars($report['domain']) ?></td></tr>
+            <tr><th>TXT</th><td class="mono"><?= $report['tlsrpt'] !== null ? htmlspecialchars($report['tlsrpt']) : '—' ?></td></tr>
+        </table>
+
+        <?php if ($report['policy_raw'] !== null): ?>
+        <details>
+            <summary>Raw policy file</summary>
+            <pre><?= htmlspecialchars($report['policy_raw']) ?></pre>
+        </details>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>

+ 426 - 0
send-test-mail.php

@@ -0,0 +1,426 @@
+<?php
+declare(strict_types=1);
+
+// ─────────────────────────────────────────────────────────────────────────────
+//  CONFIGURATION — set the access password here.
+//  Every visitor must enter this before they can send a test mail.
+//  Set it to '' (empty string) to disable the password gate entirely.
+// ─────────────────────────────────────────────────────────────────────────────
+const ACCESS_PASSWORD = 'asdf123!';
+
+const SENDER_ADDRESS   = 'test@med0.de';
+const SENDER_NAME      = 'med0.de Mail Test';
+const MIN_FORM_SECONDS = 3;         // reject sends submitted faster than a human could type
+const HONEYPOT_FIELD   = 'website'; // hidden decoy field; if filled, the sender is a bot
+
+const SMTP_TIMEOUT = 15;               // seconds per SMTP step
+const EHLO_NAME    = 'tool.medowar.de'; // name announced in EHLO
+
+/**
+ * Builds the fixed test message. Returns the subject, body and an ordered map of
+ * header name => value. The subject and body are predefined — only the recipient
+ * and (optionally) the target server vary.
+ *
+ * Mail hygiene is kept deliberately lightweight but complete: a real From with
+ * display name, matching Reply-To, a unique Message-ID, Date, correct MIME
+ * headers, and Auto-Submitted so downstream systems know it is machine-generated
+ * and should not auto-reply. The envelope sender is always SENDER_ADDRESS, which
+ * is what SPF evaluates.
+ *
+ * @return array{subject:string, body:string, headers:array<string,string>}
+ */
+function buildMessage(string $selfUrl): array
+{
+    $now  = new DateTimeImmutable('now', new DateTimeZone('UTC'));
+
+    // Subject stays ASCII so it needs no MIME encoded-word wrapping.
+    $subject = 'Test message from the med0.de mail tools';
+
+    $body =
+        "Hello,\r\n" .
+        "\r\n" .
+        "This is an automated TEST message sent by the med0.de mail-testing tools.\r\n" .
+        "If you received it, delivery from " . SENDER_ADDRESS . " to your address is working.\r\n" .
+        "You can safely ignore or delete this message — no action is required.\r\n" .
+        "\r\n" .
+        "Test page: " . $selfUrl . "\r\n" .
+        "Sent (UTC): " . $now->format('Y-m-d H:i:s') . "\r\n" .
+        "\r\n" .
+        "— med0.de mail tools\r\n";
+
+    // A unique, domain-scoped Message-ID aids threading and spam scoring.
+    $messageId = sprintf('<%s.%s@med0.de>', $now->format('YmdHis'), bin2hex(random_bytes(8)));
+
+    $headers = [
+        'From'                      => sprintf('%s <%s>', SENDER_NAME, SENDER_ADDRESS),
+        'Reply-To'                  => SENDER_ADDRESS,
+        'Message-ID'                => $messageId,
+        'Date'                      => $now->format(DateTimeInterface::RFC2822),
+        'MIME-Version'              => '1.0',
+        'Content-Type'              => 'text/plain; charset=UTF-8',
+        'Content-Transfer-Encoding' => '8bit',
+        'Auto-Submitted'            => 'auto-generated', // RFC 3834: do not auto-reply
+        'X-Mailer'                  => 'med0.de-test-tool',
+    ];
+
+    return ['subject' => $subject, 'body' => $body, 'headers' => $headers];
+}
+
+/**
+ * Dispatches the test message. With $mailserver empty the message goes through
+ * PHP's mail() (the local MTA does the usual MX routing). With $mailserver set it
+ * is delivered by talking SMTP directly to that host — no local MTA involved.
+ *
+ * @param string[] $transcript filled with the SMTP conversation (direct mode only)
+ */
+function sendTestMail(string $recipient, string $selfUrl, ?string $mailserver, ?string &$error, ?array &$transcript): bool
+{
+    $error = null;
+    $transcript = [];
+    $msg = buildMessage($selfUrl);
+
+    if ($mailserver === null || $mailserver === '') {
+        // Normal path: hand off to the local MTA. The 5th parameter sets the
+        // envelope sender (Return-Path) so SPF checks the med0.de domain.
+        $headerLines = [];
+        foreach ($msg['headers'] as $k => $v) {
+            $headerLines[] = $k . ': ' . $v;
+        }
+        $ok = @mail($recipient, $msg['subject'], $msg['body'], implode("\r\n", $headerLines), '-f' . SENDER_ADDRESS);
+        if (!$ok) {
+            $error = 'The local mail server rejected or failed to accept the message. '
+                . 'Check that this host is configured to send mail for med0.de.';
+            return false;
+        }
+        return true;
+    }
+
+    // Direct path: parse host[:port] (default 25) and speak SMTP to that server.
+    $host = $mailserver;
+    $port = 25;
+    if (preg_match('/^(.+):(\d+)$/', $mailserver, $m)) {
+        $host = $m[1];
+        $port = (int) $m[2];
+    }
+
+    // Assemble the full RFC 5322 message (To/Subject go inside DATA here).
+    $lines = ['To: ' . $recipient, 'Subject: ' . $msg['subject']];
+    foreach ($msg['headers'] as $k => $v) {
+        $lines[] = $k . ': ' . $v;
+    }
+    $raw = implode("\r\n", $lines) . "\r\n\r\n" . $msg['body'];
+
+    return smtpDeliver($host, $port, SENDER_ADDRESS, $recipient, $raw, $error, $transcript);
+}
+
+/**
+ * Minimal SMTP client: connects, EHLOs, opportunistically upgrades with STARTTLS
+ * when offered, then MAIL FROM / RCPT TO / DATA. No AUTH — this mirrors how an MTA
+ * delivers straight to a recipient's mail exchanger on port 25.
+ *
+ * @param string[] $transcript
+ */
+function smtpDeliver(string $host, int $port, string $from, string $to, string $rawMessage, ?string &$error, array &$transcript): bool
+{
+    $errno = 0; $errstr = '';
+    $stream = @stream_socket_client(
+        sprintf('tcp://%s:%d', $host, $port), $errno, $errstr,
+        SMTP_TIMEOUT, STREAM_CLIENT_CONNECT
+    );
+    if (!is_resource($stream)) {
+        $error = $errstr !== '' ? "Connection to $host:$port failed: $errstr (errno $errno)" : 'Connection failed.';
+        return false;
+    }
+    stream_set_timeout($stream, SMTP_TIMEOUT);
+
+    // Reads a full (possibly multiline) reply and asserts the leading status code.
+    $expect = function (string $code) use ($stream, &$transcript, &$error): bool {
+        $status = '';
+        while (($line = fgets($stream, 4096)) !== false) {
+            $transcript[] = 'S: ' . rtrim($line, "\r\n");
+            $status = substr($line, 0, 3);
+            if (strlen($line) < 4 || $line[3] !== '-') { // last line has a space, not '-'
+                break;
+            }
+        }
+        if (strpos($status, $code) !== 0) {
+            $error = sprintf('Expected %s but server said: %s', $code, trim($status . ' ...'));
+            return false;
+        }
+        return true;
+    };
+    $send = function (string $cmd) use ($stream, &$transcript): void {
+        $transcript[] = 'C: ' . $cmd;
+        fwrite($stream, $cmd . "\r\n");
+    };
+
+    try {
+        if (!$expect('220')) { throw new RuntimeException($error); }
+        $send('EHLO ' . EHLO_NAME);
+
+        // Capture EHLO capabilities to decide whether STARTTLS is offered.
+        $caps = '';
+        while (($line = fgets($stream, 4096)) !== false) {
+            $transcript[] = 'S: ' . rtrim($line, "\r\n");
+            $caps .= $line;
+            if (strlen($line) < 4 || $line[3] !== '-') { break; }
+        }
+        if (strpos($caps, '250') !== 0) {
+            throw new RuntimeException('EHLO was rejected by ' . $host . '.');
+        }
+
+        // Opportunistic STARTTLS — encrypt when offered, but do not require a
+        // trusted certificate (recipient MXs routinely use self-signed certs).
+        if (stripos($caps, 'STARTTLS') !== false) {
+            $send('STARTTLS');
+            if (!$expect('220')) { throw new RuntimeException($error); }
+            stream_context_set_option($stream, 'ssl', 'verify_peer', false);
+            stream_context_set_option($stream, 'ssl', 'verify_peer_name', false);
+            stream_context_set_option($stream, 'ssl', 'allow_self_signed', true);
+            if (@stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT) !== true) {
+                throw new RuntimeException('STARTTLS negotiation failed with ' . $host . '.');
+            }
+            $transcript[] = '* TLS established';
+            $send('EHLO ' . EHLO_NAME);   // RFC 3207: re-issue EHLO after the upgrade
+            if (!$expect('250')) { throw new RuntimeException($error); }
+        }
+
+        $send('MAIL FROM:<' . $from . '>');
+        if (!$expect('250')) { throw new RuntimeException($error); }
+        $send('RCPT TO:<' . $to . '>');
+        if (!$expect('25')) { throw new RuntimeException($error); } // 250 or 251
+        $send('DATA');
+        if (!$expect('354')) { throw new RuntimeException($error); }
+
+        // Dot-stuff any line that begins with '.' then terminate with <CRLF>.<CRLF>.
+        $data = preg_replace('/^\./m', '..', $rawMessage);
+        $transcript[] = 'C: [message data, ' . strlen($data) . ' bytes]';
+        fwrite($stream, $data . "\r\n.\r\n");
+        if (!$expect('250')) { throw new RuntimeException($error); }
+
+        $send('QUIT');
+        $expect('221'); // best-effort; delivery already accepted above
+        fclose($stream);
+        return true;
+    } catch (\Throwable $e) {
+        @fclose($stream);
+        if ($error === null || $error === '') {
+            $error = $e->getMessage();
+        }
+        return false;
+    }
+}
+
+session_start();
+
+// ── Access gate ──────────────────────────────────────────────────────────────
+// A correct password unlocks the tool for the rest of the session.
+$gateEnabled = (ACCESS_PASSWORD !== '');
+$authed      = !$gateEnabled || !empty($_SESSION['stm_authed']);
+$loginError  = null;
+$isPost      = $_SERVER['REQUEST_METHOD'] === 'POST';
+
+// The login form carries only "password"; the send form carries "recipient".
+$isLoginAttempt = $isPost && isset($_POST['password']) && !isset($_POST['recipient']);
+if (!$authed && $isLoginAttempt) {
+    if (hash_equals(ACCESS_PASSWORD, (string) $_POST['password'])) {
+        session_regenerate_id(true); // fresh id on privilege change (fixation defence)
+        $_SESSION['stm_authed'] = true;
+        $authed = true;
+    } else {
+        $loginError = 'Incorrect password.';
+    }
+}
+
+// Build an absolute URL to this page, used in the mail body and the form action.
+$scheme  = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+$host    = $_SERVER['HTTP_HOST'] ?? 'tool.medowar.de';
+$path    = strtok($_SERVER['REQUEST_URI'] ?? '/send-test-mail.php', '?');
+$selfUrl = $scheme . '://' . $host . $path;
+
+$recipient  = trim((string) ($_POST['recipient'] ?? ''));
+$mailserver = trim((string) ($_POST['mailserver'] ?? ''));
+$sent       = false;
+$sendError  = null;
+$inputError = null;
+$transcript = [];
+
+$isSendAttempt = $authed && $isPost && isset($_POST['recipient']);
+if ($isSendAttempt) {
+    $honeypot = trim((string) ($_POST[HONEYPOT_FIELD] ?? ''));
+    $formTs   = (int) ($_SESSION['stm_form_ts'] ?? 0);
+    $elapsed  = time() - $formTs;
+
+    if ($honeypot !== '') {
+        // (1) Honeypot: a hidden field only a bot would fill. Feign success, send nothing.
+        $sent = true;
+    } elseif ($formTs === 0 || $elapsed < MIN_FORM_SECONDS) {
+        // (2) Time-trap: nobody fills and submits the form this fast — likely a bot.
+        $inputError = 'That was submitted a little too quickly — please try again.';
+    } else {
+        // FILTER_VALIDATE_EMAIL also rejects CR/LF, closing the header-injection door.
+        $clean = filter_var($recipient, FILTER_VALIDATE_EMAIL);
+        if ($clean === false) {
+            $inputError = 'Please enter a valid recipient email address.';
+        } elseif ($mailserver !== '' && !preg_match('/^(?:[a-z0-9](?:-?[a-z0-9])*\.)+[a-z]{2,}(?::\d{1,5})?$|^\d{1,3}(?:\.\d{1,3}){3}(?::\d{1,5})?$/i', $mailserver)) {
+            $inputError = 'Please enter a valid mailserver as host or host:port (or leave it blank for normal MX delivery).';
+        } else {
+            $recipient = $clean;
+            $sent = sendTestMail($recipient, $selfUrl, $mailserver !== '' ? $mailserver : null, $sendError, $transcript);
+        }
+    }
+}
+
+// Stamp a fresh render time so the next send has a timing baseline to check against.
+if ($authed) {
+    $_SESSION['stm_form_ts'] = time();
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Send Test Mail</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; align-items: stretch; }
+        input[type=email], input[type=password], input[type=text] {
+            padding: 10px; font-size: 15px; border: 1px solid #ccc; border-radius: 5px; background: white; flex: 1; min-width: 220px;
+        }
+        details.transcript { margin: 16px 0; }
+        details.transcript pre {
+            background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px;
+            font-size: 12px; overflow-x: auto; line-height: 1.5;
+        }
+        details.transcript summary { cursor: pointer; font-weight: 600; color: #1976D2; }
+        /* Honeypot: kept in the layout for bots but invisible and unfocusable for humans. */
+        .hp { position: absolute; left: -9999px; top: -9999px; width: 1px; height: 1px; overflow: hidden; }
+        .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: 6px 0; }
+        .verdict {
+            display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
+            font-size: 18px; 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; }
+        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: 10px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
+        th { width: 180px; color: #555; background: #fafafa; font-weight: 600; }
+        td.mono, .mono { font-family: monospace; word-break: break-all; }
+        pre.preview {
+            background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px;
+            font-size: 12px; overflow-x: auto; line-height: 1.5; white-space: pre-wrap; word-break: break-word;
+        }
+    </style>
+</head>
+<body>
+    <h1>✉️ Send Test Mail</h1>
+
+    <?php if (!$authed): ?>
+        <div class="info">🔒 This tool is password-protected. Enter the password to continue.</div>
+        <?php if ($loginError): ?>
+            <div class="error"><?= htmlspecialchars($loginError) ?></div>
+        <?php endif; ?>
+        <form class="lookup" method="POST" action="<?= htmlspecialchars($selfUrl) ?>">
+            <input type="password" name="password" placeholder="Password" required autofocus>
+            <button type="submit" class="btn">🔓 Unlock</button>
+        </form>
+    <?php else: ?>
+
+    <div class="info">
+        Sends a fixed, clearly-labelled <strong>test message</strong> from
+        <code><?= htmlspecialchars(SENDER_ADDRESS) ?></code> to the address you enter.
+        The subject and body are predefined — the only input is the recipient. Use it to
+        confirm that outbound delivery for <code>med0.de</code> reaches a given mailbox.
+    </div>
+
+    <form class="lookup" method="POST" action="<?= htmlspecialchars($selfUrl) ?>">
+        <input type="email" name="recipient" placeholder="recipient@example.com"
+               value="<?= htmlspecialchars($recipient) ?>" required autofocus>
+        <input type="text" name="mailserver" placeholder="mailserver (optional, e.g. mx.example.com:25)"
+               value="<?= htmlspecialchars($mailserver) ?>">
+        <!-- Honeypot: humans never see or fill this; bots that auto-fill forms do. -->
+        <div class="hp" aria-hidden="true">
+            <label>Leave this field empty
+                <input type="text" name="<?= htmlspecialchars(HONEYPOT_FIELD) ?>" tabindex="-1" autocomplete="off">
+            </label>
+        </div>
+        <button type="submit" class="btn">✉️ Send test mail</button>
+    </form>
+    <p style="color:#888;font-size:12px;margin-top:-10px;">
+        Leave <strong>mailserver</strong> empty to deliver via the local mail system (normal MX routing).
+        Fill it to deliver <strong>directly</strong> to that host over SMTP (default port 25, opportunistic STARTTLS).
+    </p>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= htmlspecialchars($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($isSendAttempt && !$inputError): ?>
+        <?php $direct = ($mailserver !== ''); ?>
+        <?php if ($sent): ?>
+            <div class="verdict ok">✅ Test message
+                <?= $direct
+                    ? 'accepted by ' . htmlspecialchars($mailserver)
+                    : 'handed off to the local mail server' ?>
+                for <?= htmlspecialchars($recipient) ?>.</div>
+            <p style="color:#888;font-size:13px;">
+                <?= $direct
+                    ? 'The target server accepted the message for delivery.'
+                    : 'A successful hand-off means the local mail system accepted the message — it does not guarantee final delivery.' ?>
+                Check the recipient's inbox (and spam folder).
+            </p>
+        <?php else: ?>
+            <div class="verdict fail">❌ Could not send the test message.</div>
+            <?php if ($sendError): ?>
+                <div class="error"><?= htmlspecialchars($sendError) ?></div>
+            <?php endif; ?>
+        <?php endif; ?>
+        <?php if (!empty($transcript)): ?>
+        <details class="transcript" open>
+            <summary>SMTP conversation with <?= htmlspecialchars($mailserver) ?></summary>
+            <pre><?= htmlspecialchars(implode("\n", $transcript)) ?></pre>
+        </details>
+        <?php endif; ?>
+    <?php endif; ?>
+
+    <h2>What gets sent</h2>
+    <table>
+        <tr><th>From</th><td class="mono"><?= htmlspecialchars(SENDER_NAME . ' <' . SENDER_ADDRESS . '>') ?></td></tr>
+        <tr><th>Reply-To</th><td class="mono"><?= htmlspecialchars(SENDER_ADDRESS) ?></td></tr>
+        <tr><th>Subject</th><td class="mono">Test message from the med0.de mail tools</td></tr>
+        <tr>
+            <th>Body</th>
+            <td>
+<pre class="preview">Hello,
+
+This is an automated TEST message sent by the med0.de mail-testing tools.
+If you received it, delivery from <?= htmlspecialchars(SENDER_ADDRESS) ?> to your address is working.
+You can safely ignore or delete this message — no action is required.
+
+Test page: <?= htmlspecialchars($selfUrl) ?>
+
+Sent (UTC): …
+
+— med0.de mail tools</pre>
+            </td>
+        </tr>
+    </table>
+    <?php endif; ?>
+</body>
+</html>