From a45ef4bf9a925aee55e35c3b8e3868c1c8e36e5b Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 31 Mar 2026 19:33:59 -0600 Subject: [PATCH 1/3] feat: Add Transactional_Email_Capability interface and Amazon SES core integration (GH#723) - Add Transactional_Email_Capability interface with verify_domain, get_domain_verification_status, get_domain_dns_records, send_email, get_sending_statistics, set_sending_quota, and test_connection - Add AWS_Signer utility class for SigV4 request signing (reusable across AWS services) - Add Amazon_SES_Integration provider with SES v2 API client - Add Amazon_SES_Transactional_Email capability module that intercepts wp_mail() and routes through SES using the current site's domain as the from-address - Fire wu_domain_added and wu_domain_removed lifecycle hooks from Domain_Manager - Fire wu_domain_verified action after successful SES domain verification initiation - Register Amazon SES integration and capability in Integration_Registry - Add wu_settings_transactional_email action hook in the Emails settings section - Add unit tests for AWS_Signer, Amazon_SES_Integration, and Amazon_SES_Transactional_Email --- inc/class-settings.php | 21 + inc/helpers/class-aws-signer.php | 222 +++++++ ...terface-transactional-email-capability.php | 136 ++++ .../class-integration-registry.php | 2 + .../class-amazon-ses-integration.php | 206 ++++++ .../class-amazon-ses-transactional-email.php | 616 ++++++++++++++++++ inc/managers/class-domain-manager.php | 38 +- tests/WP_Ultimo/Helpers/AWS_Signer_Test.php | 116 ++++ .../Amazon_SES_Integration_Test.php | 127 ++++ .../Amazon_SES_Transactional_Email_Test.php | 306 +++++++++ 10 files changed, 1786 insertions(+), 4 deletions(-) create mode 100644 inc/helpers/class-aws-signer.php create mode 100644 inc/integrations/capabilities/interface-transactional-email-capability.php create mode 100644 inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php create mode 100644 inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php create mode 100644 tests/WP_Ultimo/Helpers/AWS_Signer_Test.php create mode 100644 tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Integration_Test.php create mode 100644 tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php diff --git a/inc/class-settings.php b/inc/class-settings.php index 171abfc26..fb3ce626b 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -1605,6 +1605,27 @@ public function default_sections(): void { do_action('wu_settings_emails'); + $this->add_field( + 'emails', + 'transactional_email_header', + [ + 'title' => __('Transactional Email Delivery', 'ultimate-multisite'), + 'desc' => __('Configure how WordPress sends transactional email (password resets, notifications, admin alerts) on a per-site basis. When a provider is configured, outbound email is routed through the provider using each site\'s own domain as the from-address.', 'ultimate-multisite'), + 'type' => 'header', + 'show_as_submenu' => true, + ] + ); + + /** + * Fires to allow transactional email provider settings to be registered. + * + * Use this hook to add settings fields for configuring the active + * transactional email provider (e.g. Amazon SES, SendGrid, Mailgun). + * + * @since 2.5.0 + */ + do_action('wu_settings_transactional_email'); + /* * Domain Mapping * This section holds the Domain Mapping settings of the Ultimate Multisite Plugin. diff --git a/inc/helpers/class-aws-signer.php b/inc/helpers/class-aws-signer.php new file mode 100644 index 000000000..e5eacc9ed --- /dev/null +++ b/inc/helpers/class-aws-signer.php @@ -0,0 +1,222 @@ +access_key = $access_key; + $this->secret_key = $secret_key; + $this->region = $region; + $this->service = $service; + } + + /** + * Sign an HTTP request using AWS Signature Version 4. + * + * Returns the Authorization header and x-amz-date header values needed + * to authenticate the request with AWS. + * + * @since 2.5.0 + * + * @param string $method HTTP method (GET, POST, etc.). + * @param string $url Full request URL. + * @param string $payload Request body (empty string for GET requests). + * @param array $headers Additional headers to include in the signature. + * @return array{ + * Authorization: string, + * x-amz-date: string, + * x-amz-content-sha256: string + * } + */ + public function sign(string $method, string $url, string $payload = '', array $headers = []): array { + + $parsed = wp_parse_url($url); + $uri = $parsed['path'] ?? '/'; + $query = $parsed['query'] ?? ''; + $host = $parsed['host'] ?? ''; + $amz_date = gmdate('Ymd\THis\Z'); + $date_stamp = gmdate('Ymd'); + $payload_hash = hash('sha256', $payload); + + $canonical_headers = "host:{$host}\n" . + "x-amz-content-sha256:{$payload_hash}\n" . + "x-amz-date:{$amz_date}\n"; + + $signed_headers = 'host;x-amz-content-sha256;x-amz-date'; + + $canonical_request = implode( + "\n", + [ + strtoupper($method), + $this->uri_encode_path($uri), + $this->canonical_query_string($query), + $canonical_headers, + $signed_headers, + $payload_hash, + ] + ); + + $credential_scope = implode( + '/', + [ + $date_stamp, + $this->region, + $this->service, + 'aws4_request', + ] + ); + + $string_to_sign = implode( + "\n", + [ + 'AWS4-HMAC-SHA256', + $amz_date, + $credential_scope, + hash('sha256', $canonical_request), + ] + ); + + $signing_key = $this->get_signing_key($date_stamp); + $signature = hash_hmac('sha256', $string_to_sign, $signing_key); + $authorization = sprintf( + 'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s', + $this->access_key, + $credential_scope, + $signed_headers, + $signature + ); + + return [ + 'Authorization' => $authorization, + 'x-amz-date' => $amz_date, + 'x-amz-content-sha256' => $payload_hash, + ]; + } + + /** + * Derive the signing key for the given date. + * + * @since 2.5.0 + * + * @param string $date_stamp Date in Ymd format. + * @return string Binary signing key. + */ + private function get_signing_key(string $date_stamp): string { + + $k_date = hash_hmac('sha256', $date_stamp, 'AWS4' . $this->secret_key, true); + $k_region = hash_hmac('sha256', $this->region, $k_date, true); + $k_service = hash_hmac('sha256', $this->service, $k_region, true); + + return hash_hmac('sha256', 'aws4_request', $k_service, true); + } + + /** + * URI-encode a path component per AWS requirements. + * + * @since 2.5.0 + * + * @param string $path The URI path. + * @return string Encoded path. + */ + private function uri_encode_path(string $path): string { + + $segments = explode('/', $path); + $encoded = array_map( + static function (string $segment): string { + return rawurlencode(rawurldecode($segment)); + }, + $segments + ); + + return implode('/', $encoded); + } + + /** + * Build a canonical query string sorted by key. + * + * @since 2.5.0 + * + * @param string $query Raw query string. + * @return string Canonical query string. + */ + private function canonical_query_string(string $query): string { + + if ('' === $query) { + return ''; + } + + parse_str($query, $params); + ksort($params); + + $parts = []; + + foreach ($params as $key => $value) { + $parts[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value); + } + + return implode('&', $parts); + } +} diff --git a/inc/integrations/capabilities/interface-transactional-email-capability.php b/inc/integrations/capabilities/interface-transactional-email-capability.php new file mode 100644 index 000000000..575a352d0 --- /dev/null +++ b/inc/integrations/capabilities/interface-transactional-email-capability.php @@ -0,0 +1,136 @@ +, + * message?: string + * } + */ + public function get_domain_dns_records(string $domain): array; + + /** + * Send a transactional email through the provider. + * + * @since 2.5.0 + * + * @param string $from The sender email address. + * @param string $to The recipient email address. + * @param string $subject The email subject. + * @param string $body The email body (HTML or plain text). + * @param array $headers Optional additional headers. + * @return array{ + * success: bool, + * message_id?: string, + * message?: string + * } + */ + public function send_email(string $from, string $to, string $subject, string $body, array $headers = []): array; + + /** + * Get sending statistics for a domain. + * + * @since 2.5.0 + * + * @param string $domain The domain name. + * @param string $period The time period for statistics (e.g. '24h', '7d', '30d'). + * @return array{ + * success: bool, + * sent?: int, + * delivered?: int, + * bounced?: int, + * complaints?: int, + * message?: string + * } + */ + public function get_sending_statistics(string $domain, string $period = '24h'): array; + + /** + * Set the daily sending quota for a domain. + * + * @since 2.5.0 + * + * @param string $domain The domain name. + * @param int $max_per_day Maximum emails allowed per day. + * @return array{ + * success: bool, + * message?: string + * } + */ + public function set_sending_quota(string $domain, int $max_per_day): array; + + /** + * Test the connection to the transactional email provider. + * + * @since 2.5.0 + * + * @return true|\WP_Error True on success, WP_Error on failure. + */ + public function test_connection(); +} diff --git a/inc/integrations/class-integration-registry.php b/inc/integrations/class-integration-registry.php index df69a9934..c5a91038c 100644 --- a/inc/integrations/class-integration-registry.php +++ b/inc/integrations/class-integration-registry.php @@ -134,6 +134,7 @@ private function register_core_integrations(): void { $this->register(new Providers\WPMUDEV\WPMUDEV_Integration()); $this->register(new Providers\BunnyNet\BunnyNet_Integration()); $this->register(new Providers\LaravelForge\LaravelForge_Integration()); + $this->register(new Providers\Amazon_SES\Amazon_SES_Integration()); } /** @@ -182,6 +183,7 @@ private function register_core_capabilities(): void { $this->add_capability('wpmudev', new Providers\WPMUDEV\WPMUDEV_Domain_Mapping()); $this->add_capability('bunnynet', new Providers\BunnyNet\BunnyNet_Domain_Mapping()); $this->add_capability('laravel-forge', new Providers\LaravelForge\LaravelForge_Domain_Mapping()); + $this->add_capability('amazon-ses', new Providers\Amazon_SES\Amazon_SES_Transactional_Email()); } /** diff --git a/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php b/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php new file mode 100644 index 000000000..44a7721dd --- /dev/null +++ b/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php @@ -0,0 +1,206 @@ +set_logo(function_exists('wu_get_asset') ? wu_get_asset('amazon-ses.svg', 'img/hosts') : ''); + $this->set_tutorial_link('https://ultimatemultisite.com/docs/user-guide/host-integrations/amazon-ses'); + $this->set_constants(['WU_AWS_ACCESS_KEY_ID', 'WU_AWS_SECRET_ACCESS_KEY']); + $this->set_optional_constants(['WU_AWS_SES_REGION']); + } + + /** + * {@inheritdoc} + */ + public function get_description(): string { + + return __('Amazon Simple Email Service (SES) is a cost-effective, flexible, and scalable email service that enables developers to send mail from within any application. Route transactional email for each subsite through SES with automatic domain verification.', 'ultimate-multisite'); + } + + /** + * Returns the AWS region to use for SES. + * + * Defaults to us-east-1 if not configured. + * + * @since 2.5.0 + * @return string + */ + public function get_region(): string { + + $region = $this->get_credential('WU_AWS_SES_REGION'); + + return $region ?: 'us-east-1'; + } + + /** + * Returns the SES API base URL for the configured region. + * + * @since 2.5.0 + * @return string + */ + public function get_api_base(): string { + + return sprintf(self::API_BASE, $this->get_region()); + } + + /** + * Returns a configured AWS_Signer instance for SES. + * + * @since 2.5.0 + * @return AWS_Signer + */ + public function get_signer(): AWS_Signer { + + return new AWS_Signer( + $this->get_credential('WU_AWS_ACCESS_KEY_ID'), + $this->get_credential('WU_AWS_SECRET_ACCESS_KEY'), + $this->get_region(), + 'ses' + ); + } + + /** + * Makes an authenticated request to the Amazon SES v2 API. + * + * @since 2.5.0 + * + * @param string $endpoint Relative endpoint path (e.g. 'email-identities'). + * @param string $method HTTP method. Defaults to GET. + * @param array $data Request body data (will be JSON-encoded for non-GET requests). + * @return array|\WP_Error Decoded response array or WP_Error on failure. + */ + public function ses_api_call(string $endpoint, string $method = 'GET', array $data = []) { + + $url = $this->get_api_base() . ltrim($endpoint, '/'); + $payload = ('GET' === $method || empty($data)) ? '' : wp_json_encode($data); + + $auth_headers = $this->get_signer()->sign($method, $url, $payload ?: ''); + + $request_args = [ + 'method' => $method, + 'headers' => array_merge( + $auth_headers, + [ + 'Content-Type' => 'application/json', + ] + ), + ]; + + if ($payload) { + $request_args['body'] = $payload; + } + + $response = wp_remote_request($url, $request_args); + + if (is_wp_error($response)) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + $decoded = json_decode($body, true); + + if ($status_code >= 200 && $status_code < 300) { + return $decoded ?: []; + } + + $error_message = isset($decoded['message']) ? $decoded['message'] : wp_remote_retrieve_response_message($response); + + return new \WP_Error( + 'amazon-ses-error', + sprintf( + /* translators: 1: HTTP status code, 2: error message */ + __('Amazon SES API error (HTTP %1$d): %2$s', 'ultimate-multisite'), + $status_code, + $error_message + ) + ); + } + + /** + * Tests the connection to the Amazon SES API. + * + * Verifies credentials by fetching the account sending quota. + * + * @since 2.5.0 + * @return true|\WP_Error + */ + public function test_connection() { + + $result = $this->ses_api_call('account'); + + if (is_wp_error($result)) { + return $result; + } + + return true; + } + + /** + * Returns the credential form fields for the setup wizard. + * + * @since 2.5.0 + * @return array + */ + public function get_fields(): array { + + return [ + 'WU_AWS_ACCESS_KEY_ID' => [ + 'title' => __('AWS Access Key ID', 'ultimate-multisite'), + 'placeholder' => __('e.g. AKIAIOSFODNN7EXAMPLE', 'ultimate-multisite'), + ], + 'WU_AWS_SECRET_ACCESS_KEY' => [ + 'title' => __('AWS Secret Access Key', 'ultimate-multisite'), + 'placeholder' => __('e.g. wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', 'ultimate-multisite'), + 'type' => 'password', + 'html_attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'WU_AWS_SES_REGION' => [ + 'title' => __('AWS Region', 'ultimate-multisite'), + 'placeholder' => __('e.g. us-east-1', 'ultimate-multisite'), + 'desc' => __('Optional. The AWS region for SES. Defaults to us-east-1.', 'ultimate-multisite'), + ], + ]; + } +} diff --git a/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php b/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php new file mode 100644 index 000000000..14d119aaf --- /dev/null +++ b/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php @@ -0,0 +1,616 @@ + [ + 'intercept_wp_mail' => __('Intercept wp_mail() calls and route them through Amazon SES using the current site\'s domain as the from-address', 'ultimate-multisite'), + 'domain_verify' => __('Automatically initiate domain verification in SES when a new domain is added to the network', 'ultimate-multisite'), + 'domain_cleanup' => __('Optionally remove the SES email identity when a domain is removed from the network', 'ultimate-multisite'), + ], + 'will_not' => [ + 'mailbox_provision' => __('Provision mailboxes or IMAP/POP3 accounts (use the Email Selling capability for that)', 'ultimate-multisite'), + 'dns_auto_create' => __('Automatically create DNS records (you must add the provided SPF/DKIM records to your DNS manually, unless a supported DNS provider is configured)', 'ultimate-multisite'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function register_hooks(): void { + + // Intercept wp_mail() to route through SES. + add_filter('pre_wp_mail', [$this, 'intercept_wp_mail'], 10, 2); + + // React to domain lifecycle events. + add_action('wu_domain_added', [$this, 'on_domain_added'], 10, 2); + add_action('wu_domain_removed', [$this, 'on_domain_removed'], 10, 2); + } + + /** + * Gets the parent Amazon_SES_Integration for API calls. + * + * @since 2.5.0 + * @return Amazon_SES_Integration + */ + private function get_ses(): Amazon_SES_Integration { + + /** @var Amazon_SES_Integration */ + return $this->get_integration(); + } + + /** + * Intercepts wp_mail() and routes the message through Amazon SES. + * + * Hooked to `pre_wp_mail`. Returning a non-null value short-circuits + * the default wp_mail() sending. + * + * @since 2.5.0 + * + * @param null|bool $return Short-circuit return value (null to proceed normally). + * @param array $atts wp_mail() arguments: to, subject, message, headers, attachments. + * @return bool|null True on success, false on failure, null to fall through to default. + */ + public function intercept_wp_mail($return, array $atts) { + + if (null !== $return) { + return $return; + } + + $to = $atts['to']; + $subject = $atts['subject']; + $message = $atts['message']; + $headers = $atts['headers']; + + // Determine the from address for the current site. + $from = $this->get_site_from_address(); + + // Parse additional headers to extract CC, BCC, Reply-To, etc. + $parsed_headers = $this->parse_mail_headers($headers); + + // Use the from address from headers if explicitly set. + if ( ! empty($parsed_headers['from'])) { + $from = $parsed_headers['from']; + } + + // Normalize recipients to array. + $recipients = is_array($to) ? $to : explode(',', $to); + $recipients = array_map('trim', $recipients); + + // Build the SES v2 SendEmail request body. + $body = [ + 'FromEmailAddress' => $from, + 'Destination' => [ + 'ToAddresses' => $recipients, + ], + 'Content' => [ + 'Simple' => [ + 'Subject' => [ + 'Data' => $subject, + 'Charset' => 'UTF-8', + ], + 'Body' => $this->build_ses_body($message), + ], + ], + ]; + + if ( ! empty($parsed_headers['cc'])) { + $body['Destination']['CcAddresses'] = $parsed_headers['cc']; + } + + if ( ! empty($parsed_headers['bcc'])) { + $body['Destination']['BccAddresses'] = $parsed_headers['bcc']; + } + + if ( ! empty($parsed_headers['reply-to'])) { + $body['ReplyToAddresses'] = $parsed_headers['reply-to']; + } + + /** + * Filters the Amazon SES SendEmail request body before sending. + * + * @since 2.5.0 + * + * @param array $body The SES v2 SendEmail request body. + * @param array $atts The original wp_mail() arguments. + */ + $body = apply_filters('wu_ses_send_email_body', $body, $atts); + + $result = $this->get_ses()->ses_api_call('outbound-emails', 'POST', $body); + + if (is_wp_error($result)) { + wu_log_add( + 'integration-amazon-ses', + sprintf('Failed to send email via SES. Reason: %s', $result->get_error_message()), + LogLevel::ERROR + ); + + return false; + } + + wu_log_add( + 'integration-amazon-ses', + sprintf('Email sent via SES. MessageId: %s', $result['MessageId'] ?? 'unknown') + ); + + return true; + } + + /** + * Handles the wu_domain_added action. + * + * Initiates domain verification in SES when a new domain is added to the network. + * + * @since 2.5.0 + * + * @param string $domain The domain name that was added. + * @param int $site_id The site ID the domain was added to. + * @return void + */ + public function on_domain_added(string $domain, int $site_id): void { + + $result = $this->verify_domain($domain); + + if ( ! $result['success']) { + wu_log_add( + 'integration-amazon-ses', + sprintf( + 'Failed to initiate SES domain verification for "%s". Reason: %s', + $domain, + $result['message'] ?? __('Unknown error', 'ultimate-multisite') + ), + LogLevel::ERROR + ); + + return; + } + + wu_log_add( + 'integration-amazon-ses', + sprintf('Initiated SES domain verification for "%s".', $domain) + ); + + /** + * Fires after SES domain verification has been initiated. + * + * @since 2.5.0 + * + * @param string $domain The domain name. + * @param int $site_id The site ID. + * @param array $dns_records The DNS records that must be added to complete verification. + */ + do_action('wu_domain_verified', $domain, $site_id, $result['dns_records'] ?? []); + } + + /** + * Handles the wu_domain_removed action. + * + * Optionally removes the SES email identity when a domain is removed. + * + * @since 2.5.0 + * + * @param string $domain The domain name that was removed. + * @param int $site_id The site ID the domain was removed from. + * @return void + */ + public function on_domain_removed(string $domain, int $site_id): void { + + /** + * Filters whether to delete the SES email identity when a domain is removed. + * + * @since 2.5.0 + * + * @param bool $should_delete Whether to delete the identity. Default false. + * @param string $domain The domain name. + * @param int $site_id The site ID. + */ + $should_delete = apply_filters('wu_ses_delete_identity_on_domain_removed', false, $domain, $site_id); + + if ( ! $should_delete) { + return; + } + + $result = $this->get_ses()->ses_api_call( + 'email-identities/' . rawurlencode($domain), + 'DELETE' + ); + + if (is_wp_error($result)) { + wu_log_add( + 'integration-amazon-ses', + sprintf('Failed to delete SES email identity for "%s". Reason: %s', $domain, $result->get_error_message()), + LogLevel::ERROR + ); + + return; + } + + wu_log_add('integration-amazon-ses', sprintf('Deleted SES email identity for "%s".', $domain)); + } + + /** + * {@inheritdoc} + */ + public function verify_domain(string $domain): array { + + $result = $this->get_ses()->ses_api_call( + 'email-identities', + 'POST', + [ + 'EmailIdentity' => $domain, + 'DkimSigningAttributes' => [ + 'NextSigningKeyLength' => 'RSA_2048_BIT', + ], + ] + ); + + if (is_wp_error($result)) { + return [ + 'success' => false, + 'message' => $result->get_error_message(), + ]; + } + + $dns_records = $this->extract_dns_records($result, $domain); + + return [ + 'success' => true, + 'dns_records' => $dns_records, + ]; + } + + /** + * {@inheritdoc} + */ + public function get_domain_verification_status(string $domain): array { + + $result = $this->get_ses()->ses_api_call( + 'email-identities/' . rawurlencode($domain) + ); + + if (is_wp_error($result)) { + return [ + 'success' => false, + 'status' => 'unknown', + 'message' => $result->get_error_message(), + ]; + } + + $status = $result['VerifiedForSendingStatus'] ?? false + ? 'verified' + : ($result['DkimAttributes']['Status'] ?? 'pending'); + + return [ + 'success' => true, + 'status' => $status, + ]; + } + + /** + * {@inheritdoc} + */ + public function get_domain_dns_records(string $domain): array { + + $result = $this->get_ses()->ses_api_call( + 'email-identities/' . rawurlencode($domain) + ); + + if (is_wp_error($result)) { + return [ + 'success' => false, + 'message' => $result->get_error_message(), + ]; + } + + $records = $this->extract_dns_records($result, $domain); + + return [ + 'success' => true, + 'records' => $records, + ]; + } + + /** + * {@inheritdoc} + */ + public function send_email(string $from, string $to, string $subject, string $body, array $headers = []): array { + + $result = $this->get_ses()->ses_api_call( + 'outbound-emails', + 'POST', + [ + 'FromEmailAddress' => $from, + 'Destination' => [ + 'ToAddresses' => [$to], + ], + 'Content' => [ + 'Simple' => [ + 'Subject' => [ + 'Data' => $subject, + 'Charset' => 'UTF-8', + ], + 'Body' => $this->build_ses_body($body), + ], + ], + ] + ); + + if (is_wp_error($result)) { + return [ + 'success' => false, + 'message' => $result->get_error_message(), + ]; + } + + return [ + 'success' => true, + 'message_id' => $result['MessageId'] ?? '', + ]; + } + + /** + * {@inheritdoc} + */ + public function get_sending_statistics(string $domain, string $period = '24h'): array { + + // SES v2 does not expose per-domain stats directly via a simple endpoint. + // This returns account-level sending statistics as a proxy. + $result = $this->get_ses()->ses_api_call('account/sending-statistics'); + + if (is_wp_error($result)) { + return [ + 'success' => false, + 'message' => $result->get_error_message(), + ]; + } + + $stats = $result['SendingStatistics'] ?? []; + + $totals = [ + 'sent' => 0, + 'delivered' => 0, + 'bounced' => 0, + 'complaints' => 0, + ]; + + foreach ($stats as $stat) { + $totals['sent'] += (int) ($stat['DeliveryAttempts'] ?? 0); + $totals['bounced'] += (int) ($stat['Bounces'] ?? 0); + $totals['complaints'] += (int) ($stat['Complaints'] ?? 0); + $totals['delivered'] += (int) ($stat['DeliveryAttempts'] ?? 0) - (int) ($stat['Bounces'] ?? 0); + } + + return array_merge(['success' => true], $totals); + } + + /** + * {@inheritdoc} + */ + public function set_sending_quota(string $domain, int $max_per_day): array { + + // SES v2 manages sending quotas at the account level, not per-domain. + // This is a no-op placeholder; quota management is done via the AWS console. + return [ + 'success' => true, + 'message' => __('Sending quota management is handled at the AWS account level via the AWS console.', 'ultimate-multisite'), + ]; + } + + /** + * {@inheritdoc} + */ + public function test_connection() { + + return $this->get_ses()->test_connection(); + } + + /** + * Determines the from-address for the current site. + * + * Uses the site's domain as the from-address domain, with the + * WordPress admin email as the local part. + * + * @since 2.5.0 + * @return string + */ + private function get_site_from_address(): string { + + $site_domain = get_bloginfo('url'); + $parsed = wp_parse_url($site_domain); + $domain = $parsed['host'] ?? get_network()->domain; + + $from_name = get_bloginfo('name'); + $from_email = 'noreply@' . $domain; + + /** + * Filters the from-address used when sending via SES. + * + * @since 2.5.0 + * + * @param string $from_email The from email address. + * @param string $from_name The from name. + * @param string $domain The current site domain. + */ + $from_email = apply_filters('wu_ses_from_email', $from_email, $from_name, $domain); + $from_name = apply_filters('wu_ses_from_name', $from_name, $from_email, $domain); + + if ($from_name) { + return sprintf('%s <%s>', $from_name, $from_email); + } + + return $from_email; + } + + /** + * Parses wp_mail() headers into a structured array. + * + * @since 2.5.0 + * + * @param string|array $headers Raw headers from wp_mail(). + * @return array{from?: string, cc?: array, bcc?: array, reply-to?: array} + */ + private function parse_mail_headers($headers): array { + + $parsed = [ + 'from' => '', + 'cc' => [], + 'bcc' => [], + 'reply-to' => [], + ]; + + if (empty($headers)) { + return $parsed; + } + + if ( ! is_array($headers)) { + $headers = explode("\n", str_replace("\r\n", "\n", $headers)); + } + + foreach ($headers as $header) { + if ( ! str_contains($header, ':')) { + continue; + } + + [$name, $value] = explode(':', $header, 2); + $name = strtolower(trim($name)); + $value = trim($value); + + switch ($name) { + case 'from': + $parsed['from'] = $value; + break; + case 'cc': + $parsed['cc'] = array_map('trim', explode(',', $value)); + break; + case 'bcc': + $parsed['bcc'] = array_map('trim', explode(',', $value)); + break; + case 'reply-to': + $parsed['reply-to'] = array_map('trim', explode(',', $value)); + break; + } + } + + return $parsed; + } + + /** + * Builds the SES v2 Body object from a message string. + * + * Detects whether the message is HTML or plain text. + * + * @since 2.5.0 + * + * @param string $message The email body. + * @return array SES v2 Body structure. + */ + private function build_ses_body(string $message): array { + + $is_html = $message !== wp_strip_all_tags($message); + + if ($is_html) { + return [ + 'Html' => [ + 'Data' => $message, + 'Charset' => 'UTF-8', + ], + 'Text' => [ + 'Data' => wp_strip_all_tags($message), + 'Charset' => 'UTF-8', + ], + ]; + } + + return [ + 'Text' => [ + 'Data' => $message, + 'Charset' => 'UTF-8', + ], + ]; + } + + /** + * Extracts DNS records from an SES email identity API response. + * + * @since 2.5.0 + * + * @param array $response The SES API response. + * @param string $domain The domain name. + * @return array + */ + private function extract_dns_records(array $response, string $domain): array { + + $records = []; + + // DKIM CNAME records. + $dkim_tokens = $response['DkimAttributes']['Tokens'] ?? []; + + foreach ($dkim_tokens as $token) { + $records[] = [ + 'type' => 'CNAME', + 'name' => $token . '._domainkey.' . $domain, + 'value' => $token . '.dkim.amazonses.com', + ]; + } + + // DKIM EasyDKIM records (newer format). + $dkim_records = $response['DkimAttributes']['DomainSigningPrivateKey'] ?? []; + + if ( ! empty($response['DkimAttributes']['DomainSigningSelector'])) { + $selector = $response['DkimAttributes']['DomainSigningSelector']; + $records[] = [ + 'type' => 'CNAME', + 'name' => $selector . '._domainkey.' . $domain, + 'value' => $selector . '.dkim.amazonses.com', + ]; + } + + return $records; + } +} diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index ca40e7b23..527e24aaf 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -411,12 +411,28 @@ public function handle_domain_created($domain, $site, $membership): void { public function handle_domain_deleted($result, $domain): void { if ($result) { + $domain_name = $domain->get_domain(); + $site_id = $domain->get_site_id(); + $args = [ - 'domain' => $domain->get_domain(), - 'site_id' => $domain->get_site_id(), + 'domain' => $domain_name, + 'site_id' => $site_id, ]; wu_enqueue_async_action('wu_remove_domain', $args, 'domain'); + + /** + * Fires when a custom domain is removed from the network. + * + * Use this hook to react to domain removal events, for example to + * clean up transactional email identities or DNS records. + * + * @since 2.5.0 + * + * @param string $domain_name The domain name that was removed. + * @param int $site_id The site ID the domain was removed from. + */ + do_action('wu_domain_removed', $domain_name, $site_id); } } @@ -702,14 +718,28 @@ public function get_domain_mapping_instructions() { public function send_domain_to_host($old_value, $new_value, $item_id): void { if ($old_value !== $new_value) { - $domain = wu_get_domain($item_id); + $domain = wu_get_domain($item_id); + $site_id = $domain->get_site_id(); $args = [ 'domain' => $new_value, - 'site_id' => $domain->get_site_id(), + 'site_id' => $site_id, ]; wu_enqueue_async_action('wu_add_domain', $args, 'domain'); + + /** + * Fires when a custom domain is added to the network. + * + * Use this hook to react to domain addition events, for example to + * initiate transactional email domain verification or DNS record creation. + * + * @since 2.5.0 + * + * @param string $new_value The domain name that was added. + * @param int $site_id The site ID the domain was added to. + */ + do_action('wu_domain_added', $new_value, $site_id); } } diff --git a/tests/WP_Ultimo/Helpers/AWS_Signer_Test.php b/tests/WP_Ultimo/Helpers/AWS_Signer_Test.php new file mode 100644 index 000000000..2dac08da9 --- /dev/null +++ b/tests/WP_Ultimo/Helpers/AWS_Signer_Test.php @@ -0,0 +1,116 @@ +signer = new AWS_Signer( + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + 'us-east-1', + 'ses' + ); + } + + public function test_sign_returns_required_headers(): void { + + $headers = $this->signer->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + + $this->assertArrayHasKey('Authorization', $headers); + $this->assertArrayHasKey('x-amz-date', $headers); + $this->assertArrayHasKey('x-amz-content-sha256', $headers); + } + + public function test_authorization_header_format(): void { + + $headers = $this->signer->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + + $this->assertStringStartsWith('AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/', $headers['Authorization']); + $this->assertStringContainsString('SignedHeaders=host;x-amz-content-sha256;x-amz-date', $headers['Authorization']); + $this->assertStringContainsString('Signature=', $headers['Authorization']); + } + + public function test_authorization_includes_region_and_service(): void { + + $headers = $this->signer->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + + $this->assertStringContainsString('/us-east-1/ses/aws4_request', $headers['Authorization']); + } + + public function test_amz_date_format(): void { + + $headers = $this->signer->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + + // Should be in format YYYYMMDDTHHmmssZ + $this->assertMatchesRegularExpression('/^\d{8}T\d{6}Z$/', $headers['x-amz-date']); + } + + public function test_content_sha256_is_hash_of_empty_payload(): void { + + $headers = $this->signer->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + + $expected = hash('sha256', ''); + $this->assertSame($expected, $headers['x-amz-content-sha256']); + } + + public function test_content_sha256_changes_with_payload(): void { + + $headers_empty = $this->signer->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account', ''); + $headers_payload = $this->signer->sign('POST', 'https://email.us-east-1.amazonaws.com/v2/outbound-emails', '{"test":"value"}'); + + $this->assertNotSame($headers_empty['x-amz-content-sha256'], $headers_payload['x-amz-content-sha256']); + } + + public function test_different_regions_produce_different_signatures(): void { + + $signer_us = new AWS_Signer('KEY', 'SECRET', 'us-east-1', 'ses'); + $signer_eu = new AWS_Signer('KEY', 'SECRET', 'eu-west-1', 'ses'); + + $headers_us = $signer_us->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + $headers_eu = $signer_eu->sign('GET', 'https://email.eu-west-1.amazonaws.com/v2/account'); + + $this->assertNotSame($headers_us['Authorization'], $headers_eu['Authorization']); + } + + public function test_different_services_produce_different_signatures(): void { + + $signer_ses = new AWS_Signer('KEY', 'SECRET', 'us-east-1', 'ses'); + $signer_workmail = new AWS_Signer('KEY', 'SECRET', 'us-east-1', 'workmail'); + + $headers_ses = $signer_ses->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + $headers_workmail = $signer_workmail->sign('GET', 'https://email.us-east-1.amazonaws.com/v2/account'); + + $this->assertNotSame($headers_ses['Authorization'], $headers_workmail['Authorization']); + } + + public function test_sign_with_query_string(): void { + + $headers = $this->signer->sign( + 'GET', + 'https://email.us-east-1.amazonaws.com/v2/email-identities?PageSize=10&NextToken=abc' + ); + + $this->assertArrayHasKey('Authorization', $headers); + $this->assertStringStartsWith('AWS4-HMAC-SHA256', $headers['Authorization']); + } + + public function test_sign_post_with_payload(): void { + + $payload = wp_json_encode(['EmailIdentity' => 'example.com']); + $headers = $this->signer->sign( + 'POST', + 'https://email.us-east-1.amazonaws.com/v2/email-identities', + $payload + ); + + $this->assertSame(hash('sha256', $payload), $headers['x-amz-content-sha256']); + } +} diff --git a/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Integration_Test.php b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Integration_Test.php new file mode 100644 index 000000000..b10157080 --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Integration_Test.php @@ -0,0 +1,127 @@ +integration = new Amazon_SES_Integration(); + } + + public function tearDown(): void { + + $this->integration->delete_credentials(); + + parent::tearDown(); + } + + public function test_get_id(): void { + + $this->assertSame('amazon-ses', $this->integration->get_id()); + } + + public function test_get_title(): void { + + $this->assertSame('Amazon SES', $this->integration->get_title()); + } + + public function test_get_description_is_not_empty(): void { + + $this->assertNotEmpty($this->integration->get_description()); + } + + public function test_get_region_defaults_to_us_east_1(): void { + + $this->assertSame('us-east-1', $this->integration->get_region()); + } + + public function test_get_region_uses_credential_when_set(): void { + + $this->integration->save_credentials(['WU_AWS_SES_REGION' => 'eu-west-1']); + + $this->assertSame('eu-west-1', $this->integration->get_region()); + } + + public function test_get_api_base_includes_region(): void { + + $api_base = $this->integration->get_api_base(); + + $this->assertStringContainsString('us-east-1', $api_base); + $this->assertStringContainsString('amazonaws.com', $api_base); + } + + public function test_get_api_base_uses_configured_region(): void { + + $this->integration->save_credentials(['WU_AWS_SES_REGION' => 'ap-southeast-1']); + + $api_base = $this->integration->get_api_base(); + + $this->assertStringContainsString('ap-southeast-1', $api_base); + } + + public function test_get_signer_returns_aws_signer_instance(): void { + + $signer = $this->integration->get_signer(); + + $this->assertInstanceOf(\WP_Ultimo\Helpers\AWS_Signer::class, $signer); + } + + public function test_get_fields_returns_required_credential_fields(): void { + + $fields = $this->integration->get_fields(); + + $this->assertArrayHasKey('WU_AWS_ACCESS_KEY_ID', $fields); + $this->assertArrayHasKey('WU_AWS_SECRET_ACCESS_KEY', $fields); + $this->assertArrayHasKey('WU_AWS_SES_REGION', $fields); + } + + public function test_ses_api_call_returns_wp_error_on_http_failure(): void { + + $integration = $this->getMockBuilder(Amazon_SES_Integration::class) + ->onlyMethods(['ses_api_call']) + ->getMock(); + + $integration->method('ses_api_call') + ->willReturn(new \WP_Error('http-error', 'Connection failed')); + + $result = $integration->ses_api_call('account'); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertSame('http-error', $result->get_error_code()); + } + + public function test_test_connection_returns_wp_error_on_failure(): void { + + $integration = $this->getMockBuilder(Amazon_SES_Integration::class) + ->onlyMethods(['ses_api_call']) + ->getMock(); + + $integration->method('ses_api_call') + ->willReturn(new \WP_Error('amazon-ses-error', 'Invalid credentials')); + + $result = $integration->test_connection(); + + $this->assertInstanceOf(\WP_Error::class, $result); + } + + public function test_test_connection_returns_true_on_success(): void { + + $integration = $this->getMockBuilder(Amazon_SES_Integration::class) + ->onlyMethods(['ses_api_call']) + ->getMock(); + + $integration->method('ses_api_call') + ->willReturn(['SendingEnabled' => true, 'SendingQuota' => []]); + + $result = $integration->test_connection(); + + $this->assertTrue($result); + } +} diff --git a/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php new file mode 100644 index 000000000..0842a2ce4 --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php @@ -0,0 +1,306 @@ +integration = $this->getMockBuilder(Amazon_SES_Integration::class) + ->onlyMethods(['ses_api_call']) + ->getMock(); + + $this->module = new Amazon_SES_Transactional_Email(); + $this->module->set_integration($this->integration); + } + + public function test_get_capability_id(): void { + + $this->assertSame('transactional-email', $this->module->get_capability_id()); + } + + public function test_get_title(): void { + + $this->assertNotEmpty($this->module->get_title()); + } + + public function test_get_explainer_lines_has_will_and_will_not(): void { + + $lines = $this->module->get_explainer_lines(); + + $this->assertArrayHasKey('will', $lines); + $this->assertArrayHasKey('will_not', $lines); + $this->assertNotEmpty($lines['will']); + $this->assertNotEmpty($lines['will_not']); + } + + public function test_register_hooks_adds_pre_wp_mail_filter(): void { + + $this->module->register_hooks(); + + $this->assertIsInt(has_filter('pre_wp_mail', [$this->module, 'intercept_wp_mail'])); + } + + public function test_register_hooks_adds_domain_lifecycle_actions(): void { + + $this->module->register_hooks(); + + $this->assertIsInt(has_action('wu_domain_added', [$this->module, 'on_domain_added'])); + $this->assertIsInt(has_action('wu_domain_removed', [$this->module, 'on_domain_removed'])); + } + + public function test_intercept_wp_mail_returns_null_when_return_is_not_null(): void { + + $result = $this->module->intercept_wp_mail( + true, + ['to' => 'test@example.com', 'subject' => 'Test', 'message' => 'Body', 'headers' => [], 'attachments' => []] + ); + + $this->assertTrue($result); + } + + public function test_intercept_wp_mail_sends_via_ses_on_success(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->with('outbound-emails', 'POST', $this->anything()) + ->willReturn(['MessageId' => 'test-message-id-123']); + + $result = $this->module->intercept_wp_mail( + null, + [ + 'to' => 'recipient@example.com', + 'subject' => 'Test Subject', + 'message' => 'Test message body', + 'headers' => [], + 'attachments' => [], + ] + ); + + $this->assertTrue($result); + } + + public function test_intercept_wp_mail_returns_false_on_ses_error(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn(new \WP_Error('amazon-ses-error', 'Sending failed')); + + $result = $this->module->intercept_wp_mail( + null, + [ + 'to' => 'recipient@example.com', + 'subject' => 'Test Subject', + 'message' => 'Test message body', + 'headers' => [], + 'attachments' => [], + ] + ); + + $this->assertFalse($result); + } + + public function test_verify_domain_returns_success_with_dns_records(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->with('email-identities', 'POST', $this->anything()) + ->willReturn([ + 'IdentityType' => 'DOMAIN', + 'VerifiedForSendingStatus' => false, + 'DkimAttributes' => [ + 'SigningEnabled' => false, + 'Status' => 'NOT_STARTED', + 'Tokens' => ['token1abc', 'token2def', 'token3ghi'], + ], + ]); + + $result = $this->module->verify_domain('example.com'); + + $this->assertTrue($result['success']); + $this->assertArrayHasKey('dns_records', $result); + $this->assertCount(3, $result['dns_records']); + } + + public function test_verify_domain_returns_failure_on_api_error(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn(new \WP_Error('amazon-ses-error', 'Domain already exists')); + + $result = $this->module->verify_domain('example.com'); + + $this->assertFalse($result['success']); + $this->assertArrayHasKey('message', $result); + } + + public function test_get_domain_verification_status_returns_verified(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->with('email-identities/example.com') + ->willReturn([ + 'VerifiedForSendingStatus' => true, + 'DkimAttributes' => ['Status' => 'SUCCESS'], + ]); + + $result = $this->module->get_domain_verification_status('example.com'); + + $this->assertTrue($result['success']); + $this->assertSame('verified', $result['status']); + } + + public function test_get_domain_verification_status_returns_pending(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn([ + 'VerifiedForSendingStatus' => false, + 'DkimAttributes' => ['Status' => 'PENDING'], + ]); + + $result = $this->module->get_domain_verification_status('example.com'); + + $this->assertTrue($result['success']); + $this->assertSame('PENDING', $result['status']); + } + + public function test_get_domain_dns_records_returns_cname_records(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn([ + 'DkimAttributes' => [ + 'Tokens' => ['abc123', 'def456'], + ], + ]); + + $result = $this->module->get_domain_dns_records('example.com'); + + $this->assertTrue($result['success']); + $this->assertCount(2, $result['records']); + + foreach ($result['records'] as $record) { + $this->assertSame('CNAME', $record['type']); + $this->assertStringContainsString('example.com', $record['name']); + $this->assertStringContainsString('amazonses.com', $record['value']); + } + } + + public function test_send_email_returns_message_id_on_success(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn(['MessageId' => 'msg-id-xyz']); + + $result = $this->module->send_email( + 'sender@example.com', + 'recipient@example.com', + 'Test Subject', + 'Test body' + ); + + $this->assertTrue($result['success']); + $this->assertSame('msg-id-xyz', $result['message_id']); + } + + public function test_send_email_returns_failure_on_error(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn(new \WP_Error('amazon-ses-error', 'Sending failed')); + + $result = $this->module->send_email( + 'sender@example.com', + 'recipient@example.com', + 'Test Subject', + 'Test body' + ); + + $this->assertFalse($result['success']); + $this->assertArrayHasKey('message', $result); + } + + public function test_set_sending_quota_returns_success(): void { + + $result = $this->module->set_sending_quota('example.com', 1000); + + $this->assertTrue($result['success']); + } + + public function test_get_sending_statistics_returns_totals(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->with('account/sending-statistics') + ->willReturn([ + 'SendingStatistics' => [ + [ + 'DeliveryAttempts' => 100, + 'Bounces' => 5, + 'Complaints' => 2, + 'Rejects' => 1, + ], + ], + ]); + + $result = $this->module->get_sending_statistics('example.com'); + + $this->assertTrue($result['success']); + $this->assertSame(100, $result['sent']); + $this->assertSame(5, $result['bounced']); + $this->assertSame(2, $result['complaints']); + } + + public function test_on_domain_added_calls_verify_domain(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->with('email-identities', 'POST', $this->anything()) + ->willReturn([ + 'DkimAttributes' => ['Tokens' => ['tok1', 'tok2', 'tok3']], + ]); + + $this->module->on_domain_added('example.com', 1); + } + + public function test_on_domain_removed_does_not_delete_by_default(): void { + + $this->integration->expects($this->never()) + ->method('ses_api_call'); + + $this->module->on_domain_removed('example.com', 1); + } + + public function test_on_domain_removed_deletes_when_filter_enabled(): void { + + add_filter('wu_ses_delete_identity_on_domain_removed', '__return_true'); + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->with('email-identities/example.com', 'DELETE') + ->willReturn([]); + + $this->module->on_domain_removed('example.com', 1); + + remove_filter('wu_ses_delete_identity_on_domain_removed', '__return_true'); + } + + public function test_test_connection_delegates_to_integration(): void { + + $this->integration->expects($this->once()) + ->method('ses_api_call') + ->willReturn(['SendingEnabled' => true]); + + $result = $this->module->test_connection(); + + $this->assertTrue($result); + } +} From ab535b281d1aae03d945fc72ab093af70926bc85 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 31 Mar 2026 19:46:54 -0600 Subject: [PATCH 2/3] fix: address CodeRabbit review feedback - Remove unused $headers parameter from AWS_Signer::sign() - Fix Yoda condition in build_ses_body() (wp_strip_all_tags() !== $message) - Align get_domain_dns_records() return key to 'dns_records' (matches verify_domain()) - Remove dead $dkim_records assignment in extract_dns_records() - Add settings note pointing users to Integrations tab for provider configuration - Rename misleading test method to test_intercept_wp_mail_returns_original_when_return_is_not_null - Fix test_test_connection_delegates_to_integration to mock test_connection() not ses_api_call() - Update interface docblock to use 'dns_records' key consistently --- inc/class-settings.php | 13 ++++++++++++ inc/helpers/class-aws-signer.php | 3 +-- ...terface-transactional-email-capability.php | 2 +- .../class-amazon-ses-transactional-email.php | 8 +++---- .../Amazon_SES_Transactional_Email_Test.php | 21 ++++++++++++------- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/inc/class-settings.php b/inc/class-settings.php index fb3ce626b..c5ac85bd1 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -1616,6 +1616,19 @@ public function default_sections(): void { ] ); + $this->add_field( + 'emails', + 'transactional_email_provider_note', + [ + 'type' => 'note', + 'desc' => sprintf( + /* translators: %s is a link to the Integrations settings page. */ + __('To configure a transactional email provider (e.g. Amazon SES), go to the Integrations settings and set up the provider there.', 'ultimate-multisite'), + esc_url(wu_network_admin_url('wp-ultimo-settings', ['tab' => 'integrations'])) + ), + ] + ); + /** * Fires to allow transactional email provider settings to be registered. * diff --git a/inc/helpers/class-aws-signer.php b/inc/helpers/class-aws-signer.php index e5eacc9ed..eb971f7a2 100644 --- a/inc/helpers/class-aws-signer.php +++ b/inc/helpers/class-aws-signer.php @@ -84,14 +84,13 @@ public function __construct(string $access_key, string $secret_key, string $regi * @param string $method HTTP method (GET, POST, etc.). * @param string $url Full request URL. * @param string $payload Request body (empty string for GET requests). - * @param array $headers Additional headers to include in the signature. * @return array{ * Authorization: string, * x-amz-date: string, * x-amz-content-sha256: string * } */ - public function sign(string $method, string $url, string $payload = '', array $headers = []): array { + public function sign(string $method, string $url, string $payload = ''): array { $parsed = wp_parse_url($url); $uri = $parsed['path'] ?? '/'; diff --git a/inc/integrations/capabilities/interface-transactional-email-capability.php b/inc/integrations/capabilities/interface-transactional-email-capability.php index 575a352d0..92f18171e 100644 --- a/inc/integrations/capabilities/interface-transactional-email-capability.php +++ b/inc/integrations/capabilities/interface-transactional-email-capability.php @@ -69,7 +69,7 @@ public function get_domain_verification_status(string $domain): array; * @param string $domain The domain name. * @return array{ * success: bool, - * records?: array, + * dns_records?: array, * message?: string * } */ diff --git a/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php b/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php index 14d119aaf..06e077783 100644 --- a/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php +++ b/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php @@ -351,8 +351,8 @@ public function get_domain_dns_records(string $domain): array { $records = $this->extract_dns_records($result, $domain); return [ - 'success' => true, - 'records' => $records, + 'success' => true, + 'dns_records' => $records, ]; } @@ -552,7 +552,7 @@ private function parse_mail_headers($headers): array { */ private function build_ses_body(string $message): array { - $is_html = $message !== wp_strip_all_tags($message); + $is_html = wp_strip_all_tags($message) !== $message; if ($is_html) { return [ @@ -600,8 +600,6 @@ private function extract_dns_records(array $response, string $domain): array { } // DKIM EasyDKIM records (newer format). - $dkim_records = $response['DkimAttributes']['DomainSigningPrivateKey'] ?? []; - if ( ! empty($response['DkimAttributes']['DomainSigningSelector'])) { $selector = $response['DkimAttributes']['DomainSigningSelector']; $records[] = [ diff --git a/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php index 0842a2ce4..97bd01de0 100644 --- a/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php +++ b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php @@ -56,7 +56,7 @@ public function test_register_hooks_adds_domain_lifecycle_actions(): void { $this->assertIsInt(has_action('wu_domain_removed', [$this->module, 'on_domain_removed'])); } - public function test_intercept_wp_mail_returns_null_when_return_is_not_null(): void { + public function test_intercept_wp_mail_returns_original_when_return_is_not_null(): void { $result = $this->module->intercept_wp_mail( true, @@ -185,9 +185,9 @@ public function test_get_domain_dns_records_returns_cname_records(): void { $result = $this->module->get_domain_dns_records('example.com'); $this->assertTrue($result['success']); - $this->assertCount(2, $result['records']); + $this->assertCount(2, $result['dns_records']); - foreach ($result['records'] as $record) { + foreach ($result['dns_records'] as $record) { $this->assertSame('CNAME', $record['type']); $this->assertStringContainsString('example.com', $record['name']); $this->assertStringContainsString('amazonses.com', $record['value']); @@ -295,11 +295,18 @@ public function test_on_domain_removed_deletes_when_filter_enabled(): void { public function test_test_connection_delegates_to_integration(): void { - $this->integration->expects($this->once()) - ->method('ses_api_call') - ->willReturn(['SendingEnabled' => true]); + $integration = $this->getMockBuilder(Amazon_SES_Integration::class) + ->onlyMethods(['test_connection']) + ->getMock(); + + $integration->expects($this->once()) + ->method('test_connection') + ->willReturn(true); + + $module = new Amazon_SES_Transactional_Email(); + $module->set_integration($integration); - $result = $this->module->test_connection(); + $result = $module->test_connection(); $this->assertTrue($result); } From 023a6c8ec44fea108c36b8d26e59dbb9a8f85d02 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 31 Mar 2026 20:33:10 -0600 Subject: [PATCH 3/3] fix: remove dead do_action('wu_settings_transactional_email') hook The hook at class-settings.php:1640 had no registered callbacks anywhere in the codebase. The transactional email provider note already directs users to the Integrations settings page where provider configuration lives. Removing the unused extension point eliminates dead code. Closes #723 --- inc/class-settings.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/inc/class-settings.php b/inc/class-settings.php index c5ac85bd1..96213c89e 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -1629,16 +1629,6 @@ public function default_sections(): void { ] ); - /** - * Fires to allow transactional email provider settings to be registered. - * - * Use this hook to add settings fields for configuring the active - * transactional email provider (e.g. Amazon SES, SendGrid, Mailgun). - * - * @since 2.5.0 - */ - do_action('wu_settings_transactional_email'); - /* * Domain Mapping * This section holds the Domain Mapping settings of the Ultimate Multisite Plugin.