From f9736d1f52cfb8a21ebb6505fa68c4e03ad0f06e Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Fri, 15 Aug 2025 20:21:56 +0300 Subject: [PATCH 1/6] Fix issue #25: Add SendingApi and pydantic.dataclasses models for mail --- mailtrap/__init__.py | 13 +++--- mailtrap/api/sending.py | 18 ++++++++ mailtrap/client.py | 66 ++++++++++++--------------- mailtrap/config.py | 3 ++ mailtrap/models/common.py | 16 +++++++ mailtrap/models/mail/__init__.py | 15 ++++++ mailtrap/models/mail/address.py | 11 +++++ mailtrap/models/mail/attachment.py | 27 +++++++++++ mailtrap/models/mail/base.py | 20 ++++++++ mailtrap/models/mail/from_template.py | 13 ++++++ mailtrap/models/mail/mail.py | 14 ++++++ 11 files changed, 171 insertions(+), 45 deletions(-) create mode 100644 mailtrap/api/sending.py create mode 100644 mailtrap/models/mail/__init__.py create mode 100644 mailtrap/models/mail/address.py create mode 100644 mailtrap/models/mail/attachment.py create mode 100644 mailtrap/models/mail/base.py create mode 100644 mailtrap/models/mail/from_template.py create mode 100644 mailtrap/models/mail/mail.py diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index f03b693..83c454b 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -3,10 +3,9 @@ from .exceptions import AuthorizationError from .exceptions import ClientConfigurationError from .exceptions import MailtrapError -from .mail import Address -from .mail import Attachment -from .mail import BaseEntity -from .mail import BaseMail -from .mail import Disposition -from .mail import Mail -from .mail import MailFromTemplate +from .models.mail import Address +from .models.mail import Attachment +from .models.mail import BaseMail +from .models.mail import Disposition +from .models.mail import Mail +from .models.mail import MailFromTemplate diff --git a/mailtrap/api/sending.py b/mailtrap/api/sending.py new file mode 100644 index 0000000..0668ea6 --- /dev/null +++ b/mailtrap/api/sending.py @@ -0,0 +1,18 @@ +from typing import Union +from typing import cast + +from mailtrap.http import HttpClient +from mailtrap.models.mail.base import BaseMail + +SEND_ENDPOINT_RESPONSE = dict[str, Union[bool, list[str]]] + + +class SendingApi: + def __init__(self, api_url: str, client: HttpClient) -> None: + self._api_url = api_url + self._client = client + + def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: + return cast( + SEND_ENDPOINT_RESPONSE, self._client.post(self._api_url, json=mail.api_data) + ) diff --git a/mailtrap/client.py b/mailtrap/client.py index b2402b2..57680eb 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -1,24 +1,21 @@ -from typing import NoReturn +import warnings from typing import Optional -from typing import Union from typing import cast -import requests - +from mailtrap.api.sending import SEND_ENDPOINT_RESPONSE +from mailtrap.api.sending import SendingApi from mailtrap.api.testing import TestingApi +from mailtrap.config import BULK_HOST from mailtrap.config import GENERAL_HOST -from mailtrap.exceptions import APIError -from mailtrap.exceptions import AuthorizationError +from mailtrap.config import SANDBOX_HOST +from mailtrap.config import SENDING_HOST from mailtrap.exceptions import ClientConfigurationError from mailtrap.http import HttpClient -from mailtrap.mail.base import BaseMail +from mailtrap.models.mail import BaseMail class MailtrapClient: - DEFAULT_HOST = "send.api.mailtrap.io" DEFAULT_PORT = 443 - BULK_HOST = "bulk.api.mailtrap.io" - SANDBOX_HOST = "sandbox.api.mailtrap.io" def __init__( self, @@ -49,27 +46,30 @@ def testing_api(self) -> TestingApi: client=HttpClient(host=GENERAL_HOST, headers=self.headers), ) - def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: - response = requests.post( - self.api_send_url, headers=self.headers, json=mail.api_data + @property + def sending_api(self) -> SendingApi: + return SendingApi( + api_url=self.api_send_url, + client=HttpClient(host=self._sending_api_host, headers=self.headers), ) - if response.ok: - data: dict[str, Union[bool, list[str]]] = response.json() - return data - - self._handle_failed_response(response) + def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: + return self.sending_api.send(mail) @property def base_url(self) -> str: - return f"https://{self._host.rstrip('/')}:{self.api_port}" + warnings.warn( + "base_url is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + return f"https://{self._sending_api_host.rstrip('/')}:{self.api_port}" @property def api_send_url(self) -> str: - url = f"{self.base_url}/api/send" + url = "/api/send" if self.sandbox and self.inbox_id: return f"{url}/{self.inbox_id}" - return url @property @@ -83,24 +83,18 @@ def headers(self) -> dict[str, str]: } @property - def _host(self) -> str: + def _sending_api_host(self) -> str: if self.api_host: return self.api_host if self.sandbox: - return self.SANDBOX_HOST + return SANDBOX_HOST if self.bulk: - return self.BULK_HOST - return self.DEFAULT_HOST - - @staticmethod - def _handle_failed_response(response: requests.Response) -> NoReturn: - status_code = response.status_code - data = response.json() - - if status_code == 401: - raise AuthorizationError(data["errors"]) + return BULK_HOST + return SENDING_HOST - raise APIError(status_code, data["errors"]) + def _validate_account_id(self) -> None: + if not self.account_id: + raise ClientConfigurationError("`account_id` is required for Testing API") def _validate_itself(self) -> None: if self.sandbox and not self.inbox_id: @@ -113,7 +107,3 @@ def _validate_itself(self) -> None: if self.bulk and self.sandbox: raise ClientConfigurationError("bulk mode is not allowed in sandbox mode") - - def _validate_account_id(self) -> None: - if not self.account_id: - raise ClientConfigurationError("`account_id` is required for Testing API") diff --git a/mailtrap/config.py b/mailtrap/config.py index c8a0527..1790a8b 100644 --- a/mailtrap/config.py +++ b/mailtrap/config.py @@ -1,3 +1,6 @@ GENERAL_HOST = "mailtrap.io" +BULK_HOST = "bulk.api.mailtrap.io" +SANDBOX_HOST = "sandbox.api.mailtrap.io" +SENDING_HOST = "send.api.mailtrap.io" DEFAULT_REQUEST_TIMEOUT = 30 # in seconds diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index 2388974..ee66df7 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -1,5 +1,21 @@ +from typing import Any +from typing import TypeVar +from typing import cast + +from pydantic import TypeAdapter from pydantic.dataclasses import dataclass +T = TypeVar("T", bound="RequestModel") + + +@dataclass +class RequestModel: + @property + def api_data(self: T) -> dict[str, Any]: + return cast( + dict[str, Any], TypeAdapter(type(self)).dump_python(self, by_alias=True) + ) + @dataclass class DeletedObject: diff --git a/mailtrap/models/mail/__init__.py b/mailtrap/models/mail/__init__.py new file mode 100644 index 0000000..2baa04c --- /dev/null +++ b/mailtrap/models/mail/__init__.py @@ -0,0 +1,15 @@ +from mailtrap.models.mail.address import Address +from mailtrap.models.mail.attachment import Attachment +from mailtrap.models.mail.attachment import Disposition +from mailtrap.models.mail.base import BaseMail +from mailtrap.models.mail.from_template import MailFromTemplate +from mailtrap.models.mail.mail import Mail + +__all__ = [ + "Address", + "Attachment", + "Disposition", + "BaseMail", + "Mail", + "MailFromTemplate", +] diff --git a/mailtrap/models/mail/address.py b/mailtrap/models/mail/address.py new file mode 100644 index 0000000..5e7281c --- /dev/null +++ b/mailtrap/models/mail/address.py @@ -0,0 +1,11 @@ +from typing import Optional + +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestModel + + +@dataclass +class Address(RequestModel): + email: str + name: Optional[str] = None diff --git a/mailtrap/models/mail/attachment.py b/mailtrap/models/mail/attachment.py new file mode 100644 index 0000000..a65b480 --- /dev/null +++ b/mailtrap/models/mail/attachment.py @@ -0,0 +1,27 @@ +from enum import Enum +from typing import Optional + +from pydantic import Field +from pydantic import FieldSerializationInfo +from pydantic import field_serializer +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestModel + + +class Disposition(str, Enum): + INLINE = "inline" + ATTACHMENT = "attachment" + + +@dataclass +class Attachment(RequestModel): + content: bytes + filename: str + disposition: Optional[Disposition] = None + mimetype: Optional[str] = Field(default=None, serialization_alias="type") + content_id: Optional[str] = None + + @field_serializer("content") + def serialize_content(self, value: bytes, _info: FieldSerializationInfo) -> str: + return value.decode() diff --git a/mailtrap/models/mail/base.py b/mailtrap/models/mail/base.py new file mode 100644 index 0000000..2e8d35d --- /dev/null +++ b/mailtrap/models/mail/base.py @@ -0,0 +1,20 @@ +from typing import Any +from typing import Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestModel +from mailtrap.models.mail.address import Address +from mailtrap.models.mail.attachment import Attachment + + +@dataclass +class BaseMail(RequestModel): + sender: Address = Field(..., serialization_alias="from") + to: list[Address] = Field(...) + cc: Optional[list[Address]] = None + bcc: Optional[list[Address]] = None + attachments: Optional[list[Attachment]] = None + headers: Optional[dict[str, str]] = None + custom_variables: Optional[dict[str, Any]] = None diff --git a/mailtrap/models/mail/from_template.py b/mailtrap/models/mail/from_template.py new file mode 100644 index 0000000..35a8e29 --- /dev/null +++ b/mailtrap/models/mail/from_template.py @@ -0,0 +1,13 @@ +from typing import Any +from typing import Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.mail.base import BaseMail + + +@dataclass +class MailFromTemplate(BaseMail): + template_uuid: str = Field(...) # type:ignore + template_variables: Optional[dict[str, Any]] = None diff --git a/mailtrap/models/mail/mail.py b/mailtrap/models/mail/mail.py new file mode 100644 index 0000000..b58077b --- /dev/null +++ b/mailtrap/models/mail/mail.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.mail.base import BaseMail + + +@dataclass +class Mail(BaseMail): + subject: str = Field(...) # type:ignore + text: Optional[str] = None + html: Optional[str] = None + category: Optional[str] = None From b0f2dfafbe1ef044e2abe18b4405a62313beb524 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 18 Aug 2025 16:17:04 +0300 Subject: [PATCH 2/6] Fix issue #25: Add SendingApi protocol, tests and examples --- examples/sending.py | 44 ++++++++++++ mailtrap/__init__.py | 1 + mailtrap/api/sending.py | 24 +++++-- mailtrap/client.py | 19 +++-- tests/unit/api/test_sending.py | 123 +++++++++++++++++++++++++++++++++ tests/unit/test_client.py | 75 +++++--------------- 6 files changed, 218 insertions(+), 68 deletions(-) create mode 100644 examples/sending.py create mode 100644 tests/unit/api/test_sending.py diff --git a/examples/sending.py b/examples/sending.py new file mode 100644 index 0000000..e2c8fab --- /dev/null +++ b/examples/sending.py @@ -0,0 +1,44 @@ +import mailtrap as mt + +API_TOKEN = "" +INBOX_ID = "" + + +default_client = mt.MailtrapClient(token=API_TOKEN) +bulk_client = mt.MailtrapClient(token=API_TOKEN, bulk=True) +sandbox_client = mt.MailtrapClient(token=API_TOKEN, sandbox=True, inbox_id=INBOX_ID) + + +mail = mt.Mail( + sender=mt.Address(email="", name=""), + to=[mt.Address(email="")], + subject="You are awesome!", + text="Congrats for sending test email with Mailtrap!", + category="Integration Test", +) +mail_from_template = mt.MailFromTemplate( + sender=mt.Address(email="", name=""), + to=[mt.Address(email="")], + template_uuid="", + template_variables={ + "company_info_name": "Test_Company_info_name", + "name": "Test_Name", + "company_info_address": "Test_Company_info_address", + "company_info_city": "Test_Company_info_city", + "company_info_zip_code": "Test_Company_info_zip_code", + "company_info_country": "Test_Company_info_country", + }, +) + + +def send(client: mt.MailtrapClient, mail: mt.BaseMail) -> mt.SEND_ENDPOINT_RESPONSE: + client.send(mail) + + +def batch_send(client: mt.MailtrapClient, mail: mt.BaseMail) -> mt.SEND_ENDPOINT_RESPONSE: + # will be added soon + pass + + +if __name__ == "__main__": + send(default_client, mail) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 83c454b..1b5a2be 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -1,3 +1,4 @@ +from .api.sending import SEND_ENDPOINT_RESPONSE from .client import MailtrapClient from .exceptions import APIError from .exceptions import AuthorizationError diff --git a/mailtrap/api/sending.py b/mailtrap/api/sending.py index 0668ea6..5df26b3 100644 --- a/mailtrap/api/sending.py +++ b/mailtrap/api/sending.py @@ -1,3 +1,4 @@ +from typing import Protocol from typing import Union from typing import cast @@ -7,12 +8,27 @@ SEND_ENDPOINT_RESPONSE = dict[str, Union[bool, list[str]]] -class SendingApi: - def __init__(self, api_url: str, client: HttpClient) -> None: - self._api_url = api_url +class SendingApi(Protocol): + def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: ... + + +class DefaultSendingApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: + return cast( + SEND_ENDPOINT_RESPONSE, self._client.post("/api/send", json=mail.api_data) + ) + + +class SandboxSendingApi: + def __init__(self, inbox_id: str, client: HttpClient) -> None: + self.inbox_id = inbox_id self._client = client def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: return cast( - SEND_ENDPOINT_RESPONSE, self._client.post(self._api_url, json=mail.api_data) + SEND_ENDPOINT_RESPONSE, + self._client.post(f"/api/send/{self.inbox_id}", json=mail.api_data), ) diff --git a/mailtrap/client.py b/mailtrap/client.py index 57680eb..c10d359 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -3,6 +3,8 @@ from typing import cast from mailtrap.api.sending import SEND_ENDPOINT_RESPONSE +from mailtrap.api.sending import DefaultSendingApi +from mailtrap.api.sending import SandboxSendingApi from mailtrap.api.sending import SendingApi from mailtrap.api.testing import TestingApi from mailtrap.config import BULK_HOST @@ -48,10 +50,12 @@ def testing_api(self) -> TestingApi: @property def sending_api(self) -> SendingApi: - return SendingApi( - api_url=self.api_send_url, - client=HttpClient(host=self._sending_api_host, headers=self.headers), - ) + http_client = HttpClient(host=self._sending_api_host, headers=self.headers) + if self.sandbox: + return SandboxSendingApi( + inbox_id=cast(str, self.inbox_id), client=http_client + ) + return DefaultSendingApi(client=http_client) def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: return self.sending_api.send(mail) @@ -67,7 +71,12 @@ def base_url(self) -> str: @property def api_send_url(self) -> str: - url = "/api/send" + warnings.warn( + "api_send_url is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=2, + ) + url = f"{self.base_url}/api/send" if self.sandbox and self.inbox_id: return f"{url}/{self.inbox_id}" return url diff --git a/tests/unit/api/test_sending.py b/tests/unit/api/test_sending.py new file mode 100644 index 0000000..6311030 --- /dev/null +++ b/tests/unit/api/test_sending.py @@ -0,0 +1,123 @@ +import json +from typing import Callable + +import pytest +import responses + +import mailtrap as mt +from mailtrap.api.sending import DefaultSendingApi +from mailtrap.api.sending import SandboxSendingApi +from mailtrap.api.sending import SendingApi +from mailtrap.config import SANDBOX_HOST +from mailtrap.config import SENDING_HOST +from mailtrap.http import HttpClient + +ACCOUNT_ID = "321" +PROJECT_ID = 123 +INBOX_ID = "456" + +DUMMY_ADDRESS = mt.Address(email="joe@mail.com") +DUMMY_MAIL = mt.Mail( + sender=DUMMY_ADDRESS, + to=[DUMMY_ADDRESS], + subject="Email subject", + text="email text", +) +DUMMY_MAIL_FROM_TEMPLATE = mt.MailFromTemplate( + sender=DUMMY_ADDRESS, + to=[DUMMY_ADDRESS], + template_uuid="fake_uuid", +) + +MAIL_ENTITIES = [DUMMY_MAIL, DUMMY_MAIL_FROM_TEMPLATE] + +SEND_FULL_URL = f"https://{SENDING_HOST}/api/send" +SANDBOX_SEND_FULL_URL = f"https://{SANDBOX_HOST}/api/send/{INBOX_ID}" + + +def get_sending_api() -> SendingApi: + return DefaultSendingApi(client=HttpClient(SENDING_HOST)) + + +def get_sandbox_sending_api() -> SendingApi: + return SandboxSendingApi(inbox_id=INBOX_ID, client=HttpClient(SANDBOX_HOST)) + + +SENDING_API_FACTORIES = [ + (get_sending_api, SEND_FULL_URL), + (get_sandbox_sending_api, SANDBOX_SEND_FULL_URL), +] + + +class TestSendingApi: + + @responses.activate + @pytest.mark.parametrize("mail", MAIL_ENTITIES) + @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) + def test_send_should_raise_authorization_error( + self, + mail: mt.BaseMail, + api_factory: Callable[[], SendingApi], + full_url: str, + ) -> None: + response_body = {"errors": ["Unauthorized"]} + responses.post(full_url, json=response_body, status=401) + + api = api_factory() + + with pytest.raises(mt.AuthorizationError): + api.send(mail) + + @responses.activate + @pytest.mark.parametrize("mail", MAIL_ENTITIES) + @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) + def test_send_should_raise_api_error_for_400_status_code( + self, + mail: mt.BaseMail, + api_factory: Callable[[], SendingApi], + full_url: str, + ) -> None: + response_body = {"errors": ["Some error msg"]} + responses.post(full_url, json=response_body, status=400) + + api = api_factory() + + with pytest.raises(mt.APIError): + api.send(mail) + + @responses.activate + @pytest.mark.parametrize("mail", MAIL_ENTITIES) + @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) + def test_send_should_raise_api_error_for_500_status_code( + self, + mail: mt.BaseMail, + api_factory: Callable[[], SendingApi], + full_url: str, + ) -> None: + response_body = {"errors": ["Some error msg"]} + responses.post(full_url, json=response_body, status=500) + + api = api_factory() + + with pytest.raises(mt.APIError): + api.send(mail) + + @responses.activate + @pytest.mark.parametrize("mail", MAIL_ENTITIES) + @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) + def test_send_should_handle_success_response( + self, + mail: mt.BaseMail, + api_factory: Callable[[], SendingApi], + full_url: str, + ) -> None: + response_body = {"success": True, "message_ids": ["12345"]} + responses.post(full_url, json=response_body) + + api = api_factory() + result = api.send(mail) + + assert result == response_body + assert len(responses.calls) == 1 + request = responses.calls[0].request # type: ignore + assert request.body == json.dumps(mail.api_data).encode() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index cc19c36..5c194b0 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,10 +1,10 @@ -import json from typing import Any import pytest -import responses import mailtrap as mt +from mailtrap.api.sending import DefaultSendingApi +from mailtrap.api.sending import SandboxSendingApi DUMMY_ADDRESS = mt.Address(email="joe@mail.com") DUMMY_MAIL = mt.Mail( @@ -45,14 +45,23 @@ def test_client_validation(self, arguments: dict[str, Any]) -> None: def test_get_testing_api_validation(self) -> None: client = self.get_client() with pytest.raises(mt.ClientConfigurationError) as exc_info: - client.testing_api + _ = client.testing_api assert "`account_id` is required for Testing API" in str(exc_info.value) - def test_base_url_should_truncate_slash_from_host(self) -> None: - client = self.get_client(api_host="example.send.com/", api_port=543) - - assert client.base_url == "https://example.send.com:543" + @pytest.mark.parametrize( + "api_cls,client_arguments", + [ + (DefaultSendingApi, {}), + (DefaultSendingApi, {"bulk": True}), + (SandboxSendingApi, {"sandbox": True, "inbox_id": "12345"}), + ], + ) + def test_get_sending_api_api_validation( + self, api_cls, client_arguments: dict[str, Any] + ) -> None: + client = self.get_client(**client_arguments) + assert isinstance(client.sending_api, api_cls) @pytest.mark.parametrize( "arguments, expected_url", @@ -97,55 +106,3 @@ def test_headers_should_return_appropriate_dict(self) -> None: "mailtrap-python (https://github.com/railsware/mailtrap-python)" ), } - - @responses.activate - @pytest.mark.parametrize("mail", MAIL_ENTITIES) - def test_send_should_handle_success_response(self, mail: mt.BaseMail) -> None: - response_body = {"success": True, "message_ids": ["12345"]} - responses.add(responses.POST, self.SEND_URL, json=response_body) - - client = self.get_client() - result = client.send(mail) - - assert result == response_body - assert len(responses.calls) == 1 - request = responses.calls[0].request # type: ignore - assert request.headers.items() >= client.headers.items() - assert request.body == json.dumps(mail.api_data).encode() - - @responses.activate - @pytest.mark.parametrize("mail", MAIL_ENTITIES) - def test_send_should_raise_authorization_error(self, mail: mt.BaseMail) -> None: - response_body = {"errors": ["Unauthorized"]} - responses.add(responses.POST, self.SEND_URL, json=response_body, status=401) - - client = self.get_client() - - with pytest.raises(mt.AuthorizationError): - client.send(mail) - - @responses.activate - @pytest.mark.parametrize("mail", MAIL_ENTITIES) - def test_send_should_raise_api_error_for_400_status_code( - self, mail: mt.BaseMail - ) -> None: - response_body = {"errors": ["Some error msg"]} - responses.add(responses.POST, self.SEND_URL, json=response_body, status=400) - - client = self.get_client() - - with pytest.raises(mt.APIError): - client.send(mail) - - @responses.activate - @pytest.mark.parametrize("mail", MAIL_ENTITIES) - def test_send_should_raise_api_error_for_500_status_code( - self, mail: mt.BaseMail - ) -> None: - response_body = {"errors": ["Some error msg"]} - responses.add(responses.POST, self.SEND_URL, json=response_body, status=500) - - client = self.get_client() - - with pytest.raises(mt.APIError): - client.send(mail) From 7b1e44f707402952c86d532cb91b168987f8613c Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 18 Aug 2025 16:45:47 +0300 Subject: [PATCH 3/6] Fix issue #25: Get rid of old models --- mailtrap/mail/__init__.py | 7 ---- mailtrap/mail/address.py | 14 ------- mailtrap/mail/attachment.py | 38 ----------------- mailtrap/mail/base.py | 53 ------------------------ mailtrap/mail/base_entity.py | 14 ------- mailtrap/mail/from_template.py | 45 -------------------- mailtrap/mail/mail.py | 59 --------------------------- mailtrap/models/common.py | 3 +- tests/unit/mail/test_address.py | 2 +- tests/unit/mail/test_attachment.py | 4 +- tests/unit/mail/test_from_template.py | 6 +-- tests/unit/mail/test_mail.py | 6 +-- 12 files changed, 11 insertions(+), 240 deletions(-) delete mode 100644 mailtrap/mail/__init__.py delete mode 100644 mailtrap/mail/address.py delete mode 100644 mailtrap/mail/attachment.py delete mode 100644 mailtrap/mail/base.py delete mode 100644 mailtrap/mail/base_entity.py delete mode 100644 mailtrap/mail/from_template.py delete mode 100644 mailtrap/mail/mail.py diff --git a/mailtrap/mail/__init__.py b/mailtrap/mail/__init__.py deleted file mode 100644 index 0bb663f..0000000 --- a/mailtrap/mail/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .address import Address -from .attachment import Attachment -from .attachment import Disposition -from .base import BaseMail -from .base_entity import BaseEntity -from .from_template import MailFromTemplate -from .mail import Mail diff --git a/mailtrap/mail/address.py b/mailtrap/mail/address.py deleted file mode 100644 index efd59c5..0000000 --- a/mailtrap/mail/address.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Any -from typing import Optional - -from mailtrap.mail.base_entity import BaseEntity - - -class Address(BaseEntity): - def __init__(self, email: str, name: Optional[str] = None) -> None: - self.email = email - self.name = name - - @property - def api_data(self) -> dict[str, Any]: - return self.omit_none_values({"email": self.email, "name": self.name}) diff --git a/mailtrap/mail/attachment.py b/mailtrap/mail/attachment.py deleted file mode 100644 index 72e6b55..0000000 --- a/mailtrap/mail/attachment.py +++ /dev/null @@ -1,38 +0,0 @@ -from enum import Enum -from typing import Any -from typing import Optional - -from mailtrap.mail.base_entity import BaseEntity - - -class Disposition(Enum): - INLINE = "inline" - ATTACHMENT = "attachment" - - -class Attachment(BaseEntity): - def __init__( - self, - content: bytes, - filename: str, - disposition: Optional[Disposition] = None, - mimetype: Optional[str] = None, - content_id: Optional[str] = None, - ) -> None: - self.content = content - self.filename = filename - self.mimetype = mimetype - self.disposition = disposition - self.content_id = content_id - - @property - def api_data(self) -> dict[str, Any]: - return self.omit_none_values( - { - "content": self.content.decode(), - "filename": self.filename, - "type": self.mimetype, - "disposition": self.disposition.value if self.disposition else None, - "content_id": self.content_id, - } - ) diff --git a/mailtrap/mail/base.py b/mailtrap/mail/base.py deleted file mode 100644 index 6032fc4..0000000 --- a/mailtrap/mail/base.py +++ /dev/null @@ -1,53 +0,0 @@ -from abc import ABCMeta -from collections.abc import Sequence -from typing import Any -from typing import Optional - -from mailtrap.mail.address import Address -from mailtrap.mail.attachment import Attachment -from mailtrap.mail.base_entity import BaseEntity - - -class BaseMail(BaseEntity, metaclass=ABCMeta): - """Base abstract class for mails.""" - - def __init__( - self, - sender: Address, - to: list[Address], - cc: Optional[list[Address]] = None, - bcc: Optional[list[Address]] = None, - attachments: Optional[list[Attachment]] = None, - headers: Optional[dict[str, str]] = None, - custom_variables: Optional[dict[str, Any]] = None, - ) -> None: - self.sender = sender - self.to = to - self.cc = cc - self.bcc = bcc - self.attachments = attachments - self.headers = headers - self.custom_variables = custom_variables - - @property - def api_data(self) -> dict[str, Any]: - return self.omit_none_values( - { - "from": self.sender.api_data, - "to": self.get_api_data_from_list(self.to), - "cc": self.get_api_data_from_list(self.cc), - "bcc": self.get_api_data_from_list(self.bcc), - "attachments": self.get_api_data_from_list(self.attachments), - "headers": self.headers, - "custom_variables": self.custom_variables, - } - ) - - @staticmethod - def get_api_data_from_list( - items: Optional[Sequence[BaseEntity]], - ) -> Optional[list[dict[str, Any]]]: - if items is None: - return None - - return [item.api_data for item in items] diff --git a/mailtrap/mail/base_entity.py b/mailtrap/mail/base_entity.py deleted file mode 100644 index 536f94c..0000000 --- a/mailtrap/mail/base_entity.py +++ /dev/null @@ -1,14 +0,0 @@ -from abc import ABCMeta -from abc import abstractmethod -from typing import Any - - -class BaseEntity(metaclass=ABCMeta): - @property - @abstractmethod - def api_data(self) -> dict[str, Any]: - raise NotImplementedError - - @staticmethod - def omit_none_values(data: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in data.items() if value is not None} diff --git a/mailtrap/mail/from_template.py b/mailtrap/mail/from_template.py deleted file mode 100644 index 24fa786..0000000 --- a/mailtrap/mail/from_template.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import Any -from typing import Optional - -from mailtrap.mail.address import Address -from mailtrap.mail.attachment import Attachment -from mailtrap.mail.base import BaseMail - - -class MailFromTemplate(BaseMail): - """Creates `EmailFromTemplate` request body for /api/send Mailtrap API v2 - endpoint.""" - - def __init__( - self, - sender: Address, - to: list[Address], - template_uuid: str, - template_variables: Optional[dict[str, Any]] = None, - cc: Optional[list[Address]] = None, - bcc: Optional[list[Address]] = None, - attachments: Optional[list[Attachment]] = None, - headers: Optional[dict[str, str]] = None, - custom_variables: Optional[dict[str, Any]] = None, - ) -> None: - super().__init__( - sender=sender, - to=to, - cc=cc, - bcc=bcc, - attachments=attachments, - headers=headers, - custom_variables=custom_variables, - ) - self.template_uuid = template_uuid - self.template_variables = template_variables - - @property - def api_data(self) -> dict[str, Any]: - return self.omit_none_values( - { - **super().api_data, - "template_uuid": self.template_uuid, - "template_variables": self.template_variables, - } - ) diff --git a/mailtrap/mail/mail.py b/mailtrap/mail/mail.py deleted file mode 100644 index 6f81e03..0000000 --- a/mailtrap/mail/mail.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any -from typing import Optional - -from mailtrap.mail.address import Address -from mailtrap.mail.attachment import Attachment -from mailtrap.mail.base import BaseMail - - -class Mail(BaseMail): - """Creates a request body for /api/send Mailtrap API v2 endpoint. - - Either `text` or `html` param must be specified. You can also - provide both of them. - - If only `text` is provided, `EmailWithText` body type will be used. - If only `html` is provided, `HtmlWithText` body type will be used. - If both `text` and `html` are provided, - `EmailWithTextAndHtml` body type will be used. - """ - - def __init__( - self, - sender: Address, - to: list[Address], - subject: str, - text: Optional[str] = None, - html: Optional[str] = None, - category: Optional[str] = None, - cc: Optional[list[Address]] = None, - bcc: Optional[list[Address]] = None, - attachments: Optional[list[Attachment]] = None, - headers: Optional[dict[str, str]] = None, - custom_variables: Optional[dict[str, Any]] = None, - ) -> None: - super().__init__( - sender=sender, - to=to, - cc=cc, - bcc=bcc, - attachments=attachments, - headers=headers, - custom_variables=custom_variables, - ) - self.subject = subject - self.text = text - self.html = html - self.category = category - - @property - def api_data(self) -> dict[str, Any]: - return self.omit_none_values( - { - **super().api_data, - "subject": self.subject, - "text": self.text, - "html": self.html, - "category": self.category, - } - ) diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index ee66df7..da0e1fd 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -13,7 +13,8 @@ class RequestModel: @property def api_data(self: T) -> dict[str, Any]: return cast( - dict[str, Any], TypeAdapter(type(self)).dump_python(self, by_alias=True) + dict[str, Any], + TypeAdapter(type(self)).dump_python(self, by_alias=True, exclude_none=True), ) diff --git a/tests/unit/mail/test_address.py b/tests/unit/mail/test_address.py index 2f4e3ef..7a286d5 100644 --- a/tests/unit/mail/test_address.py +++ b/tests/unit/mail/test_address.py @@ -1,4 +1,4 @@ -from mailtrap.mail.address import Address +from mailtrap.models.mail.address import Address class TestAddress: diff --git a/tests/unit/mail/test_attachment.py b/tests/unit/mail/test_attachment.py index d5bab61..e44c1d9 100644 --- a/tests/unit/mail/test_attachment.py +++ b/tests/unit/mail/test_attachment.py @@ -1,5 +1,5 @@ -from mailtrap.mail.attachment import Attachment -from mailtrap.mail.attachment import Disposition +from mailtrap.models.mail.attachment import Attachment +from mailtrap.models.mail.attachment import Disposition class TestAttachment: diff --git a/tests/unit/mail/test_from_template.py b/tests/unit/mail/test_from_template.py index d83c338..853cd2b 100644 --- a/tests/unit/mail/test_from_template.py +++ b/tests/unit/mail/test_from_template.py @@ -1,6 +1,6 @@ -from mailtrap.mail.from_template import MailFromTemplate -from mailtrap.mail.mail import Address -from mailtrap.mail.mail import Attachment +from mailtrap.models.mail import Address +from mailtrap.models.mail import Attachment +from mailtrap.models.mail import MailFromTemplate class TestAttachment: diff --git a/tests/unit/mail/test_mail.py b/tests/unit/mail/test_mail.py index ae8c5f5..8afab27 100644 --- a/tests/unit/mail/test_mail.py +++ b/tests/unit/mail/test_mail.py @@ -1,6 +1,6 @@ -from mailtrap.mail.mail import Address -from mailtrap.mail.mail import Attachment -from mailtrap.mail.mail import Mail +from mailtrap.models.mail import Address +from mailtrap.models.mail import Attachment +from mailtrap.models.mail import Mail class TestAttachment: From 27e299ccff03930ac54628ddce202fa228f12cb9 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 18 Aug 2025 17:08:10 +0300 Subject: [PATCH 4/6] Fix issue #25: Add return type in examples function --- examples/sending.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/sending.py b/examples/sending.py index e2c8fab..49ed1d3 100644 --- a/examples/sending.py +++ b/examples/sending.py @@ -32,7 +32,7 @@ def send(client: mt.MailtrapClient, mail: mt.BaseMail) -> mt.SEND_ENDPOINT_RESPONSE: - client.send(mail) + return client.send(mail) def batch_send(client: mt.MailtrapClient, mail: mt.BaseMail) -> mt.SEND_ENDPOINT_RESPONSE: @@ -41,4 +41,4 @@ def batch_send(client: mt.MailtrapClient, mail: mt.BaseMail) -> mt.SEND_ENDPOINT if __name__ == "__main__": - send(default_client, mail) + print(send(default_client, mail)) From c21e0fc86b9617608f4863adbcd2d5ff274dffca Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Wed, 20 Aug 2025 18:51:04 +0300 Subject: [PATCH 5/6] Fix issue #25: Add SendingMailResponse model, simplify SendingApi class --- mailtrap/__init__.py | 2 +- mailtrap/api/sending.py | 40 ++++++++++----------------- mailtrap/client.py | 21 ++++++++------ mailtrap/models/mail/base.py | 6 ++++ tests/unit/api/test_sending.py | 50 ++++++++-------------------------- tests/unit/test_client.py | 16 ----------- 6 files changed, 45 insertions(+), 90 deletions(-) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 1b5a2be..9c039e0 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -1,4 +1,4 @@ -from .api.sending import SEND_ENDPOINT_RESPONSE +from .client import SEND_ENDPOINT_RESPONSE from .client import MailtrapClient from .exceptions import APIError from .exceptions import AuthorizationError diff --git a/mailtrap/api/sending.py b/mailtrap/api/sending.py index 5df26b3..08a5cde 100644 --- a/mailtrap/api/sending.py +++ b/mailtrap/api/sending.py @@ -1,34 +1,22 @@ -from typing import Protocol -from typing import Union -from typing import cast +from typing import Optional from mailtrap.http import HttpClient from mailtrap.models.mail.base import BaseMail +from mailtrap.models.mail.base import SendingMailResponse -SEND_ENDPOINT_RESPONSE = dict[str, Union[bool, list[str]]] - -class SendingApi(Protocol): - def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: ... - - -class DefaultSendingApi: - def __init__(self, client: HttpClient) -> None: +class SendingApi: + def __init__(self, client: HttpClient, inbox_id: Optional[str] = None) -> None: + self._inbox_id = inbox_id self._client = client - def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: - return cast( - SEND_ENDPOINT_RESPONSE, self._client.post("/api/send", json=mail.api_data) - ) - - -class SandboxSendingApi: - def __init__(self, inbox_id: str, client: HttpClient) -> None: - self.inbox_id = inbox_id - self._client = client + @property + def _api_url(self) -> str: + url = "/api/send" + if self._inbox_id: + return f"{url}/{self._inbox_id}" + return url - def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: - return cast( - SEND_ENDPOINT_RESPONSE, - self._client.post(f"/api/send/{self.inbox_id}", json=mail.api_data), - ) + def send(self, mail: BaseMail) -> SendingMailResponse: + response = self._client.post(self._api_url, json=mail.api_data) + return SendingMailResponse(**response) diff --git a/mailtrap/client.py b/mailtrap/client.py index c10d359..49d5d0b 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -1,10 +1,10 @@ import warnings from typing import Optional +from typing import Union from typing import cast -from mailtrap.api.sending import SEND_ENDPOINT_RESPONSE -from mailtrap.api.sending import DefaultSendingApi -from mailtrap.api.sending import SandboxSendingApi +from pydantic import TypeAdapter + from mailtrap.api.sending import SendingApi from mailtrap.api.testing import TestingApi from mailtrap.config import BULK_HOST @@ -14,6 +14,9 @@ from mailtrap.exceptions import ClientConfigurationError from mailtrap.http import HttpClient from mailtrap.models.mail import BaseMail +from mailtrap.models.mail.base import SendingMailResponse + +SEND_ENDPOINT_RESPONSE = dict[str, Union[bool, list[str]]] class MailtrapClient: @@ -51,14 +54,14 @@ def testing_api(self) -> TestingApi: @property def sending_api(self) -> SendingApi: http_client = HttpClient(host=self._sending_api_host, headers=self.headers) - if self.sandbox: - return SandboxSendingApi( - inbox_id=cast(str, self.inbox_id), client=http_client - ) - return DefaultSendingApi(client=http_client) + return SendingApi(client=http_client, inbox_id=self.inbox_id) def send(self, mail: BaseMail) -> SEND_ENDPOINT_RESPONSE: - return self.sending_api.send(mail) + sending_response = self.sending_api.send(mail) + return cast( + SEND_ENDPOINT_RESPONSE, + TypeAdapter(SendingMailResponse).dump_python(sending_response), + ) @property def base_url(self) -> str: diff --git a/mailtrap/models/mail/base.py b/mailtrap/models/mail/base.py index 2e8d35d..bd41ba9 100644 --- a/mailtrap/models/mail/base.py +++ b/mailtrap/models/mail/base.py @@ -18,3 +18,9 @@ class BaseMail(RequestModel): attachments: Optional[list[Attachment]] = None headers: Optional[dict[str, str]] = None custom_variables: Optional[dict[str, Any]] = None + + +@dataclass +class SendingMailResponse: + success: bool + message_ids: list[str] diff --git a/tests/unit/api/test_sending.py b/tests/unit/api/test_sending.py index 6311030..619419a 100644 --- a/tests/unit/api/test_sending.py +++ b/tests/unit/api/test_sending.py @@ -1,16 +1,13 @@ import json -from typing import Callable import pytest import responses import mailtrap as mt -from mailtrap.api.sending import DefaultSendingApi -from mailtrap.api.sending import SandboxSendingApi from mailtrap.api.sending import SendingApi -from mailtrap.config import SANDBOX_HOST from mailtrap.config import SENDING_HOST from mailtrap.http import HttpClient +from mailtrap.models.mail.base import SendingMailResponse ACCOUNT_ID = "321" PROJECT_ID = 123 @@ -32,92 +29,69 @@ MAIL_ENTITIES = [DUMMY_MAIL, DUMMY_MAIL_FROM_TEMPLATE] SEND_FULL_URL = f"https://{SENDING_HOST}/api/send" -SANDBOX_SEND_FULL_URL = f"https://{SANDBOX_HOST}/api/send/{INBOX_ID}" def get_sending_api() -> SendingApi: - return DefaultSendingApi(client=HttpClient(SENDING_HOST)) - - -def get_sandbox_sending_api() -> SendingApi: - return SandboxSendingApi(inbox_id=INBOX_ID, client=HttpClient(SANDBOX_HOST)) - - -SENDING_API_FACTORIES = [ - (get_sending_api, SEND_FULL_URL), - (get_sandbox_sending_api, SANDBOX_SEND_FULL_URL), -] + return SendingApi(client=HttpClient(SENDING_HOST)) class TestSendingApi: @responses.activate @pytest.mark.parametrize("mail", MAIL_ENTITIES) - @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) def test_send_should_raise_authorization_error( self, mail: mt.BaseMail, - api_factory: Callable[[], SendingApi], - full_url: str, ) -> None: response_body = {"errors": ["Unauthorized"]} - responses.post(full_url, json=response_body, status=401) - - api = api_factory() + responses.post(SEND_FULL_URL, json=response_body, status=401) + api = get_sending_api() with pytest.raises(mt.AuthorizationError): api.send(mail) @responses.activate @pytest.mark.parametrize("mail", MAIL_ENTITIES) - @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) def test_send_should_raise_api_error_for_400_status_code( self, mail: mt.BaseMail, - api_factory: Callable[[], SendingApi], - full_url: str, ) -> None: response_body = {"errors": ["Some error msg"]} - responses.post(full_url, json=response_body, status=400) + responses.post(SEND_FULL_URL, json=response_body, status=400) - api = api_factory() + api = get_sending_api() with pytest.raises(mt.APIError): api.send(mail) @responses.activate @pytest.mark.parametrize("mail", MAIL_ENTITIES) - @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) def test_send_should_raise_api_error_for_500_status_code( self, mail: mt.BaseMail, - api_factory: Callable[[], SendingApi], - full_url: str, ) -> None: response_body = {"errors": ["Some error msg"]} - responses.post(full_url, json=response_body, status=500) + responses.post(SEND_FULL_URL, json=response_body, status=500) - api = api_factory() + api = get_sending_api() with pytest.raises(mt.APIError): api.send(mail) @responses.activate @pytest.mark.parametrize("mail", MAIL_ENTITIES) - @pytest.mark.parametrize("api_factory,full_url", SENDING_API_FACTORIES) def test_send_should_handle_success_response( self, mail: mt.BaseMail, - api_factory: Callable[[], SendingApi], - full_url: str, ) -> None: response_body = {"success": True, "message_ids": ["12345"]} - responses.post(full_url, json=response_body) + responses.post(SEND_FULL_URL, json=response_body) - api = api_factory() + api = get_sending_api() result = api.send(mail) - assert result == response_body + assert isinstance(result, SendingMailResponse) + assert result.success is True assert len(responses.calls) == 1 request = responses.calls[0].request # type: ignore assert request.body == json.dumps(mail.api_data).encode() diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 5c194b0..4ca4ad8 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -3,8 +3,6 @@ import pytest import mailtrap as mt -from mailtrap.api.sending import DefaultSendingApi -from mailtrap.api.sending import SandboxSendingApi DUMMY_ADDRESS = mt.Address(email="joe@mail.com") DUMMY_MAIL = mt.Mail( @@ -49,20 +47,6 @@ def test_get_testing_api_validation(self) -> None: assert "`account_id` is required for Testing API" in str(exc_info.value) - @pytest.mark.parametrize( - "api_cls,client_arguments", - [ - (DefaultSendingApi, {}), - (DefaultSendingApi, {"bulk": True}), - (SandboxSendingApi, {"sandbox": True, "inbox_id": "12345"}), - ], - ) - def test_get_sending_api_api_validation( - self, api_cls, client_arguments: dict[str, Any] - ) -> None: - client = self.get_client(**client_arguments) - assert isinstance(client.sending_api, api_cls) - @pytest.mark.parametrize( "arguments, expected_url", [ From 2f2a229eea560e5f003a0d7ff92b7fc87b668d40 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Thu, 21 Aug 2025 11:57:51 +0300 Subject: [PATCH 6/6] Fix issue #25: Revert removing constants from MailtrapClient class --- mailtrap/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mailtrap/client.py b/mailtrap/client.py index 49d5d0b..68409cf 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -20,7 +20,10 @@ class MailtrapClient: + DEFAULT_HOST = SENDING_HOST DEFAULT_PORT = 443 + BULK_HOST = BULK_HOST + SANDBOX_HOST = SANDBOX_HOST def __init__( self,