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..588d6a3 --- /dev/null +++ b/examples/marketing-stats/all.php @@ -0,0 +1,58 @@ +marketingStats(); + +// The last 30 days, today included (both dates are inclusive) +$today = new DateTimeImmutable(); +$startDate = $today->modify('-29 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. + * Pass `sendingDomainIds` as well to narrow the stats to specific 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..048220b --- /dev/null +++ b/src/Api/General/MarketingStats.php @@ -0,0 +1,34 @@ +handleResponse( + $this->httpGet($this->getHost() . '/api/marketing/stats', $filter->toArray()) + ); + } +} diff --git a/src/DTO/Request/MarketingStats/MarketingStatsFilter.php b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php new file mode 100644 index 0000000..8ec2823 --- /dev/null +++ b/src/DTO/Request/MarketingStats/MarketingStatsFilter.php @@ -0,0 +1,56 @@ + $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/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..374abbc 100644 --- a/tests/Api/AbstractApiTest.php +++ b/tests/Api/AbstractApiTest.php @@ -26,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'); @@ -42,4 +36,33 @@ public function testNormalizeArrayParamsUsesBracketNotationForMultipleValues(): 'Query must use filters[category][value][]=Cat1&filters[category][value][]=Cat2' ); } + + /** + * Ensures top-level list params (such as the marketing stats `sources[]`) are sent as + * key[]=a&key[]=b, whatever their numeric indices. + */ + public function testNormalizeArrayParamsUsesBracketNotationForListParams(): void + { + $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); + } + + 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 new file mode 100644 index 0000000..b99a78a --- /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, + ], + ]) + ); + } +}