diff --git a/examples/email_templates/templates.py b/examples/email_templates/templates.py new file mode 100644 index 0000000..e028540 --- /dev/null +++ b/examples/email_templates/templates.py @@ -0,0 +1,62 @@ +from typing import Optional + +import mailtrap as mt +from mailtrap.models.common import DeletedObject +from mailtrap.models.templates import EmailTemplate + +API_TOKEN = "YOU_API_TOKEN" +ACCOUNT_ID = "YOU_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +templates_api = client.email_templates_api.templates + + +def list_templates() -> list[EmailTemplate]: + return templates_api.get_list() + + +def create_template( + name: str, + subject: str, + category: str, + body_text: Optional[str] = None, + body_html: Optional[str] = None, +) -> EmailTemplate: + params = mt.CreateEmailTemplateParams( + name=name, + subject=subject, + category=category, + body_text=body_text, + body_html=body_html, + ) + return templates_api.create(params) + + +def get_template(template_id: str) -> EmailTemplate: + return templates_api.get_by_id(template_id) + + +def update_template( + template_id: str, + name: Optional[str] = None, + subject: Optional[str] = None, + category: Optional[str] = None, + body_text: Optional[str] = None, + body_html: Optional[str] = None, +) -> EmailTemplate: + params = mt.UpdateEmailTemplateParams( + name=name, + subject=subject, + category=category, + body_text=body_text, + body_html=body_html, + ) + return templates_api.update(template_id, params) + + +def delete_template(template_id: str) -> DeletedObject: + return templates_api.delete(template_id) + + +if __name__ == "__main__": + print(list_templates()) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 9c039e0..009f79a 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -10,3 +10,5 @@ from .models.mail import Disposition from .models.mail import Mail from .models.mail import MailFromTemplate +from .models.templates import CreateEmailTemplateParams +from .models.templates import UpdateEmailTemplateParams diff --git a/mailtrap/api/resources/templates.py b/mailtrap/api/resources/templates.py new file mode 100644 index 0000000..beedbfc --- /dev/null +++ b/mailtrap/api/resources/templates.py @@ -0,0 +1,43 @@ +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.templates import CreateEmailTemplateParams +from mailtrap.models.templates import EmailTemplate +from mailtrap.models.templates import UpdateEmailTemplateParams + + +class TemplatesApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + def get_list(self) -> list[EmailTemplate]: + response = self._client.get(f"/api/accounts/{self._account_id}/email_templates") + return [EmailTemplate(**template) for template in response] + + def get_by_id(self, template_id: int) -> EmailTemplate: + response = self._client.get( + f"/api/accounts/{self._account_id}/email_templates/{template_id}" + ) + return EmailTemplate(**response) + + def create(self, template_params: CreateEmailTemplateParams) -> EmailTemplate: + response = self._client.post( + f"/api/accounts/{self._account_id}/email_templates", + json={"email_template": template_params.api_data}, + ) + return EmailTemplate(**response) + + def update( + self, template_id: int, template_params: UpdateEmailTemplateParams + ) -> EmailTemplate: + response = self._client.patch( + f"/api/accounts/{self._account_id}/email_templates/{template_id}", + json={"email_template": template_params.api_data}, + ) + return EmailTemplate(**response) + + def delete(self, template_id: int) -> DeletedObject: + self._client.delete( + f"/api/accounts/{self._account_id}/email_templates/{template_id}" + ) + return DeletedObject(template_id) diff --git a/mailtrap/api/templates.py b/mailtrap/api/templates.py new file mode 100644 index 0000000..cc2640d --- /dev/null +++ b/mailtrap/api/templates.py @@ -0,0 +1,12 @@ +from mailtrap.api.resources.templates import TemplatesApi +from mailtrap.http import HttpClient + + +class EmailTemplatesApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + @property + def templates(self) -> TemplatesApi: + return TemplatesApi(account_id=self._account_id, client=self._client) diff --git a/mailtrap/client.py b/mailtrap/client.py index 68409cf..f1d9f54 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -6,6 +6,7 @@ from pydantic import TypeAdapter from mailtrap.api.sending import SendingApi +from mailtrap.api.templates import EmailTemplatesApi from mailtrap.api.testing import TestingApi from mailtrap.config import BULK_HOST from mailtrap.config import GENERAL_HOST @@ -54,6 +55,14 @@ def testing_api(self) -> TestingApi: client=HttpClient(host=GENERAL_HOST, headers=self.headers), ) + @property + def email_templates_api(self) -> EmailTemplatesApi: + self._validate_account_id() + return EmailTemplatesApi( + account_id=cast(str, self.account_id), + client=HttpClient(host=GENERAL_HOST, headers=self.headers), + ) + @property def sending_api(self) -> SendingApi: http_client = HttpClient(host=self._sending_api_host, headers=self.headers) diff --git a/mailtrap/http.py b/mailtrap/http.py index 4e0c9bf..06524bd 100644 --- a/mailtrap/http.py +++ b/mailtrap/http.py @@ -50,6 +50,10 @@ def _url(self, path: str) -> str: def _process_response(self, response: Response) -> Any: if not response.ok: self._handle_failed_response(response) + + if not response.content.strip(): + return None + return response.json() def _handle_failed_response(self, response: Response) -> NoReturn: diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index da0e1fd..42fdf82 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -5,11 +5,11 @@ from pydantic import TypeAdapter from pydantic.dataclasses import dataclass -T = TypeVar("T", bound="RequestModel") +T = TypeVar("T", bound="RequestParams") @dataclass -class RequestModel: +class RequestParams: @property def api_data(self: T) -> dict[str, Any]: return cast( diff --git a/mailtrap/models/mail/address.py b/mailtrap/models/mail/address.py index 5e7281c..afaee6a 100644 --- a/mailtrap/models/mail/address.py +++ b/mailtrap/models/mail/address.py @@ -2,10 +2,10 @@ from pydantic.dataclasses import dataclass -from mailtrap.models.common import RequestModel +from mailtrap.models.common import RequestParams @dataclass -class Address(RequestModel): +class Address(RequestParams): email: str name: Optional[str] = None diff --git a/mailtrap/models/mail/attachment.py b/mailtrap/models/mail/attachment.py index a65b480..56373b5 100644 --- a/mailtrap/models/mail/attachment.py +++ b/mailtrap/models/mail/attachment.py @@ -6,7 +6,7 @@ from pydantic import field_serializer from pydantic.dataclasses import dataclass -from mailtrap.models.common import RequestModel +from mailtrap.models.common import RequestParams class Disposition(str, Enum): @@ -15,7 +15,7 @@ class Disposition(str, Enum): @dataclass -class Attachment(RequestModel): +class Attachment(RequestParams): content: bytes filename: str disposition: Optional[Disposition] = None diff --git a/mailtrap/models/mail/base.py b/mailtrap/models/mail/base.py index bd41ba9..5964583 100644 --- a/mailtrap/models/mail/base.py +++ b/mailtrap/models/mail/base.py @@ -4,13 +4,13 @@ from pydantic import Field from pydantic.dataclasses import dataclass -from mailtrap.models.common import RequestModel +from mailtrap.models.common import RequestParams from mailtrap.models.mail.address import Address from mailtrap.models.mail.attachment import Attachment @dataclass -class BaseMail(RequestModel): +class BaseMail(RequestParams): sender: Address = Field(..., serialization_alias="from") to: list[Address] = Field(...) cc: Optional[list[Address]] = None diff --git a/mailtrap/models/templates.py b/mailtrap/models/templates.py new file mode 100644 index 0000000..27ba319 --- /dev/null +++ b/mailtrap/models/templates.py @@ -0,0 +1,49 @@ +from typing import Optional + +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams + + +@dataclass +class CreateEmailTemplateParams(RequestParams): + name: str + subject: str + category: str + body_text: Optional[str] = None + body_html: Optional[str] = None + + +@dataclass +class UpdateEmailTemplateParams(RequestParams): + name: Optional[str] = None + subject: Optional[str] = None + category: Optional[str] = None + body_text: Optional[str] = None + body_html: Optional[str] = None + + def __post_init__(self) -> None: + if all( + value is None + for value in [ + self.name, + self.subject, + self.category, + self.body_text, + self.body_html, + ] + ): + raise ValueError("At least one field must be provided for update action") + + +@dataclass +class EmailTemplate: + id: int + name: str + uuid: str + category: str + subject: str + body_text: Optional[str] + body_html: Optional[str] + created_at: str + updated_at: str diff --git a/tests/unit/api/test_email_templates.py b/tests/unit/api/test_email_templates.py new file mode 100644 index 0000000..a1bb56b --- /dev/null +++ b/tests/unit/api/test_email_templates.py @@ -0,0 +1,330 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.templates import TemplatesApi +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.templates import CreateEmailTemplateParams +from mailtrap.models.templates import EmailTemplate +from mailtrap.models.templates import UpdateEmailTemplateParams +from tests import conftest + +ACCOUNT_ID = "321" +TEMPLATE_ID = 26730 +BASE_TEMPLATES_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/email_templates" + + +@pytest.fixture +def client() -> TemplatesApi: + return TemplatesApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_template_dict() -> dict[str, Any]: + return { + "id": TEMPLATE_ID, + "name": "Promotion Template", + "uuid": "b81aabcd-1a1e-41cf-91b6-eca0254b3d96", + "category": "Promotion", + "subject": "Promotion Template subject", + "body_text": "Text body", + "body_html": "
body
", + "created_at": "2025-01-01T10:00:00Z", + "updated_at": "2025-01-02T10:00:00Z", + } + + +class TestTemplatesApi: + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_list_should_raise_api_errors( + self, + client: TemplatesApi, + 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_list( + self, client: TemplatesApi, sample_template_dict: dict + ) -> None: + responses.get( + BASE_TEMPLATES_URL, + json=[sample_template_dict], + status=200, + ) + + templates = client.get_list() + + assert isinstance(templates, list) + assert all(isinstance(t, EmailTemplate) for t in templates) + assert templates[0].id == TEMPLATE_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_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: TemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}" + responses.get( + url, + 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_return_single_template( + self, client: TemplatesApi, sample_template_dict: dict + ) -> None: + url = f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}" + responses.get( + url, + json=sample_template_dict, + status=200, + ) + + template = client.get_by_id(TEMPLATE_ID) + + assert isinstance(template, EmailTemplate) + assert template.id == TEMPLATE_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: TemplatesApi, + 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( + template_params=CreateEmailTemplateParams( + name="test", subject="test", category="test" + ) + ) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_return_new_template( + self, client: TemplatesApi, sample_template_dict: dict + ) -> None: + responses.post( + BASE_TEMPLATES_URL, + json=sample_template_dict, + status=201, + ) + + template = client.create( + template_params=CreateEmailTemplateParams( + name=sample_template_dict["name"], + subject=sample_template_dict["subject"], + category=sample_template_dict["category"], + ) + ) + + assert isinstance(template, EmailTemplate) + assert template.name == sample_template_dict["name"] + assert template.subject == sample_template_dict["subject"] + assert template.category == sample_template_dict["category"] + assert template.uuid == sample_template_dict["uuid"] + assert template.created_at == sample_template_dict["created_at"] + assert template.updated_at == sample_template_dict["updated_at"] + assert template.body_html == sample_template_dict["body_html"] + assert template.body_text == sample_template_dict["body_text"] + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_update_should_raise_api_errors( + self, + client: TemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}" + responses.patch( + url, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.update( + TEMPLATE_ID, template_params=UpdateEmailTemplateParams(name="test") + ) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_update_should_return_updated_template( + self, client: TemplatesApi, sample_template_dict: dict + ) -> None: + url = f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}" + updated_name = "Updated Promotion Template" + updated_template_dict = sample_template_dict.copy() + updated_template_dict["name"] = updated_name + + responses.patch( + url, + json=updated_template_dict, + status=200, + ) + + template = client.update( + TEMPLATE_ID, template_params=UpdateEmailTemplateParams(name=updated_name) + ) + + assert isinstance(template, EmailTemplate) + assert template.name == updated_name + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_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: TemplatesApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}" + responses.delete( + url, + 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: TemplatesApi) -> None: + url = f"{BASE_TEMPLATES_URL}/{TEMPLATE_ID}" + responses.delete( + url, + status=204, + ) + + result = client.delete(TEMPLATE_ID) + + assert isinstance(result, DeletedObject) + assert result.id == TEMPLATE_ID diff --git a/tests/unit/mail/__init__.py b/tests/unit/models/mail/__init__.py similarity index 100% rename from tests/unit/mail/__init__.py rename to tests/unit/models/mail/__init__.py diff --git a/tests/unit/mail/test_address.py b/tests/unit/models/mail/test_address.py similarity index 100% rename from tests/unit/mail/test_address.py rename to tests/unit/models/mail/test_address.py diff --git a/tests/unit/mail/test_attachment.py b/tests/unit/models/mail/test_attachment.py similarity index 100% rename from tests/unit/mail/test_attachment.py rename to tests/unit/models/mail/test_attachment.py diff --git a/tests/unit/mail/test_from_template.py b/tests/unit/models/mail/test_from_template.py similarity index 100% rename from tests/unit/mail/test_from_template.py rename to tests/unit/models/mail/test_from_template.py diff --git a/tests/unit/mail/test_mail.py b/tests/unit/models/mail/test_mail.py similarity index 100% rename from tests/unit/mail/test_mail.py rename to tests/unit/models/mail/test_mail.py diff --git a/tests/unit/models/test_templates.py b/tests/unit/models/test_templates.py new file mode 100644 index 0000000..a021aef --- /dev/null +++ b/tests/unit/models/test_templates.py @@ -0,0 +1,63 @@ +import pytest +from pydantic import ValidationError + +from mailtrap.models.templates import CreateEmailTemplateParams +from mailtrap.models.templates import UpdateEmailTemplateParams + + +class TestCreateEmailTemplateParams: + def test_api_data_should_return_dict_with_required_props_only(self) -> None: + entity = CreateEmailTemplateParams(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 = CreateEmailTemplateParams( + name="test", + subject="test", + category="test", + body_text="test", + body_html="test", + ) + assert entity.api_data == { + "name": "test", + "subject": "test", + "category": "test", + "body_text": "test", + "body_html": "test", + } + + +class TestUpdateEmailTemplateParams: + def test_raise_error_when_all_fields_are_missing(self) -> None: + with pytest.raises(ValidationError) as exc: + _ = UpdateEmailTemplateParams() + + assert "At least one field must be provided for update actio" in str(exc) + + def test_api_data_should_return_dict_with_required_props_only(self) -> None: + entity = UpdateEmailTemplateParams(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 = UpdateEmailTemplateParams( + name="test", + subject="test", + category="test", + body_text="test", + body_html="test", + ) + assert entity.api_data == { + "name": "test", + "subject": "test", + "category": "test", + "body_text": "test", + "body_html": "test", + }