diff --git a/README.md b/README.md index 0cd5f38..2d196ca 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ The same situation applies to both `client.batch_send()` and `client.sending_api - Contact Imports – [`contacts/contact_imports.py`](examples/contacts/contact_imports.py) ### Email Templates API: +- Templates (experimental, `/api/templates`) – [`paginated_templates/templates.py`](examples/paginated_templates/templates.py) - Templates management – [`email_templates/templates.py`](examples/email_templates/templates.py) ### Sending Domains API: diff --git a/examples/paginated_templates/templates.py b/examples/paginated_templates/templates.py new file mode 100644 index 0000000..bd99886 --- /dev/null +++ b/examples/paginated_templates/templates.py @@ -0,0 +1,80 @@ +import os +from typing import Optional + +import mailtrap as mt +from mailtrap.models.common import DeletedObject +from mailtrap.models.paginated_templates import Template +from mailtrap.models.paginated_templates import TemplateListResponse + +API_KEY = os.environ["MAILTRAP_API_KEY"] +ACCOUNT_ID = os.environ["MAILTRAP_ACCOUNT_ID"] + +client = mt.MailtrapClient(token=API_KEY, account_id=ACCOUNT_ID) +templates_api = client.templates_api.templates + + +def list_templates() -> TemplateListResponse: + # `token` is the page number (page-token pagination); `per_page` caps at 100. + response = templates_api.get_list(mt.TemplateListParams(per_page=50, token=1)) + print(response.data) + print(response.pagination) + return response + + +def create_template( + name: str, + subject: str, + category: str, + body_html: Optional[str] = None, + body_text: Optional[str] = None, +) -> Template: + params = mt.CreateTemplateParams( + name=name, + subject=subject, + category=category, + body_html=body_html, + body_text=body_text, + ) + return templates_api.create(params) + + +def get_template(template_id: int) -> Template: + return templates_api.get_by_id(template_id) + + +def update_template( + template_id: int, + name: Optional[str] = None, + subject: Optional[str] = None, + category: Optional[str] = None, + body_html: Optional[str] = None, + body_text: Optional[str] = None, +) -> Template: + params = mt.UpdateTemplateParams( + name=name, + subject=subject, + category=category, + body_html=body_html, + body_text=body_text, + ) + return templates_api.update(template_id, params) + + +def delete_template(template_id: int) -> DeletedObject: + return templates_api.delete(template_id) + + +if __name__ == "__main__": + list_templates() + + created = create_template( + name="Welcome", + subject="Welcome aboard", + category="Onboarding", + body_html="

Hello!

", + ) + print(created) + + print(get_template(created.id)) + print(update_template(created.id, subject="Welcome to Mailtrap")) + print(delete_template(created.id)) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index eb34f7c..96015ce 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -58,6 +58,11 @@ from .models.mail import MailFromTemplate from .models.messages import UpdateEmailMessageParams from .models.organizations import CreateSubAccountParams +from .models.paginated_templates import CreateTemplateParams +from .models.paginated_templates import Template +from .models.paginated_templates import TemplateListParams +from .models.paginated_templates import TemplateListResponse +from .models.paginated_templates import UpdateTemplateParams from .models.permissions import PermissionResourceParams from .models.projects import ProjectParams from .models.sending_domains import CreateSendingDomainParams diff --git a/mailtrap/api/paginated_templates.py b/mailtrap/api/paginated_templates.py new file mode 100644 index 0000000..4ad88a3 --- /dev/null +++ b/mailtrap/api/paginated_templates.py @@ -0,0 +1,12 @@ +from mailtrap.api.resources.paginated_templates import PaginatedTemplatesApi +from mailtrap.http import HttpClient + + +class TemplatesBaseApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + @property + def templates(self) -> PaginatedTemplatesApi: + return PaginatedTemplatesApi(account_id=self._account_id, client=self._client) diff --git a/mailtrap/api/resources/paginated_templates.py b/mailtrap/api/resources/paginated_templates.py new file mode 100644 index 0000000..8d2ce75 --- /dev/null +++ b/mailtrap/api/resources/paginated_templates.py @@ -0,0 +1,62 @@ +from typing import Optional + +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.paginated_templates import CreateTemplateParams +from mailtrap.models.paginated_templates import Template +from mailtrap.models.paginated_templates import TemplateListParams +from mailtrap.models.paginated_templates import TemplateListResponse +from mailtrap.models.paginated_templates import TemplateResponse +from mailtrap.models.paginated_templates import UpdateTemplateParams + + +class PaginatedTemplatesApi: + """ + Templates API. The ``/api/templates`` endpoints are experimental: their + request and response shapes may change before general availability. + """ + + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + def get_list( + self, params: Optional[TemplateListParams] = None + ) -> TemplateListResponse: + """ + List email templates in the account, one page at a time. Unlike the + ``email_templates_api`` list, it does not return every template: pass + ``pagination.next_token`` with the same ``per_page`` to get the next + page. Omit ``params`` for the first page with API defaults. + """ + query_params = params.api_query_params if params else None + response = self._client.get(self._api_path(), params=query_params or None) + return TemplateListResponse(**response) + + def get_by_id(self, template_id: int) -> Template: + """Get an email template by ID.""" + response = self._client.get(self._api_path(template_id)) + return TemplateResponse(**response).data + + def create(self, template_params: CreateTemplateParams) -> Template: + """Create a new email template.""" + response = self._client.post(self._api_path(), json=template_params.api_data) + return TemplateResponse(**response).data + + def update(self, template_id: int, template_params: UpdateTemplateParams) -> Template: + """Update an email template. Only the supplied fields are changed.""" + response = self._client.patch( + self._api_path(template_id), json=template_params.api_data + ) + return TemplateResponse(**response).data + + def delete(self, template_id: int) -> DeletedObject: + """Delete an email template.""" + self._client.delete(self._api_path(template_id)) + return DeletedObject(template_id) + + def _api_path(self, template_id: Optional[int] = None) -> str: + path = f"/api/accounts/{self._account_id}/templates" + if template_id is not None: + return f"{path}/{template_id}" + return path diff --git a/mailtrap/client.py b/mailtrap/client.py index 520e7b1..85e6236 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -13,6 +13,7 @@ from mailtrap.api.general import GeneralApi from mailtrap.api.inbound import InboundBaseApi from mailtrap.api.organizations import OrganizationsBaseApi +from mailtrap.api.paginated_templates import TemplatesBaseApi from mailtrap.api.resources.stats import StatsApi from mailtrap.api.sending import SendingApi from mailtrap.api.sending_domains import SendingDomainsBaseApi @@ -103,6 +104,14 @@ def email_templates_api(self) -> EmailTemplatesApi: client=HttpClient(host=GENERAL_HOST, headers=self.headers), ) + @property + def templates_api(self) -> TemplatesBaseApi: + self._validate_account_id("Templates API") + return TemplatesBaseApi( + account_id=cast(str, self.account_id), + client=HttpClient(host=GENERAL_HOST, headers=self.headers), + ) + @property def contacts_api(self) -> ContactsBaseApi: self._validate_account_id("Contacts API") diff --git a/mailtrap/models/paginated_templates.py b/mailtrap/models/paginated_templates.py new file mode 100644 index 0000000..3f339f4 --- /dev/null +++ b/mailtrap/models/paginated_templates.py @@ -0,0 +1,88 @@ +"""Models for the account-scoped, paginated Templates API (``/api/templates``).""" + +from typing import Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import Pagination +from mailtrap.models.common import RequestParams + + +@dataclass +class Template: + """A single email template.""" + + id: int + uuid: Optional[str] = None + name: Optional[str] = None + category: Optional[str] = None + subject: Optional[str] = None + body_html: Optional[str] = None + body_text: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +@dataclass +class TemplateResponse: + """Envelope of a single-template response.""" + + data: Template + + +@dataclass +class TemplateListResponse: + """Paginated response from listing templates.""" + + data: list[Template] = Field(default_factory=list) + pagination: Optional[Pagination] = None + + +@dataclass +class TemplateListParams(RequestParams): + """ + Query params for listing templates. ``token`` is the page number and + ``per_page`` is capped at 100. + """ + + per_page: Optional[int] = None + token: Optional[int] = None + + +@dataclass +class CreateTemplateParams(RequestParams): + """Attributes for creating a template (sent as a flat JSON body).""" + + name: str + subject: str + category: str + body_html: Optional[str] = None + body_text: Optional[str] = None + + +@dataclass +class UpdateTemplateParams(RequestParams): + """ + Attributes for updating a template (sent as a flat JSON body). All fields + are optional, but at least one must be provided. + """ + + name: Optional[str] = None + subject: Optional[str] = None + category: Optional[str] = None + body_html: Optional[str] = None + body_text: Optional[str] = None + + def __post_init__(self) -> None: + if all( + value is None + for value in [ + self.name, + self.subject, + self.category, + self.body_html, + self.body_text, + ] + ): + raise ValueError("At least one field must be provided for update action") diff --git a/tests/unit/api/paginated_templates/__init__.py b/tests/unit/api/paginated_templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/paginated_templates/test_paginated_templates.py b/tests/unit/api/paginated_templates/test_paginated_templates.py new file mode 100644 index 0000000..409360a --- /dev/null +++ b/tests/unit/api/paginated_templates/test_paginated_templates.py @@ -0,0 +1,363 @@ +import json +from typing import Any +from urllib.parse import parse_qs +from urllib.parse import urlparse + +import pytest +import responses + +from mailtrap.api.resources.paginated_templates import PaginatedTemplatesApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.paginated_templates import CreateTemplateParams +from mailtrap.models.paginated_templates import Template +from mailtrap.models.paginated_templates import TemplateListParams +from mailtrap.models.paginated_templates import TemplateListResponse +from mailtrap.models.paginated_templates import UpdateTemplateParams +from tests import conftest + +ACCOUNT_ID = "321" +TEMPLATE_ID = 26730 +BASE_TEMPLATES_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/templates" + + +@pytest.fixture +def client() -> PaginatedTemplatesApi: + return PaginatedTemplatesApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_template_dict() -> dict[str, Any]: + return { + "id": TEMPLATE_ID, + "uuid": "b81aabcd-1a1e-41cf-91b6-eca0254b3d96", + "name": "Promotion Template", + "category": "Promotion", + "subject": "Promotion Template subject", + "body_html": "
body
", + "body_text": "Text body", + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + } + + +class TestPaginatedTemplatesApi: + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.RATE_LIMIT_ERROR_STATUS_CODE, + conftest.RATE_LIMIT_ERROR_RESPONSE, + conftest.RATE_LIMIT_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": "token is out of range"}, + "token is out of range", + ), + ], + ) + @responses.activate + def test_get_list_should_raise_api_errors( + self, + client: PaginatedTemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get(BASE_TEMPLATES_URL, status=status_code, json=response_json) + + with pytest.raises(APIError) as exc_info: + client.get_list() + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_list_should_return_templates_and_pagination( + self, client: PaginatedTemplatesApi, sample_template_dict: dict + ) -> None: + responses.get( + BASE_TEMPLATES_URL, + json={ + "data": [sample_template_dict, {"id": 26731, "name": "Second"}], + "pagination": { + "token": 1, + "prev_token": None, + "next_token": 2, + "first_url": f"{BASE_TEMPLATES_URL}?per_page=50&token=1", + "prev_url": None, + "current_url": f"{BASE_TEMPLATES_URL}?per_page=50&token=1", + "next_url": f"{BASE_TEMPLATES_URL}?per_page=50&token=2", + }, + }, + status=200, + ) + + result = client.get_list() + + assert isinstance(result, TemplateListResponse) + assert all(isinstance(t, Template) for t in result.data) + assert len(result.data) == 2 + assert result.data[0].id == TEMPLATE_ID + assert result.data[0].uuid == "b81aabcd-1a1e-41cf-91b6-eca0254b3d96" + assert result.data[0].body_html == "
body
" + assert result.data[1].name == "Second" + assert result.data[1].body_html is None + assert result.pagination is not None + assert result.pagination.token == 1 + assert result.pagination.prev_token is None + assert result.pagination.next_token == 2 + assert result.pagination.next_url == f"{BASE_TEMPLATES_URL}?per_page=50&token=2" + + @responses.activate + def test_get_list_should_return_empty_list( + self, client: PaginatedTemplatesApi + ) -> None: + responses.get( + BASE_TEMPLATES_URL, json={"data": [], "pagination": {"token": 1}}, status=200 + ) + + result = client.get_list() + + assert isinstance(result, TemplateListResponse) + assert result.data == [] + + @responses.activate + def test_get_list_should_send_per_page_and_token_query_params( + self, client: PaginatedTemplatesApi + ) -> None: + responses.get(BASE_TEMPLATES_URL, json={"data": [], "pagination": {}}, status=200) + + client.get_list(TemplateListParams(per_page=25, token=2)) + + query = parse_qs(urlparse(responses.calls[0].request.url).query) + assert query["per_page"] == ["25"] + assert query["token"] == ["2"] + + @responses.activate + def test_get_list_should_send_no_query_params_by_default( + self, client: PaginatedTemplatesApi + ) -> None: + responses.get(BASE_TEMPLATES_URL, json={"data": [], "pagination": {}}, status=200) + + client.get_list() + + assert urlparse(responses.calls[0].request.url).query == "" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_by_id_should_raise_api_errors( + self, + client: PaginatedTemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_by_id(TEMPLATE_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_by_id_should_unwrap_data_envelope( + self, client: PaginatedTemplatesApi, sample_template_dict: dict + ) -> None: + responses.get( + f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}", + json={"data": sample_template_dict}, + status=200, + ) + + template = client.get_by_id(TEMPLATE_ID) + + assert isinstance(template, Template) + assert template.id == TEMPLATE_ID + assert template.name == "Promotion Template" + assert template.category == "Promotion" + assert template.subject == "Promotion Template subject" + assert template.body_text == "Text body" + assert template.created_at == "2026-05-01T10:15:00.000Z" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": {"name": ["can't be blank"]}}, + "name: can't be blank", + ), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: PaginatedTemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post(BASE_TEMPLATES_URL, status=status_code, json=response_json) + + with pytest.raises(APIError) as exc_info: + client.create(CreateTemplateParams(name="", subject="s", category="c")) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_send_flat_body_and_unwrap_response( + self, client: PaginatedTemplatesApi, sample_template_dict: dict + ) -> None: + responses.post( + BASE_TEMPLATES_URL, json={"data": sample_template_dict}, status=201 + ) + + template = client.create( + CreateTemplateParams( + name="Promotion Template", + subject="Promotion Template subject", + category="Promotion", + body_html="
body
", + ) + ) + + assert isinstance(template, Template) + assert template.id == TEMPLATE_ID + assert json.loads(responses.calls[0].request.body) == { + "name": "Promotion Template", + "subject": "Promotion Template subject", + "category": "Promotion", + "body_html": "
body
", + } + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": {"subject": ["can't be blank"]}}, + "subject: can't be blank", + ), + ], + ) + @responses.activate + def test_update_should_raise_api_errors( + self, + client: PaginatedTemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.patch( + f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.update(TEMPLATE_ID, UpdateTemplateParams(subject="")) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_update_should_patch_flat_body_and_unwrap_response( + self, client: PaginatedTemplatesApi, sample_template_dict: dict + ) -> None: + responses.patch( + f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}", + json={"data": {**sample_template_dict, "name": "Renamed"}}, + status=200, + ) + + template = client.update(TEMPLATE_ID, UpdateTemplateParams(name="Renamed")) + + assert isinstance(template, Template) + assert template.name == "Renamed" + assert json.loads(responses.calls[0].request.body) == {"name": "Renamed"} + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_delete_should_raise_api_errors( + self, + client: PaginatedTemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.delete( + f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(TEMPLATE_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_should_return_deleted_object( + self, client: PaginatedTemplatesApi + ) -> None: + responses.delete(f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}", status=204) + + result = client.delete(TEMPLATE_ID) + + assert isinstance(result, DeletedObject) + assert result.id == TEMPLATE_ID diff --git a/tests/unit/models/test_paginated_templates.py b/tests/unit/models/test_paginated_templates.py new file mode 100644 index 0000000..866d197 --- /dev/null +++ b/tests/unit/models/test_paginated_templates.py @@ -0,0 +1,52 @@ +import pytest + +from mailtrap.models.paginated_templates import CreateTemplateParams +from mailtrap.models.paginated_templates import TemplateListParams +from mailtrap.models.paginated_templates import UpdateTemplateParams + + +class TestTemplateListParams: + def test_api_query_params_should_drop_unset_fields(self) -> None: + assert TemplateListParams(per_page=10).api_query_params == {"per_page": 10} + + def test_api_query_params_should_include_all_fields(self) -> None: + params = TemplateListParams(per_page=10, token=2) + assert params.api_query_params == {"per_page": 10, "token": 2} + + +class TestCreateTemplateParams: + def test_api_data_should_return_dict_with_required_props_only(self) -> None: + entity = CreateTemplateParams(name="test", subject="test", category="test") + assert entity.api_data == { + "name": "test", + "subject": "test", + "category": "test", + } + + def test_api_data_should_return_dict_with_all_props(self) -> None: + entity = CreateTemplateParams( + name="test", + subject="test", + category="test", + body_html="

test

", + body_text="test", + ) + assert entity.api_data == { + "name": "test", + "subject": "test", + "category": "test", + "body_html": "

test

", + "body_text": "test", + } + + +class TestUpdateTemplateParams: + def test_raise_error_when_all_fields_are_missing(self) -> None: + with pytest.raises(ValueError) as exc: + _ = UpdateTemplateParams() + + assert "At least one field must be provided for update action" in str(exc) + + def test_api_data_should_return_only_provided_props(self) -> None: + entity = UpdateTemplateParams(name="test", body_text="text") + assert entity.api_data == {"name": "test", "body_text": "text"} diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 02fb138..9178089 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -63,6 +63,13 @@ def test_webhooks_api_requires_account_id(self) -> None: assert "`account_id` is required for Webhooks API" in str(exc_info.value) + def test_templates_api_requires_account_id(self) -> None: + client = self.get_client() + with pytest.raises(mt.ClientConfigurationError) as exc_info: + _ = client.templates_api + + assert "`account_id` is required for Templates API" in str(exc_info.value) + def test_email_campaigns_api_does_not_require_account_id(self) -> None: client = self.get_client()