diff --git a/CHANGELOG.md b/CHANGELOG.md index 609b6a1..6495d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,24 @@ +## [2.5.0] - 2026-03-06 + +- Add StatsApi with get, by_domains, by_categories, by_email_service_providers, by_date endpoints +- Add api_query_params to RequestParams for automatic [] serialization of list query params + ## [2.4.0] - 2025-12-04 -* Fix issue #52: Update README.md using new guideline by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/55 -* Fix issue #53: Add full usage in all examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/56 -* Merge functionality and examples in Readme by @yanchuk in https://github.com/mailtrap/mailtrap-python/pull/57 -* Fix issue #54: Add SendingDomainsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/58 + +- Fix issue #52: Update README.md using new guideline by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/55 +- Fix issue #53: Add full usage in all examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/56 +- Merge functionality and examples in Readme by @yanchuk in https://github.com/mailtrap/mailtrap-python/pull/57 +- Fix issue #54: Add SendingDomainsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/58 ## [2.3.0] - 2025-10-24 -* Fix issue #24: Add batch_send method to SendingApi, add models by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/47 -* Fix issue #42: Add GeneralApi, related models, examples, tests. by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/48 -* Fix issue #41: Add ContactExportsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/49 -* Fix issue #45: Add ContactEventsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/51 + +- Fix issue #24: Add batch_send method to SendingApi, add models by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/47 +- Fix issue #42: Add GeneralApi, related models, examples, tests. by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/48 +- Fix issue #41: Add ContactExportsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/49 +- Fix issue #45: Add ContactEventsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/51 ## [2.2.0] - 2025-09-18 + - Potential fix for code scanning alert no. 1: Workflow does not contain permissions by @mklocek in https://github.com/railsware/mailtrap-python/pull/15 - Fix issue #29. Add support of Emails Sandbox (Testing) API: Projects by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/31 - Issue 25 by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/33 @@ -25,6 +33,7 @@ - Fix issue #28: Add AttachmentsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/44 ## [2.1.0] - 2025-05-12 + - Add sandbox mode support in MailtrapClient - It requires inbox_id parameter to be set - Add bulk mode support in MailtrapClient @@ -32,6 +41,7 @@ - Add support for python 3.12 - 3.13 ## [2.0.1] - 2023-05-18 + - Add User-Agent header to all requests ## [2.0.0] - 2023-03-11 diff --git a/examples/general/stats.py b/examples/general/stats.py new file mode 100644 index 0000000..e1b9590 --- /dev/null +++ b/examples/general/stats.py @@ -0,0 +1,67 @@ +import mailtrap as mt +from mailtrap.models.stats import SendingStatGroup +from mailtrap.models.stats import SendingStats +from mailtrap.models.stats import StatsFilterParams + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN) +stats_api = client.general_api.stats + + +def get_stats(account_id: int) -> SendingStats: + params = StatsFilterParams(start_date="2026-01-01", end_date="2026-01-31") + return stats_api.get(account_id=account_id, params=params) + + +def get_stats_by_domains(account_id: int) -> list[SendingStatGroup]: + params = StatsFilterParams(start_date="2026-01-01", end_date="2026-01-31") + return stats_api.by_domains(account_id=account_id, params=params) + + +def get_stats_by_categories(account_id: int) -> list[SendingStatGroup]: + params = StatsFilterParams(start_date="2026-01-01", end_date="2026-01-31") + return stats_api.by_categories(account_id=account_id, params=params) + + +def get_stats_by_email_service_providers(account_id: int) -> list[SendingStatGroup]: + params = StatsFilterParams(start_date="2026-01-01", end_date="2026-01-31") + return stats_api.by_email_service_providers(account_id=account_id, params=params) + + +def get_stats_by_date(account_id: int) -> list[SendingStatGroup]: + params = StatsFilterParams(start_date="2026-01-01", end_date="2026-01-31") + return stats_api.by_date(account_id=account_id, params=params) + + +def get_stats_with_filters(account_id: int) -> SendingStats: + params = StatsFilterParams( + start_date="2026-01-01", + end_date="2026-01-31", + sending_domain_ids=[1, 2], + sending_streams=["transactional"], + categories=["Transactional", "Marketing"], + email_service_providers=["Gmail", "Yahoo"], + ) + return stats_api.get(account_id=account_id, params=params) + + +def get_stats_by_domains_with_filters(account_id: int) -> list[SendingStatGroup]: + params = StatsFilterParams( + start_date="2026-01-01", + end_date="2026-01-31", + sending_streams=["transactional"], + categories=["Transactional"], + ) + return stats_api.by_domains(account_id=account_id, params=params) + + +if __name__ == "__main__": + print(get_stats(ACCOUNT_ID)) + print(get_stats_by_domains(ACCOUNT_ID)) + print(get_stats_by_categories(ACCOUNT_ID)) + print(get_stats_by_email_service_providers(ACCOUNT_ID)) + print(get_stats_by_date(ACCOUNT_ID)) + print(get_stats_with_filters(ACCOUNT_ID)) + print(get_stats_by_domains_with_filters(ACCOUNT_ID)) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 67b5832..054f277 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -32,5 +32,6 @@ from .models.projects import ProjectParams from .models.sending_domains import CreateSendingDomainParams from .models.sending_domains import SendSetupInstructionsParams +from .models.stats import StatsFilterParams from .models.templates import CreateEmailTemplateParams from .models.templates import UpdateEmailTemplateParams diff --git a/mailtrap/api/general.py b/mailtrap/api/general.py index b47646e..9693212 100644 --- a/mailtrap/api/general.py +++ b/mailtrap/api/general.py @@ -2,6 +2,7 @@ from mailtrap.api.resources.accounts import AccountsApi from mailtrap.api.resources.billing import BillingApi from mailtrap.api.resources.permissions import PermissionsApi +from mailtrap.api.resources.stats import StatsApi from mailtrap.http import HttpClient @@ -24,3 +25,7 @@ def billing(self) -> BillingApi: @property def permissions(self) -> PermissionsApi: return PermissionsApi(client=self._client) + + @property + def stats(self) -> StatsApi: + return StatsApi(client=self._client) diff --git a/mailtrap/api/resources/stats.py b/mailtrap/api/resources/stats.py new file mode 100644 index 0000000..7c9ccc7 --- /dev/null +++ b/mailtrap/api/resources/stats.py @@ -0,0 +1,69 @@ +from mailtrap.http import HttpClient +from mailtrap.models.stats import SendingStatGroup +from mailtrap.models.stats import SendingStats +from mailtrap.models.stats import StatsFilterParams + +GROUP_KEYS = { + "domains": "sending_domain_id", + "categories": "category", + "email_service_providers": "email_service_provider", + "date": "date", +} + + +class StatsApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def get(self, account_id: int, params: StatsFilterParams) -> SendingStats: + """Get aggregated sending stats.""" + response = self._client.get( + self._base_path(account_id), + params=params.api_query_params, + ) + return SendingStats(**response) + + def by_domains( + self, account_id: int, params: StatsFilterParams + ) -> list[SendingStatGroup]: + """Get sending stats grouped by domains.""" + return self._grouped_stats(account_id, "domains", params) + + def by_categories( + self, account_id: int, params: StatsFilterParams + ) -> list[SendingStatGroup]: + """Get sending stats grouped by categories.""" + return self._grouped_stats(account_id, "categories", params) + + def by_email_service_providers( + self, account_id: int, params: StatsFilterParams + ) -> list[SendingStatGroup]: + """Get sending stats grouped by email service providers.""" + return self._grouped_stats(account_id, "email_service_providers", params) + + def by_date( + self, account_id: int, params: StatsFilterParams + ) -> list[SendingStatGroup]: + """Get sending stats grouped by date.""" + return self._grouped_stats(account_id, "date", params) + + def _grouped_stats( + self, account_id: int, group: str, params: StatsFilterParams + ) -> list[SendingStatGroup]: + response = self._client.get( + f"{self._base_path(account_id)}/{group}", params=params.api_query_params + ) + group_key = GROUP_KEYS[group] + + return [ + SendingStatGroup( + name=group_key, + value=item[group_key], + stats=SendingStats(**item["stats"]), + ) + for item in response + ] + + @staticmethod + def _base_path(account_id: int) -> str: + return f"/api/accounts/{account_id}/stats" diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index 4bb767a..8bb2c41 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -18,6 +18,14 @@ def api_data(self: T) -> dict[str, Any]: TypeAdapter(type(self)).dump_python(self, by_alias=True, exclude_none=True), ) + @property + def api_query_params(self: T) -> dict[str, Any]: + data = self.api_data + for key, value in list(data.items()): + if isinstance(value, list): + data[f"{key}[]"] = data.pop(key) + return data + @dataclass class DeletedObject: diff --git a/mailtrap/models/stats.py b/mailtrap/models/stats.py new file mode 100644 index 0000000..c74798c --- /dev/null +++ b/mailtrap/models/stats.py @@ -0,0 +1,37 @@ +from typing import Optional +from typing import Union + +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams + + +@dataclass +class SendingStats: + delivery_count: int + delivery_rate: float + bounce_count: int + bounce_rate: float + open_count: int + open_rate: float + click_count: int + click_rate: float + spam_count: int + spam_rate: float + + +@dataclass +class SendingStatGroup: + name: str + value: Union[str, int] + stats: SendingStats + + +@dataclass +class StatsFilterParams(RequestParams): + start_date: Optional[str] = None + end_date: Optional[str] = None + sending_domain_ids: Optional[list[int]] = None + sending_streams: Optional[list[str]] = None + categories: Optional[list[str]] = None + email_service_providers: Optional[list[str]] = None diff --git a/pyproject.toml b/pyproject.toml index 938e0a0..0dc996d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mailtrap" -version = "2.4.0" +version = "2.5.0" description = "Official mailtrap.io API client" readme = "README.md" license = {file = "LICENSE.txt"} @@ -23,7 +23,7 @@ dynamic = ["dependencies"] Homepage = "https://mailtrap.io/" Documentation = "https://github.com/railsware/mailtrap-python" Repository = "https://github.com/railsware/mailtrap-python.git" -"API documentation" = "https://api-docs.mailtrap.io/" +"API documentation" = "https://docs.mailtrap.io/developers" [build-system] requires = ["setuptools"] diff --git a/tests/unit/api/general/test_stats.py b/tests/unit/api/general/test_stats.py new file mode 100644 index 0000000..2e0a35d --- /dev/null +++ b/tests/unit/api/general/test_stats.py @@ -0,0 +1,289 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.stats import StatsApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.stats import SendingStatGroup +from mailtrap.models.stats import SendingStats +from mailtrap.models.stats import StatsFilterParams +from tests import conftest + +ACCOUNT_ID = 26730 +BASE_STATS_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/stats" + + +@pytest.fixture +def client() -> StatsApi: + return StatsApi(client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_stats_dict() -> dict[str, Any]: + return { + "delivery_count": 150, + "delivery_rate": 0.95, + "bounce_count": 8, + "bounce_rate": 0.05, + "open_count": 120, + "open_rate": 0.8, + "click_count": 60, + "click_rate": 0.5, + "spam_count": 2, + "spam_rate": 0.013, + } + + +@pytest.fixture +def sample_grouped_stats_response() -> list[dict[str, Any]]: + return [ + { + "sending_domain_id": 1, + "stats": { + "delivery_count": 100, + "delivery_rate": 0.96, + "bounce_count": 4, + "bounce_rate": 0.04, + "open_count": 80, + "open_rate": 0.8, + "click_count": 40, + "click_rate": 0.5, + "spam_count": 1, + "spam_rate": 0.01, + }, + }, + { + "sending_domain_id": 2, + "stats": { + "delivery_count": 50, + "delivery_rate": 0.93, + "bounce_count": 4, + "bounce_rate": 0.07, + "open_count": 40, + "open_rate": 0.8, + "click_count": 20, + "click_rate": 0.5, + "spam_count": 1, + "spam_rate": 0.02, + }, + }, + ] + + +def _default_params() -> StatsFilterParams: + return StatsFilterParams(start_date="2026-01-01", end_date="2026-01-31") + + +class TestStatsApi: + + @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_should_raise_api_errors( + self, + client: StatsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_STATS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get(ACCOUNT_ID, _default_params()) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_should_return_sending_stats( + self, client: StatsApi, sample_stats_dict: dict + ) -> None: + responses.get( + BASE_STATS_URL, + json=sample_stats_dict, + status=200, + ) + + stats = client.get(ACCOUNT_ID, _default_params()) + + assert isinstance(stats, SendingStats) + assert stats.delivery_count == 150 + assert stats.delivery_rate == 0.95 + assert stats.bounce_count == 8 + assert stats.bounce_rate == 0.05 + assert stats.open_count == 120 + assert stats.open_rate == 0.8 + assert stats.click_count == 60 + assert stats.click_rate == 0.5 + assert stats.spam_count == 2 + assert stats.spam_rate == 0.013 + + @responses.activate + def test_get_with_filter_params( + self, client: StatsApi, sample_stats_dict: dict + ) -> None: + responses.get( + BASE_STATS_URL, + json=sample_stats_dict, + status=200, + ) + + params = StatsFilterParams( + start_date="2026-01-01", + end_date="2026-01-31", + sending_domain_ids=[1, 2], + sending_streams=["transactional"], + categories=["Transactional"], + email_service_providers=["Gmail"], + ) + client.get(ACCOUNT_ID, params) + + request_params = responses.calls[0].request.params + assert "sending_domain_ids[]" in request_params + assert "sending_streams[]" in request_params + assert "categories[]" in request_params + assert "email_service_providers[]" in request_params + + @responses.activate + def test_by_domains_should_return_grouped_stats( + self, client: StatsApi, sample_grouped_stats_response: list + ) -> None: + responses.get( + f"{BASE_STATS_URL}/domains", + json=sample_grouped_stats_response, + status=200, + ) + + result = client.by_domains(ACCOUNT_ID, _default_params()) + + assert len(result) == 2 + assert isinstance(result[0], SendingStatGroup) + assert result[0].name == "sending_domain_id" + assert result[0].value == 1 + assert isinstance(result[0].stats, SendingStats) + assert result[0].stats.delivery_count == 100 + assert result[1].name == "sending_domain_id" + assert result[1].value == 2 + assert result[1].stats.delivery_count == 50 + + @responses.activate + def test_by_categories_should_return_grouped_stats(self, client: StatsApi) -> None: + response_data = [ + { + "category": "Transactional", + "stats": { + "delivery_count": 100, + "delivery_rate": 0.97, + "bounce_count": 3, + "bounce_rate": 0.03, + "open_count": 85, + "open_rate": 0.85, + "click_count": 45, + "click_rate": 0.53, + "spam_count": 0, + "spam_rate": 0.0, + }, + }, + ] + responses.get( + f"{BASE_STATS_URL}/categories", + json=response_data, + status=200, + ) + + result = client.by_categories(ACCOUNT_ID, _default_params()) + + assert len(result) == 1 + assert result[0].name == "category" + assert result[0].value == "Transactional" + assert result[0].stats.delivery_count == 100 + + @responses.activate + def test_by_email_service_providers_should_return_grouped_stats( + self, client: StatsApi + ) -> None: + response_data = [ + { + "email_service_provider": "Gmail", + "stats": { + "delivery_count": 80, + "delivery_rate": 0.97, + "bounce_count": 2, + "bounce_rate": 0.03, + "open_count": 70, + "open_rate": 0.88, + "click_count": 35, + "click_rate": 0.5, + "spam_count": 1, + "spam_rate": 0.013, + }, + }, + ] + responses.get( + f"{BASE_STATS_URL}/email_service_providers", + json=response_data, + status=200, + ) + + result = client.by_email_service_providers(ACCOUNT_ID, _default_params()) + + assert len(result) == 1 + assert result[0].name == "email_service_provider" + assert result[0].value == "Gmail" + assert result[0].stats.delivery_count == 80 + + @responses.activate + def test_by_date_should_return_grouped_stats(self, client: StatsApi) -> None: + response_data = [ + { + "date": "2026-01-01", + "stats": { + "delivery_count": 5, + "delivery_rate": 1.0, + "bounce_count": 0, + "bounce_rate": 0.0, + "open_count": 4, + "open_rate": 0.8, + "click_count": 2, + "click_rate": 0.5, + "spam_count": 0, + "spam_rate": 0.0, + }, + }, + ] + responses.get( + f"{BASE_STATS_URL}/date", + json=response_data, + status=200, + ) + + result = client.by_date(ACCOUNT_ID, _default_params()) + + assert len(result) == 1 + assert result[0].name == "date" + assert result[0].value == "2026-01-01" + assert result[0].stats.delivery_count == 5