= 32 && 0 === strlen( $payload ) % 16; } /** * Clean a typed SMTP password without altering it: only control * characters are dropped (no tag stripping, no trimming), since any * other change would silently authenticate with a different password. * * @param mixed $password Raw submitted password. * * @return string */ public static function sanitize_password( $password ): string { return (string) preg_replace( '/[\x00-\x1F\x7F]/', '', is_scalar( $password ) ? (string) $password : '' ); } /** * Whether new and edited SMTP passwords are saved as secrets (a reference in the * option) instead of an `enc:` value in the option. * * Off by default for one release: every version that can read a reference can be * rolled back to, but an older version would send "secret:..." as the password. * Reading references is always on. * * @return bool */ public static function use_secrets_store(): bool { return (bool) apply_filters( 'adminoptim_smtp_store_passwords_as_secrets', false ); } /** * Name of the secret for a profile's password, or '' if the profile ID cannot form one. * * @param string $profile_id `primary` or an additional profile's ID. * * @return string */ public static function secret_name( string $profile_id ): string { $name = AO_Secrets::NAMESPACE_PREFIX . '/smtp-' . ( 'primary' === $profile_id ? 'primary' : 'profile-' . $profile_id ); return AO_Secrets_Fallback_Store::is_valid_name( $name ) ? $name : ''; } /** * Whether a value is already a stored password: an `enc:` value, or a reference to * a secret that exists. Used to tell a carried-forward setting from a typed password. * * @param mixed $value Submitted or stored value. * * @return bool */ public static function is_stored_password( $value ): bool { if ( self::is_encrypted( $value ) ) { return true; } $name = AO_Secrets::name_from_reference( $value ); return null !== $name && AO_Secrets::exists( $name ); } /** * Turn a typed password into the value saved in the option. * * With secrets storage on, the password goes to AO_Secrets and a reference is * returned; if that fails, or storage is off, it falls back to the `enc:` value so a * password is never lost. An existing reference is reused, so profiles that swapped * places (Make Primary) keep their own secret. * * @param string $plain Typed password. * @param string $profile_id `primary` or the profile's ID. * @param mixed $current The value currently saved for this slot. * * @return string */ public static function store_password( string $plain, string $profile_id, $current = '' ): string { if ( self::is_stored_password( $plain ) ) { return $plain; } if ( self::use_secrets_store() ) { $name = AO_Secrets::name_from_reference( $current ) ?? self::secret_name( $profile_id ); // A new slot must not take over a secret another slot still references. if ( '' !== $name && null === AO_Secrets::name_from_reference( $current ) ) { $base = $name; $suffix = 2; while ( AO_Secrets::exists( $name ) && $suffix < 50 ) { $name = $base . '-' . $suffix++; } } if ( '' !== $name && true === AO_Secrets::set( $name, $plain ) ) { return AO_Secrets::reference( $name ); } } return self::encrypt_password( $plain ); } /** * Names of the secrets referenced by a saved SMTP option (primary and profiles). * * @param mixed $options Saved option value. * * @return string[] */ public static function referenced_secrets( $options ): array { if ( ! is_array( $options ) ) { return []; } $stored = [ $options['smtp_password'] ?? '' ]; foreach ( (array) ( $options['additional_profiles'] ?? [] ) as $profile ) { $stored[] = is_array( $profile ) ? ( $profile['password'] ?? '' ) : ''; } return array_values( array_unique( array_filter( array_map( [ AO_Secrets::class, 'name_from_reference' ], $stored ) ) ) ); } /** * After a save, delete secrets the saved option no longer references (a deleted * profile, a reset primary, a replaced password), so credentials are not left behind. * * @param mixed $old_value Option value before the save. * @param mixed $new_value Option value after the save. * * @return void */ public static function sweep_orphan_secrets( $old_value, $new_value ): void { $kept = self::referenced_secrets( $new_value ); foreach ( self::referenced_secrets( $old_value ) as $name ) { if ( ! in_array( $name, $kept, true ) ) { AO_Secrets::delete( $name ); } } } /** * Move passwords still saved as `enc:` or plain text into secrets, once secrets * storage is on. Runs for administrators on admin_init; a password that cannot be * read, or whose secret fails the read-back check, stays as it is and the next * admin page load retries. * * @return void */ public static function maybe_upgrade_passwords(): void { if ( ! self::use_secrets_store() || get_option( 'adminoptim_smtp_passwords_upgraded' ) || ! current_user_can( 'manage_options' ) ) { return; } $options = get_option( self::OPTION_NAME, [] ); if ( ! is_array( $options ) ) { return; } // --- 1. Collect the slots that still hold a non-reference password --- $slots = [ 'primary' => $options['smtp_password'] ?? '' ]; foreach ( (array) ( $options['additional_profiles'] ?? [] ) as $profile_id => $profile ) { $slots[ (string) $profile_id ] = is_array( $profile ) ? ( $profile['password'] ?? '' ) : ''; } // --- 2. Move each one, verifying the read-back before swapping the setting --- $failed = false; $changed = false; foreach ( $slots as $profile_id => $stored ) { if ( ! is_string( $stored ) || '' === $stored || null !== AO_Secrets::name_from_reference( $stored ) ) { continue; } $plain = self::decrypt_password( $stored ); $name = self::secret_name( (string) $profile_id ); if ( '' === $plain || '' === $name ) { $failed = true; continue; } // Reuse a free name; never overwrite a secret some other slot references. $base = $name; $suffix = 2; while ( AO_Secrets::exists( $name ) && $suffix < 50 ) { $name = $base . '-' . $suffix++; } if ( true !== AO_Secrets::set( $name, $plain ) || AO_Secrets::get( $name ) !== $plain ) { AO_Secrets::delete( $name ); $failed = true; continue; } if ( 'primary' === $profile_id ) { $options['smtp_password'] = AO_Secrets::reference( $name ); } else { $options['additional_profiles'][ $profile_id ]['password'] = AO_Secrets::reference( $name ); } $changed = true; } // --- 3. Save the swapped settings without re-running the sanitizer --- if ( $changed ) { SMTP_Email_Settings::update_raw( $options ); } if ( ! $failed ) { update_option( 'adminoptim_smtp_passwords_upgraded', 1, false ); } } /** * Encrypt SMTP password for storage. * * @param string $password Plain-text password. * * @return string */ public static function encrypt_password( $password ) { if ( '' === $password ) { return ''; } if ( self::is_encrypted( $password ) ) { return $password; } if ( ! function_exists( 'openssl_encrypt' ) ) { return $password; } $cipher = 'aes-256-cbc'; $iv_len = openssl_cipher_iv_length( $cipher ); if ( false === $iv_len ) { return $password; } $iv = random_bytes( $iv_len ); $key = self::get_encryption_key(); $encrypted = openssl_encrypt( $password, $cipher, $key, OPENSSL_RAW_DATA, $iv ); if ( false === $encrypted ) { return $password; } return self::PASSWORD_PREFIX . base64_encode( $iv . $encrypted ); } /** * Decrypt SMTP password from storage. * * @param string $stored Encrypted password or legacy plain-text. * * @return string */ public static function decrypt_password( $stored ) { if ( ! is_string( $stored ) || '' === $stored ) { return ''; } // A reference to a secret: the value lives in AO_Secrets, not in the option. if ( null !== AO_Secrets::name_from_reference( $stored ) ) { $value = AO_Secrets::resolve( $stored ); return is_string( $value ) ? $value : ''; } // Legacy plain text, including a plain password that happens to start with "enc:". if ( ! self::is_encrypted( $stored ) ) { return $stored; } if ( ! function_exists( 'openssl_decrypt' ) ) { return ''; } $payload = base64_decode( substr( $stored, strlen( self::PASSWORD_PREFIX ) ), true ); if ( false === $payload ) { return ''; } $cipher = 'aes-256-cbc'; $iv_len = openssl_cipher_iv_length( $cipher ); if ( false === $iv_len || strlen( $payload ) <= $iv_len ) { return ''; } $iv = substr( $payload, 0, $iv_len ); $ciphertext = substr( $payload, $iv_len ); $key = self::get_encryption_key(); $decrypted = openssl_decrypt( $ciphertext, $cipher, $key, OPENSSL_RAW_DATA, $iv ); return false === $decrypted ? '' : $decrypted; } /** * Start the engine running * * @return void */ protected function init(): void { $this->settings = new SMTP_Email_Settings( $this->options ); add_action( 'phpmailer_init', [ $this, 'phpmailer_smtp_setup' ] ); add_action( 'update_option_' . self::OPTION_NAME, [ self::class, 'sweep_orphan_secrets' ], 10, 2 ); add_action( 'admin_init', [ self::class, 'maybe_upgrade_passwords' ] ); $from_priority = ! empty( $this->options['force_from'] ) ? PHP_INT_MAX : 1; add_filter( 'wp_mail_from', [ $this, 'default_mail_from' ], $from_priority ); add_filter( 'wp_mail_from_name', [ $this, 'default_mail_from_name' ], $from_priority ); } /** * Default from-address filter — overridable by other plugins at priority >= 1. * * @param string $from Current from address. * @return string */ public function default_mail_from( $from ) { if ( apply_filters( 'adminoptim_smtp_bypass_transport', false ) ) { return $from; } /** * Filters the resolved From address for the current send. * * Free only ever has one profile (its own options); Pro hooks this * to resolve routing rules / delivery chains to a specific SMTP * profile and return that profile's From address outright. Return * a non-string (default null) to defer to Free's own single-profile * logic below. * * @param string|null $routed Resolved From address, or null to defer to Free. * @param string $from Current From address. * @param array $options Free's own SMTP options. */ $routed = apply_filters( 'adminoptim_smtp_route_mail_from', null, $from, $this->options ); if ( is_string( $routed ) ) { return $routed; } if ( empty( $this->options['email_from'] ) ) { return $from; } if ( ! empty( $this->options['force_from'] ) ) { return $this->options['email_from']; } // Default mode: only replace the WordPress-generated default; honour explicit From: headers. $sitename = wp_parse_url( network_home_url(), PHP_URL_HOST ); $sitename = preg_replace( '#^www\.#', '', strtolower( $sitename ) ); return ( 'wordpress@' . $sitename === $from ) ? $this->options['email_from'] : $from; } /** * Default from-name filter — overridable by other plugins at priority >= 1. * * @param string $name Current from name. * @return string */ public function default_mail_from_name( $name ) { if ( apply_filters( 'adminoptim_smtp_bypass_transport', false ) ) { return $name; } /** * Filters the resolved From name for the current send. Mirrors * `adminoptim_smtp_route_mail_from` — see that filter's docs. * * @param string|null $routed Resolved From name, or null to defer to Free. * @param string $name Current From name. * @param array $options Free's own SMTP options. */ $routed = apply_filters( 'adminoptim_smtp_route_mail_from_name', null, $name, $this->options ); if ( is_string( $routed ) ) { return $routed; } if ( empty( $this->options['send_from'] ) ) { return $name; } if ( ! empty( $this->options['force_from'] ) ) { return $this->options['send_from']; } // Default mode: only replace the WordPress-generated default; honour explicit From: headers. return ( 'WordPress' === $name ) ? $this->options['send_from'] : $name; } /** * Phpmailer smtp setup * * @param \PHPMailer $phpmailer phpmailer class. * * @return void */ public function phpmailer_smtp_setup( $phpmailer ) { if ( apply_filters( 'adminoptim_smtp_bypass_transport', false ) ) { return; } /** * Filters whether SMTP transport for $phpmailer is already * configured for this send. * * Free applies its own single-profile config only when this stays * false; Pro's router/delivery-chain executor hooks this, mutates * $phpmailer directly for whichever profile it resolved, and * returns true so Free's own setup below is skipped. * * @param bool $handled Whether $phpmailer is already configured. Default false. * @param \PHPMailer $phpmailer PHPMailer instance, mutated directly by a Pro callback. * @param array $options Free's own SMTP options. */ if ( apply_filters( 'adminoptim_smtp_route_phpmailer', false, $phpmailer, $this->options ) ) { return; } if ( ! empty( $this->options ) ) { if ( isset( $this->options['smtp_provider'] ) && 'default_wp_mail' === $this->options['smtp_provider'] ) { $this->apply_sender_to_phpmailer( $phpmailer ); return; } $phpmailer->isSMTP(); if ( isset( $this->options['smtp_server'] ) ) { $phpmailer->Host = $this->options['smtp_server']; } // For SES, always derive the host from the stored region so it stays correct if the region changes. if ( isset( $this->options['smtp_provider'], $this->options['smtp_ses_region'] ) && 'ses' === $this->options['smtp_provider'] ) { // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Building the user's own configured AWS SES SMTP relay host, not offloading plugin assets to a third-party service. $phpmailer->Host = 'email-smtp.' . sanitize_key( $this->options['smtp_ses_region'] ) . '.amazonaws.com'; } if ( isset( $this->options['smtp_auth'] ) ) { $phpmailer->SMTPAuth = true; } if ( isset( $this->options['smtp_port'] ) ) { $phpmailer->Port = $this->options['smtp_port']; } if ( isset( $this->options['smtp_username'] ) ) { $phpmailer->Username = $this->options['smtp_username']; } if ( isset( $this->options['smtp_password'] ) ) { $phpmailer->Password = self::decrypt_password( $this->options['smtp_password'] ); } if ( isset( $this->options['smtp_secure'] ) ) { $phpmailer->SMTPSecure = $this->options['smtp_secure']; } $this->apply_sender_to_phpmailer( $phpmailer ); } } /** * Apply sender identity values without changing PHPMailer transport. * * @param \PHPMailer $phpmailer phpmailer class. * * @return void */ private function apply_sender_to_phpmailer( $phpmailer ) { $from_email = ! empty( $this->options['email_from'] ) && is_email( $this->options['email_from'] ) ? (string) $this->options['email_from'] : ''; $from_name = isset( $this->options['send_from'] ) ? (string) $this->options['send_from'] : ''; if ( '' !== $from_email && empty( $phpmailer->Sender ) ) { $phpmailer->Sender = $from_email; } if ( ! empty( $this->options['reply_to'] ) && is_email( $this->options['reply_to'] ) ) { $phpmailer->clearReplyTos(); try { $phpmailer->addReplyTo( (string) $this->options['reply_to'], $from_name ); } catch ( \Exception $exception ) { unset( $exception ); } } } }