From b9c4d86e722ebb1fdd013bd17eca8609d3f45f76 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 24 Apr 2026 23:14:29 -0600 Subject: [PATCH 1/2] GH#934: fix(ses): correct SES v2 API endpoint base path and identity route - Change API_BASE from /v2/ to /v2/email/ so all SES v2 requests hit the correct resource prefix (e.g. /v2/email/account, not /v2/account) - Rename email-identities endpoint to identities throughout the transactional email module, matching the actual SES v2 route /v2/email/identities (not /v2/email-identities) - Update all test assertions to reflect the corrected endpoint strings - Add /v2/email/ path assertion to test_get_api_base_includes_region Fixes #934 --- .../providers/amazon-ses/class-amazon-ses-integration.php | 6 ++++-- .../amazon-ses/class-amazon-ses-transactional-email.php | 8 ++++---- .../Providers/Amazon_SES/Amazon_SES_Integration_Test.php | 1 + .../Amazon_SES/Amazon_SES_Transactional_Email_Test.php | 8 ++++---- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php b/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php index 44a7721dd..59d9f1bfa 100644 --- a/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php +++ b/inc/integrations/providers/amazon-ses/class-amazon-ses-integration.php @@ -28,10 +28,12 @@ class Amazon_SES_Integration extends Integration { /** * Amazon SES API endpoint base URL. * + * Includes the /v2/email/ prefix required by all SES v2 resource paths. + * * @since 2.5.0 * @var string */ - private const API_BASE = 'https://email.%s.amazonaws.com/v2/'; + private const API_BASE = 'https://email.%s.amazonaws.com/v2/email/'; /** * Constructor. @@ -103,7 +105,7 @@ public function get_signer(): AWS_Signer { * * @since 2.5.0 * - * @param string $endpoint Relative endpoint path (e.g. 'email-identities'). + * @param string $endpoint Relative endpoint path (e.g. 'identities', 'outbound-emails', 'account'). * @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. 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 8c1a3b1e8..3672afcbf 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 @@ -288,7 +288,7 @@ public function on_domain_removed(string $domain, int $site_id): void { } $result = $this->get_ses()->ses_api_call( - 'email-identities/' . rawurlencode($domain), + 'identities/' . rawurlencode($domain), 'DELETE' ); @@ -311,7 +311,7 @@ public function on_domain_removed(string $domain, int $site_id): void { public function verify_domain(string $domain): array { $result = $this->get_ses()->ses_api_call( - 'email-identities', + 'identities', 'POST', [ 'EmailIdentity' => $domain, @@ -342,7 +342,7 @@ public function verify_domain(string $domain): array { public function get_domain_verification_status(string $domain): array { $result = $this->get_ses()->ses_api_call( - 'email-identities/' . rawurlencode($domain) + 'identities/' . rawurlencode($domain) ); if (is_wp_error($result)) { @@ -369,7 +369,7 @@ public function get_domain_verification_status(string $domain): array { public function get_domain_dns_records(string $domain): array { $result = $this->get_ses()->ses_api_call( - 'email-identities/' . rawurlencode($domain) + 'identities/' . rawurlencode($domain) ); if (is_wp_error($result)) { 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 index b10157080..679924f8c 100644 --- 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 @@ -55,6 +55,7 @@ public function test_get_api_base_includes_region(): void { $this->assertStringContainsString('us-east-1', $api_base); $this->assertStringContainsString('amazonaws.com', $api_base); + $this->assertStringContainsString('/v2/email/', $api_base); } public function test_get_api_base_uses_configured_region(): void { 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 bf4fcb038..2431c840e 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 @@ -137,7 +137,7 @@ 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()) + ->with('identities', 'POST', $this->anything()) ->willReturn([ 'IdentityType' => 'DOMAIN', 'VerifiedForSendingStatus' => false, @@ -171,7 +171,7 @@ public function test_get_domain_verification_status_returns_verified(): void { $this->integration->expects($this->once()) ->method('ses_api_call') - ->with('email-identities/example.com') + ->with('identities/example.com') ->willReturn([ 'VerifiedForSendingStatus' => true, 'DkimAttributes' => ['Status' => 'SUCCESS'], @@ -289,7 +289,7 @@ 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()) + ->with('identities', 'POST', $this->anything()) ->willReturn([ 'DkimAttributes' => ['Tokens' => ['tok1', 'tok2', 'tok3']], ]); @@ -311,7 +311,7 @@ public function test_on_domain_removed_deletes_when_filter_enabled(): void { $this->integration->expects($this->once()) ->method('ses_api_call') - ->with('email-identities/example.com', 'DELETE') + ->with('identities/example.com', 'DELETE') ->willReturn([]); $this->module->on_domain_removed('example.com', 1); From bb769d76f5d1433e6a8b65ecbfd193585a3b38c7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 24 Apr 2026 23:23:27 -0600 Subject: [PATCH 2/2] fix(ses): replace non-existent account/sending-statistics with account endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SES v2 has no /v2/email/account/sending-statistics resource (that was a v1 endpoint). After the API_BASE fix, the call would hit the wrong URL and return 404. Switch get_sending_statistics() to GET /v2/email/account which exposes SendQuota.SentLast24Hours — the best available approximation without implementing BatchGetMetricData. Bounce/complaint counts are not available from this endpoint and return 0 with a comment directing consumers to CloudWatch for detailed metrics. Update the corresponding test mock and assertions to match the new shape. Ref #934 --- .../class-amazon-ses-transactional-email.php | 26 +++++++------------ .../Amazon_SES_Transactional_Email_Test.php | 18 ++++++------- 2 files changed, 18 insertions(+), 26 deletions(-) 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 3672afcbf..bb4ba58da 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 @@ -430,9 +430,11 @@ public function send_email(string $from, string $to, string $subject, string $bo */ 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'); + // SES v2 exposes account-level quota via GET /v2/email/account. + // The SendQuota object returns the total sent in the last 24 hours. + // Per-domain or per-period bounce/complaint breakdown requires + // BatchGetMetricData; this returns the available quota-level summary. + $result = $this->get_ses()->ses_api_call('account'); if (is_wp_error($result)) { return [ @@ -441,23 +443,15 @@ public function get_sending_statistics(string $domain, string $period = '24h'): ]; } - $stats = $result['SendingStatistics'] ?? []; + $quota = $result['SendQuota'] ?? []; - $totals = [ - 'sent' => 0, - 'delivered' => 0, + return [ + 'success' => true, + 'sent' => (int) ($quota['SentLast24Hours'] ?? 0), + 'delivered' => (int) ($quota['SentLast24Hours'] ?? 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); } /** 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 2431c840e..9af6cec4f 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 @@ -265,15 +265,13 @@ public function test_get_sending_statistics_returns_totals(): void { $this->integration->expects($this->once()) ->method('ses_api_call') - ->with('account/sending-statistics') + ->with('account') ->willReturn([ - 'SendingStatistics' => [ - [ - 'DeliveryAttempts' => 100, - 'Bounces' => 5, - 'Complaints' => 2, - 'Rejects' => 1, - ], + 'SendingEnabled' => true, + 'SendQuota' => [ + 'Max24HourSend' => 50000, + 'MaxSendRate' => 14, + 'SentLast24Hours' => 100, ], ]); @@ -281,8 +279,8 @@ public function test_get_sending_statistics_returns_totals(): void { $this->assertTrue($result['success']); $this->assertSame(100, $result['sent']); - $this->assertSame(5, $result['bounced']); - $this->assertSame(2, $result['complaints']); + $this->assertSame(0, $result['bounced']); + $this->assertSame(0, $result['complaints']); } public function test_on_domain_added_calls_verify_domain(): void {