*/ private static $last_trace = array(); /** * Last human-readable error message from the most recent delivery attempt. * * @var string */ private static $last_error = ''; /** * Guard flag to prevent recursive hooks registered multiple times. * * @var bool */ private static $booted = false; /** * Boot runtime WordPress mail hooks. */ public static function init_hooks() { if ( self::$booted ) { return; } self::$booted = true; add_filter( 'wp_mail_from', array( __CLASS__, 'filter_wp_mail_from' ), 999 ); add_filter( 'wp_mail_from_name', array( __CLASS__, 'filter_wp_mail_from_name' ), 999 ); add_filter( 'pre_wp_mail', array( __CLASS__, 'handle_pre_wp_mail' ), 10, 2 ); add_action( 'phpmailer_init', array( __CLASS__, 'configure_phpmailer' ), 20 ); } /** * Append a timestamped line to the diagnostic trace. * * @param string $line Trace message. */ public static function log_trace( $line ) { self::$last_trace[] = '[' . gmdate( 'Y-m-d H:i:s' ) . ' UTC] ' . $line; } /** * Reset diagnostic state before a new test or send call. */ public static function reset_diagnostics() { self::$last_trace = array(); self::$last_error = ''; } /** * Retrieve formatted diagnostic log string. * * @return string */ public static function get_last_trace_text() { return implode( "\n", self::$last_trace ); } /** * Retrieve last error summary. * * @return string */ public static function get_last_error() { return self::$last_error; } /** * Resolve OAuth 2.0 credentials for Google or Microsoft with full backward compatibility. * * @param string $provider 'google' or 'microsoft'. * @param array|null $options Optional preloaded aio_options array. * @return array */ public static function get_oauth_credentials( $provider, $options = null ) { if ( ! is_array( $options ) ) { $options = get_option( 'aio_options', array() ); } if ( 'google' === $provider ) { $client_id = array_key_exists( 'txtGoogleOAuthClientId', $options ) ? trim( (string) $options['txtGoogleOAuthClientId'] ) : trim( (string) ( $options['txtOAuthClientId'] ?? '' ) ); $secret_enc = array_key_exists( 'txtGoogleOAuthClientSecret', $options ) ? (string) $options['txtGoogleOAuthClientSecret'] : (string) ( $options['txtOAuthClientSecret'] ?? '' ); } else { $client_id = array_key_exists( 'txtMsOAuthClientId', $options ) ? trim( (string) $options['txtMsOAuthClientId'] ) : trim( (string) ( $options['txtOAuthClientId'] ?? '' ) ); $secret_enc = array_key_exists( 'txtMsOAuthClientSecret', $options ) ? (string) $options['txtMsOAuthClientSecret'] : (string) ( $options['txtOAuthClientSecret'] ?? '' ); } $client_secret = '' !== $secret_enc ? AIO_Crypto::decrypt( $secret_enc ) : ''; $refresh_enc = (string) ( $options[ 'optOAuthRefreshToken_' . $provider ] ?? '' ); $refresh_token = '' !== $refresh_enc ? AIO_Crypto::decrypt( $refresh_enc ) : ''; return array( 'client_id' => $client_id, 'client_secret' => $client_secret, 'refresh_token' => $refresh_token, 'connected_email' => sanitize_email( (string) ( $options[ 'optOAuthEmail_' . $provider ] ?? '' ) ), 'granted_scopes' => (string) ( $options[ 'optOAuthScopes_' . $provider ] ?? '' ), ); } /** * Build deterministic transient cache key tied to exact provider, client_id, and refresh_token. * * @param string $provider Provider slug. * @param string $client_id OAuth Client ID. * @param string $refresh_token OAuth Refresh Token. * @return string */ public static function get_token_cache_key( $provider, $client_id, $refresh_token ) { return 'aio_smtp_tok_' . substr( hash( 'sha256', $provider . '|' . $client_id . '|' . $refresh_token ), 0, 24 ); } /** * Clear cached OAuth access token for a provider. * * @param string $provider 'google' or 'microsoft'. * @param array|null $options Optional options array. */ public static function clear_cached_access_token( $provider, $options = null ) { $creds = self::get_oauth_credentials( $provider, $options ); if ( '' !== $creds['client_id'] && '' !== $creds['refresh_token'] ) { delete_transient( self::get_token_cache_key( $provider, $creds['client_id'], $creds['refresh_token'] ) ); } } /** * Extract email claim from a JWT id_token without external libraries. * * @param string $id_token Raw JWT string. * @return string */ public static function extract_email_from_id_token( $id_token ) { if ( ! is_string( $id_token ) || substr_count( $id_token, '.' ) < 2 ) { return ''; } $parts = explode( '.', $id_token ); $payload = strtr( $parts[1], '-_', '+/' ); $pad = strlen( $payload ) % 4; if ( $pad > 0 ) { $payload .= str_repeat( '=', 4 - $pad ); } $decoded = json_decode( (string) base64_decode( $payload ), true ); if ( ! is_array( $decoded ) ) { return ''; } foreach ( array( 'email', 'preferred_username', 'upn' ) as $claim ) { if ( ! empty( $decoded[ $claim ] ) && is_email( $decoded[ $claim ] ) ) { return sanitize_email( $decoded[ $claim ] ); } } return ''; } /** * Obtain a valid OAuth 2.0 Bearer access_token (from encrypted cache or live refresh). * * @param string $provider 'google' or 'microsoft'. * @param bool $force_refresh Bypass transient cache. * @param array|null $options Optional options array. * @return array */ public static function get_valid_access_token( $provider, $force_refresh = false, $options = null ) { if ( ! is_array( $options ) ) { $options = get_option( 'aio_options', array() ); } $creds = self::get_oauth_credentials( $provider, $options ); if ( '' === $creds['client_id'] || '' === $creds['client_secret'] ) { return array( 'ok' => false, 'error' => __( 'Missing OAuth Client ID or Client Secret. Please enter and save your API credentials.', 'aio-tools' ), ); } if ( '' === $creds['refresh_token'] ) { return array( 'ok' => false, 'error' => __( 'OAuth 2.0 account is not authorized yet. Please click "Save & Authenticate" to connect your account.', 'aio-tools' ), ); } $cache_key = self::get_token_cache_key( $provider, $creds['client_id'], $creds['refresh_token'] ); if ( ! $force_refresh ) { $cached = get_transient( $cache_key ); if ( is_string( $cached ) && '' !== $cached ) { $decrypted = AIO_Crypto::decrypt( $cached ); if ( '' !== $decrypted ) { return array( 'ok' => true, 'access_token' => $decrypted, 'scope' => $creds['granted_scopes'], 'id_token_email' => '', 'from_cache' => true, 'connected_email' => $creds['connected_email'], ); } } } $token_url = 'google' === $provider ? self::GOOGLE_TOKEN_URL : self::MS_TOKEN_URL; $response = wp_remote_post( $token_url, array( 'timeout' => 15, 'body' => array( 'client_id' => $creds['client_id'], 'client_secret' => $creds['client_secret'], 'refresh_token' => $creds['refresh_token'], 'grant_type' => 'refresh_token', ), ) ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'error' => sprintf( /* translators: %s: HTTP error message */ __( 'Could not reach OAuth token endpoint: %s', 'aio-tools' ), $response->get_error_message() ), ); } $http_code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['access_token'] ) ) { $err_code = isset( $body['error'] ) ? (string) $body['error'] : 'http_' . $http_code; $err_desc = isset( $body['error_description'] ) ? (string) $body['error_description'] : $err_code; if ( 'invalid_grant' === $err_code ) { $err_desc .= ' — ' . __( 'Your OAuth Refresh Token has expired or been revoked (Google Cloud apps in "Testing" status expire tokens every 7 days). Publish your OAuth Consent Screen to "In production" and click Re-authenticate.', 'aio-tools' ); } elseif ( 'invalid_client' === $err_code ) { $err_desc .= ' — ' . __( 'Client ID or Client Secret is incorrect. Please double-check your API credentials.', 'aio-tools' ); } return array( 'ok' => false, 'error_code' => $err_code, 'error' => sprintf( /* translators: 1: OAuth error code, 2: error description */ __( 'OAuth token refresh failed (%1$s): %2$s', 'aio-tools' ), $err_code, $err_desc ), ); } $access_token = (string) $body['access_token']; $ttl = isset( $body['expires_in'] ) ? max( 60, (int) $body['expires_in'] - 120 ) : 3000; $id_token_email = ! empty( $body['id_token'] ) ? self::extract_email_from_id_token( (string) $body['id_token'] ) : ''; $scope = ! empty( $body['scope'] ) ? (string) $body['scope'] : $creds['granted_scopes']; // Persist rotated refresh_token (Microsoft frequently rotates refresh tokens) or updated scopes/email $options_dirty = false; $live_options = get_option( 'aio_options', array() ); if ( ! empty( $body['refresh_token'] ) && (string) $body['refresh_token'] !== $creds['refresh_token'] ) { $live_options[ 'optOAuthRefreshToken_' . $provider ] = AIO_Crypto::encrypt( (string) $body['refresh_token'] ); $cache_key = self::get_token_cache_key( $provider, $creds['client_id'], (string) $body['refresh_token'] ); $options_dirty = true; } if ( '' !== $scope && ( $live_options[ 'optOAuthScopes_' . $provider ] ?? '' ) !== $scope ) { $live_options[ 'optOAuthScopes_' . $provider ] = sanitize_text_field( $scope ); $options_dirty = true; } if ( '' !== $id_token_email && empty( $live_options[ 'optOAuthEmail_' . $provider ] ) ) { $live_options[ 'optOAuthEmail_' . $provider ] = $id_token_email; $creds['connected_email'] = $id_token_email; $options_dirty = true; } if ( $options_dirty ) { update_option( 'aio_options', $live_options ); } set_transient( $cache_key, AIO_Crypto::encrypt( $access_token ), $ttl ); return array( 'ok' => true, 'access_token' => $access_token, 'scope' => $scope, 'id_token_email' => $id_token_email, 'from_cache' => false, 'connected_email' => $creds['connected_email'], ); } /** * Discover the authenticated mailbox email & verify REST API readiness. * * @param string $provider 'google' or 'microsoft'. * @param string $access_token Valid OAuth Bearer token. * @param string $id_token_email Optional email already extracted from JWT id_token. * @return array */ public static function discover_account_profile( $provider, $access_token, $id_token_email = '' ) { $result = array( 'email' => sanitize_email( $id_token_email ), 'scopes' => '', 'api_ready' => false, 'api_status_msg' => '', 'enable_api_url' => '', ); if ( 'google' === $provider ) { // 1. Check tokeninfo for granted scopes & email (works even if Gmail API is not enabled) $tok_res = wp_remote_get( add_query_arg( 'access_token', rawurlencode( $access_token ), self::GOOGLE_TOKENINFO_URL ), array( 'timeout' => 10 ) ); if ( ! is_wp_error( $tok_res ) && 200 === (int) wp_remote_retrieve_response_code( $tok_res ) ) { $tok_body = json_decode( wp_remote_retrieve_body( $tok_res ), true ); if ( is_array( $tok_body ) ) { if ( ! empty( $tok_body['email'] ) && is_email( $tok_body['email'] ) ) { $result['email'] = sanitize_email( $tok_body['email'] ); } if ( ! empty( $tok_body['scope'] ) ) { $result['scopes'] = (string) $tok_body['scope']; } } } // 2. Fallback to userinfo if email still empty if ( '' === $result['email'] ) { $ui_res = wp_remote_get( self::GOOGLE_USERINFO_URL, array( 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token ), ) ); if ( ! is_wp_error( $ui_res ) && 200 === (int) wp_remote_retrieve_response_code( $ui_res ) ) { $ui_body = json_decode( wp_remote_retrieve_body( $ui_res ), true ); if ( ! empty( $ui_body['email'] ) && is_email( $ui_body['email'] ) ) { $result['email'] = sanitize_email( $ui_body['email'] ); } } } // 3. Probe Gmail API profile (verifies gmail.googleapis.com status; note: gmail.send-only returns 403 insufficientPermissions which STILL proves Gmail API IS enabled!) $gm_res = wp_remote_get( self::GOOGLE_GMAIL_PROFILE_URL, array( 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token ), ) ); if ( ! is_wp_error( $gm_res ) ) { $gm_code = (int) wp_remote_retrieve_response_code( $gm_res ); $gm_body = json_decode( wp_remote_retrieve_body( $gm_res ), true ); if ( 200 === $gm_code ) { $result['api_ready'] = true; $result['api_status_msg'] = __( 'Gmail REST API v1 is active and ready (HTTPS Port 443).', 'aio-tools' ); if ( ! empty( $gm_body['emailAddress'] ) && is_email( $gm_body['emailAddress'] ) ) { $result['email'] = sanitize_email( $gm_body['emailAddress'] ); } } else { $err_msg = (string) ( $gm_body['error']['message'] ?? '' ); $err_reason = (string) ( $gm_body['error']['errors'][0]['reason'] ?? '' ); if ( 'accessNotConfigured' === $err_reason || false !== stripos( $err_msg, 'has not been used in project' ) || false !== stripos( $err_msg, 'is disabled' ) ) { $result['api_ready'] = false; $result['api_status_msg'] = __( 'Gmail API is currently DISABLED in your Google Cloud project.', 'aio-tools' ); if ( preg_match( '#https://console\.developers\.google\.com/apis/api/gmail\.googleapis\.com/overview\?project=[0-9]+#i', $err_msg, $m ) ) { $result['enable_api_url'] = $m[0]; } } elseif ( 403 === $gm_code && ( 'insufficientPermissions' === $err_reason || false !== stripos( $err_msg, 'insufficient authentication scopes' ) ) ) { // With narrow gmail.send scope, users.getProfile returns 403 insufficientPermissions, proving Gmail API IS enabled! $has_send_scope = false !== strpos( $result['scopes'], 'gmail.send' ) || false !== strpos( $result['scopes'], 'mail.google.com' ); $result['api_ready'] = $has_send_scope; $result['api_status_msg'] = $has_send_scope ? __( 'Gmail REST API v1 (gmail.send) is active and ready (HTTPS Port 443).', 'aio-tools' ) : __( 'Connected token is missing the gmail.send permission scope. Please click Re-authenticate.', 'aio-tools' ); } } } } elseif ( 'microsoft' === $provider ) { $ms_res = wp_remote_get( self::MS_GRAPH_ME_URL, array( 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token ), ) ); if ( ! is_wp_error( $ms_res ) && 200 === (int) wp_remote_retrieve_response_code( $ms_res ) ) { $ms_body = json_decode( wp_remote_retrieve_body( $ms_res ), true ); $candidate_email = ! empty( $ms_body['mail'] ) ? $ms_body['mail'] : ( $ms_body['userPrincipalName'] ?? '' ); if ( ! empty( $candidate_email ) && is_email( $candidate_email ) ) { $result['email'] = sanitize_email( $candidate_email ); } $result['api_ready'] = true; $result['api_status_msg'] = __( 'Microsoft Graph API v1.0 is active and ready (HTTPS Port 443).', 'aio-tools' ); } else { $result['api_ready'] = '' !== $result['email']; $result['api_status_msg'] = __( 'Microsoft token refreshed; click Re-authenticate if Graph Mail.Send permission was recently added.', 'aio-tools' ); } } // Persist discovered email & scopes into aio_options if newly discovered if ( '' !== $result['email'] || '' !== $result['scopes'] ) { $opts = get_option( 'aio_options', array() ); $dirty = false; if ( '' !== $result['email'] && ( $opts[ 'optOAuthEmail_' . $provider ] ?? '' ) !== $result['email'] ) { $opts[ 'optOAuthEmail_' . $provider ] = $result['email']; $dirty = true; } if ( '' !== $result['scopes'] && ( $opts[ 'optOAuthScopes_' . $provider ] ?? '' ) !== $result['scopes'] ) { $opts[ 'optOAuthScopes_' . $provider ] = sanitize_text_field( $result['scopes'] ); $dirty = true; } if ( '' !== $result['email'] && empty( $opts['txtSmtpMail'] ) ) { $opts['txtSmtpMail'] = $result['email']; $dirty = true; } if ( $dirty ) { update_option( 'aio_options', $opts ); } } return $result; } /** * Filter default WordPress From email cleanly before PHPMailer validates it. * * @param string $original_email Original From address. * @return string */ public static function filter_wp_mail_from( $original_email ) { $options = get_option( 'aio_options', array() ); $configured = isset( $options['txtSmtpMail'] ) ? sanitize_email( (string) $options['txtSmtpMail'] ) : ''; $force_from = ! empty( $options['optSmtpForceFrom'] ); $mailer = isset( $options['opt-mailer'] ) ? (string) $options['opt-mailer'] : 'smtp'; if ( '' === $configured && in_array( $mailer, array( 'google', 'microsoft' ), true ) ) { $oauth_email = isset( $options[ 'optOAuthEmail_' . $mailer ] ) ? sanitize_email( (string) $options[ 'optOAuthEmail_' . $mailer ] ) : ''; if ( '' !== $oauth_email && is_email( $oauth_email ) ) { $configured = $oauth_email; } } if ( '' !== $configured && is_email( $configured ) ) { $is_wp_default = empty( $original_email ) || 0 === strpos( strtolower( (string) $original_email ), 'wordpress@' ) || ! is_email( $original_email ); if ( $force_from || $is_wp_default ) { return $configured; } } return $original_email; } /** * Filter default WordPress From Name cleanly. * * @param string $original_name Original From Name. * @return string */ public static function filter_wp_mail_from_name( $original_name ) { $options = get_option( 'aio_options', array() ); $configured = isset( $options['txtSmtpName'] ) ? sanitize_text_field( (string) $options['txtSmtpName'] ) : ''; $force_from = ! empty( $options['optSmtpForceFrom'] ); if ( '' !== $configured ) { $is_wp_default = empty( $original_name ) || 'WordPress' === $original_name; if ( $force_from || $is_wp_default ) { return $configured; } } return $original_name; } /** * Parse recipient/address string into [email, name]. * * @param string $raw Raw address e.g. "John " or "john@example.com". * @return array{0: string, 1: string} */ private static function split_address( $raw ) { $raw = trim( (string) $raw ); $name = ''; $addr = $raw; if ( false !== strpos( $raw, '<' ) && '>' === substr( $raw, -1 ) ) { $bracket_pos = strpos( $raw, '<' ); $name = trim( str_replace( '"', '', substr( $raw, 0, $bracket_pos ) ) ); $addr = trim( substr( $raw, $bracket_pos + 1, -1 ) ); } return array( $addr, $name ); } /** * Build an RFC 5322 compliant MIME message using WordPress's bundled PHPMailer. * * @param array $atts wp_mail $atts array. * @param string $override_from_email Optional explicit From email override. * @param string $add_reply_to Optional Reply-To fallback when From is overridden. * @return array{ok: bool, mime?: string, from_email?: string, from_name?: string, error?: string} */ public static function build_mime_message( $atts, $override_from_email = '', $add_reply_to = '' ) { if ( ! class_exists( '\PHPMailer\PHPMailer\PHPMailer' ) ) { require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php'; require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php'; require_once ABSPATH . WPINC . '/PHPMailer/Exception.php'; } $to = $atts['to'] ?? ''; $subject = (string) ( $atts['subject'] ?? '' ); $message = (string) ( $atts['message'] ?? '' ); $headers = $atts['headers'] ?? ''; $attachments = $atts['attachments'] ?? array(); $cc = array(); $bcc = array(); $reply_to = array(); $ext_hdrs = array(); $from_email = ''; $from_name = ''; $content_type = ''; $charset = ''; $boundary = ''; if ( ! empty( $headers ) ) { $temp_headers = is_array( $headers ) ? $headers : explode( "\n", str_replace( "\r\n", "\n", (string) $headers ) ); foreach ( $temp_headers as $header_line ) { if ( false === strpos( $header_line, ':' ) ) { if ( false !== stripos( $header_line, 'boundary=' ) ) { $parts = preg_split( '/boundary=/i', trim( $header_line ) ); $boundary = trim( str_replace( array( "'", '"' ), '', $parts[1] ?? '' ) ); } continue; } list( $h_name, $h_val ) = explode( ':', trim( $header_line ), 2 ); $h_name = trim( $h_name ); $h_val = trim( $h_val ); switch ( strtolower( $h_name ) ) { case 'from': list( $from_email, $from_name ) = self::split_address( $h_val ); break; case 'content-type': if ( false !== strpos( $h_val, ';' ) ) { list( $type_part, $rest_part ) = explode( ';', $h_val, 2 ); $content_type = trim( $type_part ); foreach ( explode( ';', $rest_part ) as $param_chunk ) { $param_chunk = trim( $param_chunk ); if ( false !== stripos( $param_chunk, 'charset=' ) ) { $charset = trim( str_replace( array( '"', "'" ), '', preg_replace( '/^charset=/i', '', $param_chunk ) ) ); } elseif ( false !== stripos( $param_chunk, 'boundary=' ) ) { $boundary = trim( str_replace( array( '"', "'" ), '', preg_replace( '/^boundary=/i', '', $param_chunk ) ) ); } } } elseif ( '' !== trim( $h_val ) ) { $content_type = trim( $h_val ); } break; case 'cc': $cc = array_merge( $cc, explode( ',', $h_val ) ); break; case 'bcc': $bcc = array_merge( $bcc, explode( ',', $h_val ) ); break; case 'reply-to': $reply_to = array_merge( $reply_to, explode( ',', $h_val ) ); break; default: $ext_hdrs[ $h_name ] = $h_val; break; } } } if ( '' === $from_email ) { $sitename = wp_parse_url( network_home_url(), PHP_URL_HOST ); if ( null === $sitename || '' === $sitename ) { $sitename = 'localhost.localdomain'; } elseif ( 'www.' === substr( $sitename, 0, 4 ) ) { $sitename = substr( $sitename, 4 ); } if ( false === strpos( $sitename, '.' ) ) { $sitename .= '.localdomain'; } $from_email = 'wordpress@' . $sitename; } if ( '' === $from_name ) { $from_name = 'WordPress'; } // Apply WordPress standard mail filters (including AIO Tools From / Force From filters) $from_email = apply_filters( 'wp_mail_from', $from_email ); $from_name = apply_filters( 'wp_mail_from_name', $from_name ); $content_type = apply_filters( 'wp_mail_content_type', '' !== $content_type ? $content_type : 'text/plain' ); $charset = apply_filters( 'wp_mail_charset', '' !== $charset ? $charset : get_bloginfo( 'charset' ) ); if ( '' !== $override_from_email && is_email( $override_from_email ) ) { $from_email = $override_from_email; } try { $mime_mailer = new \PHPMailer\PHPMailer\PHPMailer( true ); $mime_mailer->CharSet = $charset; // Setting Mailer = 'mail' ensures Bcc headers are included inside getSentMIMEMessage() for Gmail/Graph APIs $mime_mailer->Mailer = 'mail'; $mime_mailer->Subject = $subject; $mime_mailer->Body = $message; $mime_mailer->setFrom( $from_email, $from_name, false ); $recipients = is_array( $to ) ? $to : explode( ',', (string) $to ); foreach ( $recipients as $dest ) { list( $r_addr, $r_name ) = self::split_address( $dest ); if ( '' !== $r_addr ) { $mime_mailer->addAddress( $r_addr, $r_name ); } } foreach ( $cc as $dest ) { list( $r_addr, $r_name ) = self::split_address( $dest ); if ( '' !== $r_addr ) { $mime_mailer->addCC( $r_addr, $r_name ); } } foreach ( $bcc as $dest ) { list( $r_addr, $r_name ) = self::split_address( $dest ); if ( '' !== $r_addr ) { $mime_mailer->addBCC( $r_addr, $r_name ); } } if ( ! empty( $reply_to ) ) { foreach ( $reply_to as $dest ) { list( $r_addr, $r_name ) = self::split_address( $dest ); if ( '' !== $r_addr ) { $mime_mailer->addReplyTo( $r_addr, $r_name ); } } } elseif ( '' !== $add_reply_to && is_email( $add_reply_to ) && strtolower( $add_reply_to ) !== strtolower( $from_email ) ) { $mime_mailer->addReplyTo( $add_reply_to, $from_name ); } if ( 'text/html' === strtolower( $content_type ) ) { $mime_mailer->isHTML( true ); } $mime_mailer->ContentType = $content_type; if ( false !== stripos( $content_type, 'multipart' ) && '' !== $boundary ) { $mime_mailer->addCustomHeader( sprintf( 'Content-Type: %s; boundary="%s"', $content_type, $boundary ) ); } foreach ( $ext_hdrs as $h_key => $h_value ) { $mime_mailer->addCustomHeader( sprintf( '%1$s: %2$s', $h_key, $h_value ) ); } if ( ! empty( $attachments ) ) { $att_list = is_array( $attachments ) ? $attachments : explode( "\n", str_replace( "\r\n", "\n", (string) $attachments ) ); foreach ( $att_list as $att_name => $att_path ) { $att_path = trim( (string) $att_path ); if ( '' === $att_path || ! file_exists( $att_path ) ) { continue; } if ( is_string( $att_name ) && '' !== $att_name ) { $mime_mailer->addAttachment( $att_path, $att_name ); } else { $mime_mailer->addAttachment( $att_path ); } } } $mime_mailer->preSend(); $raw_mime = $mime_mailer->getSentMIMEMessage(); return array( 'ok' => true, 'mime' => $raw_mime, 'mailer' => $mime_mailer, 'from_email' => $from_email, 'from_name' => $from_name, ); } catch ( \Throwable $e ) { return array( 'ok' => false, 'error' => sprintf( /* translators: %s: MIME build error message */ __( 'Failed to build email MIME payload: %s', 'aio-tools' ), $e->getMessage() ), ); } } /** * Intercept wp_mail via WordPress 5.7+ pre_wp_mail filter for Google & Microsoft HTTPS REST APIs. * * @param null|bool $return Short-circuit return value. * @param array $atts wp_mail arguments. * @return null|bool */ public static function handle_pre_wp_mail( $return, $atts ) { if ( null !== $return ) { return $return; } $options = get_option( 'aio_options', array() ); $mailer_type = isset( $options['opt-mailer'] ) ? (string) $options['opt-mailer'] : 'smtp'; if ( ! in_array( $mailer_type, array( 'google', 'microsoft' ), true ) ) { return null; } $mail_data = array( 'to' => is_array( $atts['to'] ?? '' ) ? $atts['to'] : array( (string) ( $atts['to'] ?? '' ) ), 'subject' => (string) ( $atts['subject'] ?? '' ), 'message' => (string) ( $atts['message'] ?? '' ), 'headers' => is_array( $atts['headers'] ?? '' ) ? $atts['headers'] : ( empty( $atts['headers'] ) ? array() : array( (string) $atts['headers'] ) ), 'attachments' => is_array( $atts['attachments'] ?? array() ) ? $atts['attachments'] : array(), ); $sent = 'google' === $mailer_type ? self::send_via_google_api( $atts, $options ) : self::send_via_microsoft_graph( $atts, $options ); if ( $sent ) { do_action( 'wp_mail_succeeded', $mail_data ); return true; } $err_msg = '' !== self::$last_error ? self::$last_error : __( 'OAuth API mail delivery failed.', 'aio-tools' ); do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $err_msg, $mail_data ) ); return false; } /** * Dispatch email via Google Gmail REST API v1 (HTTPS Port 443) with smart XOAUTH2 fallback. * * @param array $atts wp_mail arguments. * @param array $options Plugin options. * @return bool */ private static function send_via_google_api( $atts, $options ) { self::log_trace( 'Transport selected: Google / Gmail OAuth 2.0 (HTTPS REST API v1 — Port 443)' ); $token_res = self::get_valid_access_token( 'google', false, $options ); if ( empty( $token_res['ok'] ) ) { self::$last_error = (string) ( $token_res['error'] ?? 'OAuth token error' ); self::log_trace( 'ERROR: ' . self::$last_error ); return false; } $access_token = (string) $token_res['access_token']; $connected_email = (string) ( $token_res['connected_email'] ?? '' ); $scopes = (string) ( $token_res['scope'] ?? '' ); self::log_trace( $token_res['from_cache'] ? 'Access token loaded from encrypted cache.' : 'Access token refreshed via https://oauth2.googleapis.com/token.' ); // Automatically discover connected Google account if not yet stored if ( '' === $connected_email ) { $profile = self::discover_account_profile( 'google', $access_token, (string) ( $token_res['id_token_email'] ?? '' ) ); if ( '' !== $profile['email'] ) { $connected_email = $profile['email']; } if ( '' !== $profile['scopes'] ) { $scopes = $profile['scopes']; } } if ( '' !== $connected_email ) { self::log_trace( 'Authenticated Google Account: ' . $connected_email ); } $configured_from = isset( $options['txtSmtpMail'] ) ? sanitize_email( (string) $options['txtSmtpMail'] ) : ''; if ( '' !== $connected_email && '' !== $configured_from && strtolower( $connected_email ) !== strtolower( $configured_from ) ) { self::log_trace( 'NOTE: Configured From Email (' . $configured_from . ') differs from connected Google Account (' . $connected_email . '). Gmail will send from ' . $connected_email . ' unless ' . $configured_from . ' is a verified "Send mail as" alias in Gmail Settings.' ); } $mime_res = self::build_mime_message( $atts, '', $configured_from ); if ( empty( $mime_res['ok'] ) ) { self::$last_error = (string) $mime_res['error']; self::log_trace( 'ERROR: ' . self::$last_error ); return false; } $raw_b64url = rtrim( strtr( base64_encode( $mime_res['mime'] ), '+/', '-_' ), '=' ); $payload = wp_json_encode( array( 'raw' => $raw_b64url ) ); self::log_trace( 'POST ' . self::GOOGLE_GMAIL_SEND_URL ); $api_res = self::post_gmail_raw( $access_token, $payload ); // If cached access_token expired early (401), force refresh once and retry immediately if ( ! is_wp_error( $api_res ) && 401 === (int) wp_remote_retrieve_response_code( $api_res ) && ! empty( $token_res['from_cache'] ) ) { self::log_trace( 'HTTP 401 on cached token — forcing fresh token exchange and retrying...' ); $fresh = self::get_valid_access_token( 'google', true, $options ); if ( ! empty( $fresh['ok'] ) ) { $access_token = (string) $fresh['access_token']; $api_res = self::post_gmail_raw( $access_token, $payload ); } } if ( is_wp_error( $api_res ) ) { self::$last_error = sprintf( /* translators: %s: network error message */ __( 'Network error connecting to Gmail API: %s', 'aio-tools' ), $api_res->get_error_message() ); self::log_trace( 'ERROR: ' . self::$last_error ); return false; } $http_code = (int) wp_remote_retrieve_response_code( $api_res ); $resp_body = json_decode( wp_remote_retrieve_body( $api_res ), true ); if ( $http_code >= 200 && $http_code < 300 ) { $msg_id = isset( $resp_body['id'] ) ? (string) $resp_body['id'] : 'OK'; self::log_trace( 'SUCCESS: HTTP ' . $http_code . ' — Delivered via Gmail REST API (Message ID: ' . $msg_id . ')' ); return true; } $api_err_msg = (string) ( $resp_body['error']['message'] ?? ( 'HTTP ' . $http_code ) ); $api_err_reason = (string) ( $resp_body['error']['errors'][0]['reason'] ?? '' ); self::log_trace( 'Gmail REST API response: HTTP ' . $http_code . ' — ' . $api_err_msg ); // Smart Fallback: If Gmail API is not enabled yet in Google Cloud project, try authenticated XOAUTH2 SMTP with the TRUE connected email! $is_api_disabled = ( 403 === $http_code ) && ( 'accessNotConfigured' === $api_err_reason || false !== stripos( $api_err_msg, 'has not been used in project' ) || false !== stripos( $api_err_msg, 'is disabled' ) ); if ( $is_api_disabled ) { $enable_url = ''; if ( preg_match( '#https://console\.developers\.google\.com/apis/api/gmail\.googleapis\.com/overview\?project=[0-9]+#i', $api_err_msg, $m ) ) { $enable_url = $m[0]; } if ( '' === $scopes ) { $prof = self::discover_account_profile( 'google', $access_token ); $scopes = $prof['scopes']; if ( '' === $connected_email && '' !== $prof['email'] ) { $connected_email = $prof['email']; } } if ( false !== strpos( $scopes, 'mail.google.com' ) || '' === $scopes ) { $auth_user = '' !== $connected_email ? $connected_email : $mime_res['from_email']; if ( '' !== $auth_user && is_email( $auth_user ) ) { self::log_trace( 'Attempting XOAUTH2 fallback via smtp.gmail.com:587 (STARTTLS) with authenticated user: ' . $auth_user ); if ( self::send_via_xoauth2_fallback( 'google', $atts, $auth_user, $access_token ) ) { if ( '' !== $enable_url ) { self::log_trace( 'TIP: Enable Gmail API for faster port-443 HTTPS delivery: ' . $enable_url ); } return true; } } } self::$last_error = '' !== $enable_url ? sprintf( /* translators: %s: Google Cloud Console URL to enable Gmail API */ __( 'Gmail API is not enabled in your Google Cloud project yet. Open this link, click "Enable", wait 30 seconds, and test again: %s', 'aio-tools' ), $enable_url ) : __( 'Gmail API is disabled in your Google Cloud Console. Please go to "APIs & Services -> Library -> Gmail API" and click Enable.', 'aio-tools' ); self::log_trace( 'ACTION REQUIRED: ' . self::$last_error ); return false; } if ( 403 === $http_code && ( 'insufficientPermissions' === $api_err_reason || false !== stripos( $api_err_msg, 'insufficient authentication scopes' ) ) ) { self::$last_error = __( 'Your Google OAuth token is missing the Gmail Send permission scope. Please click "Re-authenticate with Google" in SMTP Settings and grant mail permission.', 'aio-tools' ); self::log_trace( 'ACTION REQUIRED: ' . self::$last_error ); return false; } self::$last_error = sprintf( /* translators: 1: HTTP status code, 2: Google API error message */ __( 'Gmail API Error (HTTP %1$d): %2$s', 'aio-tools' ), $http_code, $api_err_msg ); return false; } /** * Helper to POST raw RFC 2822 JSON to Gmail API. * * @param string $access_token Bearer token. * @param string $json_payload JSON body. * @return array|WP_Error */ private static function post_gmail_raw( $access_token, $json_payload ) { return wp_remote_post( self::GOOGLE_GMAIL_SEND_URL, array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ), 'body' => $json_payload, ) ); } /** * Dispatch email via Microsoft Graph REST API v1.0 (HTTPS Port 443 — Raw MIME mode). * * @param array $atts wp_mail arguments. * @param array $options Plugin options. * @return bool */ private static function send_via_microsoft_graph( $atts, $options ) { self::log_trace( 'Transport selected: Microsoft 365 / Outlook OAuth 2.0 (Graph REST API v1.0 — Port 443)' ); $token_res = self::get_valid_access_token( 'microsoft', false, $options ); if ( empty( $token_res['ok'] ) ) { self::$last_error = (string) ( $token_res['error'] ?? 'OAuth token error' ); self::log_trace( 'ERROR: ' . self::$last_error ); return false; } $access_token = (string) $token_res['access_token']; $connected_email = (string) ( $token_res['connected_email'] ?? '' ); self::log_trace( $token_res['from_cache'] ? 'Access token loaded from encrypted cache.' : 'Access token refreshed via Microsoft Identity Platform.' ); if ( '' === $connected_email ) { $profile = self::discover_account_profile( 'microsoft', $access_token, (string) ( $token_res['id_token_email'] ?? '' ) ); if ( '' !== $profile['email'] ) { $connected_email = $profile['email']; } } if ( '' !== $connected_email ) { self::log_trace( 'Authenticated Microsoft Account: ' . $connected_email ); } $configured_from = isset( $options['txtSmtpMail'] ) ? sanitize_email( (string) $options['txtSmtpMail'] ) : ''; // Microsoft Graph rejects From addresses that do not belong to the authenticated mailbox unless SendAs permission exists $mime_res = self::build_mime_message( $atts, $connected_email, $configured_from ); if ( empty( $mime_res['ok'] ) ) { self::$last_error = (string) $mime_res['error']; self::log_trace( 'ERROR: ' . self::$last_error ); return false; } $b64_mime = base64_encode( $mime_res['mime'] ); self::log_trace( 'POST ' . self::MS_GRAPH_SEND_URL ); $api_res = wp_remote_post( self::MS_GRAPH_SEND_URL, array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'text/plain', ), 'body' => $b64_mime, ) ); if ( ! is_wp_error( $api_res ) && 401 === (int) wp_remote_retrieve_response_code( $api_res ) && ! empty( $token_res['from_cache'] ) ) { self::log_trace( 'HTTP 401 on cached token — forcing fresh token exchange and retrying...' ); $fresh = self::get_valid_access_token( 'microsoft', true, $options ); if ( ! empty( $fresh['ok'] ) ) { $access_token = (string) $fresh['access_token']; $api_res = wp_remote_post( self::MS_GRAPH_SEND_URL, array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'text/plain', ), 'body' => $b64_mime, ) ); } } if ( is_wp_error( $api_res ) ) { self::$last_error = sprintf( /* translators: %s: network error message */ __( 'Network error connecting to Microsoft Graph API: %s', 'aio-tools' ), $api_res->get_error_message() ); self::log_trace( 'ERROR: ' . self::$last_error ); return false; } $http_code = (int) wp_remote_retrieve_response_code( $api_res ); if ( 202 === $http_code || ( $http_code >= 200 && $http_code < 300 ) ) { self::log_trace( 'SUCCESS: HTTP ' . $http_code . ' Accepted — Delivered via Microsoft Graph REST API.' ); return true; } $resp_body = json_decode( wp_remote_retrieve_body( $api_res ), true ); $err_code = (string) ( $resp_body['error']['code'] ?? '' ); $err_msg = (string) ( $resp_body['error']['message'] ?? ( 'HTTP ' . $http_code ) ); self::log_trace( 'Microsoft Graph response: HTTP ' . $http_code . ' (' . $err_code . ') — ' . $err_msg ); if ( 403 === $http_code && false !== stripos( $err_code, 'ErrorAccessDenied' ) ) { // Legacy token may only have Outlook SMTP.Send scope instead of Graph Mail.Send $auth_user = '' !== $connected_email ? $connected_email : $mime_res['from_email']; if ( '' !== $auth_user && is_email( $auth_user ) ) { self::log_trace( 'Legacy token missing Graph Mail.Send scope — attempting XOAUTH2 SMTP fallback via smtp.office365.com:587...' ); if ( self::send_via_xoauth2_fallback( 'microsoft', $atts, $auth_user, $access_token ) ) { return true; } } self::$last_error = __( 'Microsoft token lacks "Mail.Send" Graph API permission. In Azure App Registration -> API permissions, add Microsoft Graph -> Delegated -> Mail.Send + User.Read, then click "Re-authenticate with Microsoft".', 'aio-tools' ); return false; } self::$last_error = sprintf( /* translators: 1: HTTP status code, 2: Microsoft Graph error message */ __( 'Microsoft Graph Error (HTTP %1$d): %2$s', 'aio-tools' ), $http_code, $err_msg ); return false; } /** * Authenticated XOAUTH2 SMTP fallback using verified OAuth mailbox identity. * * @param string $provider 'google' or 'microsoft'. * @param array $atts wp_mail arguments. * @param string $auth_email Exact mailbox email owning the OAuth token. * @param string $access_token Fresh OAuth Bearer token. * @return bool */ private static function send_via_xoauth2_fallback( $provider, $atts, $auth_email, $access_token ) { if ( ! interface_exists( '\PHPMailer\PHPMailer\OAuthTokenProvider' ) && file_exists( ABSPATH . WPINC . '/PHPMailer/OAuthTokenProvider.php' ) ) { require_once ABSPATH . WPINC . '/PHPMailer/OAuthTokenProvider.php'; } if ( ! interface_exists( '\PHPMailer\PHPMailer\OAuthTokenProvider' ) ) { return false; } $configured_from = sanitize_email( (string) aio_get_option( 'txtSmtpMail', '' ) ); $prep = self::build_mime_message( $atts, $auth_email, $configured_from ); if ( empty( $prep['ok'] ) || empty( $prep['mailer'] ) ) { return false; } $phpmailer = $prep['mailer']; $oauth_bridge = new AIO_Static_OAuth_Bridge( $auth_email, $access_token ); $smtp_lines = array(); $phpmailer->isSMTP(); $phpmailer->SMTPAuth = true; $phpmailer->AuthType = 'XOAUTH2'; $phpmailer->Host = 'google' === $provider ? 'smtp.gmail.com' : 'smtp.office365.com'; $phpmailer->Port = 587; $phpmailer->SMTPSecure = 'tls'; $phpmailer->SMTPAutoTLS = true; $phpmailer->Timeout = 15; $phpmailer->setOAuth( $oauth_bridge ); $phpmailer->SMTPDebug = 2; $phpmailer->Debugoutput = function ( $str ) use ( &$smtp_lines ) { $smtp_lines[] = rtrim( (string) $str ); }; try { $ok = $phpmailer->send(); } catch ( \Throwable $e ) { $ok = false; $smtp_lines[] = 'Exception: ' . $e->getMessage(); } if ( ! empty( $smtp_lines ) ) { $decoded_log = self::format_smtp_debug_log( implode( "\n", $smtp_lines ) ); foreach ( explode( "\n", $decoded_log ) as $l ) { if ( '' !== trim( $l ) ) { self::$last_trace[] = ' ' . $l; } } } if ( $ok ) { self::log_trace( 'SUCCESS: Delivered via XOAUTH2 SMTP fallback.' ); } return (bool) $ok; } /** * Configure PHPMailer when Standard SMTP ('smtp') transport is selected. * * @param \PHPMailer\PHPMailer\PHPMailer $phpmailer PHPMailer instance. */ public static function configure_phpmailer( $phpmailer ) { $options = get_option( 'aio_options', array() ); $mailer_type = isset( $options['opt-mailer'] ) ? (string) $options['opt-mailer'] : 'smtp'; if ( 'smtp' !== $mailer_type ) { return; } $host = isset( $options['txtSmtpHost'] ) ? trim( sanitize_text_field( (string) $options['txtSmtpHost'] ) ) : ''; if ( '' === $host ) { return; } $phpmailer->isSMTP(); $phpmailer->Host = $host; $phpmailer->Port = ! empty( $options['txtSmtpPort'] ) ? (int) $options['txtSmtpPort'] : 587; $phpmailer->Timeout = 15; $username = isset( $options['txtSmtpUserName'] ) ? trim( sanitize_text_field( (string) $options['txtSmtpUserName'] ) ) : ''; $password = isset( $options['txtSmtpPassword'] ) ? (string) $options['txtSmtpPassword'] : ''; if ( '' !== $password ) { $password = AIO_Crypto::decrypt( $password ); } if ( '' !== $username ) { $phpmailer->SMTPAuth = true; $phpmailer->Username = $username; $phpmailer->Password = $password; } else { $phpmailer->SMTPAuth = false; } $enc = isset( $options['opt-smtp-enc'] ) ? (string) $options['opt-smtp-enc'] : 'none'; if ( in_array( $enc, array( 'ssl', 'tls' ), true ) ) { $phpmailer->SMTPSecure = $enc; $phpmailer->SMTPAutoTLS = true; } else { $phpmailer->SMTPSecure = ''; $phpmailer->SMTPAutoTLS = false; } } /** * Enrich raw SMTP debug output by decoding Base64 334 challenges and appending actionable troubleshooting hints. * * @param string $raw_log Raw PHPMailer debug transcript. * @param string $error_info Optional PHPMailer ErrorInfo string. * @return string */ public static function format_smtp_debug_log( $raw_log, $error_info = '' ) { $lines = explode( "\n", str_replace( "\r\n", "\n", (string) $raw_log ) ); $out = array(); $hints = array(); $combined = $raw_log . "\n" . $error_info; foreach ( $lines as $line ) { $out[] = $line; if ( preg_match( '/\b334\s+([A-Za-z0-9+\/=]{16,})/', $line, $m ) ) { $decoded = base64_decode( $m[1], true ); if ( is_string( $decoded ) && '' !== $decoded && preg_match( '/^[\x20-\x7E\r\n\t]+$/', $decoded ) ) { $out[] = ' ↳ [Decoded SASL 334 Challenge]: ' . $decoded; } } } if ( false !== stripos( $combined, 'smtp.gmail.com' ) && ( false !== stripos( $combined, '535-5.7.8' ) || false !== stripos( $combined, '534-5.7.9' ) || false !== stripos( $combined, 'BadCredentials' ) ) ) { $hints[] = __( 'Google blocks regular account passwords over SMTP. Either create a 16-character Google App Password (requires 2-Step Verification) or switch Mailer Transport to "Google / Gmail OAuth 2.0".', 'aio-tools' ); } if ( false !== stripos( $combined, 'SmtpClientAuthentication is disabled' ) || false !== stripos( $combined, '5.7.139' ) ) { $hints[] = __( 'Microsoft 365 disables legacy SMTP AUTH by default. Switch Mailer Transport to "Microsoft / Outlook OAuth 2.0" to send via Microsoft Graph API over HTTPS Port 443.', 'aio-tools' ); } if ( false !== stripos( $combined, 'Connection timed out' ) || false !== stripos( $combined, 'Failed to connect to server' ) ) { $hints[] = __( 'Your hosting firewall appears to block outbound SMTP ports (25/465/587). Use Google or Microsoft OAuth 2.0 API (HTTPS Port 443) or ask your host to open port 587.', 'aio-tools' ); } if ( ! empty( $hints ) ) { $out[] = ''; $out[] = '=== ' . __( 'AIO Tools Diagnostic Recommendation', 'aio-tools' ) . ' ==='; foreach ( $hints as $h ) { $out[] = '• ' . $h; } } return implode( "\n", $out ); } } /** * Lightweight PHPMailer OAuthTokenProvider bridge for verified XOAUTH2 fallback. */ if ( ! interface_exists( '\PHPMailer\PHPMailer\OAuthTokenProvider' ) && file_exists( ABSPATH . WPINC . '/PHPMailer/OAuthTokenProvider.php' ) ) { require_once ABSPATH . WPINC . '/PHPMailer/OAuthTokenProvider.php'; } if ( interface_exists( '\PHPMailer\PHPMailer\OAuthTokenProvider' ) && ! class_exists( 'AIO_Static_OAuth_Bridge' ) ) { class AIO_Static_OAuth_Bridge implements \PHPMailer\PHPMailer\OAuthTokenProvider { private $email; private $token; public function __construct( $email, $token ) { $this->email = (string) $email; $this->token = (string) $token; } public function getOauth64() { return base64_encode( 'user=' . $this->email . "\001auth=Bearer " . $this->token . "\001\001" ); } } } }