diff --git a/inc/class-settings.php b/inc/class-settings.php index 171abfc26..96213c89e 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -1605,6 +1605,30 @@ 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, + ] + ); + + $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'])) + ), + ] + ); + /* * 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..eb971f7a2 --- /dev/null +++ b/inc/helpers/class-aws-signer.php @@ -0,0 +1,221 @@ +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). + * @return array{ + * Authorization: string, + * x-amz-date: string, + * x-amz-content-sha256: string + * } + */ + public function sign(string $method, string $url, string $payload = ''): 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..92f18171e --- /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..06e077783 --- /dev/null +++ b/inc/integrations/providers/amazon-ses/class-amazon-ses-transactional-email.php @@ -0,0 +1,614 @@ + [ + '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, + 'dns_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 = wp_strip_all_tags($message) !== $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). + 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..97bd01de0 --- /dev/null +++ b/tests/WP_Ultimo/Integrations/Providers/Amazon_SES/Amazon_SES_Transactional_Email_Test.php @@ -0,0 +1,313 @@ +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_original_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['dns_records']); + + foreach ($result['dns_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 { + + $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 = $module->test_connection(); + + $this->assertTrue($result); + } +}