false, 'message' => __( 'Too many test emails sent for this connection recently. Please wait a few minutes and try again.', 'chargeforms' ), 'duration_ms' => 0, 'error_category' => 'rate_limited', ]; } if ( ! is_email( $recipient ) ) { return [ 'success' => false, 'message' => __( 'Enter a valid recipient email address.', 'chargeforms' ), 'duration_ms' => 0, 'error_category' => 'invalid_recipient', ]; } Smtp_Connection::set_test_override( $connection ); Email_Log::set_next_context( [ 'email_type' => 'test' ] ); $error_message = ''; $capture_error = function ( \WP_Error $wp_error ) use ( &$error_message ) { $error_message = $wp_error->get_error_message(); }; add_action( 'wp_mail_failed', $capture_error ); $subject = __( 'ChargeForms SMTP test email', 'chargeforms' ); $body = sprintf( /* translators: %s: connection name */ __( "This is a test email from ChargeForms, sent through your \"%s\" SMTP connection.\n\nIf you received this, the connection is working correctly.", 'chargeforms' ), $connection['name'] ?? __( 'unnamed', 'chargeforms' ) ); $start = microtime( true ); $sent = wp_mail( $recipient, $subject, $body ); $duration_ms = (int) round( ( microtime( true ) - $start ) * 1000 ); remove_action( 'wp_mail_failed', $capture_error ); Smtp_Connection::set_test_override( null ); self::bump_rate_limit( $connection['id'] ?? 'unsaved' ); if ( $sent ) { return [ 'success' => true, // Deliberately "sent successfully", never "delivered" — // spec §15: SMTP acceptance is not proof of inbox // delivery. 'message' => sprintf( /* translators: %s: recipient email */ __( 'Test email sent successfully to %s.', 'chargeforms' ), $recipient ), 'duration_ms' => $duration_ms, ]; } $category = self::classify_message( $error_message ); $redacted = Email_Log::redact( $error_message ); return [ 'success' => false, 'message' => self::human_message( $category ), 'duration_ms' => $duration_ms, 'error_category' => $category, 'error_detail' => $redacted ?: __( 'No further detail available.', 'chargeforms' ), ]; } /** * Maps a raw PHPMailer/WP_Error message to one fixed category — see * docs/SMTP_ARCHITECTURE.md §9. These are PHPMailer's own actual, * stable error-string prefixes (checked against the PHPMailer source * bundled with WordPress core) — this is pattern-matching against * real known strings, not guessing. Anything unmatched is 'unknown', * never a fabricated specific cause. */ public static function classify_message( $message ) { $message = (string) $message; // Order matters: more specific categories are checked BEFORE the // generic 'connection_refused' pattern, which otherwise shadows // them — "could not connect to smtp host" appears in real // TLS-negotiation failures too, and 'connection_refused' used to // claim every one of those before 'tls_failure' ever got a chance // to match (a real bug found during the production audit, see // docs/SMTP_PRODUCTION_AUDIT.md — 'tls_failure' was effectively // unreachable for that message shape). $patterns = [ 'dns_failure' => '/could not resolve host|getaddrinfo|gethostbyname|name or service not known/i', // "timed out"/"connection timeout" — not a bare "timeout" // substring, which also appears in unrelated debug output like // PHPMailer's own "timeout=300" connection-parameter logging. 'timeout' => '/timed out|connection timeout/i', 'tls_failure' => '/could not connect to smtp host.*(?:ssl|tls)|starttls|opportunistic tls|tls negotiation/i', 'cert_failure' => '/certificate verify failed|ssl certificate problem|unable to get local issuer certificate/i', 'auth_failure' => '/could not authenticate|smtp\s*auth|authentication (?:failed|unsuccessful)|username and password not accepted|invalid login/i', 'invalid_recipient' => '/invalid address|recipient address rejected|mailbox unavailable/i', 'rejected' => '/message not accepted|spam|blocked|denied|550 |554 /i', // Checked LAST, not first — its own alternative // ("could not connect to smtp host") is deliberately the // broadest, catch-all phrasing of "couldn't connect at all" // PHPMailer uses for several distinct underlying causes, so // every more specific category above gets first refusal. 'connection_refused' => '/connection refused|could not connect to smtp host/i', ]; foreach ( $patterns as $category => $pattern ) { if ( preg_match( $pattern, $message ) ) { return $category; } } return 'unknown'; } /** Fixed, honest copy per category — never a claimed cause the classifier can't actually distinguish (spec §7). */ public static function human_message( $category ) { $messages = [ 'dns_failure' => __( 'Could not resolve the SMTP host. Double-check the hostname for typos.', 'chargeforms' ), 'connection_refused' => __( 'The SMTP server refused the connection. Check the host and port — a wrong port is the most common cause.', 'chargeforms' ), 'timeout' => __( 'The connection to the SMTP server timed out. The server may be unreachable, or a firewall may be blocking outbound connections on this port.', 'chargeforms' ), 'tls_failure' => __( 'Could not negotiate a secure (TLS/SSL) connection. Try a different Encryption setting, or confirm the port matches the encryption type (587 for TLS, 465 for SSL).', 'chargeforms' ), 'cert_failure' => __( 'The SMTP server\'s SSL certificate could not be verified.', 'chargeforms' ), 'auth_failure' => __( 'Authentication failed. Possible causes: incorrect username/password, SMTP authentication disabled by the provider, an app password is required, or the provider blocked this connection.', 'chargeforms' ), 'invalid_recipient' => __( 'The recipient address was rejected by the SMTP server.', 'chargeforms' ), 'rejected' => __( 'The connection succeeded, but the message itself was rejected by the SMTP server.', 'chargeforms' ), 'unknown' => __( 'The SMTP test failed for an unrecognized reason. See the technical details below.', 'chargeforms' ), ]; return $messages[ $category ] ?? $messages['unknown']; } /** Same transient-based pattern SubmissionHandler::is_rate_limited() already uses. */ private static function is_rate_limited( $connection_id ) { $key = 'chargeforms_smtp_test_' . md5( (string) $connection_id ); return (int) get_transient( $key ) >= self::RATE_LIMIT_MAX; } private static function bump_rate_limit( $connection_id ) { $key = 'chargeforms_smtp_test_' . md5( (string) $connection_id ); $count = (int) get_transient( $key ); set_transient( $key, $count + 1, self::RATE_LIMIT_WINDOW ); } }