$host, 'port' => $port, 'protocol' => $protocol, 'mode' => in_array($protocol, ['smtps', 'imaps', 'pop3s'], true) ? 'implicit' : 'starttls', 'connected' => false, 'starttls' => null, // negotiation transcript lines (starttls only) 'tls_ok' => false, // handshake succeeded *and* peer verified 'trusted' => false, // chain verified against CA bundle 'hostname_ok' => false, // certificate valid for the requested host 'errors' => [], 'cert' => null, 'tls_version' => null, 'cipher' => null, ]; // 1) Handshake with full verification to learn whether the chain is trusted. $verified = $this->handshake($host, $port, $protocol, true, $vErr, $transcript); $report['starttls'] = $transcript; if ($verified === null) { // Could not even reach the TLS stage — connection/protocol error. $report['errors'][] = $vErr; return $report; } $report['connected'] = true; $report['trusted'] = ($vErr === null); if ($vErr !== null) { $report['errors'][] = 'Certificate chain not trusted: ' . $vErr; } // 2) Handshake again without verification so we can always inspect the cert, // even when it is self-signed, expired, or for the wrong hostname. $stream = $this->handshake($host, $port, $protocol, false, $rErr, $t2); if (!is_resource($stream)) { if ($rErr !== null) { $report['errors'][] = $rErr; } return $report; } $params = stream_context_get_params($stream); $meta = stream_get_meta_data($stream); $certRes = $params['options']['ssl']['peer_certificate'] ?? null; // Negotiated protocol version / cipher (from the crypto meta, when exposed). foreach (($meta['crypto'] ?? []) as $k => $v) { if ($k === 'protocol') { $report['tls_version'] = $v; } if ($k === 'cipher_name') { $report['cipher'] = $v; } } if ($certRes) { $report['cert'] = $this->describeCert($certRes, $host, $report); $report['hostname_ok'] = $report['cert']['hostname_ok']; } else { $report['errors'][] = 'TLS handshake completed but no peer certificate was presented.'; } fclose($stream); $report['tls_ok'] = $report['trusted'] && $report['hostname_ok'] && ($report['cert']['time_ok'] ?? false); return $report; } /** * Opens a TCP connection, runs the STARTTLS dance if required, then enables * crypto. Returns the stream resource on success. On verification failure * with $verify=true, returns false-y stream but populates $err. On connection * failure returns null. $transcript receives the plaintext protocol lines. * * @return resource|false|null */ private function handshake(string $host, int $port, string $protocol, bool $verify, ?string &$err, ?array &$transcript) { $err = null; $transcript = []; $ctx = stream_context_create(['ssl' => [ 'verify_peer' => $verify, 'verify_peer_name' => $verify, 'allow_self_signed' => !$verify, 'capture_peer_cert' => true, 'SNI_enabled' => true, 'peer_name' => $host, ]]); // Always connect over plain TCP and drive the crypto switch ourselves. // For implicit-TLS ports that simply means enabling crypto immediately, // with no plaintext exchange — this keeps a single code path and gives us // reliable error reporting via the OpenSSL error queue. $errno = 0; $errstr = ''; $stream = @stream_socket_client( sprintf('tcp://%s:%d', $host, $port), $errno, $errstr, CONNECT_TIMEOUT, STREAM_CLIENT_CONNECT, $ctx ); if (!is_resource($stream)) { $err = $errstr !== '' ? "Connection failed: $errstr (errno $errno)" : 'Connection failed.'; return null; } stream_set_timeout($stream, CONNECT_TIMEOUT); // STARTTLS protocols exchange a few plaintext commands before the switch. if (!in_array($protocol, ['smtps', 'imaps', 'pop3s'], true)) { try { $this->negotiateStartTls($stream, $protocol, $transcript); } catch (\RuntimeException $e) { fclose($stream); $err = $e->getMessage(); return null; } } openssl_error_string(); // clear any stale queue entries error_clear_last(); $ok = @stream_socket_enable_crypto( $stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT ); if ($ok !== true) { $sslErr = $this->drainSslErrors(); $last = error_get_last()['message'] ?? ''; fclose($stream); // Trim PHP's noisy function prefix and collapse multi-line OpenSSL blurb. $detail = trim(preg_replace('/\s+/', ' ', str_replace('stream_socket_enable_crypto():', '', $sslErr ?: $last))); if ($verify) { // The handshake was reached; the certificate simply did not verify. $err = $detail ?: 'Certificate verification failed.'; return false; } $err = $detail ?: 'TLS handshake failed.'; return null; } return $stream; } /** * Speaks the protocol-specific STARTTLS handshake up to (not including) the * crypto switch. Throws RuntimeException if the server refuses. * @param resource $stream */ private function negotiateStartTls($stream, string $protocol, array &$transcript): void { switch ($protocol) { case 'smtp': // submission, port 587 case 'smtp25': // MX / server-to-server, port 25 $this->expect($stream, '220', $transcript); $this->send($stream, 'EHLO ' . EHLO_NAME, $transcript); $ehlo = $this->readSmtp($stream, $transcript); if (stripos($ehlo, 'STARTTLS') === false) { throw new \RuntimeException('Server did not advertise STARTTLS in its EHLO response.'); } $this->send($stream, 'STARTTLS', $transcript); $this->expect($stream, '220', $transcript); break; case 'imap': $this->expect($stream, '* OK', $transcript, true); $this->send($stream, 'a1 STARTTLS', $transcript); $resp = $this->readLine($stream, $transcript); if (stripos($resp, 'a1 OK') === false) { throw new \RuntimeException('IMAP server refused STARTTLS: ' . trim($resp)); } break; case 'pop3': $this->expect($stream, '+OK', $transcript, true); $this->send($stream, 'STLS', $transcript); $resp = $this->readLine($stream, $transcript); if (stripos($resp, '+OK') !== 0) { throw new \RuntimeException('POP3 server refused STLS: ' . trim($resp)); } break; default: throw new \RuntimeException('Unsupported protocol: ' . $protocol); } } /** @param resource $stream */ private function send($stream, string $line, array &$transcript): void { $transcript[] = 'C: ' . $line; fwrite($stream, $line . "\r\n"); } /** * Reads one line and asserts it starts with $code (or contains it, when $contains). * @param resource $stream */ private function expect($stream, string $code, array &$transcript, bool $contains = false): void { $line = $this->readLine($stream, $transcript); $hit = $contains ? (stripos($line, $code) !== false) : (strpos(ltrim($line), $code) === 0); if (!$hit) { throw new \RuntimeException(sprintf('Expected "%s" but got: %s', $code, trim($line))); } } /** * Reads a full multiline SMTP reply (lines like "250-..." until "250 ..."). * @param resource $stream */ private function readSmtp($stream, array &$transcript): string { $all = ''; while (($line = $this->readLine($stream, $transcript)) !== '') { $all .= $line; // Continuation lines have a hyphen as the 4th character. if (strlen($line) < 4 || $line[3] !== '-') { break; } } return $all; } /** @param resource $stream */ private function readLine($stream, array &$transcript): string { $line = fgets($stream, 4096); if ($line === false) { $meta = stream_get_meta_data($stream); if (!empty($meta['timed_out'])) { throw new \RuntimeException('Timed out waiting for the server to respond.'); } throw new \RuntimeException('Connection closed by server before a reply was received.'); } $transcript[] = 'S: ' . rtrim($line, "\r\n"); return $line; } /** Collects any pending messages from the OpenSSL error queue. */ private function drainSslErrors(): string { $msgs = []; while ($e = openssl_error_string()) { $msgs[] = $e; } return implode('; ', $msgs); } /** * Parses the peer certificate and evaluates validity window + hostname match. * @param \OpenSSLCertificate|resource $certRes */ private function describeCert($certRes, string $host, array $report): array { $info = openssl_x509_parse($certRes) ?: []; $now = time(); $from = $info['validFrom_time_t'] ?? 0; $to = $info['validTo_time_t'] ?? 0; $sans = []; $altName = $info['extensions']['subjectAltName'] ?? ''; foreach (array_filter(array_map('trim', explode(',', $altName))) as $entry) { if (stripos($entry, 'DNS:') === 0) { $sans[] = substr($entry, 4); } elseif (stripos($entry, 'IP Address:') === 0) { $sans[] = substr($entry, 11); } else { $sans[] = $entry; } } // Fall back to CN if there are no SANs (legacy certs). $cn = $info['subject']['CN'] ?? null; $names = $sans ?: ($cn !== null ? [$cn] : []); $pem = ''; openssl_x509_export($certRes, $pem); return [ 'subject' => $this->dn($info['subject'] ?? []), 'issuer' => $this->dn($info['issuer'] ?? []), 'cn' => $cn, 'sans' => $sans, 'valid_from' => $from, 'valid_to' => $to, 'time_ok' => $from && $to && $now >= $from && $now <= $to, 'expired' => $to && $now > $to, 'not_yet' => $from && $now < $from, 'days_left' => $to ? (int) floor(($to - $now) / 86400) : null, 'hostname_ok' => $this->matchesHost($host, $names), 'self_signed' => ($this->dn($info['subject'] ?? []) === $this->dn($info['issuer'] ?? [])), 'serial' => $info['serialNumberHex'] ?? ($info['serialNumber'] ?? ''), 'sig_type' => $info['signatureTypeSN'] ?? '', 'fingerprint' => openssl_x509_fingerprint($certRes, 'sha256') ?: '', ]; } /** @param array $dn */ private function dn(array $dn): string { $parts = []; foreach ($dn as $k => $v) { $v = is_array($v) ? implode(' + ', $v) : $v; $parts[] = "$k=$v"; } return implode(', ', $parts); } /** * RFC 6125 style match: exact or single leftmost wildcard. * @param string[] $names */ private function matchesHost(string $host, array $names): bool { $host = strtolower(rtrim($host, '.')); foreach ($names as $name) { $name = strtolower(rtrim($name, '.')); if ($name === $host) { return true; } if (str_starts_with($name, '*.')) { $suffix = substr($name, 1); // ".example.com" // Wildcard matches exactly one leftmost label. if (str_ends_with($host, $suffix) && substr_count($host, '.') === substr_count($name, '.')) { return true; } } } return false; } } /** Default port for each protocol, used when the user leaves the port blank. */ const DEFAULT_PORTS = [ 'smtp25' => 25, 'smtp' => 587, 'smtps' => 465, 'imap' => 143, 'imaps' => 993, 'pop3' => 110, 'pop3s' => 995, ]; const PROTOCOL_LABELS = [ 'smtp25' => 'SMTP + STARTTLS — MX / server-to-server (25)', 'smtp' => 'SMTP + STARTTLS — submission (587)', 'smtps' => 'SMTPS — implicit TLS (465)', 'imap' => 'IMAP + STARTTLS (143)', 'imaps' => 'IMAPS — implicit TLS (993)', 'pop3' => 'POP3 + STLS (110)', 'pop3s' => 'POP3S — implicit TLS (995)', ]; $host = trim((string) ($_GET['host'] ?? '')); $protocol = (string) ($_GET['protocol'] ?? 'smtp'); $portRaw = trim((string) ($_GET['port'] ?? '')); $report = null; $inputError = null; if (!isset(DEFAULT_PORTS[$protocol])) { $protocol = 'smtp'; } $port = $portRaw !== '' ? (int) $portRaw : DEFAULT_PORTS[$protocol]; if ($host !== '') { // Accept a bare hostname; strip scheme/path/port if a URL was pasted. $host = preg_replace('#^\w+://#', '', $host); $host = explode('/', $host)[0]; $host = strtolower(trim($host)); if (str_contains($host, ':')) { $host = explode(':', $host)[0]; } if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $host)) { $inputError = 'Please enter a valid mail server hostname (e.g. mail.example.com).'; } elseif ($port < 1 || $port > 65535) { $inputError = 'Please enter a valid port between 1 and 65535.'; } else { $report = (new MailTlsChecker())->check($host, $port, $protocol); } } function fmtDate(?int $ts): string { return $ts ? gmdate('Y-m-d H:i:s', $ts) . ' UTC' : '—'; } ?> Mail TLS Checker

🔐 Mail TLS Checker

Connects to a mail server, negotiates STARTTLS (or implicit TLS) and reports on the TLS handshake and server certificate: trust chain, hostname match, validity window, issuer and SANs. Nothing is sent beyond the TLS negotiation — no login, no mail.

Leave the port blank to use the protocol default ( "$p=$n", array_keys(DEFAULT_PORTS), DEFAULT_PORTS)) ?>).

✅ TLS is valid — trusted chain, correct hostname and within its validity period.
❌ TLS check failed — see the details below.
⚠️
%s
%s
%s
', $cls, $ic, htmlspecialchars($label), htmlspecialchars($detail) ); }; $renderCheck($report['trusted'], 'Trusted chain', $report['trusted'] ? 'Verified against system CA bundle' : 'Not verifiable / self-signed'); $renderCheck($report['hostname_ok'], 'Hostname match', $report['hostname_ok'] ? $report['host'] . ' is covered' : 'Certificate not valid for ' . $report['host']); $renderCheck((bool) $timeOk, 'Validity period', $timeOk ? ($cert['days_left'] !== null ? $cert['days_left'] . ' day(s) until expiry' : 'Currently valid') : (($cert['expired'] ?? false) ? 'Certificate has expired' : 'Certificate not yet valid')); if ($cert && ($cert['days_left'] ?? null) !== null && $cert['days_left'] >= 0 && $cert['days_left'] <= 21) { $renderCheck(false, 'Expiry warning', 'Expires in ' . $cert['days_left'] . ' day(s)', false); } ?>

Connection

Server
Protocol
TLS version
Cipher

Certificate

Subject
Common name
Issuer
Self-signed yes' : 'no' ?>
Valid from not yet valid' : '' ?>
Valid to expired day(s) left day(s) left
Subject Alt Names
Signature
Serial
SHA-256 fingerprint
STARTTLS negotiation transcript