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())],
};