diff --git a/README.md b/README.md index 3f41bcb..e5f6f07 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,8 @@ Email marketing: - Email campaigns CRUD, listing, lifecycle & stats – [`email-campaigns/all.php`](examples/email-campaigns/all.php) General API: -- Templates CRUD – [`templates/all.php`](examples/templates/all.php) +- Templates CRUD, paginated (experimental) – [`templates/templates.php`](examples/templates/templates.php) +- Email templates CRUD – [`templates/all.php`](examples/templates/all.php) - API tokens CRUD – [`api-tokens/all.php`](examples/api-tokens/all.php) - Billing info – [`general/billing.php`](examples/general/billing.php) - Accounts info – [`general/accounts.php`](examples/general/accounts.php) diff --git a/examples/README.md b/examples/README.md index 9c791c6..7b17155 100644 --- a/examples/README.md +++ b/examples/README.md @@ -64,7 +64,8 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature | Purpose | File | |---------|------| -| Templates CRUD | [`templates/all.php`](templates/all.php) | +| Templates CRUD + paginated list (experimental) | [`templates/templates.php`](templates/templates.php) | +| Email templates CRUD | [`templates/all.php`](templates/all.php) | | Sending domains CRUD | [`sending-domains/all.php`](sending-domains/all.php) | | Sending domain company info | [`sending-domains/company-info.php`](sending-domains/company-info.php) | diff --git a/examples/templates/all.php b/examples/templates/all.php index 3d6c405..bbe4900 100644 --- a/examples/templates/all.php +++ b/examples/templates/all.php @@ -7,6 +7,7 @@ use Mailtrap\Helper\ResponseHelper; use Mailtrap\MailtrapGeneralClient; +// This covers the /api/email_templates API; see templates.php for the paginated, experimental /api/templates API. require __DIR__ . '/../../vendor/autoload.php'; $accountId = $_ENV['MAILTRAP_ACCOUNT_ID']; @@ -53,8 +54,8 @@ $response = $emailTemplates->createEmailTemplate( EmailTemplate::init( 'Welcome Email', // Name - 'Welcome to our service!', // Subject 'Transactional', // Category + 'Welcome to our service!', // Subject 'Welcome to our service!', // Text Body '
Welcome to our service!
' // HTML Body ) @@ -78,8 +79,8 @@ $templateId, EmailTemplate::init( 'Updated Welcome Email', // Name - 'Updated Subject', // Subject 'Transactional', // Category + 'Updated Subject', // Subject 'Updated Text Body', // Text Body '
Updated HTML Body
', // HTML Body ) diff --git a/examples/templates/templates.php b/examples/templates/templates.php new file mode 100644 index 0000000..a8daaf5 --- /dev/null +++ b/examples/templates/templates.php @@ -0,0 +1,97 @@ +templates($accountId); + +/** + * Get a paginated list of templates. + * Optional filters: $perPage (max 100, default 50), $token (page number). + * + * The response is wrapped in `{ data: [...], pagination }`. + * + * GET https://mailtrap.io/api/accounts/{account_id}/templates + */ +try { + $response = $templates->getTemplates(perPage: 50, token: 1); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} + +/** + * Create a new template (wrapped in `data`). + * `name`, `subject` and `category` are required. + * + * POST https://mailtrap.io/api/accounts/{account_id}/templates + */ +try { + $response = $templates->createTemplate(new CreateTemplate( + name: 'Welcome Email', + subject: 'Welcome to our service!', + category: 'Transactional', + bodyHtml: '
Welcome to our service!
', + bodyText: 'Welcome to our service!', + )); + + $template = ResponseHelper::toArray($response); + $templateId = $template['data']['id']; // reused by all the examples below + + var_dump($template); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; + exit(1); +} + +/** + * Get a template by ID (wrapped in `data`). + * + * GET https://mailtrap.io/api/accounts/{account_id}/templates/{template_id} + */ +try { + $response = $templates->getTemplate($templateId); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} + +/** + * Update a template (PATCH, partial: only the provided fields are sent). + * + * PATCH https://mailtrap.io/api/accounts/{account_id}/templates/{template_id} + */ +try { + $response = $templates->updateTemplate( + $templateId, + new UpdateTemplate(subject: 'Updated subject', bodyHtml: '
Updated HTML body
') + ); + + var_dump(ResponseHelper::toArray($response)); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} + +/** + * Delete a template. Returns `204 No Content`. + * + * DELETE https://mailtrap.io/api/accounts/{account_id}/templates/{template_id} + */ +try { + $response = $templates->deleteTemplate($templateId); + + var_dump($response->getStatusCode()); +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), PHP_EOL; +} diff --git a/src/Api/General/EmailTemplate.php b/src/Api/General/EmailTemplate.php index a037033..d85b64b 100644 --- a/src/Api/General/EmailTemplate.php +++ b/src/Api/General/EmailTemplate.php @@ -11,6 +11,8 @@ /** * Class EmailTemplate + * + * For the paginated, experimental templates endpoints, see {@see Template}. */ class EmailTemplate extends AbstractApi implements GeneralInterface { diff --git a/src/Api/General/Template.php b/src/Api/General/Template.php new file mode 100644 index 0000000..efe0361 --- /dev/null +++ b/src/Api/General/Template.php @@ -0,0 +1,118 @@ +handleResponse( + $this->httpGet($this->getBasePath(), $parameters) + ); + } + + /** + * Get a template by ID. The template is wrapped in `data`. + * + * @param int $templateId + * @return ResponseInterface + */ + public function getTemplate(int $templateId): ResponseInterface + { + return $this->handleResponse( + $this->httpGet($this->getBasePath() . '/' . $templateId) + ); + } + + /** + * Create a new template. The template is wrapped in `data`. + * + * @param CreateTemplate $template + * @return ResponseInterface + */ + public function createTemplate(CreateTemplate $template): ResponseInterface + { + return $this->handleResponse( + $this->httpPost( + path: $this->getBasePath(), + body: $template->toArray() + ) + ); + } + + /** + * Update an existing template (PATCH, partial). The template is wrapped in `data`. + * + * @param int $templateId + * @param UpdateTemplate $template + * @return ResponseInterface + */ + public function updateTemplate(int $templateId, UpdateTemplate $template): ResponseInterface + { + return $this->handleResponse( + $this->httpPatch( + path: $this->getBasePath() . '/' . $templateId, + body: $template->toArray() + ) + ); + } + + /** + * Delete a template. Returns `204 No Content` with an empty body. + * + * @param int $templateId + * @return ResponseInterface + */ + public function deleteTemplate(int $templateId): ResponseInterface + { + return $this->handleResponse( + $this->httpDelete($this->getBasePath() . '/' . $templateId) + ); + } + + private function getBasePath(): string + { + return sprintf('%s/api/accounts/%s/templates', $this->getHost(), $this->accountId); + } +} diff --git a/src/DTO/Request/Template/CreateTemplate.php b/src/DTO/Request/Template/CreateTemplate.php new file mode 100644 index 0000000..2257b07 --- /dev/null +++ b/src/DTO/Request/Template/CreateTemplate.php @@ -0,0 +1,45 @@ + $this->name, + 'subject' => $this->subject, + 'category' => $this->category, + 'body_html' => $this->bodyHtml, + 'body_text' => $this->bodyText, + ], + fn ($value) => $value !== null + ); + } +} diff --git a/src/DTO/Request/Template/UpdateTemplate.php b/src/DTO/Request/Template/UpdateTemplate.php new file mode 100644 index 0000000..a48b655 --- /dev/null +++ b/src/DTO/Request/Template/UpdateTemplate.php @@ -0,0 +1,55 @@ + $this->name, + 'subject' => $this->subject, + 'category' => $this->category, + 'body_html' => $this->bodyHtml, + 'body_text' => $this->bodyText, + ], + fn ($value) => $value !== null + ); + + if ($payload === []) { + throw new RuntimeException('At least one attribute must be provided to update a template.'); + } + + return $payload; + } +} diff --git a/src/MailtrapGeneralClient.php b/src/MailtrapGeneralClient.php index d11dc4b..809c923 100644 --- a/src/MailtrapGeneralClient.php +++ b/src/MailtrapGeneralClient.php @@ -10,6 +10,7 @@ * @method Api\General\Permission permissions(int $accountId) * @method Api\General\Contact contacts(int $accountId) * @method Api\General\EmailTemplate emailTemplates(int $accountId) + * @method Api\General\Template templates(int $accountId) * @method Api\General\Billing billing(int $accountId) * @method Api\General\ApiToken apiTokens(int $accountId) * @method Api\General\Organization organization(int $organizationId) @@ -25,6 +26,7 @@ final class MailtrapGeneralClient extends AbstractMailtrapClient 'permissions' => Api\General\Permission::class, 'contacts' => Api\General\Contact::class, 'emailTemplates' => Api\General\EmailTemplate::class, + 'templates' => Api\General\Template::class, 'billing' => Api\General\Billing::class, 'apiTokens' => Api\General\ApiToken::class, 'organization' => Api\General\Organization::class, diff --git a/tests/Api/General/TemplateTest.php b/tests/Api/General/TemplateTest.php new file mode 100644 index 0000000..6e8c535 --- /dev/null +++ b/tests/Api/General/TemplateTest.php @@ -0,0 +1,300 @@ +template = $this->getMockBuilder(Template::class) + ->onlyMethods(['httpGet', 'httpPost', 'httpPatch', 'httpDelete']) + ->setConstructorArgs([$this->getConfigMock(), self::FAKE_ACCOUNT_ID]) + ->getMock(); + } + + protected function tearDown(): void + { + $this->template = null; + parent::tearDown(); + } + + public function testGetTemplates(): void + { + $this->template->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_PATH, []) + ->willReturn( + new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'data' => [$this->getExpectedTemplateResponse()], + 'pagination' => $this->getExpectedPagination(), + ]) + ) + ); + + $response = $this->template->getTemplates(); + $responseData = ResponseHelper::toArray($response); + + $this->assertArrayHasKey('data', $responseData); + $this->assertArrayHasKey('pagination', $responseData); + $this->assertCount(1, $responseData['data']); + $this->assertEquals(4567, $responseData['data'][0]['id']); + $this->assertEquals(1, $responseData['pagination']['token']); + $this->assertEquals(2, $responseData['pagination']['next_token']); + } + + public function testGetTemplatesWithFilters(): void + { + $this->template->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_PATH, ['per_page' => 25, 'token' => 2]) + ->willReturn( + new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'data' => [$this->getExpectedTemplateResponse()], + 'pagination' => $this->getExpectedPagination(), + ]) + ) + ); + + $response = $this->template->getTemplates(perPage: 25, token: 2); + $responseData = ResponseHelper::toArray($response); + + $this->assertCount(1, $responseData['data']); + } + + public function testGetTemplate(): void + { + $templateId = 4567; + + $this->template->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_PATH . '/' . $templateId) + ->willReturn( + new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getExpectedTemplateResponse()]) + ) + ); + + $response = $this->template->getTemplate($templateId); + $responseData = ResponseHelper::toArray($response); + + $this->assertArrayHasKey('data', $responseData); + $this->assertEquals($templateId, $responseData['data']['id']); + $this->assertEquals('Welcome Email', $responseData['data']['name']); + } + + public function testGetTemplateFailsWithNotFoundError(): void + { + $templateId = 999; + + $this->template->expects($this->once()) + ->method('httpGet') + ->with(self::BASE_PATH . '/' . $templateId) + ->willReturn( + new Response(404, ['Content-Type' => 'application/json'], json_encode(['error' => 'Not Found'])) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('The requested entity has not been found. Errors: Not Found.'); + + $this->template->getTemplate($templateId); + } + + public function testCreateTemplate(): void + { + $createDto = $this->getCreateTemplateDTO(); + + $this->template->expects($this->once()) + ->method('httpPost') + ->with( + self::BASE_PATH, + [], + $createDto->toArray() + ) + ->willReturn( + new Response( + 201, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getExpectedTemplateResponse()]) + ) + ); + + $response = $this->template->createTemplate($createDto); + $responseData = ResponseHelper::toArray($response); + + $this->assertEquals(201, $response->getStatusCode()); + $this->assertArrayHasKey('data', $responseData); + $this->assertEquals('Welcome Email', $responseData['data']['name']); + } + + public function testCreateTemplateSerializesFlatBody(): void + { + $this->assertEquals( + [ + 'name' => 'Welcome Email', + 'subject' => 'Welcome to our service!', + 'category' => 'Transactional', + 'body_html' => '
Welcome to our service!
', + 'body_text' => 'Welcome to our service!', + ], + $this->getCreateTemplateDTO()->toArray() + ); + } + + public function testCreateTemplateOmitsUnsetBodies(): void + { + $payload = (new CreateTemplate(name: 'Welcome Email', subject: 'Hi', category: 'Transactional'))->toArray(); + + $this->assertEquals(['name' => 'Welcome Email', 'subject' => 'Hi', 'category' => 'Transactional'], $payload); + } + + public function testCreateTemplateFailsWithValidationErrors(): void + { + $invalidDto = new CreateTemplate(name: '', subject: 'Hi', category: 'Transactional'); + + $this->template->expects($this->once()) + ->method('httpPost') + ->with( + self::BASE_PATH, + [], + $invalidDto->toArray() + ) + ->willReturn( + new Response( + 422, + ['Content-Type' => 'application/json'], + json_encode(['errors' => ['name' => ['can\'t be blank']]]) + ) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('Errors: name -> can\'t be blank.'); + + $this->template->createTemplate($invalidDto); + } + + public function testUpdateTemplate(): void + { + $templateId = 4567; + $updateDto = new UpdateTemplate(name: 'Updated Welcome Email', bodyHtml: '
Updated
'); + + $this->template->expects($this->once()) + ->method('httpPatch') + ->with( + self::BASE_PATH . '/' . $templateId, + [], + $updateDto->toArray() + ) + ->willReturn( + new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode(['data' => $this->getExpectedTemplateResponse()]) + ) + ); + + $response = $this->template->updateTemplate($templateId, $updateDto); + $responseData = ResponseHelper::toArray($response); + + $this->assertArrayHasKey('data', $responseData); + $this->assertEquals($templateId, $responseData['data']['id']); + } + + public function testUpdateTemplateSerializesProvidedAttributesOnly(): void + { + $payload = (new UpdateTemplate(name: 'Updated Welcome Email', bodyHtml: '
Updated
'))->toArray(); + + $this->assertEquals(['name' => 'Updated Welcome Email', 'body_html' => '
Updated
'], $payload); + } + + public function testUpdateTemplateRejectsEmptyPayload(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('At least one attribute must be provided to update a template.'); + + (new UpdateTemplate())->toArray(); + } + + public function testDeleteTemplate(): void + { + $templateId = 4567; + + $this->template->expects($this->once()) + ->method('httpDelete') + ->with(self::BASE_PATH . '/' . $templateId) + ->willReturn(new Response(204)); + + $response = $this->template->deleteTemplate($templateId); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertEmpty($response->getBody()->__toString()); + } + + private function getCreateTemplateDTO(): CreateTemplate + { + return new CreateTemplate( + name: 'Welcome Email', + subject: 'Welcome to our service!', + category: 'Transactional', + bodyHtml: '
Welcome to our service!
', + bodyText: 'Welcome to our service!', + ); + } + + private function getExpectedTemplateResponse(): array + { + return [ + 'id' => 4567, + 'uuid' => 'bfa432fd-0000-0000-0000-8493da283a69', + 'name' => 'Welcome Email', + 'category' => 'Transactional', + 'subject' => 'Welcome to our service!', + 'body_html' => '
Welcome to our service!
', + 'body_text' => 'Welcome to our service!', + 'created_at' => '2026-05-01T10:15:00.000Z', + 'updated_at' => '2026-05-02T09:00:00.000Z', + ]; + } + + private function getExpectedPagination(): array + { + return [ + 'token' => 1, + 'prev_token' => null, + 'next_token' => 2, + 'first_url' => 'https://mailtrap.io/api/templates?per_page=50&token=1', + 'prev_url' => null, + 'current_url' => 'https://mailtrap.io/api/templates?per_page=50&token=1', + 'next_url' => 'https://mailtrap.io/api/templates?per_page=50&token=2', + ]; + } +} diff --git a/tests/MailtrapGeneralClientTest.php b/tests/MailtrapGeneralClientTest.php index 9cf2a93..4135baa 100644 --- a/tests/MailtrapGeneralClientTest.php +++ b/tests/MailtrapGeneralClientTest.php @@ -28,7 +28,7 @@ public function mapInstancesProvider(): iterable { foreach (MailtrapGeneralClient::API_MAPPING as $key => $item) { yield match ($key) { - 'permissions', 'users', 'contacts', 'emailTemplates', 'billing', 'apiTokens', 'emailCampaigns' => [new $item($this->getConfigMock(), self::FAKE_ACCOUNT_ID)], + 'permissions', 'users', 'contacts', 'emailTemplates', 'templates', 'billing', 'apiTokens', 'emailCampaigns' => [new $item($this->getConfigMock(), self::FAKE_ACCOUNT_ID)], 'organization' => [new $item($this->getConfigMock(), self::FAKE_ORGANIZATION_ID)], default => [new $item($this->getConfigMock())], };