diff --git a/README.md b/README.md index 1b669971..9ba4abc0 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,9 @@ General: - Accounts API – [`accounts_api.rb`](examples/accounts_api.rb) - Account Accesses API – [`account_accesses_api.rb`](examples/account_accesses_api.rb) +- API Tokens API – [`api_tokens_api.rb`](examples/api_tokens_api.rb) - Billing API – [`billing_api.rb`](examples/billing_api.rb) +- Permissions API – [`permissions_api.rb`](examples/permissions_api.rb) - Templates API – [`email_templates_api.rb`](examples/email_templates_api.rb) - Action Mailer – [`action_mailer.rb`](examples/action_mailer.rb) - Verifying webhook signatures – [`webhooks_signature_verification.ru`](examples/webhooks_signature_verification.ru) diff --git a/examples/api_tokens_api.rb b/examples/api_tokens_api.rb index 8954fddc..0d0933ca 100644 --- a/examples/api_tokens_api.rb +++ b/examples/api_tokens_api.rb @@ -13,18 +13,37 @@ # => # # Create a new API token. The full `token` value is returned ONLY once — store it securely. +# `expires_at` is optional – omit it for the server default (a 1-year default is being +# rolled out), pass an ISO 8601 date-time for an explicit expiry, or pass explicit nil +# for a token that never expires. api_tokens.create( name: 'My API Token', + expires_at: '2027-06-01T00:00:00Z', resources: [ { resource_type: 'account', resource_id: account_id, access_level: 100 } ] ) -# => # +# => # + +# Create a token that never expires +api_tokens.create( + name: 'My API Token', + expires_at: nil, + resources: [ + { resource_type: 'account', resource_id: account_id, access_level: 100 } + ] +) +# => # # Reset a token — expires the old value (short grace period) and returns a new value once. +# `expires_at` is optional and works the same as on create. api_tokens.reset(12_345) # => # +# Reset a token with an explicit expiry for the new token +api_tokens.reset(12_345, expires_at: '2027-06-01T00:00:00Z') +# => # + # Permanently delete a token api_tokens.delete(12_345) # => nil diff --git a/lib/mailtrap/api_tokens_api.rb b/lib/mailtrap/api_tokens_api.rb index 5249bb84..fdf46796 100644 --- a/lib/mailtrap/api_tokens_api.rb +++ b/lib/mailtrap/api_tokens_api.rb @@ -7,7 +7,7 @@ module Mailtrap class ApiTokensAPI include BaseAPI - self.supported_options = %i[name resources].freeze + self.supported_options = %i[name expires_at resources].freeze self.response_class = ApiToken @@ -30,6 +30,9 @@ def get(token_id) # Creates a new API token. The full `token` value is returned ONLY ONCE — store it securely. # @param [Hash] options The parameters to create # @option options [String] :name Display name for the token + # @option options [String, nil] :expires_at Optional token expiration as an ISO 8601 date-time. + # Omit for the server default (a 1-year default is being rolled out). Pass explicit nil for + # a token that never expires. Past or more-than-5-years-ahead values are rejected with 422 # @option options [Array] :resources Permissions to assign # - `{ resource_type:, resource_id:, access_level: }` # @return [ApiToken] Created token (full `token` value populated) @@ -43,10 +46,17 @@ def create(options) # The old token stops working after a short grace period. The new `token` value is # returned ONLY ONCE — store it securely # @param token_id [Integer] The API token ID + # @param [Hash] options The reset parameters + # @option options [String, nil] :expires_at Optional token expiration as an ISO 8601 date-time. + # Omit for the server default (a 1-year default is being rolled out). Pass explicit nil for + # a token that never expires. Past or more-than-5-years-ahead values are rejected with 422 # @return [ApiToken] New token (full `token` value populated) # @!macro api_errors - def reset(token_id) - response = client.post("#{base_path}/#{token_id}/reset") + # @raise [ArgumentError] If invalid options are provided + def reset(token_id, options = {}) + validate_options!(options, %i[expires_at]) + # An empty hash must not be sent as a body — the endpoint historically takes no body + response = client.post("#{base_path}/#{token_id}/reset", options.empty? ? nil : options) handle_response(response) end diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_given/serializes_expires_at_in_the_request_body.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_given/serializes_expires_at_in_the_request_body.yml new file mode 100644 index 00000000..cd8c69e6 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_given/serializes_expires_at_in_the_request_body.yml @@ -0,0 +1,74 @@ +--- +http_interactions: +- request: + method: post + uri: https://mailtrap.io/api/accounts/1111111/api_tokens + body: + encoding: UTF-8 + string: '{"name":"Ruby SDK Test Token","expires_at":"2027-06-01T00:00:00Z","resources":[{"resource_type":"account","resource_id":1111111,"access_level":100}]}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 03 Aug 2026 10:00:00 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '149' + Etag: + - W/"1f0d2c3b4a5968778695a4b3c2d1e0f9" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.141212' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"resources":[{"resource_type":"account","resource_id":1719941,"access_level":100}],"id":2498720,"name":"Ruby + SDK Test Token","last_4_digits":"9d10","created_by":"SDK Dev Account Token","expires_at":"2027-06-01T00:00:00Z","token":"7f2f0a9b8f1e4bfa9d3c5e6a7b8c9d10"}' + recorded_at: Mon, 03 Aug 2026 10:00:00 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_in_the_past/raises_a_Mailtrap_Error.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_in_the_past/raises_a_Mailtrap_Error.yml new file mode 100644 index 00000000..c3efbc6f --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_in_the_past/raises_a_Mailtrap_Error.yml @@ -0,0 +1,71 @@ +--- +http_interactions: +- request: + method: post + uri: https://mailtrap.io/api/accounts/1111111/api_tokens + body: + encoding: UTF-8 + string: '{"name":"Ruby SDK Test Token","expires_at":"2020-01-01T00:00:00Z","resources":[{"resource_type":"account","resource_id":1111111,"access_level":100}]}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 422 + message: Unprocessable Entity + headers: + Date: + - Mon, 03 Aug 2026 10:00:02 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '61' + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '147' + Cache-Control: + - no-cache + X-Runtime: + - '0.011321' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: '{"errors":{"base":["Expiration date must be in the future"]}}' + recorded_at: Mon, 03 Aug 2026 10:00:02 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_nil/serializes_expires_at_as_JSON_null.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_nil/serializes_expires_at_as_JSON_null.yml new file mode 100644 index 00000000..6f4c6caa --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_create/when_expires_at_is_nil/serializes_expires_at_as_JSON_null.yml @@ -0,0 +1,74 @@ +--- +http_interactions: +- request: + method: post + uri: https://mailtrap.io/api/accounts/1111111/api_tokens + body: + encoding: UTF-8 + string: '{"name":"Ruby SDK Test Token","expires_at":null,"resources":[{"resource_type":"account","resource_id":1111111,"access_level":100}]}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 03 Aug 2026 10:00:01 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '148' + Etag: + - W/"2e1d0c3b4a5968778695a4b3c2d1e0a8" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.132404' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"resources":[{"resource_type":"account","resource_id":1719941,"access_level":100}],"id":2498721,"name":"Ruby + SDK Test Token","last_4_digits":"4d21","created_by":"SDK Dev Account Token","expires_at":null,"token":"4b1c2d3e4f5a6b7c8d9e0f1a2b3c4d21"}' + recorded_at: Mon, 03 Aug 2026 10:00:01 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_given/serializes_expires_at_in_the_request_body.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_given/serializes_expires_at_in_the_request_body.yml new file mode 100644 index 00000000..90980e36 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_given/serializes_expires_at_in_the_request_body.yml @@ -0,0 +1,75 @@ +--- +http_interactions: +- request: + method: post + uri: https://mailtrap.io/api/accounts/1111111/api_tokens/2498713/reset + body: + encoding: UTF-8 + string: '{"expires_at":"2027-06-01T00:00:00Z"}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 03 Aug 2026 10:00:03 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '149' + Etag: + - W/"3f2e1d0c4b5a69788796a5b4c3d2e1b7" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.157893' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"resources":[{"resource_type":"account","resource_id":1719941,"access_level":100}],"id":2498722,"name":"Ruby + SDK Test Token 2498722","last_4_digits":"899a","created_by":"SDK Dev Account + Token","expires_at":"2027-06-01T00:00:00Z","token":"a3b4c5d6e7f80912233445566778899a"}' + recorded_at: Mon, 03 Aug 2026 10:00:03 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_in_the_past/raises_a_Mailtrap_Error.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_in_the_past/raises_a_Mailtrap_Error.yml new file mode 100644 index 00000000..ef2975ae --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_in_the_past/raises_a_Mailtrap_Error.yml @@ -0,0 +1,71 @@ +--- +http_interactions: +- request: + method: post + uri: https://mailtrap.io/api/accounts/1111111/api_tokens/2498713/reset + body: + encoding: UTF-8 + string: '{"expires_at":"2020-01-01T00:00:00Z"}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 422 + message: Unprocessable Entity + headers: + Date: + - Mon, 03 Aug 2026 10:00:05 GMT + Content-Type: + - application/json; charset=utf-8 + Content-Length: + - '61' + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '146' + Cache-Control: + - no-cache + X-Runtime: + - '0.012108' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: '{"errors":{"base":["Expiration date must be in the future"]}}' + recorded_at: Mon, 03 Aug 2026 10:00:05 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_nil/serializes_expires_at_as_JSON_null.yml b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_nil/serializes_expires_at_as_JSON_null.yml new file mode 100644 index 00000000..46a94239 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/Mailtrap_ApiTokensAPI/_reset/when_expires_at_is_nil/serializes_expires_at_as_JSON_null.yml @@ -0,0 +1,75 @@ +--- +http_interactions: +- request: + method: post + uri: https://mailtrap.io/api/accounts/1111111/api_tokens/2498713/reset + body: + encoding: UTF-8 + string: '{"expires_at":null}' + headers: + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - mailtrap-ruby (https://github.com/mailtrap/mailtrap-ruby) + Host: + - mailtrap.io + Authorization: + - Bearer + Content-Type: + - application/json + response: + status: + code: 200 + message: OK + headers: + Date: + - Mon, 03 Aug 2026 10:00:04 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Server: + - cloudflare + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + X-Content-Type-Options: + - nosniff + X-Download-Options: + - noopen + X-Permitted-Cross-Domain-Policies: + - none + Referrer-Policy: + - strict-origin-when-cross-origin + Vary: + - Accept + X-Mailtrap-Version: + - v2 + X-Ratelimit-Limit: + - '150' + X-Ratelimit-Remaining: + - '148' + Etag: + - W/"4a3f2e1d5c6b7a899807b6c5d4e3f2c6" + Cache-Control: + - max-age=0, private, must-revalidate + X-Runtime: + - '0.149015' + Strict-Transport-Security: + - max-age=2592000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: '{"resources":[{"resource_type":"account","resource_id":1719941,"access_level":100}],"id":2498723,"name":"Ruby + SDK Test Token 2498723","last_4_digits":"6789","created_by":"SDK Dev Account + Token","expires_at":null,"token":"0918273645abcdefabcdef0123456789"}' + recorded_at: Mon, 03 Aug 2026 10:00:04 GMT +recorded_with: VCR 6.4.0 diff --git a/spec/mailtrap/api_tokens_api_spec.rb b/spec/mailtrap/api_tokens_api_spec.rb index 3b494c20..4e0886a4 100644 --- a/spec/mailtrap/api_tokens_api_spec.rb +++ b/spec/mailtrap/api_tokens_api_spec.rb @@ -49,6 +49,7 @@ describe '#create' do subject(:create) { api_tokens_api.create(request) } + let(:url) { "https://mailtrap.io/api/accounts/#{account_id}/api_tokens" } let(:request) do { name: 'Ruby SDK Test Token', @@ -65,6 +66,61 @@ name: 'Ruby SDK Test Token', token: an_instance_of(String) ) + expect(WebMock).to(have_requested(:post, url).with { |req| !req.body.include?('expires_at') }) + end + + context 'when expires_at is given' do + let(:request) do + { + name: 'Ruby SDK Test Token', + expires_at: '2027-06-01T00:00:00Z', + resources: [ + { resource_type: 'account', resource_id: account_id, access_level: 100 } + ] + } + end + + it 'serializes expires_at in the request body' do + expect(create).to have_attributes( + expires_at: '2027-06-01T00:00:00Z', + token: an_instance_of(String) + ) + expect(WebMock).to(have_requested(:post, url) + .with { |req| req.body.include?('"expires_at":"2027-06-01T00:00:00Z"') }) + end + end + + context 'when expires_at is nil' do + let(:request) do + { + name: 'Ruby SDK Test Token', + expires_at: nil, + resources: [ + { resource_type: 'account', resource_id: account_id, access_level: 100 } + ] + } + end + + it 'serializes expires_at as JSON null' do + expect(create).to have_attributes(expires_at: nil, token: an_instance_of(String)) + expect(WebMock).to(have_requested(:post, url).with { |req| req.body.include?('"expires_at":null') }) + end + end + + context 'when expires_at is in the past' do + let(:request) do + { + name: 'Ruby SDK Test Token', + expires_at: '2020-01-01T00:00:00Z', + resources: [ + { resource_type: 'account', resource_id: account_id, access_level: 100 } + ] + } + end + + it 'raises a Mailtrap::Error' do + expect { create }.to raise_error(Mailtrap::Error) + end end context 'when invalid options are provided' do @@ -88,6 +144,7 @@ subject(:reset) { api_tokens_api.reset(token_id) } let(:token_id) { 2_498_713 } + let(:url) { "https://mailtrap.io/api/accounts/#{account_id}/api_tokens/#{token_id}/reset" } it 'maps response data to ApiToken with new token value' do expect(reset).to be_a(Mailtrap::ApiToken) @@ -95,6 +152,44 @@ id: an_instance_of(Integer), token: an_instance_of(String) ) + expect(WebMock).to(have_requested(:post, url).with { |req| req.body.to_s.empty? }) + end + + context 'when expires_at is given' do + subject(:reset) { api_tokens_api.reset(token_id, expires_at: '2027-06-01T00:00:00Z') } + + it 'serializes expires_at in the request body' do + expect(reset).to have_attributes( + expires_at: '2027-06-01T00:00:00Z', + token: an_instance_of(String) + ) + expect(WebMock).to have_requested(:post, url).with(body: '{"expires_at":"2027-06-01T00:00:00Z"}') + end + end + + context 'when expires_at is nil' do + subject(:reset) { api_tokens_api.reset(token_id, expires_at: nil) } + + it 'serializes expires_at as JSON null' do + expect(reset).to have_attributes(expires_at: nil, token: an_instance_of(String)) + expect(WebMock).to have_requested(:post, url).with(body: '{"expires_at":null}') + end + end + + context 'when expires_at is in the past' do + subject(:reset) { api_tokens_api.reset(token_id, expires_at: '2020-01-01T00:00:00Z') } + + it 'raises a Mailtrap::Error' do + expect { reset }.to raise_error(Mailtrap::Error) + end + end + + context 'when invalid options are provided' do + subject(:reset) { api_tokens_api.reset(token_id, unknown_option: true) } + + it 'raises ArgumentError' do + expect { reset }.to raise_error(ArgumentError, /invalid options are given/) + end end context 'when token does not exist' do