From 8d035bdd3635f85b88ba8eecf9e008242f67841a Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 13 Oct 2026 10:28:33 +0200 Subject: [PATCH 1/3] Add marketing stats API for GET /api/marketing/stats - New token-scoped `marketingStats()` resource on MailtrapGeneralClient, with no account id, because the endpoint resolves the account from the API token. It follows TrackingOptOut rather than EmailCampaign, which still takes an unused account id. - One MarketingStatsFilter DTO carries the required date range and the optional sending domain, source and ESP lists. Empty lists stay out of the query, and new filters can be added without breaking callers. - Unknown sources throw InvalidArgumentException before the request. The other SDKs reject them through their types, and the API would answer 400. - Responses stay raw PSR-7 with the `data` envelope, as with email campaign stats. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + examples/README.md | 1 + examples/marketing-stats/all.php | 58 +++++++ src/Api/General/MarketingStats.php | 39 +++++ .../MarketingStats/MarketingStatsFilter.php | 64 +++++++ .../MarketingStatsInterface.php | 19 ++ src/MailtrapGeneralClient.php | 22 +-- tests/Api/AbstractApiTest.php | 29 ++++ tests/Api/General/MarketingStatsTest.php | 163 ++++++++++++++++++ 9 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 examples/marketing-stats/all.php create mode 100644 src/Api/General/MarketingStats.php create mode 100644 src/DTO/Request/MarketingStats/MarketingStatsFilter.php create mode 100644 src/DTO/Request/MarketingStats/MarketingStatsInterface.php create mode 100644 tests/Api/General/MarketingStatsTest.php diff --git a/README.md b/README.md index 8bd774f..0ff9cad 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,7 @@ Contact management: Email marketing: - Email campaigns CRUD, listing, lifecycle & stats – [`email-campaigns/all.php`](examples/email-campaigns/all.php) +- Email marketing stats (campaigns & automations) – [`marketing-stats/all.php`](examples/marketing-stats/all.php) General API: - Templates CRUD, paginated (experimental) – [`templates/templates.php`](examples/templates/templates.php) diff --git a/examples/README.md b/examples/README.md index 31177e2..084516e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -60,6 +60,7 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | Contact lists CRUD | [`contact-lists/all.php`](contact-lists/all.php) | | Custom fields CRUD | [`contact-fields/all.php`](contact-fields/all.php) | | Email campaigns CRUD + list + lifecycle + stats | [`email-campaigns/all.php`](email-campaigns/all.php) | +| Email marketing stats (campaigns + automations) | [`marketing-stats/all.php`](marketing-stats/all.php) | ### Templates & Domains diff --git a/examples/marketing-stats/all.php b/examples/marketing-stats/all.php new file mode 100644 index 0000000..e6571b0 --- /dev/null +++ b/examples/marketing-stats/all.php @@ -0,0 +1,58 @@ +marketingStats(); + +// The last 30 days +$today = new DateTimeImmutable(); +$startDate = $today->modify('-30 days')->format('Y-m-d'); +$endDate = $today->format('Y-m-d'); + +/** + * Get aggregated Email Marketing stats (campaigns and automations), wrapped in `data`. + * Delivery and bounce rates are relative to `sent_count`; open, click, spam and + * unsubscription rates to `delivery_count`. + * + * GET https://mailtrap.io/api/marketing/stats + */ +try { + $response = $marketingStats->getMarketingStats( + new MarketingStatsFilter(startDate: $startDate, endDate: $endDate) + ); + + // print the response body (array) + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} + +/** + * Get Email Marketing stats with filters: campaigns only, sent to Google and Yahoo. + * `sendingDomainIds` narrows the stats to the given sending domains. + * + * GET https://mailtrap.io/api/marketing/stats + */ +try { + $response = $marketingStats->getMarketingStats( + new MarketingStatsFilter( + startDate: $startDate, + endDate: $endDate, + sources: [MarketingStatsInterface::SOURCE_CAMPAIGNS], + emailServiceProviders: ['Google', 'Yahoo'], + ) + ); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} diff --git a/src/Api/General/MarketingStats.php b/src/Api/General/MarketingStats.php new file mode 100644 index 0000000..0909b32 --- /dev/null +++ b/src/Api/General/MarketingStats.php @@ -0,0 +1,39 @@ +handleResponse( + $this->httpGet($this->getBasePath(), $filter->toArray()) + ); + } + + private function getBasePath(): string + { + return sprintf('%s/api/marketing/stats', $this->getHost()); + } +} diff --git a/src/DTO/Request/MarketingStats/MarketingStatsFilter.php b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php new file mode 100644 index 0000000..9e3ee1c --- /dev/null +++ b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php @@ -0,0 +1,64 @@ + $this->startDate, + 'end_date' => $this->endDate, + ]; + + if ($this->sendingDomainIds !== []) { + $payload['sending_domain_ids'] = $this->sendingDomainIds; + } + + if ($this->sources !== []) { + $payload['sources'] = $this->sources; + } + + if ($this->emailServiceProviders !== []) { + $payload['email_service_providers'] = $this->emailServiceProviders; + } + + return $payload; + } +} diff --git a/src/DTO/Request/MarketingStats/MarketingStatsInterface.php b/src/DTO/Request/MarketingStats/MarketingStatsInterface.php new file mode 100644 index 0000000..9d90b42 --- /dev/null +++ b/src/DTO/Request/MarketingStats/MarketingStatsInterface.php @@ -0,0 +1,19 @@ + Api\General\ApiToken::class, 'organization' => Api\General\Organization::class, 'emailCampaigns' => Api\General\EmailCampaign::class, + 'marketingStats' => Api\General\MarketingStats::class, ]; } diff --git a/tests/Api/AbstractApiTest.php b/tests/Api/AbstractApiTest.php index ff82990..4c7740d 100644 --- a/tests/Api/AbstractApiTest.php +++ b/tests/Api/AbstractApiTest.php @@ -5,7 +5,10 @@ namespace Mailtrap\Tests\Api; use Mailtrap\Api\AbstractApi; +use Mailtrap\Api\General\MarketingStats; use Mailtrap\Api\Sending\EmailLogs; +use Mailtrap\DTO\Request\MarketingStats\MarketingStatsFilter; +use Mailtrap\DTO\Request\MarketingStats\MarketingStatsInterface; use Mailtrap\Tests\MailtrapTestCase; use ReflectionClass; @@ -42,4 +45,30 @@ public function testNormalizeArrayParamsUsesBracketNotationForMultipleValues(): 'Query must use filters[category][value][]=Cat1&filters[category][value][]=Cat2' ); } + + /** + * Ensures top-level list filters (such as the marketing stats `sources[]`) are sent as + * key[]=a&key[]=b, with empty lists left out of the query by the filter DTO. + */ + public function testNormalizeArrayParamsUsesBracketNotationForListFilters(): void + { + $params = (new MarketingStatsFilter( + startDate: '2026-05-01', + endDate: '2026-05-31', + sources: [MarketingStatsInterface::SOURCE_CAMPAIGNS, MarketingStatsInterface::SOURCE_AUTOMATIONS], + emailServiceProviders: ['Google'], + ))->toArray(); + $api = new MarketingStats($this->getConfigMock()); + $reflection = new ReflectionClass(AbstractApi::class); + $method = $reflection->getMethod('normalizeArrayParams'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + + $this->assertSame( + 'start_date=2026-05-01&end_date=2026-05-31&sources[]=campaigns&sources[]=automations' + . '&email_service_providers[]=Google', + urldecode($method->invoke($api, $params)) + ); + } } diff --git a/tests/Api/General/MarketingStatsTest.php b/tests/Api/General/MarketingStatsTest.php new file mode 100644 index 0000000..3bba5ff --- /dev/null +++ b/tests/Api/General/MarketingStatsTest.php @@ -0,0 +1,163 @@ +marketingStats = $this->getMockBuilder(MarketingStats::class) + ->onlyMethods(['httpGet']) + ->setConstructorArgs([$this->getConfigMock()]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->marketingStats = null; + parent::tearDown(); + } + + public function testGetMarketingStats(): void + { + $this->marketingStats->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_PATH, ['start_date' => self::START_DATE, 'end_date' => self::END_DATE]) + ->willReturn($this->getStatsResponse()); + + $response = $this->marketingStats->getMarketingStats( + new MarketingStatsFilter(startDate: self::START_DATE, endDate: self::END_DATE) + ); + $responseData = ResponseHelper::toArray($response); + + // stats are wrapped in a `data` envelope + $this->assertSame(200, $response->getStatusCode()); + $this->assertArrayHasKey('data', $responseData); + $this->assertEquals(1450, $responseData['data']['delivery_count']); + $this->assertEquals(0.9667, $responseData['data']['delivery_rate']); + $this->assertEquals(1500, $responseData['data']['sent_count']); + } + + public function testGetMarketingStatsWithFilters(): void + { + $this->marketingStats->expects($this->once()) + ->method('httpGet') + ->with( + self::BASE_PATH, + [ + 'start_date' => self::START_DATE, + 'end_date' => self::END_DATE, + 'sending_domain_ids' => [self::FAKE_SENDING_DOMAIN_ID], + 'sources' => [MarketingStatsInterface::SOURCE_CAMPAIGNS], + 'email_service_providers' => ['Google', 'Yahoo'], + ] + ) + ->willReturn($this->getStatsResponse()); + + $response = $this->marketingStats->getMarketingStats( + new MarketingStatsFilter( + startDate: self::START_DATE, + endDate: self::END_DATE, + sendingDomainIds: [self::FAKE_SENDING_DOMAIN_ID], + sources: [MarketingStatsInterface::SOURCE_CAMPAIGNS], + emailServiceProviders: ['Google', 'Yahoo'], + ) + ); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testMarketingStatsFilterRejectsUnknownSource(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"sources" must contain only "campaigns", "automations", "newsletters" given'); + + new MarketingStatsFilter(startDate: self::START_DATE, endDate: self::END_DATE, sources: ['newsletters']); + } + + public function testGetMarketingStatsInvalidDate(): void + { + $this->marketingStats->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_PATH, ['start_date' => 'yesterday', 'end_date' => self::END_DATE]) + ->willReturn( + new Response( + 400, + ['Content-Type' => 'application/json'], + json_encode(['errors' => "Invalid start_date: 'yesterday'"]) + ) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage("Invalid start_date: 'yesterday'"); + + $this->marketingStats->getMarketingStats( + new MarketingStatsFilter(startDate: 'yesterday', endDate: self::END_DATE) + ); + } + + public function testGetMarketingStatsForbidden(): void + { + $this->marketingStats->expects($this->once()) + ->method('httpGet') + ->willReturn( + new Response(403, ['Content-Type' => 'application/json'], json_encode(['errors' => 'Access forbidden'])) + ); + + $this->expectException(HttpClientException::class); + + $this->marketingStats->getMarketingStats( + new MarketingStatsFilter(startDate: self::START_DATE, endDate: self::END_DATE) + ); + } + + private function getStatsResponse(): Response + { + return new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'data' => [ + 'delivery_count' => 1450, + 'delivery_rate' => 0.9667, + 'bounce_count' => 30, + 'bounce_rate' => 0.02, + 'open_count' => 820, + 'open_rate' => 0.5655, + 'click_count' => 310, + 'click_rate' => 0.2138, + 'spam_count' => 5, + 'spam_rate' => 0.0033, + 'unsubscription_count' => 12, + 'unsubscription_rate' => 0.0083, + 'sent_count' => 1500, + ], + ]) + ); + } +} From 9d0a47c4b8b1a28a887b234bc28756ca6df350a3 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 13 Oct 2026 11:58:52 +0200 Subject: [PATCH 2/3] Tidy marketing stats filter query and its tests after review Decisions: - Separate the rejected source from the allowed ones in the error message; it read as if the bad value were allowed - Send list filters through array_values() so string-keyed input still goes out as key[]= - Build toArray() with array_filter, the idiom the other request DTOs use, instead of three copy-pasted ifs - Test normalizeArrayParams with a plain array, not the marketing stats DTO, so a DTO change can't break the shared helper's test - Correct the example comment, which mentioned a sendingDomainIds filter its call doesn't pass Co-Authored-By: Claude Opus 5.5 --- examples/marketing-stats/all.php | 2 +- .../MarketingStats/MarketingStatsFilter.php | 30 +++++------- tests/Api/AbstractApiTest.php | 48 ++++++++----------- tests/Api/General/MarketingStatsTest.php | 2 +- 4 files changed, 34 insertions(+), 48 deletions(-) diff --git a/examples/marketing-stats/all.php b/examples/marketing-stats/all.php index e6571b0..a366a6d 100644 --- a/examples/marketing-stats/all.php +++ b/examples/marketing-stats/all.php @@ -38,7 +38,7 @@ /** * Get Email Marketing stats with filters: campaigns only, sent to Google and Yahoo. - * `sendingDomainIds` narrows the stats to the given sending domains. + * Pass `sendingDomainIds` as well to narrow the stats to specific sending domains. * * GET https://mailtrap.io/api/marketing/stats */ diff --git a/src/DTO/Request/MarketingStats/MarketingStatsFilter.php b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php index 9e3ee1c..b31800d 100644 --- a/src/DTO/Request/MarketingStats/MarketingStatsFilter.php +++ b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php @@ -32,7 +32,7 @@ public function __construct( foreach ($sources as $source) { if (!in_array($source, self::SOURCES, true)) { throw new InvalidArgumentException(sprintf( - '"sources" must contain only "%s", "%s" given', + '"sources" must contain only "%s"; "%s" given', implode('", "', self::SOURCES), $source )); @@ -42,23 +42,15 @@ public function __construct( public function toArray(): array { - $payload = [ - 'start_date' => $this->startDate, - 'end_date' => $this->endDate, - ]; - - if ($this->sendingDomainIds !== []) { - $payload['sending_domain_ids'] = $this->sendingDomainIds; - } - - if ($this->sources !== []) { - $payload['sources'] = $this->sources; - } - - if ($this->emailServiceProviders !== []) { - $payload['email_service_providers'] = $this->emailServiceProviders; - } - - return $payload; + return array_filter( + [ + 'start_date' => $this->startDate, + 'end_date' => $this->endDate, + 'sending_domain_ids' => array_values($this->sendingDomainIds), + 'sources' => array_values($this->sources), + 'email_service_providers' => array_values($this->emailServiceProviders), + ], + fn ($value) => $value !== [] + ); } } diff --git a/tests/Api/AbstractApiTest.php b/tests/Api/AbstractApiTest.php index 4c7740d..374abbc 100644 --- a/tests/Api/AbstractApiTest.php +++ b/tests/Api/AbstractApiTest.php @@ -5,10 +5,7 @@ namespace Mailtrap\Tests\Api; use Mailtrap\Api\AbstractApi; -use Mailtrap\Api\General\MarketingStats; use Mailtrap\Api\Sending\EmailLogs; -use Mailtrap\DTO\Request\MarketingStats\MarketingStatsFilter; -use Mailtrap\DTO\Request\MarketingStats\MarketingStatsInterface; use Mailtrap\Tests\MailtrapTestCase; use ReflectionClass; @@ -29,13 +26,7 @@ public function testNormalizeArrayParamsUsesBracketNotationForMultipleValues(): 'category' => ['operator' => 'equal', 'value' => ['Cat1', 'Cat2']], ], ]; - $api = new EmailLogs($this->getConfigMock(), self::FAKE_ACCOUNT_ID); - $reflection = new ReflectionClass(AbstractApi::class); - $method = $reflection->getMethod('normalizeArrayParams'); - if (PHP_VERSION_ID < 80100) { - $method->setAccessible(true); - } - $queryString = $method->invoke($api, $params); + $queryString = $this->normalizeArrayParams($params); $this->assertStringNotContainsString('[0]', $queryString, 'Numeric indices must be normalized to bracket notation'); $this->assertStringNotContainsString('[1]', $queryString, 'Numeric indices must be normalized to bracket notation'); @@ -47,28 +38,31 @@ public function testNormalizeArrayParamsUsesBracketNotationForMultipleValues(): } /** - * Ensures top-level list filters (such as the marketing stats `sources[]`) are sent as - * key[]=a&key[]=b, with empty lists left out of the query by the filter DTO. + * Ensures top-level list params (such as the marketing stats `sources[]`) are sent as + * key[]=a&key[]=b, whatever their numeric indices. */ - public function testNormalizeArrayParamsUsesBracketNotationForListFilters(): void + public function testNormalizeArrayParamsUsesBracketNotationForListParams(): void { - $params = (new MarketingStatsFilter( - startDate: '2026-05-01', - endDate: '2026-05-31', - sources: [MarketingStatsInterface::SOURCE_CAMPAIGNS, MarketingStatsInterface::SOURCE_AUTOMATIONS], - emailServiceProviders: ['Google'], - ))->toArray(); - $api = new MarketingStats($this->getConfigMock()); - $reflection = new ReflectionClass(AbstractApi::class); - $method = $reflection->getMethod('normalizeArrayParams'); + $params = [ + 'start_date' => '2026-05-01', + 'sources' => ['campaigns', 'automations'], + 'sending_domain_ids' => [3 => 10, 7 => 20], + ]; + + $this->assertSame( + 'start_date=2026-05-01&sources[]=campaigns&sources[]=automations' + . '&sending_domain_ids[]=10&sending_domain_ids[]=20', + urldecode($this->normalizeArrayParams($params)) + ); + } + + private function normalizeArrayParams(array $params): string + { + $method = (new ReflectionClass(AbstractApi::class))->getMethod('normalizeArrayParams'); if (PHP_VERSION_ID < 80100) { $method->setAccessible(true); } - $this->assertSame( - 'start_date=2026-05-01&end_date=2026-05-31&sources[]=campaigns&sources[]=automations' - . '&email_service_providers[]=Google', - urldecode($method->invoke($api, $params)) - ); + return $method->invoke(new EmailLogs($this->getConfigMock(), self::FAKE_ACCOUNT_ID), $params); } } diff --git a/tests/Api/General/MarketingStatsTest.php b/tests/Api/General/MarketingStatsTest.php index 3bba5ff..b99a78a 100644 --- a/tests/Api/General/MarketingStatsTest.php +++ b/tests/Api/General/MarketingStatsTest.php @@ -95,7 +95,7 @@ public function testGetMarketingStatsWithFilters(): void public function testMarketingStatsFilterRejectsUnknownSource(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('"sources" must contain only "campaigns", "automations", "newsletters" given'); + $this->expectExceptionMessage('"sources" must contain only "campaigns", "automations"; "newsletters" given'); new MarketingStatsFilter(startDate: self::START_DATE, endDate: self::END_DATE, sources: ['newsletters']); } From ed306741e88f14d02e9fbe79ace4f0c8dfdcd4ab Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 13 Oct 2026 12:49:54 +0200 Subject: [PATCH 3/3] Polish marketing stats after AI review Decisions: - Report a non-string source by its type in the error, so a bad value raises InvalidArgumentException instead of a PHP conversion error - Inline the single-use base path helper, as the token-scoped Account API does - Start the example range 29 days back: both dates are inclusive, so -30 days covered 31 dates, not the 30 its comment promises Co-Authored-By: Claude Opus 5.5 --- examples/marketing-stats/all.php | 4 ++-- src/Api/General/MarketingStats.php | 7 +------ src/DTO/Request/MarketingStats/MarketingStatsFilter.php | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/examples/marketing-stats/all.php b/examples/marketing-stats/all.php index a366a6d..588d6a3 100644 --- a/examples/marketing-stats/all.php +++ b/examples/marketing-stats/all.php @@ -13,9 +13,9 @@ $config = new Config($_ENV['MAILTRAP_API_KEY']); // your API token from https://mailtrap.io/api-tokens $marketingStats = (new MailtrapGeneralClient($config))->marketingStats(); -// The last 30 days +// The last 30 days, today included (both dates are inclusive) $today = new DateTimeImmutable(); -$startDate = $today->modify('-30 days')->format('Y-m-d'); +$startDate = $today->modify('-29 days')->format('Y-m-d'); $endDate = $today->format('Y-m-d'); /** diff --git a/src/Api/General/MarketingStats.php b/src/Api/General/MarketingStats.php index 0909b32..048220b 100644 --- a/src/Api/General/MarketingStats.php +++ b/src/Api/General/MarketingStats.php @@ -28,12 +28,7 @@ class MarketingStats extends AbstractApi implements GeneralInterface public function getMarketingStats(MarketingStatsFilter $filter): ResponseInterface { return $this->handleResponse( - $this->httpGet($this->getBasePath(), $filter->toArray()) + $this->httpGet($this->getHost() . '/api/marketing/stats', $filter->toArray()) ); } - - private function getBasePath(): string - { - return sprintf('%s/api/marketing/stats', $this->getHost()); - } } diff --git a/src/DTO/Request/MarketingStats/MarketingStatsFilter.php b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php index b31800d..8ec2823 100644 --- a/src/DTO/Request/MarketingStats/MarketingStatsFilter.php +++ b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php @@ -34,7 +34,7 @@ public function __construct( throw new InvalidArgumentException(sprintf( '"sources" must contain only "%s"; "%s" given', implode('", "', self::SOURCES), - $source + is_string($source) ? $source : get_debug_type($source) )); } }