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);
+ }
+}