From 3ccb2d4862a75c2a303501b5887ab548dcf581b8 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:26:00 +0200 Subject: [PATCH 1/6] MT-23076: add TokenExpiration request value object --- src/DTO/Request/ApiToken/TokenExpiration.php | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/DTO/Request/ApiToken/TokenExpiration.php diff --git a/src/DTO/Request/ApiToken/TokenExpiration.php b/src/DTO/Request/ApiToken/TokenExpiration.php new file mode 100644 index 0000000..26bc314 --- /dev/null +++ b/src/DTO/Request/ApiToken/TokenExpiration.php @@ -0,0 +1,51 @@ +format(DateTimeInterface::ATOM) : $value + ); + } + + /** + * Token never expires. + * + * @return self + */ + public static function never(): self + { + return new self(null); + } + + public function getValue(): ?string + { + return $this->value; + } +} From d30a42c4751eb7c6f00569c3ee2b109b419cc9ad Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:26:30 +0200 Subject: [PATCH 2/6] MT-23076: support expires_at when creating an api token --- src/Api/General/ApiToken.php | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Api/General/ApiToken.php b/src/Api/General/ApiToken.php index 76f1e5c..8f538ed 100644 --- a/src/Api/General/ApiToken.php +++ b/src/Api/General/ApiToken.php @@ -6,6 +6,7 @@ use Mailtrap\Api\AbstractApi; use Mailtrap\ConfigInterface; +use Mailtrap\DTO\Request\ApiToken\TokenExpiration; use Mailtrap\DTO\Request\Permission\Permissions; use Psr\Http\Message\ResponseInterface; @@ -47,19 +48,29 @@ public function getApiToken(int $apiTokenId): ResponseInterface /** * Create a new API token. The full token value is returned only in this response. * - * @param string $name - * @param Permissions $permissions + * @param string $name + * @param Permissions $permissions + * @param TokenExpiration|null $expiration Optional token expiration as an ISO 8601 date-time. + * Omit for the server default (a 1-year default is being rolled out). + * Use TokenExpiration::never() for a token that never expires. + * Past or more-than-5-years-ahead values are rejected with 422. * * @return ResponseInterface */ - public function createApiToken(string $name, Permissions $permissions): ResponseInterface + public function createApiToken(string $name, Permissions $permissions, ?TokenExpiration $expiration = null): ResponseInterface { + $body = [ + 'name' => $name, + 'resources' => $permissions->toPayload(), + ]; + + if ($expiration !== null) { + $body['expires_at'] = $expiration->getValue(); + } + return $this->handleResponse($this->httpPost( path: $this->getBasePath(), - body: [ - 'name' => $name, - 'resources' => $permissions->toPayload(), - ] + body: $body )); } From e6ec67d3ef46f37ada4a0500bf6f171191ce2115 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:26:55 +0200 Subject: [PATCH 3/6] MT-23076: support expires_at when resetting an api token --- src/Api/General/ApiToken.php | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/Api/General/ApiToken.php b/src/Api/General/ApiToken.php index 8f538ed..7eabc82 100644 --- a/src/Api/General/ApiToken.php +++ b/src/Api/General/ApiToken.php @@ -90,14 +90,28 @@ public function deleteApiToken(int $apiTokenId): ResponseInterface /** * Reset an API token by ID. Returns a new token value; the previous value stops working. * - * @param int $apiTokenId + * @param int $apiTokenId + * @param TokenExpiration|null $expiration Optional expiration of the new token as an ISO 8601 date-time. + * Omit for the server default (a 1-year default is being rolled out). + * Use TokenExpiration::never() for a token that never expires. + * Past or more-than-5-years-ahead values are rejected with 422. + * * @return ResponseInterface */ - public function resetApiToken(int $apiTokenId): ResponseInterface + public function resetApiToken(int $apiTokenId, ?TokenExpiration $expiration = null): ResponseInterface { - return $this->handleResponse( - $this->httpPost($this->getBasePath() . '/' . $apiTokenId . '/reset') - ); + $path = $this->getBasePath() . '/' . $apiTokenId . '/reset'; + + if ($expiration === null) { + return $this->handleResponse( + $this->httpPost($path) + ); + } + + return $this->handleResponse($this->httpPost( + path: $path, + body: ['expires_at' => $expiration->getValue()] + )); } public function getAccountId(): int From 457d02f380cd505f20f7b88fe83b9408034448d2 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:29:44 +0200 Subject: [PATCH 4/6] MT-23076: cover token expiration in api token tests --- tests/Api/General/ApiTokenTest.php | 223 ++++++++++++++++++++++++++++- 1 file changed, 222 insertions(+), 1 deletion(-) diff --git a/tests/Api/General/ApiTokenTest.php b/tests/Api/General/ApiTokenTest.php index 31780dc..4640043 100644 --- a/tests/Api/General/ApiTokenTest.php +++ b/tests/Api/General/ApiTokenTest.php @@ -4,8 +4,10 @@ namespace Mailtrap\Tests\Api\General; +use DateTimeImmutable; use Mailtrap\Api\AbstractApi; use Mailtrap\Api\General\ApiToken; +use Mailtrap\DTO\Request\ApiToken\TokenExpiration; use Mailtrap\DTO\Request\Permission\CreateOrUpdatePermission; use Mailtrap\DTO\Request\Permission\PermissionInterface; use Mailtrap\DTO\Request\Permission\Permissions; @@ -149,6 +151,149 @@ public function testCreateApiToken(): void $this->assertEquals('fresh-secret-token-value', $responseData['token']); } + public function testCreateApiTokenWithNeverExpiration(): void + { + $name = 'My API token'; + + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens', + [], + [ + 'name' => $name, + 'resources' => [ + [ + 'resource_id' => (string) self::FAKE_ACCOUNT_ID, + 'resource_type' => PermissionInterface::TYPE_ACCOUNT, + 'access_level' => '1000', + ], + ], + 'expires_at' => null, + ] + ) + ->willReturn( + new Response( + 201, + ['Content-Type' => 'application/json'], + json_encode($this->getExpectedApiTokenResponse() + ['token' => 'fresh-secret-token-value']) + ) + ); + + $this->apiToken->createApiToken($name, $this->getPermissions(), TokenExpiration::never()); + } + + public function testCreateApiTokenWithExpirationDateString(): void + { + $name = 'My API token'; + + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens', + [], + [ + 'name' => $name, + 'resources' => [ + [ + 'resource_id' => (string) self::FAKE_ACCOUNT_ID, + 'resource_type' => PermissionInterface::TYPE_ACCOUNT, + 'access_level' => '1000', + ], + ], + 'expires_at' => '2027-06-01T00:00:00Z', + ] + ) + ->willReturn( + new Response( + 201, + ['Content-Type' => 'application/json'], + json_encode( + array_merge($this->getExpectedApiTokenResponse(), ['expires_at' => '2027-06-01T00:00:00Z']) + + ['token' => 'fresh-secret-token-value'] + ) + ) + ); + + $response = $this->apiToken->createApiToken($name, $this->getPermissions(), TokenExpiration::at('2027-06-01T00:00:00Z')); + $responseData = ResponseHelper::toArray($response); + + $this->assertEquals('2027-06-01T00:00:00Z', $responseData['expires_at']); + } + + public function testCreateApiTokenWithExpirationDateTimeObject(): void + { + $name = 'My API token'; + + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens', + [], + [ + 'name' => $name, + 'resources' => [ + [ + 'resource_id' => (string) self::FAKE_ACCOUNT_ID, + 'resource_type' => PermissionInterface::TYPE_ACCOUNT, + 'access_level' => '1000', + ], + ], + 'expires_at' => '2027-06-01T00:00:00+00:00', + ] + ) + ->willReturn( + new Response( + 201, + ['Content-Type' => 'application/json'], + json_encode($this->getExpectedApiTokenResponse() + ['token' => 'fresh-secret-token-value']) + ) + ); + + $this->apiToken->createApiToken( + $name, + $this->getPermissions(), + TokenExpiration::at(new DateTimeImmutable('2027-06-01T00:00:00+00:00')) + ); + } + + public function testCreateApiTokenFailsWithInvalidExpiration(): void + { + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens', + [], + [ + 'name' => 'My API token', + 'resources' => [ + [ + 'resource_id' => (string) self::FAKE_ACCOUNT_ID, + 'resource_type' => PermissionInterface::TYPE_ACCOUNT, + 'access_level' => '1000', + ], + ], + 'expires_at' => '2020-01-01T00:00:00Z', + ] + ) + ->willReturn( + new Response( + 422, + ['Content-Type' => 'application/json'], + json_encode(['errors' => ['expires_at' => ['must be in the future']]]) + ) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('must be in the future'); + + $this->apiToken->createApiToken( + 'My API token', + $this->getPermissions(), + TokenExpiration::at('2020-01-01T00:00:00Z') + ); + } + public function testCreateApiTokenFailsWithoutPermissions(): void { $this->expectException(RuntimeException::class); @@ -177,7 +322,11 @@ public function testResetApiToken(): void $this->apiToken->expects($this->once()) ->method('httpPost') - ->with(AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens/' . $apiTokenId . '/reset') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens/' . $apiTokenId . '/reset', + [], + null // no expiration argument -> no request body at all + ) ->willReturn( new Response( 200, @@ -194,6 +343,78 @@ public function testResetApiToken(): void $this->assertEquals('rotated-secret-token-value', $responseData['token']); } + public function testResetApiTokenWithNeverExpiration(): void + { + $apiTokenId = 1; + + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens/' . $apiTokenId . '/reset', + [], + ['expires_at' => null] + ) + ->willReturn( + new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode($this->getExpectedApiTokenResponse() + ['token' => 'rotated-secret-token-value']) + ) + ); + + $this->apiToken->resetApiToken($apiTokenId, TokenExpiration::never()); + } + + public function testResetApiTokenWithExpirationDateString(): void + { + $apiTokenId = 1; + + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens/' . $apiTokenId . '/reset', + [], + ['expires_at' => '2027-06-01T00:00:00Z'] + ) + ->willReturn( + new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode( + array_merge($this->getExpectedApiTokenResponse(), ['expires_at' => '2027-06-01T00:00:00Z']) + + ['token' => 'rotated-secret-token-value'] + ) + ) + ); + + $this->apiToken->resetApiToken($apiTokenId, TokenExpiration::at('2027-06-01T00:00:00Z')); + } + + public function testResetApiTokenFailsWithInvalidExpiration(): void + { + $apiTokenId = 1; + + $this->apiToken->expects($this->once()) + ->method('httpPost') + ->with( + AbstractApi::DEFAULT_HOST . '/api/accounts/' . self::FAKE_ACCOUNT_ID . '/api_tokens/' . $apiTokenId . '/reset', + [], + ['expires_at' => '2020-01-01T00:00:00Z'] + ) + ->willReturn( + new Response( + 422, + ['Content-Type' => 'application/json'], + json_encode(['errors' => ['expires_at' => ['must be in the future']]]) + ) + ); + + $this->expectException(HttpClientException::class); + $this->expectExceptionMessage('must be in the future'); + + $this->apiToken->resetApiToken($apiTokenId, TokenExpiration::at('2020-01-01T00:00:00Z')); + } + public function testResetApiTokenFailsWhenAlreadyReset(): void { $apiTokenId = 1; From ec86c434485610d5c31c73af790e150aa1786a7f Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:30:23 +0200 Subject: [PATCH 5/6] MT-23076: show token expiration in api tokens example --- examples/api-tokens/all.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/examples/api-tokens/all.php b/examples/api-tokens/all.php index dc59384..a131a7e 100644 --- a/examples/api-tokens/all.php +++ b/examples/api-tokens/all.php @@ -1,6 +1,7 @@ createApiToken('My new API token', $permissions); + $response = $apiTokens->createApiToken( + 'My new API token', + $permissions, + TokenExpiration::at('2027-06-01T00:00:00Z') // or TokenExpiration::never(), or omit + ); var_dump(ResponseHelper::toArray($response)); } catch (Exception $e) { @@ -65,12 +74,16 @@ /** * Reset an API token by ID. Returns a new token value; the previous value stops working. * + * Expiration of the new token is optional: omit the argument for the server default (a 1-year + * default is being rolled out), pass TokenExpiration::at(...) for a specific ISO 8601 date-time, + * or TokenExpiration::never() for a token that never expires. + * * POST https://mailtrap.io/api/accounts/{account_id}/api_tokens/{id}/reset */ try { $apiTokenId = 1; - $response = $apiTokens->resetApiToken($apiTokenId); + $response = $apiTokens->resetApiToken($apiTokenId, TokenExpiration::never()); var_dump(ResponseHelper::toArray($response)); } catch (Exception $e) { From 93a64f0b0fec45db9c2baeaf47951a2ceec70785 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:31:12 +0200 Subject: [PATCH 6/6] MT-23076: list api tokens example in readme --- README.md | 1 + examples/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index fc5eef2..4bd52ee 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ Contact management: General API: - 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) - Permissions listing – [`general/permissions.php`](examples/general/permissions.php) diff --git a/examples/README.md b/examples/README.md index 72c1633..82a0b5d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -54,6 +54,7 @@ Central index of runnable example scripts demonstrating Mailtrap PHP SDK feature ### 6. General API | Purpose | File | |---------|------| +| API tokens CRUD | [`api-tokens/all.php`](api-tokens/all.php) | | Accounts info | [`general/accounts.php`](general/accounts.php) | | Billing info | [`general/billing.php`](general/billing.php) | | Permissions listing | [`general/permissions.php`](general/permissions.php) |