diff --git a/README.md b/README.md
index d94bbd0..0144c85 100644
--- a/README.md
+++ b/README.md
@@ -211,7 +211,8 @@ General:
- 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)
+- Templates API (experimental) – [`templates_api.rb`](examples/templates_api.rb)
+- Email 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/templates_api.rb b/examples/templates_api.rb
new file mode 100644
index 0000000..e76bd28
--- /dev/null
+++ b/examples/templates_api.rb
@@ -0,0 +1,45 @@
+require 'mailtrap'
+
+account_id = 3229
+client = Mailtrap::Client.new(api_key: 'your-api-key')
+templates = Mailtrap::TemplatesAPI.new(account_id, client)
+
+# Set your API credentials as environment variables
+# export MAILTRAP_API_KEY='your-api-key'
+# export MAILTRAP_ACCOUNT_ID=your-account-id
+#
+# templates = Mailtrap::TemplatesAPI.new
+
+# Create a new Template
+template = templates.create(
+ name: 'Welcome Email',
+ subject: 'Welcome to Acme!',
+ category: 'Welcome',
+ body_html: '
Welcome, {{name}}!
',
+ body_text: 'Welcome, {{name}}!'
+)
+# => #
+
+# Get all Templates (paginated)
+list = templates.list(per_page: 50, token: 1)
+# => #], pagination={...}>
+list.data
+# => [#]
+list.pagination
+# => {:token=>1, :prev_token=>nil, :next_token=>2, ...}
+
+# Follow the pagination to the next page
+templates.list(per_page: 50, token: list.pagination[:next_token]) if list.pagination[:next_token]
+# => #
+
+# Get a single Template
+template = templates.get(template.id)
+# => #
+
+# Update a Template (partial)
+template = templates.update(template.id, subject: 'Welcome aboard!')
+# => #
+
+# Delete a Template (returns nil)
+templates.delete(template.id)
+# => nil
diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb
index 44dca60..920f90d 100644
--- a/lib/mailtrap.rb
+++ b/lib/mailtrap.rb
@@ -11,6 +11,7 @@
require_relative 'mailtrap/sub_accounts_api'
require_relative 'mailtrap/billing_api'
require_relative 'mailtrap/email_templates_api'
+require_relative 'mailtrap/templates_api'
require_relative 'mailtrap/contacts_api'
require_relative 'mailtrap/contact_lists_api'
require_relative 'mailtrap/contact_fields_api'
diff --git a/lib/mailtrap/template.rb b/lib/mailtrap/template.rb
new file mode 100644
index 0000000..f1deec2
--- /dev/null
+++ b/lib/mailtrap/template.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Mailtrap
+ # Data Transfer Object for Template
+ # @attr_reader id [Integer] The template ID
+ # @attr_reader uuid [String] The template UUID
+ # @attr_reader name [String] The template name
+ # @attr_reader subject [String] The email subject
+ # @attr_reader category [String] The template category
+ # @attr_reader body_html [String] The HTML content
+ # @attr_reader body_text [String] The plain text content
+ # @attr_reader created_at [String] The creation timestamp
+ # @attr_reader updated_at [String] The last update timestamp
+ Template = Struct.new(
+ :id,
+ :uuid,
+ :name,
+ :subject,
+ :category,
+ :body_html,
+ :body_text,
+ :created_at,
+ :updated_at,
+ keyword_init: true
+ )
+
+ # Response from listing templates (paginated)
+ # @attr_reader data [Array] Page of templates
+ # @attr_reader pagination [Hash] Page-token pagination metadata
+ # (+token+, +prev_token+, +next_token+, +first_url+, +prev_url+, +current_url+, +next_url+)
+ TemplatesListResponse = Struct.new(
+ :data,
+ :pagination,
+ keyword_init: true
+ )
+end
diff --git a/lib/mailtrap/templates_api.rb b/lib/mailtrap/templates_api.rb
new file mode 100644
index 0000000..cece72b
--- /dev/null
+++ b/lib/mailtrap/templates_api.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require_relative 'base_api'
+require_relative 'template'
+
+module Mailtrap
+ # @note Experimental: the +/api/templates+ endpoints may change their request and response
+ # shapes before general availability.
+ class TemplatesAPI
+ include BaseAPI
+
+ self.supported_options = %i[name subject category body_html body_text]
+
+ self.response_class = Template
+
+ # Lists templates for the account, one page at a time. Unlike {EmailTemplatesAPI#list}, it does
+ # not return every template: pass +pagination[:next_token]+ with the same +per_page+ to get the
+ # next page.
+ # @param per_page [Integer, nil] Number of templates per page (max 100, default 50)
+ # @param token [Integer, nil] Page number to retrieve (page-token pagination, default 1)
+ # @return [TemplatesListResponse] The page of templates and pagination metadata
+ # @!macro api_errors
+ def list(per_page: nil, token: nil)
+ query_params = {}
+ query_params[:per_page] = per_page unless per_page.nil?
+ query_params[:token] = token unless token.nil?
+
+ response = client.get(base_path, query_params)
+
+ TemplatesListResponse.new(
+ data: Array(response[:data]).map { |item| build_entity(item, response_class) },
+ pagination: response[:pagination]
+ )
+ end
+
+ # Retrieves a specific template
+ # @param template_id [Integer] The template ID
+ # @return [Template] Template object
+ # @!macro api_errors
+ def get(template_id)
+ base_get(template_id)
+ end
+
+ # Creates a new template
+ # @param [Hash] options The parameters to create
+ # @option options [String] :name The template name (required)
+ # @option options [String] :subject The email subject (required)
+ # @option options [String] :category The template category (required)
+ # @option options [String, nil] :body_html The HTML content. Default: nil.
+ # @option options [String, nil] :body_text The plain text content. Default: nil.
+ # @return [Template] Created template object
+ # @!macro api_errors
+ # @raise [ArgumentError] If invalid options are provided
+ def create(options)
+ base_create(options)
+ end
+
+ # Updates an existing template. Only the provided attributes are changed.
+ # @param template_id [Integer] The template ID
+ # @param [Hash] options The parameters to update
+ # @option options [String] :name The template name
+ # @option options [String] :subject The email subject
+ # @option options [String] :category The template category
+ # @option options [String, nil] :body_html The HTML content
+ # @option options [String, nil] :body_text The plain text content
+ # @return [Template] Updated template object
+ # @!macro api_errors
+ # @raise [ArgumentError] If invalid options are provided
+ def update(template_id, options)
+ base_update(template_id, options)
+ end
+
+ # Deletes a template
+ # @param template_id [Integer] The template ID
+ # @return [nil]
+ # @!macro api_errors
+ def delete(template_id)
+ base_delete(template_id)
+ end
+
+ private
+
+ def base_path
+ "/api/accounts/#{account_id}/templates"
+ end
+
+ def handle_response(response)
+ build_entity(response[:data], response_class)
+ end
+ end
+end
diff --git a/spec/mailtrap/template_spec.rb b/spec/mailtrap/template_spec.rb
new file mode 100644
index 0000000..6f8d420
--- /dev/null
+++ b/spec/mailtrap/template_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+RSpec.describe Mailtrap::Template do
+ describe '#initialize' do
+ subject(:template) { described_class.new(attributes) }
+
+ let(:attributes) do
+ {
+ id: 26_730,
+ uuid: '018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132',
+ name: 'My Template',
+ subject: 'My Subject',
+ category: 'My Category',
+ body_html: 'HTML
',
+ body_text: 'Text',
+ created_at: '2021-01-01T00:00:00Z',
+ updated_at: '2021-01-01T00:00:00Z'
+ }
+ end
+
+ it 'creates a template with all attributes' do
+ expect(template).to have_attributes(attributes)
+ end
+ end
+
+ describe Mailtrap::TemplatesListResponse do
+ subject(:response) { described_class.new(data: [template], pagination: { token: 1 }) }
+
+ let(:template) { Mailtrap::Template.new(id: 1) }
+
+ it 'exposes the page and its pagination' do
+ expect(response).to have_attributes(data: [template], pagination: { token: 1 })
+ end
+ end
+end
diff --git a/spec/mailtrap/templates_api_spec.rb b/spec/mailtrap/templates_api_spec.rb
new file mode 100644
index 0000000..dc85f0f
--- /dev/null
+++ b/spec/mailtrap/templates_api_spec.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+RSpec.describe Mailtrap::TemplatesAPI do
+ subject(:templates_api) { described_class.new(1_111_111, Mailtrap::Client.new(api_key: 'correct-api-key')) }
+
+ let(:base_url) { 'https://mailtrap.io/api/accounts/1111111/templates' }
+ let(:json_headers) { { 'Content-Type' => 'application/json' } }
+ let(:template_attributes) do
+ {
+ 'id' => 26_730,
+ 'uuid' => '018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132',
+ 'name' => 'Welcome Email',
+ 'subject' => 'Welcome!',
+ 'category' => 'Welcome',
+ 'body_html' => 'Hi
',
+ 'body_text' => 'Hi',
+ 'created_at' => '2021-01-01T00:00:00Z',
+ 'updated_at' => '2021-01-01T00:00:00Z'
+ }
+ end
+
+ describe '#list' do
+ let(:pagination) do
+ { 'token' => 1, 'prev_token' => nil, 'next_token' => 2, 'first_url' => "#{base_url}?token=1" }
+ end
+
+ it 'returns a paginated list of Template objects' do
+ stub_request(:get, base_url)
+ .to_return(
+ status: 200,
+ body: { 'data' => [template_attributes], 'pagination' => pagination }.to_json,
+ headers: json_headers
+ )
+
+ response = templates_api.list
+ expect(response).to be_a(Mailtrap::TemplatesListResponse)
+ expect(response.data).to all(be_a(Mailtrap::Template))
+ expect(response.data.first).to have_attributes(id: 26_730, name: 'Welcome Email', category: 'Welcome')
+ expect(response.pagination).to eq(
+ token: 1, prev_token: nil, next_token: 2, first_url: "#{base_url}?token=1"
+ )
+ end
+
+ it 'passes pagination params' do
+ stub = stub_request(:get, base_url)
+ .with(query: { per_page: '10', token: '2' })
+ .to_return(
+ status: 200,
+ body: { 'data' => [], 'pagination' => { 'token' => 2 } }.to_json,
+ headers: json_headers
+ )
+
+ response = templates_api.list(per_page: 10, token: 2)
+ expect(stub).to have_been_requested
+ expect(response.data).to eq([])
+ end
+
+ it 'returns an empty page when the token is past the last page' do
+ stub = stub_request(:get, base_url)
+ .with(query: { token: '3' })
+ .to_return(
+ status: 200,
+ body: { 'data' => [], 'pagination' => { 'token' => 3, 'prev_token' => 2, 'next_token' => nil } }.to_json,
+ headers: json_headers
+ )
+
+ response = templates_api.list(token: 3)
+ expect(stub).to have_been_requested
+ expect(response.data).to eq([])
+ expect(response.pagination).to eq(token: 3, prev_token: 2, next_token: nil)
+ end
+
+ it 'raises error when api key is incorrect' do
+ stub_request(:get, base_url)
+ .to_return(status: 401, body: { 'error' => 'Incorrect API token' }.to_json, headers: json_headers)
+
+ expect { templates_api.list }.to raise_error(Mailtrap::AuthorizationError, /Incorrect API token/)
+ end
+ end
+
+ describe '#get' do
+ it 'returns a Template object' do
+ stub_request(:get, "#{base_url}/26730")
+ .to_return(status: 200, body: { 'data' => template_attributes }.to_json, headers: json_headers)
+
+ response = templates_api.get(26_730)
+ expect(response).to be_a(Mailtrap::Template)
+ expect(response).to have_attributes(id: 26_730, uuid: '018dd5e3-f6d2-7c00-8f9b-e5c3f2d8a132')
+ end
+
+ it 'raises error when the template does not exist' do
+ stub_request(:get, "#{base_url}/999")
+ .to_return(status: 404, body: { 'error' => 'Not Found' }.to_json, headers: json_headers)
+
+ expect { templates_api.get(999) }.to raise_error(Mailtrap::Error, /Not Found/)
+ end
+ end
+
+ describe '#create' do
+ let(:request) do
+ { name: 'Welcome Email', subject: 'Welcome!', category: 'Welcome', body_html: 'Hi
' }
+ end
+
+ it 'sends a flat request body and returns the created Template' do
+ stub = stub_request(:post, base_url)
+ .with(body: request.to_json)
+ .to_return(status: 201, body: { 'data' => template_attributes }.to_json, headers: json_headers)
+
+ response = templates_api.create(request)
+ expect(stub).to have_been_requested
+ expect(response).to be_a(Mailtrap::Template)
+ expect(response).to have_attributes(id: 26_730, name: 'Welcome Email')
+ end
+
+ it 'raises ArgumentError when invalid options are provided' do
+ expect { templates_api.create(name: 'Welcome Email', unknown_option: true) }
+ .to raise_error(ArgumentError, /invalid options are given/)
+ end
+
+ it 'raises error when validation fails' do
+ stub_request(:post, base_url)
+ .to_return(
+ status: 422,
+ body: { 'errors' => { 'subject' => ["can't be blank"] } }.to_json,
+ headers: json_headers
+ )
+
+ expect { templates_api.create(name: 'Welcome Email') }.to raise_error(Mailtrap::Error, /subject/)
+ end
+ end
+
+ describe '#update' do
+ let(:request) { { name: 'Welcome Email (updated)', subject: 'Welcome aboard!' } }
+
+ it 'sends a flat PATCH request body and returns the updated Template' do
+ stub = stub_request(:patch, "#{base_url}/26730")
+ .with(body: request.to_json)
+ .to_return(
+ status: 200,
+ body: { 'data' => template_attributes.merge('name' => 'Welcome Email (updated)') }.to_json,
+ headers: json_headers
+ )
+
+ response = templates_api.update(26_730, request)
+ expect(stub).to have_been_requested
+ expect(response).to have_attributes(id: 26_730, name: 'Welcome Email (updated)')
+ end
+
+ it 'raises ArgumentError when invalid options are provided' do
+ expect { templates_api.update(26_730, unknown_option: true) }
+ .to raise_error(ArgumentError, /invalid options are given/)
+ end
+
+ it 'raises error when validation fails' do
+ stub_request(:patch, "#{base_url}/26730")
+ .to_return(
+ status: 422,
+ body: { 'errors' => { 'name' => ["can't be blank"] } }.to_json,
+ headers: json_headers
+ )
+
+ expect { templates_api.update(26_730, name: '') }.to raise_error(Mailtrap::Error, /name/)
+ end
+ end
+
+ describe '#delete' do
+ it 'deletes the template and returns nil' do
+ stub_request(:delete, "#{base_url}/26730").to_return(status: 204)
+
+ expect(templates_api.delete(26_730)).to be_nil
+ end
+
+ it 'raises error when the template does not exist' do
+ stub_request(:delete, "#{base_url}/999")
+ .to_return(status: 404, body: { 'error' => 'Not Found' }.to_json, headers: json_headers)
+
+ expect { templates_api.delete(999) }.to raise_error(Mailtrap::Error, /Not Found/)
+ end
+ end
+end