! empty( $row['enabled'] ) ) ); if ( ! $enabled ) { return null; } foreach ( $enabled as $row ) { if ( ! empty( $row['is_default'] ) ) { return $row; } } // No explicit default (shouldn't normally happen — create()/delete() // keep exactly one default among enabled connections — but fail // safe to the first enabled one rather than sending nothing). return $enabled[0]; } /** Count of configured connections. */ public static function count() { return count( self::all() ); } /** * @return array|\WP_Error The new connection (public shape, see * to_public_array()) or a validation error. */ public static function create( array $data ) { $clean = self::sanitize( $data ); if ( is_wp_error( $clean ) ) { return $clean; } // Re-checked immediately before the write — see // docs/SMTP_PRODUCTION_AUDIT.md's race-condition finding. A // wp_options-backed store has no row-level lock, so two concurrent // requests can both pass the gate above; this narrows (does not // eliminate) the window to the time between here and update_option() // rather than the whole sanitize() call. $now = current_time( 'mysql', true ); // GMT, matches project-wide convention. $row = array_merge( $clean, [ 'id' => self::generate_id(), 'is_default' => 0 === self::count(), // first connection on this site is automatically default. 'created_at' => $now, 'updated_at' => $now, 'last_tested_at' => null, 'last_test_status' => null, 'last_error' => null, ] ); $rows = self::all(); $rows[] = $row; if ( $row['is_default'] ) { $rows = self::with_single_default( $rows, $row['id'] ); } update_option( self::OPTION_KEY, $rows ); return self::to_public_array( $row ); } /** * @return array|\WP_Error The updated connection (public shape) or a * WP_Error (not found, or validation failure). * Changing host/port/username/password resets * `last_test_status` to null — a saved * credential change means "Not Tested" again * (spec §16), never silently keeps showing a * stale "Tested successfully". */ public static function update( $id, array $data ) { $rows = self::all(); $index = null; foreach ( $rows as $i => $row ) { if ( $row['id'] === $id ) { $index = $i; break; } } if ( null === $index ) { return new \WP_Error( 'chargeforms_smtp_not_found', __( 'SMTP connection not found.', 'chargeforms' ), [ 'status' => 404 ] ); } $existing = $rows[ $index ]; $clean = self::sanitize( $data, $existing ); if ( is_wp_error( $clean ) ) { return $clean; } $connection_fields_changed = ( $clean['host'] !== $existing['host'] || $clean['port'] !== $existing['port'] || $clean['username'] !== $existing['username'] || $clean['password_encrypted'] !== $existing['password_encrypted'] || $clean['encryption'] !== $existing['encryption'] || $clean['auth_enabled'] !== $existing['auth_enabled'] ); $rows[ $index ] = array_merge( $existing, $clean, [ 'updated_at' => current_time( 'mysql', true ), 'last_test_status' => $connection_fields_changed ? null : $existing['last_test_status'], 'last_tested_at' => $connection_fields_changed ? null : $existing['last_tested_at'], 'last_error' => $connection_fields_changed ? null : $existing['last_error'], ] ); update_option( self::OPTION_KEY, $rows ); return self::to_public_array( $rows[ $index ] ); } /** * @return true|\WP_Error True on success. Promotes the oldest * remaining enabled connection to default if * the deleted one was the default (spec §17: * "deleted default with no replacement" is an * invalid state to leave the site in whenever * a valid replacement exists). */ public static function delete( $id ) { $rows = self::all(); $was_default = false; $remaining = []; foreach ( $rows as $row ) { if ( $row['id'] === $id ) { $was_default = ! empty( $row['is_default'] ); continue; } $remaining[] = $row; } if ( count( $remaining ) === count( $rows ) ) { return new \WP_Error( 'chargeforms_smtp_not_found', __( 'SMTP connection not found.', 'chargeforms' ), [ 'status' => 404 ] ); } if ( $was_default && $remaining ) { $candidates = array_values( array_filter( $remaining, fn( $r ) => ! empty( $r['enabled'] ) ) ) ?: $remaining; usort( $candidates, fn( $a, $b ) => strcmp( $a['created_at'], $b['created_at'] ) ); $remaining = self::with_single_default( $remaining, $candidates[0]['id'] ); } update_option( self::OPTION_KEY, $remaining ); return true; } /** @return array|\WP_Error The connection's public shape with `is_default` now true, or a WP_Error if not found/disabled. */ public static function set_default( $id ) { $rows = self::all(); $found = null; foreach ( $rows as $row ) { if ( $row['id'] === $id ) { $found = $row; break; } } if ( ! $found ) { return new \WP_Error( 'chargeforms_smtp_not_found', __( 'SMTP connection not found.', 'chargeforms' ), [ 'status' => 404 ] ); } if ( empty( $found['enabled'] ) ) { return new \WP_Error( 'chargeforms_smtp_disabled', __( 'A disabled connection cannot be made the default.', 'chargeforms' ), [ 'status' => 400 ] ); } $rows = self::with_single_default( $rows, $id ); update_option( self::OPTION_KEY, $rows ); return self::to_public_array( self::get( $id ) ); } /** Exactly one connection in $rows ends up with is_default => true (the one matching $default_id); every other one is explicitly false — the only code path allowed to set this flag, preventing the "multiple defaults" invalid state (spec §17). */ private static function with_single_default( array $rows, $default_id ) { return array_map( function ( $row ) use ( $default_id ) { $row['is_default'] = ( $row['id'] === $default_id ); return $row; }, $rows ); } /** * Validates + sanitizes raw input into the stored shape. `$existing` * (when updating) supplies fallbacks for fields not present in $data, * and lets the password field be omitted entirely on update without * clearing it (spec §3: "Do not send the existing password to the * browser" — the browser also never needs to send it back unless the * user is actually changing it). * * @return array|\WP_Error */ private static function sanitize( array $data, ?array $existing = null ) { $name = isset( $data['name'] ) ? sanitize_text_field( $data['name'] ) : ( $existing['name'] ?? '' ); if ( '' === trim( $name ) ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'Connection name is required.', 'chargeforms' ), [ 'status' => 400 ] ); } $provider = isset( $data['provider'] ) ? sanitize_key( $data['provider'] ) : ( $existing['provider'] ?? 'custom' ); $host = isset( $data['host'] ) ? trim( (string) $data['host'] ) : ( $existing['host'] ?? '' ); // A CRLF check on the host is defense-in-depth, not the primary // guard against malformed hosts — see the hostname-shape check // below. `sanitize_text_field()` already strips control // characters, but this is validated pre-sanitization intent. if ( '' === $host || preg_match( '/[\r\n]/', $host ) || ! self::is_valid_hostname( $host ) ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'Enter a valid SMTP host (e.g. smtp.example.com) or IP address.', 'chargeforms' ), [ 'status' => 400 ] ); } $port = isset( $data['port'] ) ? (int) $data['port'] : (int) ( $existing['port'] ?? 587 ); if ( $port < 1 || $port > 65535 ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'Port must be between 1 and 65535.', 'chargeforms' ), [ 'status' => 400 ] ); } $encryption = isset( $data['encryption'] ) ? sanitize_key( $data['encryption'] ) : ( $existing['encryption'] ?? 'tls' ); if ( ! in_array( $encryption, self::VALID_ENCRYPTIONS, true ) ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'Encryption must be none, tls, or ssl.', 'chargeforms' ), [ 'status' => 400 ] ); } $auth_enabled = array_key_exists( 'auth_enabled', $data ) ? ! empty( $data['auth_enabled'] ) : ( $existing['auth_enabled'] ?? true ); $username = isset( $data['username'] ) ? sanitize_text_field( $data['username'] ) : ( $existing['username'] ?? '' ); // Password: only touched if the caller actually sent one — see // this method's own doc comment. An explicit empty string DOES // clear it (distinct from "field omitted"), matching how a real // "remove credential" action should behave. if ( array_key_exists( 'password', $data ) ) { $password_encrypted = Credential_Cipher::encrypt( (string) $data['password'] ); } else { $password_encrypted = $existing['password_encrypted'] ?? ''; } $from_email = isset( $data['from_email'] ) ? sanitize_email( $data['from_email'] ) : ( $existing['from_email'] ?? '' ); if ( '' === $from_email || ! is_email( $from_email ) ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'From Email must be a valid email address.', 'chargeforms' ), [ 'status' => 400 ] ); } $from_name = isset( $data['from_name'] ) ? sanitize_text_field( $data['from_name'] ) : ( $existing['from_name'] ?? '' ); if ( preg_match( '/[\r\n]/', (string) ( $data['from_name'] ?? '' ) ) ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'From Name contains invalid characters.', 'chargeforms' ), [ 'status' => 400 ] ); } $reply_to = isset( $data['reply_to'] ) ? trim( (string) $data['reply_to'] ) : ( $existing['reply_to'] ?? '' ); if ( '' !== $reply_to && ( preg_match( '/[\r\n]/', $reply_to ) || ! is_email( $reply_to ) ) ) { return new \WP_Error( 'chargeforms_smtp_invalid', __( 'Reply-To must be a valid email address, or left blank.', 'chargeforms' ), [ 'status' => 400 ] ); } $reply_to = '' === $reply_to ? '' : sanitize_email( $reply_to ); $enabled = array_key_exists( 'enabled', $data ) ? ! empty( $data['enabled'] ) : ( $existing['enabled'] ?? true ); // Defaults match the behavior this connection type has always had // (force the From address, leave the display name and envelope // sender alone... except Return-Path, which is a pure // deliverability improvement safe to turn on for existing // connections too) — so an upgrade from before these existed // changes nothing for a site that never opens this form again. $force_from_email = array_key_exists( 'force_from_email', $data ) ? ! empty( $data['force_from_email'] ) : ( $existing['force_from_email'] ?? true ); $force_from_name = array_key_exists( 'force_from_name', $data ) ? ! empty( $data['force_from_name'] ) : ( $existing['force_from_name'] ?? false ); $return_path_match_from = array_key_exists( 'return_path_match_from', $data ) ? ! empty( $data['return_path_match_from'] ) : ( $existing['return_path_match_from'] ?? true ); return [ 'name' => $name, 'provider' => $provider, 'host' => $host, 'port' => $port, 'encryption' => $encryption, 'auth_enabled' => $auth_enabled, 'username' => $username, 'password_encrypted' => $password_encrypted, 'from_email' => $from_email, 'from_name' => $from_name, 'reply_to' => $reply_to, 'force_from_email' => $force_from_email, 'force_from_name' => $force_from_name, 'return_path_match_from' => $return_path_match_from, 'enabled' => $enabled, ]; } /** * Deliberately permissive shape check (spec §23: "do not over-restrict * legitimate SMTP hosts") — accepts a normal domain, a bare hostname * like `localhost` (real use: a local mail-catcher during * development, or an internal relay), or an IPv4/IPv6 literal. Rejects * only clearly malformed values (whitespace, empty labels, illegal * characters) — never attempts to resolve or connect to the host. */ private static function is_valid_hostname( $host ) { if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return true; } // RFC 1123-ish: labels of letters/digits/hyphens, not starting/ending // with a hyphen, dot-separated; `localhost`/`mailhog` etc. pass as a // single label. return (bool) preg_match( '/^(?!-)[A-Za-z0-9-]{1,63}(? $row['id'], 'name' => $row['name'], 'provider' => $row['provider'], 'host' => $row['host'], 'port' => $row['port'], 'encryption' => $row['encryption'], 'auth_enabled' => (bool) $row['auth_enabled'], 'username' => $row['username'], 'password_configured' => '' !== ( $row['password_encrypted'] ?? '' ), 'from_email' => $row['from_email'], 'from_name' => $row['from_name'], 'reply_to' => $row['reply_to'], 'force_from_email' => (bool) ( $row['force_from_email'] ?? true ), 'force_from_name' => (bool) ( $row['force_from_name'] ?? false ), 'return_path_match_from' => (bool) ( $row['return_path_match_from'] ?? true ), 'enabled' => (bool) $row['enabled'], 'is_default' => (bool) $row['is_default'], 'created_at' => $row['created_at'], 'updated_at' => $row['updated_at'], 'last_tested_at' => $row['last_tested_at'], 'last_test_status' => $row['last_test_status'], 'last_error' => $row['last_error'], ]; } /** Decrypted password for transport use ONLY (Smtp_Transport/Email_Test) — never call this to build a REST response. */ public static function get_decrypted_password( array $row ) { return Credential_Cipher::decrypt( $row['password_encrypted'] ?? '' ); } }