From 20d5953dffe3581f2de4c6ce311b7f312efb4939 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 30 Jul 2026 13:28:22 +0200 Subject: [PATCH 1/8] MT-22401: Add Email Campaigns API to the Python SDK Decisions: - Send flat request bodies (no email_campaign wrapper) per the current OpenAPI contract - Unwrap the data envelope on single-campaign and stats responses; list keeps {data, pagination} - delete returns DeletedObject(email_campaign_id) since the API responds 204 No Content - Add the five lifecycle methods (start/schedule/cancel/terminate/reset) with ScheduleEmailCampaignParams and stats start_date/end_date params - Require name, domain_id (integer), from_local_part, and template_attributes on create; split request-side TemplateAttributes from the response-side CampaignTemplate --- README.md | 3 + examples/email_campaigns/email_campaigns.py | 141 ++++ mailtrap/__init__.py | 10 + mailtrap/api/email_campaigns.py | 12 + mailtrap/api/resources/email_campaigns.py | 143 ++++ mailtrap/client.py | 9 + mailtrap/models/email_campaigns.py | 221 ++++++ tests/unit/api/email_campaigns/__init__.py | 0 .../email_campaigns/test_email_campaigns.py | 677 ++++++++++++++++++ tests/unit/test_client.py | 7 + 10 files changed, 1223 insertions(+) create mode 100644 examples/email_campaigns/email_campaigns.py create mode 100644 mailtrap/api/email_campaigns.py create mode 100644 mailtrap/api/resources/email_campaigns.py create mode 100644 mailtrap/models/email_campaigns.py create mode 100644 tests/unit/api/email_campaigns/__init__.py create mode 100644 tests/unit/api/email_campaigns/test_email_campaigns.py diff --git a/README.md b/README.md index 913fd03..5b7e8e0 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,9 @@ The same situation applies to both `client.batch_send()` and `client.sending_api - Messages (list/get/delete + reply/reply_all/forward) – [`inbound/messages.py`](examples/inbound/messages.py) - Threads (list/get/delete) – [`inbound/threads.py`](examples/inbound/threads.py) +### Email Campaigns API: +- Email Campaigns (list, create, get, update, delete, lifecycle actions, stats) – [`email_campaigns/email_campaigns.py`](examples/email_campaigns/email_campaigns.py) + ### Webhooks API: - Webhooks management – [`webhooks/webhooks.py`](examples/webhooks/webhooks.py) - Verifying webhook signatures – [`webhooks/verify_signature.py`](examples/webhooks/verify_signature.py) diff --git a/examples/email_campaigns/email_campaigns.py b/examples/email_campaigns/email_campaigns.py new file mode 100644 index 0000000..e78eeca --- /dev/null +++ b/examples/email_campaigns/email_campaigns.py @@ -0,0 +1,141 @@ +import mailtrap as mt +from mailtrap.models.common import DeletedObject +from mailtrap.models.email_campaigns import EmailCampaign +from mailtrap.models.email_campaigns import EmailCampaignListResponse +from mailtrap.models.email_campaigns import EmailCampaignStats + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" +DOMAIN_ID = 4321 + +client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +email_campaigns_api = client.email_campaigns_api.email_campaigns + + +def list_email_campaigns() -> EmailCampaignListResponse: + # `search` filters by name; `token` is the page number (page-token + # pagination); `per_page` caps at 100 (default 50). + return email_campaigns_api.get_list(per_page=50, search="Spring", token=1) + + +def get_email_campaign(email_campaign_id: int) -> EmailCampaign: + return email_campaigns_api.get_by_id(email_campaign_id=email_campaign_id) + + +def create_email_campaign() -> EmailCampaign: + # A campaign is created in the `draft` state and must reference a verified + # sending domain via `domain_id` (as returned by the Sending Domains + # endpoints). + return email_campaigns_api.create( + mt.CreateEmailCampaignParams( + name="Spring Sale", + domain_id=DOMAIN_ID, + from_display_name="Acme Marketing", + from_local_part="news", + reply_to=mt.ReplyTo( + display_name="Acme Support", + local_part="support", + domain="acme.com", + ), + template_attributes=mt.TemplateAttributes(subject="Spring is here — 30% off"), + ) + ) + + +def update_email_campaign(email_campaign_id: int) -> EmailCampaign: + # Only supplied fields are changed. The campaign's template is edited in + # place — pass only the `template_attributes` sub-fields you want changed. + return email_campaigns_api.update( + email_campaign_id=email_campaign_id, + campaign_params=mt.UpdateEmailCampaignParams( + name="Spring Sale (updated)", + delivery_mode="gradual", + delivery_options=mt.DeliveryOptions(emails_per_hour=1000), + contact_list_ids=[55, 56], + contact_segment_ids=[12], + template_attributes=mt.TemplateAttributes( + subject="Spring is here — 30% off everything", + body_html=( + "" + "

Hi {{first_name}}!

" + '

Unsubscribe

' + "" + ), + merge_tags=["first_name"], + ), + ), + ) + + +def schedule_email_campaign(email_campaign_id: int) -> EmailCampaign: + # The campaign must be a `draft`; the time comes back in + # `current_state_metadata.scheduled_at`. + return email_campaigns_api.schedule( + email_campaign_id=email_campaign_id, + schedule_params=mt.ScheduleEmailCampaignParams( + datetime="2026-06-01T09:00:00.000Z" + ), + ) + + +def cancel_email_campaign(email_campaign_id: int) -> EmailCampaign: + # Cancels a `scheduled` campaign, returning it to `draft`. + return email_campaigns_api.cancel(email_campaign_id=email_campaign_id) + + +def start_email_campaign(email_campaign_id: int) -> EmailCampaign: + # Starts sending a `draft` campaign immediately. + return email_campaigns_api.start(email_campaign_id=email_campaign_id) + + +def terminate_email_campaign(email_campaign_id: int) -> EmailCampaign: + # Aborts a campaign that is currently sending. + return email_campaigns_api.terminate(email_campaign_id=email_campaign_id) + + +def reset_email_campaign(email_campaign_id: int) -> EmailCampaign: + # Resets a `scheduled` campaign back to `draft`. + return email_campaigns_api.reset(email_campaign_id=email_campaign_id) + + +def get_email_campaign_stats(email_campaign_id: int) -> EmailCampaignStats: + return email_campaigns_api.get_stats( + email_campaign_id=email_campaign_id, + start_date="2026-05-01", + end_date="2026-05-31", + ) + + +def delete_email_campaign(email_campaign_id: int) -> DeletedObject: + # The API responds with 204 No Content. + return email_campaigns_api.delete(email_campaign_id=email_campaign_id) + + +if __name__ == "__main__": + listed = list_email_campaigns() + print(listed.data) + print(listed.pagination) + + created = create_email_campaign() + print(created) + + fetched = get_email_campaign(created.id) + print(fetched) + + updated = update_email_campaign(created.id) + print(updated) + + scheduled = schedule_email_campaign(created.id) + print(scheduled.current_state_metadata) + + cancelled = cancel_email_campaign(created.id) + print(cancelled.current_state) + + started = start_email_campaign(created.id) + print(started.current_state) + + stats = get_email_campaign_stats(created.id) + print(stats) + + deleted = delete_email_campaign(created.id) + print(deleted) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index bf32a5b..6fd4bc4 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -17,6 +17,16 @@ from .models.contacts import ImportContactParams from .models.contacts import UpdateContactFieldParams from .models.contacts import UpdateContactParams +from .models.email_campaigns import CampaignTemplate +from .models.email_campaigns import CreateEmailCampaignParams +from .models.email_campaigns import DeliveryOptions +from .models.email_campaigns import EmailCampaign +from .models.email_campaigns import EmailCampaignListResponse +from .models.email_campaigns import EmailCampaignStats +from .models.email_campaigns import ReplyTo +from .models.email_campaigns import ScheduleEmailCampaignParams +from .models.email_campaigns import TemplateAttributes +from .models.email_campaigns import UpdateEmailCampaignParams from .models.email_logs import EmailLogMessage from .models.email_logs import EmailLogsListFilters from .models.email_logs import EmailLogsListResponse diff --git a/mailtrap/api/email_campaigns.py b/mailtrap/api/email_campaigns.py new file mode 100644 index 0000000..045d6fb --- /dev/null +++ b/mailtrap/api/email_campaigns.py @@ -0,0 +1,12 @@ +from mailtrap.api.resources.email_campaigns import EmailCampaignsApi +from mailtrap.http import HttpClient + + +class EmailCampaignsBaseApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + @property + def email_campaigns(self) -> EmailCampaignsApi: + return EmailCampaignsApi(account_id=self._account_id, client=self._client) diff --git a/mailtrap/api/resources/email_campaigns.py b/mailtrap/api/resources/email_campaigns.py new file mode 100644 index 0000000..f11e176 --- /dev/null +++ b/mailtrap/api/resources/email_campaigns.py @@ -0,0 +1,143 @@ +from typing import Optional + +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.email_campaigns import CreateEmailCampaignParams +from mailtrap.models.email_campaigns import EmailCampaign +from mailtrap.models.email_campaigns import EmailCampaignListParams +from mailtrap.models.email_campaigns import EmailCampaignListResponse +from mailtrap.models.email_campaigns import EmailCampaignResponse +from mailtrap.models.email_campaigns import EmailCampaignStats +from mailtrap.models.email_campaigns import EmailCampaignStatsParams +from mailtrap.models.email_campaigns import EmailCampaignStatsResponse +from mailtrap.models.email_campaigns import ScheduleEmailCampaignParams +from mailtrap.models.email_campaigns import UpdateEmailCampaignParams + + +class EmailCampaignsApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + def get_list( + self, + per_page: Optional[int] = None, + search: Optional[str] = None, + token: Optional[int] = None, + ) -> EmailCampaignListResponse: + """ + List email campaigns for the account, newest first. ``search`` filters + by name, ``per_page`` sets the page size (max 100, default 50), and + ``token`` is the page number to retrieve (default 1). + """ + params = EmailCampaignListParams( + per_page=per_page, search=search, token=token + ).api_query_params + response = self._client.get(self._api_path(), params=params or None) + return EmailCampaignListResponse(**response) + + def get_by_id(self, email_campaign_id: int) -> EmailCampaign: + """ + Get a single email campaign by id. + """ + response = self._client.get(self._api_path(email_campaign_id)) + return EmailCampaignResponse(**response).data + + def create(self, campaign_params: CreateEmailCampaignParams) -> EmailCampaign: + """ + Create a new email campaign in the ``draft`` state. The campaign must + reference an existing sending domain via ``domain_id`` and + include a template ``subject`` within ``template_attributes``. + """ + response = self._client.post(self._api_path(), json=campaign_params.api_data) + return EmailCampaignResponse(**response).data + + def update( + self, email_campaign_id: int, campaign_params: UpdateEmailCampaignParams + ) -> EmailCampaign: + """ + Update an existing ``draft`` email campaign. Only the fields supplied + in ``campaign_params`` are sent to the API. + """ + response = self._client.patch( + self._api_path(email_campaign_id), + json=campaign_params.api_data, + ) + return EmailCampaignResponse(**response).data + + def delete(self, email_campaign_id: int) -> DeletedObject: + """ + Delete an email campaign. The campaign must not be in a sending state. + """ + self._client.delete(self._api_path(email_campaign_id)) + return DeletedObject(email_campaign_id) + + def start(self, email_campaign_id: int) -> EmailCampaign: + """ + Start sending a ``draft`` campaign immediately. + """ + return self._action(email_campaign_id, "start") + + def schedule( + self, email_campaign_id: int, schedule_params: ScheduleEmailCampaignParams + ) -> EmailCampaign: + """ + Schedule a ``draft`` campaign to start sending at a future time. The + time is reported back in ``current_state_metadata.scheduled_at``. + """ + response = self._client.post( + f"{self._api_path(email_campaign_id)}/schedule", + json=schedule_params.api_data, + ) + return EmailCampaignResponse(**response).data + + def cancel(self, email_campaign_id: int) -> EmailCampaign: + """ + Cancel a ``scheduled`` campaign, returning it to the ``draft`` state. + """ + return self._action(email_campaign_id, "cancel") + + def terminate(self, email_campaign_id: int) -> EmailCampaign: + """ + Terminate a campaign that is currently sending (``started``, + ``queued``, or ``paused``), aborting the in-flight send. + """ + return self._action(email_campaign_id, "terminate") + + def reset(self, email_campaign_id: int) -> EmailCampaign: + """ + Reset a ``scheduled`` campaign back to the ``draft`` state. + """ + return self._action(email_campaign_id, "reset") + + def get_stats( + self, + email_campaign_id: int, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + ) -> EmailCampaignStats: + """ + Get aggregated performance statistics for a single campaign. If the + campaign has never been started, all counts and rates are ``0``. + ``start_date``/``end_date`` (``YYYY-MM-DD``) narrow the aggregation + window; it defaults to the whole period since the last start. + """ + params = EmailCampaignStatsParams( + start_date=start_date, end_date=end_date + ).api_query_params + response = self._client.get( + f"{self._api_path(email_campaign_id)}/stats", params=params or None + ) + return EmailCampaignStatsResponse(**response).data + + def _action(self, email_campaign_id: int, action: str) -> EmailCampaign: + response = self._client.post(f"{self._api_path(email_campaign_id)}/{action}") + return EmailCampaignResponse(**response).data + + def _api_path(self, email_campaign_id: Optional[int] = None) -> str: + # The Email Campaigns endpoint is token-scoped, NOT account-scoped: + # the account is resolved from the API token server-side. + path = "/api/email_campaigns" + if email_campaign_id is not None: + return f"{path}/{email_campaign_id}" + return path diff --git a/mailtrap/client.py b/mailtrap/client.py index dcb42e1..9169f8f 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -7,6 +7,7 @@ from pydantic import TypeAdapter from mailtrap.api.contacts import ContactsBaseApi +from mailtrap.api.email_campaigns import EmailCampaignsBaseApi from mailtrap.api.email_logs import EmailLogsBaseApi from mailtrap.api.general import GeneralApi from mailtrap.api.inbound import InboundBaseApi @@ -124,6 +125,14 @@ def sending_domains_api(self) -> SendingDomainsBaseApi: client=HttpClient(host=GENERAL_HOST, headers=self.headers), ) + @property + def email_campaigns_api(self) -> EmailCampaignsBaseApi: + self._validate_account_id("Email Campaigns API") + return EmailCampaignsBaseApi( + account_id=cast(str, self.account_id), + client=HttpClient(host=GENERAL_HOST, headers=self.headers), + ) + @property def email_logs_api(self) -> EmailLogsBaseApi: self._validate_account_id("Email Logs API") diff --git a/mailtrap/models/email_campaigns.py b/mailtrap/models/email_campaigns.py new file mode 100644 index 0000000..ea4a8c0 --- /dev/null +++ b/mailtrap/models/email_campaigns.py @@ -0,0 +1,221 @@ +"""Models for the Email Campaigns API (campaigns + stats).""" + +from typing import Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams + + +@dataclass +class ReplyTo: + """Reply-To address parts.""" + + display_name: Optional[str] = None + local_part: Optional[str] = None + domain: Optional[str] = None + + +@dataclass +class DeliveryOptions: + """Delivery throttling options. Applies when ``delivery_mode`` is ``gradual``.""" + + emails_per_hour: Optional[int] = None + + +@dataclass +class TemplateAttributes: + """ + Inline email template — the campaign's subject and design. ``subject`` is + required when creating a campaign. On update only the sub-fields you + provide change; ``merge_tags`` is replaced as a whole when provided. + """ + + subject: Optional[str] = None + body_html: Optional[str] = None + body_text: Optional[str] = None + merge_tags: Optional[list[str]] = None + + +@dataclass +class EmailCampaignStats: + """ + Aggregated campaign performance metrics. All counts and rates are ``0`` + when the campaign has not been started. + """ + + delivery_count: Optional[int] = None + open_count: Optional[int] = None + click_count: Optional[int] = None + bounce_count: Optional[int] = None + unsubscription_count: Optional[int] = None + sent_count: Optional[int] = None + spam_count: Optional[int] = None + delivery_rate: Optional[float] = None + open_rate: Optional[float] = None + click_rate: Optional[float] = None + bounce_rate: Optional[float] = None + spam_rate: Optional[float] = None + unsubscription_rate: Optional[float] = None + + +@dataclass +class CampaignStateError: + """A per-recipient error recorded when sending failed.""" + + message: Optional[str] = None + rcpt_index: Optional[int] = None + + +@dataclass +class CurrentStateMetadata: + """Metadata about the most recent campaign state transition.""" + + reason: Optional[str] = None + error: Optional[str] = None + scheduled_at: Optional[str] = None + errors: list[CampaignStateError] = Field(default_factory=list) + + +@dataclass +class CampaignTemplate: + """ + The campaign's template as returned by the API. ``body_html`` and + ``body_text`` are returned only on single-campaign responses; the list + endpoint omits them. + """ + + id: Optional[int] = None + subject: Optional[str] = None + merge_tags: list[str] = Field(default_factory=list) + body_html: Optional[str] = None + body_text: Optional[str] = None + + +@dataclass +class EmailCampaign: + """A single email campaign.""" + + id: int + domain_id: Optional[int] = None + domain_name: Optional[str] = None + name: Optional[str] = None + from_local_part: Optional[str] = None + from_display_name: Optional[str] = None + reply_to: Optional[ReplyTo] = None + current_state: Optional[str] = None + current_state_metadata: Optional[CurrentStateMetadata] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + last_started_at: Optional[str] = None + last_started_at_date: Optional[str] = None + recipient_total_count: Optional[int] = None + contact_list_ids: list[int] = Field(default_factory=list) + contact_segment_ids: list[int] = Field(default_factory=list) + delivery_mode: Optional[str] = None + delivery_options: Optional[DeliveryOptions] = None + template: Optional[CampaignTemplate] = None + + +@dataclass +class Pagination: + """Page-token pagination metadata.""" + + token: Optional[int] = None + prev_token: Optional[int] = None + next_token: Optional[int] = None + first_url: Optional[str] = None + prev_url: Optional[str] = None + current_url: Optional[str] = None + next_url: Optional[str] = None + + +@dataclass +class EmailCampaignResponse: + """Envelope of a single-campaign response.""" + + data: EmailCampaign + + +@dataclass +class EmailCampaignStatsResponse: + """Envelope of the campaign stats response.""" + + data: EmailCampaignStats + + +@dataclass +class EmailCampaignListResponse: + """Paginated response from listing email campaigns.""" + + data: list[EmailCampaign] = Field(default_factory=list) + pagination: Optional[Pagination] = None + + +@dataclass +class EmailCampaignListParams(RequestParams): + """ + Query params for listing email campaigns. ``search`` filters by name and + serializes to the ``search`` wire parameter. + """ + + per_page: Optional[int] = None + search: Optional[str] = None + token: Optional[int] = None + + +@dataclass +class EmailCampaignStatsParams(RequestParams): + """Query params for campaign stats (``YYYY-MM-DD`` aggregation window).""" + + start_date: Optional[str] = None + end_date: Optional[str] = None + + +@dataclass +class CreateEmailCampaignParams(RequestParams): + """ + Attributes for creating an email campaign (sent as a flat JSON body). + The campaign is always created in the ``draft`` state. + """ + + name: str + domain_id: int + from_local_part: str + template_attributes: TemplateAttributes + from_display_name: Optional[str] = None + reply_to: Optional[ReplyTo] = None + delivery_mode: Optional[str] = None + delivery_options: Optional[DeliveryOptions] = None + contact_list_ids: Optional[list[int]] = None + contact_segment_ids: Optional[list[int]] = None + + +@dataclass +class UpdateEmailCampaignParams(RequestParams): + """ + Attributes for updating a draft email campaign (sent as a flat JSON body). + All fields are optional; only provided fields are changed. + """ + + name: Optional[str] = None + domain_id: Optional[int] = None + from_local_part: Optional[str] = None + from_display_name: Optional[str] = None + reply_to: Optional[ReplyTo] = None + template_attributes: Optional[TemplateAttributes] = None + delivery_mode: Optional[str] = None + delivery_options: Optional[DeliveryOptions] = None + contact_list_ids: Optional[list[int]] = None + contact_segment_ids: Optional[list[int]] = None + + +@dataclass +class ScheduleEmailCampaignParams(RequestParams): + """ + When to start sending the campaign. ``datetime`` is an ISO 8601 timestamp + that must be in the future and no more than 1 month ahead. + """ + + datetime: str diff --git a/tests/unit/api/email_campaigns/__init__.py b/tests/unit/api/email_campaigns/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/email_campaigns/test_email_campaigns.py b/tests/unit/api/email_campaigns/test_email_campaigns.py new file mode 100644 index 0000000..95ce60e --- /dev/null +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -0,0 +1,677 @@ +from typing import Any +from urllib.parse import parse_qs +from urllib.parse import urlparse + +import pytest +import responses + +from mailtrap.api.resources.email_campaigns import EmailCampaignsApi +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.email_campaigns import CreateEmailCampaignParams +from mailtrap.models.email_campaigns import DeliveryOptions +from mailtrap.models.email_campaigns import EmailCampaign +from mailtrap.models.email_campaigns import EmailCampaignListResponse +from mailtrap.models.email_campaigns import EmailCampaignStats +from mailtrap.models.email_campaigns import ReplyTo +from mailtrap.models.email_campaigns import ScheduleEmailCampaignParams +from mailtrap.models.email_campaigns import TemplateAttributes +from mailtrap.models.email_campaigns import UpdateEmailCampaignParams +from tests import conftest + +ACCOUNT_ID = "26730" +CAMPAIGN_ID = 4567 +DOMAIN_ID = 4321 +# The endpoint is token-scoped, NOT under /api/accounts/{account_id}. +BASE_CAMPAIGNS_URL = f"https://{GENERAL_HOST}/api/email_campaigns" + + +@pytest.fixture +def client() -> EmailCampaignsApi: + return EmailCampaignsApi(client=HttpClient(GENERAL_HOST), account_id=ACCOUNT_ID) + + +@pytest.fixture +def sample_stats_dict() -> dict[str, Any]: + return { + "delivery_count": 1450, + "open_count": 820, + "click_count": 310, + "bounce_count": 30, + "unsubscription_count": 12, + "sent_count": 1500, + "spam_count": 5, + "delivery_rate": 0.9667, + "open_rate": 0.5655, + "click_rate": 0.2138, + "bounce_rate": 0.02, + "spam_rate": 0.0033, + "unsubscription_rate": 0.0083, + } + + +@pytest.fixture +def sample_campaign_dict() -> dict[str, Any]: + return { + "id": CAMPAIGN_ID, + "domain_id": DOMAIN_ID, + "domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com", + }, + "current_state": "draft", + "current_state_metadata": {"reason": None, "errors": []}, + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + "last_started_at": None, + "last_started_at_date": None, + "recipient_total_count": 1500, + "contact_list_ids": [55, 56], + "contact_segment_ids": [12], + "delivery_mode": "rapid", + "delivery_options": {"emails_per_hour": 1000}, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + "body_html": "

Hi {{first_name}}!

", + "body_text": None, + }, + } + + +class TestEmailCampaignsApi: + + @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: EmailCampaignsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get(BASE_CAMPAIGNS_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_campaigns_and_pagination( + self, client: EmailCampaignsApi, sample_campaign_dict: dict + ) -> None: + # List items omit template bodies. + list_item = { + **sample_campaign_dict, + "template": { + "id": 789, + "subject": "Spring is here — 30% off", + "merge_tags": ["first_name"], + }, + } + responses.get( + BASE_CAMPAIGNS_URL, + json={ + "data": [ + list_item, + {"id": 4568, "name": "Summer Sale", "current_state": "finished"}, + ], + "pagination": { + "token": 1, + "prev_token": None, + "next_token": 2, + "first_url": f"{BASE_CAMPAIGNS_URL}?per_page=50&token=1", + "prev_url": None, + "current_url": f"{BASE_CAMPAIGNS_URL}?per_page=50&token=1", + "next_url": f"{BASE_CAMPAIGNS_URL}?per_page=50&token=2", + }, + }, + status=200, + ) + + result = client.get_list() + + assert isinstance(result, EmailCampaignListResponse) + assert all(isinstance(c, EmailCampaign) for c in result.data) + assert len(result.data) == 2 + assert result.data[0].id == CAMPAIGN_ID + assert result.data[0].name == "Spring Sale" + assert result.data[0].contact_list_ids == [55, 56] + assert result.data[0].template is not None + assert result.data[0].template.body_html is None + assert result.data[1].current_state == "finished" + assert result.pagination is not None + assert result.pagination.token == 1 + assert result.pagination.prev_token is None + assert result.pagination.next_token == 2 + + @responses.activate + def test_get_list_should_return_empty_list(self, client: EmailCampaignsApi) -> None: + responses.get( + BASE_CAMPAIGNS_URL, + json={"data": [], "pagination": {"token": 1}}, + status=200, + ) + + result = client.get_list() + + assert isinstance(result, EmailCampaignListResponse) + assert result.data == [] + + @responses.activate + def test_get_list_should_send_search_per_page_and_token_query_params( + self, client: EmailCampaignsApi + ) -> None: + responses.get(BASE_CAMPAIGNS_URL, json={"data": [], "pagination": {}}, status=200) + + client.get_list(per_page=25, search="Spring", token=2) + + query = parse_qs(urlparse(responses.calls[0].request.url).query) + # The name filter must serialize to `search`, not `name`. + assert query["search"] == ["Spring"] + assert query["per_page"] == ["25"] + assert query["token"] == ["2"] + assert "name" not in 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: EmailCampaignsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_by_id(CAMPAIGN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_by_id_should_unwrap_data_envelope( + self, client: EmailCampaignsApi, sample_campaign_dict: dict + ) -> None: + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", + json={"data": sample_campaign_dict}, + status=200, + ) + + campaign = client.get_by_id(CAMPAIGN_ID) + + assert isinstance(campaign, EmailCampaign) + assert campaign.id == CAMPAIGN_ID + assert campaign.domain_id == DOMAIN_ID + assert campaign.domain_name == "acme.com" + assert campaign.current_state == "draft" + assert campaign.contact_list_ids == [55, 56] + assert campaign.contact_segment_ids == [12] + assert campaign.delivery_mode == "rapid" + assert campaign.reply_to is not None + assert campaign.reply_to.local_part == "support" + assert campaign.template is not None + assert campaign.template.id == 789 + assert campaign.template.merge_tags == ["first_name"] + assert campaign.template.body_html is not None + assert campaign.delivery_options is not None + assert campaign.delivery_options.emails_per_hour == 1000 + + @responses.activate + def test_get_by_id_should_parse_state_metadata_errors( + self, client: EmailCampaignsApi, sample_campaign_dict: dict + ) -> None: + failed = { + **sample_campaign_dict, + "current_state": "failed", + "current_state_metadata": { + "error": "Sending failed", + "errors": [{"message": "Invalid recipient address", "rcpt_index": 0}], + }, + } + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", + json={"data": failed}, + status=200, + ) + + campaign = client.get_by_id(CAMPAIGN_ID) + + assert campaign.current_state_metadata is not None + assert campaign.current_state_metadata.error == "Sending failed" + assert len(campaign.current_state_metadata.errors) == 1 + assert ( + campaign.current_state_metadata.errors[0].message + == "Invalid recipient address" + ) + assert campaign.current_state_metadata.errors[0].rcpt_index == 0 + + @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": {"domain_id": ["must exist"]}}, + "domain_id: must exist", + ), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: EmailCampaignsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post(BASE_CAMPAIGNS_URL, status=status_code, json=response_json) + + with pytest.raises(APIError) as exc_info: + client.create( + CreateEmailCampaignParams( + name="Spring Sale", + domain_id=DOMAIN_ID, + from_local_part="news", + template_attributes=TemplateAttributes(subject="Spring!"), + ) + ) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_send_flat_body_and_unwrap_data_envelope( + self, client: EmailCampaignsApi, sample_campaign_dict: dict + ) -> None: + responses.post( + BASE_CAMPAIGNS_URL, json={"data": sample_campaign_dict}, status=201 + ) + + campaign = client.create( + CreateEmailCampaignParams( + name="Spring Sale", + domain_id=DOMAIN_ID, + from_local_part="news", + template_attributes=TemplateAttributes( + subject="Spring is here — 30% off" + ), + from_display_name="Acme Marketing", + reply_to=ReplyTo( + display_name="Acme Support", + local_part="support", + domain="acme.com", + ), + contact_list_ids=[55, 56], + ) + ) + + assert isinstance(campaign, EmailCampaign) + assert campaign.id == CAMPAIGN_ID + assert campaign.current_state == "draft" + + assert len(responses.calls) == 1 + # The request body is flat — no `email_campaign` wrapper. + assert responses.calls[0].request.body == ( + b'{"name": "Spring Sale", ' + b'"domain_id": 4321, ' + b'"from_local_part": "news", ' + b'"template_attributes": {"subject": "Spring is here \\u2014 30% off"}, ' + b'"from_display_name": "Acme Marketing", ' + b'"reply_to": {"display_name": "Acme Support", ' + b'"local_part": "support", "domain": "acme.com"}, ' + b'"contact_list_ids": [55, 56]}' + ) + + @responses.activate + def test_update_should_send_only_supplied_fields_flat( + self, client: EmailCampaignsApi, sample_campaign_dict: dict + ) -> None: + responses.patch( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", + json={"data": {**sample_campaign_dict, "delivery_mode": "gradual"}}, + status=200, + ) + + campaign = client.update( + CAMPAIGN_ID, + UpdateEmailCampaignParams( + template_attributes=TemplateAttributes( + subject="New subject", + body_html="Hi", + merge_tags=["first_name"], + ), + delivery_mode="gradual", + delivery_options=DeliveryOptions(emails_per_hour=1000), + contact_segment_ids=[12], + ), + ) + + assert isinstance(campaign, EmailCampaign) + assert campaign.delivery_mode == "gradual" + + assert responses.calls[0].request.body == ( + b'{"template_attributes": {"subject": "New subject", ' + b'"body_html": "Hi", ' + b'"merge_tags": ["first_name"]}, ' + b'"delivery_mode": "gradual", ' + b'"delivery_options": {"emails_per_hour": 1000}, ' + b'"contact_segment_ids": [12]}' + ) + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": {"base": ["Campaign is not editable"]}}, + "base: Campaign is not editable", + ), + ], + ) + @responses.activate + def test_update_should_raise_api_errors( + self, + client: EmailCampaignsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.patch( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.update(CAMPAIGN_ID, UpdateEmailCampaignParams(name="x")) + + assert expected_error_message in str(exc_info.value) + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": {"base": ["campaign is sending"]}}, + "base: campaign is sending", + ), + ], + ) + @responses.activate + def test_delete_should_raise_api_errors( + self, + client: EmailCampaignsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.delete( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(CAMPAIGN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_should_return_deleted_object_on_204( + self, client: EmailCampaignsApi + ) -> None: + responses.delete(f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}", status=204) + + result = client.delete(CAMPAIGN_ID) + + assert isinstance(result, DeletedObject) + assert result.id == CAMPAIGN_ID + + @pytest.mark.parametrize("action", ["start", "cancel", "terminate", "reset"]) + @responses.activate + def test_lifecycle_actions_should_post_and_unwrap_data_envelope( + self, client: EmailCampaignsApi, sample_campaign_dict: dict, action: str + ) -> None: + responses.post( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/{action}", + json={"data": {**sample_campaign_dict, "current_state": "started"}}, + status=200, + ) + + campaign = getattr(client, action)(CAMPAIGN_ID) + + assert isinstance(campaign, EmailCampaign) + assert campaign.id == CAMPAIGN_ID + assert campaign.current_state == "started" + assert responses.calls[0].request.body is None + + @pytest.mark.parametrize( + "response_json,expected_error_message", + [ + ( + {"errors": "Cannot transition from 'started' to 'scheduled'"}, + "Cannot transition from 'started' to 'scheduled'", + ), + ( + {"errors": ["Campaign design can't be blank"]}, + "Campaign design can't be blank", + ), + ], + ) + @responses.activate + def test_start_should_raise_action_validation_errors( + self, + client: EmailCampaignsApi, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/start", + status=conftest.VALIDATION_ERRORS_STATUS_CODE, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.start(CAMPAIGN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_schedule_should_send_datetime_and_unwrap_data_envelope( + self, client: EmailCampaignsApi, sample_campaign_dict: dict + ) -> None: + scheduled = { + **sample_campaign_dict, + "current_state": "scheduled", + "current_state_metadata": {"scheduled_at": "2026-06-01T09:00:00.000Z"}, + } + responses.post( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/schedule", + json={"data": scheduled}, + status=200, + ) + + campaign = client.schedule( + CAMPAIGN_ID, + ScheduleEmailCampaignParams(datetime="2026-06-01T09:00:00.000Z"), + ) + + assert isinstance(campaign, EmailCampaign) + assert campaign.current_state == "scheduled" + assert campaign.current_state_metadata is not None + assert campaign.current_state_metadata.scheduled_at == "2026-06-01T09:00:00.000Z" + assert responses.calls[0].request.body == ( + b'{"datetime": "2026-06-01T09:00:00.000Z"}' + ) + + @responses.activate + def test_schedule_should_raise_api_error_for_invalid_datetime( + self, client: EmailCampaignsApi + ) -> None: + responses.post( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/schedule", + status=conftest.VALIDATION_ERRORS_STATUS_CODE, + json={"errors": "Datetime must be in the future"}, + ) + + with pytest.raises(APIError) as exc_info: + client.schedule( + CAMPAIGN_ID, + ScheduleEmailCampaignParams(datetime="2020-01-01T00:00:00.000Z"), + ) + + assert "Datetime must be in the future" in str(exc_info.value) + + @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_stats_should_raise_api_errors( + self, + client: EmailCampaignsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/stats", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_stats(CAMPAIGN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_stats_should_unwrap_data_envelope( + self, client: EmailCampaignsApi, sample_stats_dict: dict + ) -> None: + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/stats", + json={"data": sample_stats_dict}, + status=200, + ) + + stats = client.get_stats(CAMPAIGN_ID) + + assert isinstance(stats, EmailCampaignStats) + assert stats.delivery_count == 1450 + assert stats.open_count == 820 + assert stats.unsubscription_rate == 0.0083 + + @responses.activate + def test_get_stats_should_send_date_query_params( + self, client: EmailCampaignsApi, sample_stats_dict: dict + ) -> None: + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/stats", + json={"data": sample_stats_dict}, + status=200, + ) + + client.get_stats(CAMPAIGN_ID, start_date="2026-05-01", end_date="2026-05-31") + + query = parse_qs(urlparse(responses.calls[0].request.url).query) + assert query["start_date"] == ["2026-05-01"] + assert query["end_date"] == ["2026-05-31"] + + @responses.activate + def test_get_stats_should_return_zeros_when_not_started( + self, client: EmailCampaignsApi + ) -> None: + zeros = { + "delivery_count": 0, + "open_count": 0, + "click_count": 0, + "bounce_count": 0, + "unsubscription_count": 0, + "sent_count": 0, + "spam_count": 0, + "delivery_rate": 0.0, + "open_rate": 0.0, + "click_rate": 0.0, + "bounce_rate": 0.0, + "spam_rate": 0.0, + "unsubscription_rate": 0.0, + } + responses.get( + f"{BASE_CAMPAIGNS_URL}/{CAMPAIGN_ID}/stats", + json={"data": zeros}, + status=200, + ) + + stats = client.get_stats(CAMPAIGN_ID) + + assert isinstance(stats, EmailCampaignStats) + assert stats.delivery_count == 0 + assert stats.delivery_rate == 0.0 diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 952b634..4efd655 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_email_campaigns_api_requires_account_id(self) -> None: + client = self.get_client() + with pytest.raises(mt.ClientConfigurationError) as exc_info: + _ = client.email_campaigns_api + + assert "`account_id` is required for Email Campaigns API" in str(exc_info.value) + @pytest.mark.parametrize( "arguments, expected_url", [ From 8135a16e160650d82e510b42bec0ce424adfe1ee Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Fri, 31 Jul 2026 11:20:54 +0200 Subject: [PATCH 2/8] MT-22401: Address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions: - Drop the account_id requirement from email_campaigns_api — the endpoint is token-scoped and the account is resolved server-side from the token - Add CreateTemplateAttributes with a required subject so create() cannot send a template the API would reject; TemplateAttributes stays partial for updates - Derive the example's schedule datetime and stats window at runtime instead of hardcoded dates that go stale - Compare request bodies as parsed JSON in tests instead of exact bytes --- examples/email_campaigns/email_campaigns.py | 24 +++++--- mailtrap/__init__.py | 1 + mailtrap/api/email_campaigns.py | 5 +- mailtrap/api/resources/email_campaigns.py | 3 +- mailtrap/client.py | 4 +- mailtrap/models/email_campaigns.py | 21 +++++-- .../email_campaigns/test_email_campaigns.py | 56 ++++++++++--------- tests/unit/test_client.py | 6 +- 8 files changed, 72 insertions(+), 48 deletions(-) diff --git a/examples/email_campaigns/email_campaigns.py b/examples/email_campaigns/email_campaigns.py index e78eeca..31419fb 100644 --- a/examples/email_campaigns/email_campaigns.py +++ b/examples/email_campaigns/email_campaigns.py @@ -1,3 +1,7 @@ +from datetime import datetime +from datetime import timedelta +from datetime import timezone + import mailtrap as mt from mailtrap.models.common import DeletedObject from mailtrap.models.email_campaigns import EmailCampaign @@ -5,10 +9,10 @@ from mailtrap.models.email_campaigns import EmailCampaignStats API_TOKEN = "YOUR_API_TOKEN" -ACCOUNT_ID = "YOUR_ACCOUNT_ID" DOMAIN_ID = 4321 -client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +# The Email Campaigns API is token-scoped — no `account_id` is needed. +client = mt.MailtrapClient(token=API_TOKEN) email_campaigns_api = client.email_campaigns_api.email_campaigns @@ -37,7 +41,9 @@ def create_email_campaign() -> EmailCampaign: local_part="support", domain="acme.com", ), - template_attributes=mt.TemplateAttributes(subject="Spring is here — 30% off"), + template_attributes=mt.CreateTemplateAttributes( + subject="Spring is here — 30% off" + ), ) ) @@ -68,12 +74,13 @@ def update_email_campaign(email_campaign_id: int) -> EmailCampaign: def schedule_email_campaign(email_campaign_id: int) -> EmailCampaign: - # The campaign must be a `draft`; the time comes back in - # `current_state_metadata.scheduled_at`. + # The campaign must be a `draft`; the time must be in the future (at most + # 1 month ahead) and comes back in `current_state_metadata.scheduled_at`. + send_at = datetime.now(timezone.utc) + timedelta(days=1) return email_campaigns_api.schedule( email_campaign_id=email_campaign_id, schedule_params=mt.ScheduleEmailCampaignParams( - datetime="2026-06-01T09:00:00.000Z" + datetime=send_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") ), ) @@ -99,10 +106,11 @@ def reset_email_campaign(email_campaign_id: int) -> EmailCampaign: def get_email_campaign_stats(email_campaign_id: int) -> EmailCampaignStats: + today = datetime.now(timezone.utc).date() return email_campaigns_api.get_stats( email_campaign_id=email_campaign_id, - start_date="2026-05-01", - end_date="2026-05-31", + start_date=(today - timedelta(days=30)).isoformat(), + end_date=today.isoformat(), ) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 6fd4bc4..d4e602b 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -19,6 +19,7 @@ from .models.contacts import UpdateContactParams from .models.email_campaigns import CampaignTemplate from .models.email_campaigns import CreateEmailCampaignParams +from .models.email_campaigns import CreateTemplateAttributes from .models.email_campaigns import DeliveryOptions from .models.email_campaigns import EmailCampaign from .models.email_campaigns import EmailCampaignListResponse diff --git a/mailtrap/api/email_campaigns.py b/mailtrap/api/email_campaigns.py index 045d6fb..612bf90 100644 --- a/mailtrap/api/email_campaigns.py +++ b/mailtrap/api/email_campaigns.py @@ -3,10 +3,9 @@ class EmailCampaignsBaseApi: - def __init__(self, client: HttpClient, account_id: str) -> None: - self._account_id = account_id + def __init__(self, client: HttpClient) -> None: self._client = client @property def email_campaigns(self) -> EmailCampaignsApi: - return EmailCampaignsApi(account_id=self._account_id, client=self._client) + return EmailCampaignsApi(client=self._client) diff --git a/mailtrap/api/resources/email_campaigns.py b/mailtrap/api/resources/email_campaigns.py index f11e176..4270d30 100644 --- a/mailtrap/api/resources/email_campaigns.py +++ b/mailtrap/api/resources/email_campaigns.py @@ -15,8 +15,7 @@ class EmailCampaignsApi: - def __init__(self, client: HttpClient, account_id: str) -> None: - self._account_id = account_id + def __init__(self, client: HttpClient) -> None: self._client = client def get_list( diff --git a/mailtrap/client.py b/mailtrap/client.py index 9169f8f..67bad8c 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -127,9 +127,9 @@ def sending_domains_api(self) -> SendingDomainsBaseApi: @property def email_campaigns_api(self) -> EmailCampaignsBaseApi: - self._validate_account_id("Email Campaigns API") + # Token-scoped (`/api/email_campaigns`) — the account is resolved + # server-side from the token, so no `account_id` is required. return EmailCampaignsBaseApi( - account_id=cast(str, self.account_id), client=HttpClient(host=GENERAL_HOST, headers=self.headers), ) diff --git a/mailtrap/models/email_campaigns.py b/mailtrap/models/email_campaigns.py index ea4a8c0..d37b089 100644 --- a/mailtrap/models/email_campaigns.py +++ b/mailtrap/models/email_campaigns.py @@ -27,9 +27,9 @@ class DeliveryOptions: @dataclass class TemplateAttributes: """ - Inline email template — the campaign's subject and design. ``subject`` is - required when creating a campaign. On update only the sub-fields you - provide change; ``merge_tags`` is replaced as a whole when provided. + Inline email template — the campaign's subject and design. On update only + the sub-fields you provide change; ``merge_tags`` is replaced as a whole + when provided. """ subject: Optional[str] = None @@ -38,6 +38,19 @@ class TemplateAttributes: merge_tags: Optional[list[str]] = None +@dataclass +class CreateTemplateAttributes: + """ + Inline email template for creating a campaign — ``subject`` is required; + the design fields are optional until the campaign is scheduled or started. + """ + + subject: str + body_html: Optional[str] = None + body_text: Optional[str] = None + merge_tags: Optional[list[str]] = None + + @dataclass class EmailCampaignStats: """ @@ -183,7 +196,7 @@ class CreateEmailCampaignParams(RequestParams): name: str domain_id: int from_local_part: str - template_attributes: TemplateAttributes + template_attributes: CreateTemplateAttributes from_display_name: Optional[str] = None reply_to: Optional[ReplyTo] = None delivery_mode: Optional[str] = None diff --git a/tests/unit/api/email_campaigns/test_email_campaigns.py b/tests/unit/api/email_campaigns/test_email_campaigns.py index 95ce60e..ff692ce 100644 --- a/tests/unit/api/email_campaigns/test_email_campaigns.py +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -1,3 +1,4 @@ +import json from typing import Any from urllib.parse import parse_qs from urllib.parse import urlparse @@ -11,6 +12,7 @@ from mailtrap.http import HttpClient from mailtrap.models.common import DeletedObject from mailtrap.models.email_campaigns import CreateEmailCampaignParams +from mailtrap.models.email_campaigns import CreateTemplateAttributes from mailtrap.models.email_campaigns import DeliveryOptions from mailtrap.models.email_campaigns import EmailCampaign from mailtrap.models.email_campaigns import EmailCampaignListResponse @@ -21,7 +23,6 @@ from mailtrap.models.email_campaigns import UpdateEmailCampaignParams from tests import conftest -ACCOUNT_ID = "26730" CAMPAIGN_ID = 4567 DOMAIN_ID = 4321 # The endpoint is token-scoped, NOT under /api/accounts/{account_id}. @@ -30,7 +31,7 @@ @pytest.fixture def client() -> EmailCampaignsApi: - return EmailCampaignsApi(client=HttpClient(GENERAL_HOST), account_id=ACCOUNT_ID) + return EmailCampaignsApi(client=HttpClient(GENERAL_HOST)) @pytest.fixture @@ -319,7 +320,7 @@ def test_create_should_raise_api_errors( name="Spring Sale", domain_id=DOMAIN_ID, from_local_part="news", - template_attributes=TemplateAttributes(subject="Spring!"), + template_attributes=CreateTemplateAttributes(subject="Spring!"), ) ) @@ -338,7 +339,7 @@ def test_create_should_send_flat_body_and_unwrap_data_envelope( name="Spring Sale", domain_id=DOMAIN_ID, from_local_part="news", - template_attributes=TemplateAttributes( + template_attributes=CreateTemplateAttributes( subject="Spring is here — 30% off" ), from_display_name="Acme Marketing", @@ -357,16 +358,19 @@ def test_create_should_send_flat_body_and_unwrap_data_envelope( assert len(responses.calls) == 1 # The request body is flat — no `email_campaign` wrapper. - assert responses.calls[0].request.body == ( - b'{"name": "Spring Sale", ' - b'"domain_id": 4321, ' - b'"from_local_part": "news", ' - b'"template_attributes": {"subject": "Spring is here \\u2014 30% off"}, ' - b'"from_display_name": "Acme Marketing", ' - b'"reply_to": {"display_name": "Acme Support", ' - b'"local_part": "support", "domain": "acme.com"}, ' - b'"contact_list_ids": [55, 56]}' - ) + assert json.loads(responses.calls[0].request.body) == { + "name": "Spring Sale", + "domain_id": 4321, + "from_local_part": "news", + "template_attributes": {"subject": "Spring is here — 30% off"}, + "from_display_name": "Acme Marketing", + "reply_to": { + "display_name": "Acme Support", + "local_part": "support", + "domain": "acme.com", + }, + "contact_list_ids": [55, 56], + } @responses.activate def test_update_should_send_only_supplied_fields_flat( @@ -395,14 +399,16 @@ def test_update_should_send_only_supplied_fields_flat( assert isinstance(campaign, EmailCampaign) assert campaign.delivery_mode == "gradual" - assert responses.calls[0].request.body == ( - b'{"template_attributes": {"subject": "New subject", ' - b'"body_html": "Hi", ' - b'"merge_tags": ["first_name"]}, ' - b'"delivery_mode": "gradual", ' - b'"delivery_options": {"emails_per_hour": 1000}, ' - b'"contact_segment_ids": [12]}' - ) + assert json.loads(responses.calls[0].request.body) == { + "template_attributes": { + "subject": "New subject", + "body_html": "Hi", + "merge_tags": ["first_name"], + }, + "delivery_mode": "gradual", + "delivery_options": {"emails_per_hour": 1000}, + "contact_segment_ids": [12], + } @pytest.mark.parametrize( "status_code,response_json,expected_error_message", @@ -556,9 +562,9 @@ def test_schedule_should_send_datetime_and_unwrap_data_envelope( assert campaign.current_state == "scheduled" assert campaign.current_state_metadata is not None assert campaign.current_state_metadata.scheduled_at == "2026-06-01T09:00:00.000Z" - assert responses.calls[0].request.body == ( - b'{"datetime": "2026-06-01T09:00:00.000Z"}' - ) + assert json.loads(responses.calls[0].request.body) == { + "datetime": "2026-06-01T09:00:00.000Z" + } @responses.activate def test_schedule_should_raise_api_error_for_invalid_datetime( diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 4efd655..02fb138 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -63,12 +63,10 @@ def test_webhooks_api_requires_account_id(self) -> None: assert "`account_id` is required for Webhooks API" in str(exc_info.value) - def test_email_campaigns_api_requires_account_id(self) -> None: + def test_email_campaigns_api_does_not_require_account_id(self) -> None: client = self.get_client() - with pytest.raises(mt.ClientConfigurationError) as exc_info: - _ = client.email_campaigns_api - assert "`account_id` is required for Email Campaigns API" in str(exc_info.value) + assert client.email_campaigns_api.email_campaigns is not None @pytest.mark.parametrize( "arguments, expected_url", From cd2e5bb0fa1632220a0acafe54770c3762504501 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Mon, 10 Aug 2026 10:13:26 +0200 Subject: [PATCH 3/8] MT-22401: prefix campaign model names with EmailCampaign Decisions: - generic names (ReplyTo, DeliveryOptions, TemplateAttributes, CampaignTemplate) are re-exported flat from the mailtrap package, so they need campaign context to avoid collisions --- examples/email_campaigns/email_campaigns.py | 8 +++--- mailtrap/__init__.py | 10 +++---- mailtrap/models/email_campaigns.py | 28 +++++++++---------- .../email_campaigns/test_email_campaigns.py | 18 ++++++------ 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/examples/email_campaigns/email_campaigns.py b/examples/email_campaigns/email_campaigns.py index 31419fb..95aabeb 100644 --- a/examples/email_campaigns/email_campaigns.py +++ b/examples/email_campaigns/email_campaigns.py @@ -36,12 +36,12 @@ def create_email_campaign() -> EmailCampaign: domain_id=DOMAIN_ID, from_display_name="Acme Marketing", from_local_part="news", - reply_to=mt.ReplyTo( + reply_to=mt.EmailCampaignReplyTo( display_name="Acme Support", local_part="support", domain="acme.com", ), - template_attributes=mt.CreateTemplateAttributes( + template_attributes=mt.CreateEmailCampaignTemplateAttributes( subject="Spring is here — 30% off" ), ) @@ -56,10 +56,10 @@ def update_email_campaign(email_campaign_id: int) -> EmailCampaign: campaign_params=mt.UpdateEmailCampaignParams( name="Spring Sale (updated)", delivery_mode="gradual", - delivery_options=mt.DeliveryOptions(emails_per_hour=1000), + delivery_options=mt.EmailCampaignDeliveryOptions(emails_per_hour=1000), contact_list_ids=[55, 56], contact_segment_ids=[12], - template_attributes=mt.TemplateAttributes( + template_attributes=mt.EmailCampaignTemplateAttributes( subject="Spring is here — 30% off everything", body_html=( "" diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index d4e602b..c049e7c 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -17,16 +17,16 @@ from .models.contacts import ImportContactParams from .models.contacts import UpdateContactFieldParams from .models.contacts import UpdateContactParams -from .models.email_campaigns import CampaignTemplate from .models.email_campaigns import CreateEmailCampaignParams -from .models.email_campaigns import CreateTemplateAttributes -from .models.email_campaigns import DeliveryOptions +from .models.email_campaigns import CreateEmailCampaignTemplateAttributes from .models.email_campaigns import EmailCampaign +from .models.email_campaigns import EmailCampaignDeliveryOptions from .models.email_campaigns import EmailCampaignListResponse +from .models.email_campaigns import EmailCampaignReplyTo from .models.email_campaigns import EmailCampaignStats -from .models.email_campaigns import ReplyTo +from .models.email_campaigns import EmailCampaignTemplate +from .models.email_campaigns import EmailCampaignTemplateAttributes from .models.email_campaigns import ScheduleEmailCampaignParams -from .models.email_campaigns import TemplateAttributes from .models.email_campaigns import UpdateEmailCampaignParams from .models.email_logs import EmailLogMessage from .models.email_logs import EmailLogsListFilters diff --git a/mailtrap/models/email_campaigns.py b/mailtrap/models/email_campaigns.py index d37b089..5426335 100644 --- a/mailtrap/models/email_campaigns.py +++ b/mailtrap/models/email_campaigns.py @@ -9,7 +9,7 @@ @dataclass -class ReplyTo: +class EmailCampaignReplyTo: """Reply-To address parts.""" display_name: Optional[str] = None @@ -18,14 +18,14 @@ class ReplyTo: @dataclass -class DeliveryOptions: +class EmailCampaignDeliveryOptions: """Delivery throttling options. Applies when ``delivery_mode`` is ``gradual``.""" emails_per_hour: Optional[int] = None @dataclass -class TemplateAttributes: +class EmailCampaignTemplateAttributes: """ Inline email template — the campaign's subject and design. On update only the sub-fields you provide change; ``merge_tags`` is replaced as a whole @@ -39,7 +39,7 @@ class TemplateAttributes: @dataclass -class CreateTemplateAttributes: +class CreateEmailCampaignTemplateAttributes: """ Inline email template for creating a campaign — ``subject`` is required; the design fields are optional until the campaign is scheduled or started. @@ -92,7 +92,7 @@ class CurrentStateMetadata: @dataclass -class CampaignTemplate: +class EmailCampaignTemplate: """ The campaign's template as returned by the API. ``body_html`` and ``body_text`` are returned only on single-campaign responses; the list @@ -116,7 +116,7 @@ class EmailCampaign: name: Optional[str] = None from_local_part: Optional[str] = None from_display_name: Optional[str] = None - reply_to: Optional[ReplyTo] = None + reply_to: Optional[EmailCampaignReplyTo] = None current_state: Optional[str] = None current_state_metadata: Optional[CurrentStateMetadata] = None created_at: Optional[str] = None @@ -127,8 +127,8 @@ class EmailCampaign: contact_list_ids: list[int] = Field(default_factory=list) contact_segment_ids: list[int] = Field(default_factory=list) delivery_mode: Optional[str] = None - delivery_options: Optional[DeliveryOptions] = None - template: Optional[CampaignTemplate] = None + delivery_options: Optional[EmailCampaignDeliveryOptions] = None + template: Optional[EmailCampaignTemplate] = None @dataclass @@ -196,11 +196,11 @@ class CreateEmailCampaignParams(RequestParams): name: str domain_id: int from_local_part: str - template_attributes: CreateTemplateAttributes + template_attributes: CreateEmailCampaignTemplateAttributes from_display_name: Optional[str] = None - reply_to: Optional[ReplyTo] = None + reply_to: Optional[EmailCampaignReplyTo] = None delivery_mode: Optional[str] = None - delivery_options: Optional[DeliveryOptions] = None + delivery_options: Optional[EmailCampaignDeliveryOptions] = None contact_list_ids: Optional[list[int]] = None contact_segment_ids: Optional[list[int]] = None @@ -216,10 +216,10 @@ class UpdateEmailCampaignParams(RequestParams): domain_id: Optional[int] = None from_local_part: Optional[str] = None from_display_name: Optional[str] = None - reply_to: Optional[ReplyTo] = None - template_attributes: Optional[TemplateAttributes] = None + reply_to: Optional[EmailCampaignReplyTo] = None + template_attributes: Optional[EmailCampaignTemplateAttributes] = None delivery_mode: Optional[str] = None - delivery_options: Optional[DeliveryOptions] = None + delivery_options: Optional[EmailCampaignDeliveryOptions] = None contact_list_ids: Optional[list[int]] = None contact_segment_ids: Optional[list[int]] = None diff --git a/tests/unit/api/email_campaigns/test_email_campaigns.py b/tests/unit/api/email_campaigns/test_email_campaigns.py index ff692ce..9d8f841 100644 --- a/tests/unit/api/email_campaigns/test_email_campaigns.py +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -12,14 +12,14 @@ from mailtrap.http import HttpClient from mailtrap.models.common import DeletedObject from mailtrap.models.email_campaigns import CreateEmailCampaignParams -from mailtrap.models.email_campaigns import CreateTemplateAttributes -from mailtrap.models.email_campaigns import DeliveryOptions +from mailtrap.models.email_campaigns import CreateEmailCampaignTemplateAttributes from mailtrap.models.email_campaigns import EmailCampaign +from mailtrap.models.email_campaigns import EmailCampaignDeliveryOptions from mailtrap.models.email_campaigns import EmailCampaignListResponse +from mailtrap.models.email_campaigns import EmailCampaignReplyTo from mailtrap.models.email_campaigns import EmailCampaignStats -from mailtrap.models.email_campaigns import ReplyTo +from mailtrap.models.email_campaigns import EmailCampaignTemplateAttributes from mailtrap.models.email_campaigns import ScheduleEmailCampaignParams -from mailtrap.models.email_campaigns import TemplateAttributes from mailtrap.models.email_campaigns import UpdateEmailCampaignParams from tests import conftest @@ -320,7 +320,7 @@ def test_create_should_raise_api_errors( name="Spring Sale", domain_id=DOMAIN_ID, from_local_part="news", - template_attributes=CreateTemplateAttributes(subject="Spring!"), + template_attributes=CreateEmailCampaignTemplateAttributes(subject="Spring!"), ) ) @@ -339,11 +339,11 @@ def test_create_should_send_flat_body_and_unwrap_data_envelope( name="Spring Sale", domain_id=DOMAIN_ID, from_local_part="news", - template_attributes=CreateTemplateAttributes( + template_attributes=CreateEmailCampaignTemplateAttributes( subject="Spring is here — 30% off" ), from_display_name="Acme Marketing", - reply_to=ReplyTo( + reply_to=EmailCampaignReplyTo( display_name="Acme Support", local_part="support", domain="acme.com", @@ -385,13 +385,13 @@ def test_update_should_send_only_supplied_fields_flat( campaign = client.update( CAMPAIGN_ID, UpdateEmailCampaignParams( - template_attributes=TemplateAttributes( + template_attributes=EmailCampaignTemplateAttributes( subject="New subject", body_html="Hi", merge_tags=["first_name"], ), delivery_mode="gradual", - delivery_options=DeliveryOptions(emails_per_hour=1000), + delivery_options=EmailCampaignDeliveryOptions(emails_per_hour=1000), contact_segment_ids=[12], ), ) From a93873544ca2bc6722e61d9c96d27e29e0a6a335 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Mon, 10 Aug 2026 12:19:09 +0200 Subject: [PATCH 4/8] MT-22401: wrap long line after model rename Decisions: - the EmailCampaign prefix rename pushed a test line past the 90-char flake8/black limit --- tests/unit/api/email_campaigns/test_email_campaigns.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/api/email_campaigns/test_email_campaigns.py b/tests/unit/api/email_campaigns/test_email_campaigns.py index 9d8f841..281a190 100644 --- a/tests/unit/api/email_campaigns/test_email_campaigns.py +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -320,7 +320,9 @@ def test_create_should_raise_api_errors( name="Spring Sale", domain_id=DOMAIN_ID, from_local_part="news", - template_attributes=CreateEmailCampaignTemplateAttributes(subject="Spring!"), + template_attributes=CreateEmailCampaignTemplateAttributes( + subject="Spring!" + ), ) ) From 717f5db85d6b094c7591e091faefdb968d80e159 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Tue, 11 Aug 2026 12:05:24 +0200 Subject: [PATCH 5/8] MT-22401: take params objects for campaign list and stats Decisions: - Accept EmailCampaignListParams/EmailCampaignStatsParams instead of loose kwargs so new query params extend the params class, matching StatsApi.get - Export both params classes; they are public call surface now, as StatsFilterParams is - Move Pagination to models/common.py; the payload is generic, not campaign-specific --- examples/email_campaigns/email_campaigns.py | 12 ++++++-- mailtrap/__init__.py | 2 ++ mailtrap/api/resources/email_campaigns.py | 30 +++++++------------ mailtrap/models/common.py | 14 +++++++++ mailtrap/models/email_campaigns.py | 14 +-------- .../email_campaigns/test_email_campaigns.py | 9 ++++-- 6 files changed, 44 insertions(+), 37 deletions(-) diff --git a/examples/email_campaigns/email_campaigns.py b/examples/email_campaigns/email_campaigns.py index 95aabeb..e2c7825 100644 --- a/examples/email_campaigns/email_campaigns.py +++ b/examples/email_campaigns/email_campaigns.py @@ -5,8 +5,10 @@ import mailtrap as mt from mailtrap.models.common import DeletedObject from mailtrap.models.email_campaigns import EmailCampaign +from mailtrap.models.email_campaigns import EmailCampaignListParams from mailtrap.models.email_campaigns import EmailCampaignListResponse from mailtrap.models.email_campaigns import EmailCampaignStats +from mailtrap.models.email_campaigns import EmailCampaignStatsParams API_TOKEN = "YOUR_API_TOKEN" DOMAIN_ID = 4321 @@ -19,7 +21,9 @@ def list_email_campaigns() -> EmailCampaignListResponse: # `search` filters by name; `token` is the page number (page-token # pagination); `per_page` caps at 100 (default 50). - return email_campaigns_api.get_list(per_page=50, search="Spring", token=1) + return email_campaigns_api.get_list( + EmailCampaignListParams(per_page=50, search="Spring", token=1) + ) def get_email_campaign(email_campaign_id: int) -> EmailCampaign: @@ -109,8 +113,10 @@ def get_email_campaign_stats(email_campaign_id: int) -> EmailCampaignStats: today = datetime.now(timezone.utc).date() return email_campaigns_api.get_stats( email_campaign_id=email_campaign_id, - start_date=(today - timedelta(days=30)).isoformat(), - end_date=today.isoformat(), + params=EmailCampaignStatsParams( + start_date=(today - timedelta(days=30)).isoformat(), + end_date=today.isoformat(), + ), ) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index c049e7c..ab75f1a 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -21,9 +21,11 @@ from .models.email_campaigns import CreateEmailCampaignTemplateAttributes from .models.email_campaigns import EmailCampaign from .models.email_campaigns import EmailCampaignDeliveryOptions +from .models.email_campaigns import EmailCampaignListParams from .models.email_campaigns import EmailCampaignListResponse from .models.email_campaigns import EmailCampaignReplyTo from .models.email_campaigns import EmailCampaignStats +from .models.email_campaigns import EmailCampaignStatsParams from .models.email_campaigns import EmailCampaignTemplate from .models.email_campaigns import EmailCampaignTemplateAttributes from .models.email_campaigns import ScheduleEmailCampaignParams diff --git a/mailtrap/api/resources/email_campaigns.py b/mailtrap/api/resources/email_campaigns.py index 4270d30..3b13b1a 100644 --- a/mailtrap/api/resources/email_campaigns.py +++ b/mailtrap/api/resources/email_campaigns.py @@ -19,20 +19,15 @@ def __init__(self, client: HttpClient) -> None: self._client = client def get_list( - self, - per_page: Optional[int] = None, - search: Optional[str] = None, - token: Optional[int] = None, + self, params: Optional[EmailCampaignListParams] = None ) -> EmailCampaignListResponse: """ - List email campaigns for the account, newest first. ``search`` filters - by name, ``per_page`` sets the page size (max 100, default 50), and - ``token`` is the page number to retrieve (default 1). + List email campaigns for the account, newest first. ``params`` filters + by name and paginates the result; omit it for the first page with API + defaults. """ - params = EmailCampaignListParams( - per_page=per_page, search=search, token=token - ).api_query_params - response = self._client.get(self._api_path(), params=params or None) + query_params = params.api_query_params if params else None + response = self._client.get(self._api_path(), params=query_params or None) return EmailCampaignListResponse(**response) def get_by_id(self, email_campaign_id: int) -> EmailCampaign: @@ -112,20 +107,17 @@ def reset(self, email_campaign_id: int) -> EmailCampaign: def get_stats( self, email_campaign_id: int, - start_date: Optional[str] = None, - end_date: Optional[str] = None, + params: Optional[EmailCampaignStatsParams] = None, ) -> EmailCampaignStats: """ Get aggregated performance statistics for a single campaign. If the campaign has never been started, all counts and rates are ``0``. - ``start_date``/``end_date`` (``YYYY-MM-DD``) narrow the aggregation - window; it defaults to the whole period since the last start. + ``params`` narrows the aggregation window; omit it to cover the whole + period since the campaign was last started. """ - params = EmailCampaignStatsParams( - start_date=start_date, end_date=end_date - ).api_query_params + query_params = params.api_query_params if params else None response = self._client.get( - f"{self._api_path(email_campaign_id)}/stats", params=params or None + f"{self._api_path(email_campaign_id)}/stats", params=query_params or None ) return EmailCampaignStatsResponse(**response).data diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index 0b7874d..5de16bb 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -1,4 +1,5 @@ from typing import Any +from typing import Optional from typing import TypeVar from typing import Union from typing import cast @@ -32,3 +33,16 @@ def api_query_params(self: T) -> dict[str, Any]: @dataclass class DeletedObject: id: Union[int, str] + + +@dataclass +class Pagination: + """Page-token pagination metadata returned with a paginated list response.""" + + token: Optional[int] = None + prev_token: Optional[int] = None + next_token: Optional[int] = None + first_url: Optional[str] = None + prev_url: Optional[str] = None + current_url: Optional[str] = None + next_url: Optional[str] = None diff --git a/mailtrap/models/email_campaigns.py b/mailtrap/models/email_campaigns.py index 5426335..bbdfffc 100644 --- a/mailtrap/models/email_campaigns.py +++ b/mailtrap/models/email_campaigns.py @@ -5,6 +5,7 @@ from pydantic import Field from pydantic.dataclasses import dataclass +from mailtrap.models.common import Pagination from mailtrap.models.common import RequestParams @@ -131,19 +132,6 @@ class EmailCampaign: template: Optional[EmailCampaignTemplate] = None -@dataclass -class Pagination: - """Page-token pagination metadata.""" - - token: Optional[int] = None - prev_token: Optional[int] = None - next_token: Optional[int] = None - first_url: Optional[str] = None - prev_url: Optional[str] = None - current_url: Optional[str] = None - next_url: Optional[str] = None - - @dataclass class EmailCampaignResponse: """Envelope of a single-campaign response.""" diff --git a/tests/unit/api/email_campaigns/test_email_campaigns.py b/tests/unit/api/email_campaigns/test_email_campaigns.py index 281a190..40021aa 100644 --- a/tests/unit/api/email_campaigns/test_email_campaigns.py +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -15,9 +15,11 @@ from mailtrap.models.email_campaigns import CreateEmailCampaignTemplateAttributes from mailtrap.models.email_campaigns import EmailCampaign from mailtrap.models.email_campaigns import EmailCampaignDeliveryOptions +from mailtrap.models.email_campaigns import EmailCampaignListParams from mailtrap.models.email_campaigns import EmailCampaignListResponse from mailtrap.models.email_campaigns import EmailCampaignReplyTo from mailtrap.models.email_campaigns import EmailCampaignStats +from mailtrap.models.email_campaigns import EmailCampaignStatsParams from mailtrap.models.email_campaigns import EmailCampaignTemplateAttributes from mailtrap.models.email_campaigns import ScheduleEmailCampaignParams from mailtrap.models.email_campaigns import UpdateEmailCampaignParams @@ -188,7 +190,7 @@ def test_get_list_should_send_search_per_page_and_token_query_params( ) -> None: responses.get(BASE_CAMPAIGNS_URL, json={"data": [], "pagination": {}}, status=200) - client.get_list(per_page=25, search="Spring", token=2) + client.get_list(EmailCampaignListParams(per_page=25, search="Spring", token=2)) query = parse_qs(urlparse(responses.calls[0].request.url).query) # The name filter must serialize to `search`, not `name`. @@ -647,7 +649,10 @@ def test_get_stats_should_send_date_query_params( status=200, ) - client.get_stats(CAMPAIGN_ID, start_date="2026-05-01", end_date="2026-05-31") + client.get_stats( + CAMPAIGN_ID, + EmailCampaignStatsParams(start_date="2026-05-01", end_date="2026-05-31"), + ) query = parse_qs(urlparse(responses.calls[0].request.url).query) assert query["start_date"] == ["2026-05-01"] From 402bea059d7f3967e07443082f0a47ac02da1cfa Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Wed, 12 Aug 2026 13:20:32 +0200 Subject: [PATCH 6/8] MT-22401: drop mentions of the account-scoped campaigns path Decisions: - "/api/accounts/{account_id}/email_campaigns" does not exist, so there is no need to contrast the real path against it - Keep the positive fact that the account comes from the API token; it explains why the path takes no account id --- mailtrap/api/resources/email_campaigns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mailtrap/api/resources/email_campaigns.py b/mailtrap/api/resources/email_campaigns.py index 3b13b1a..071a0ea 100644 --- a/mailtrap/api/resources/email_campaigns.py +++ b/mailtrap/api/resources/email_campaigns.py @@ -126,8 +126,8 @@ def _action(self, email_campaign_id: int, action: str) -> EmailCampaign: return EmailCampaignResponse(**response).data def _api_path(self, email_campaign_id: Optional[int] = None) -> str: - # The Email Campaigns endpoint is token-scoped, NOT account-scoped: - # the account is resolved from the API token server-side. + # Token-scoped: the account is resolved from the API token server-side, + # so the path takes no account id. path = "/api/email_campaigns" if email_campaign_id is not None: return f"{path}/{email_campaign_id}" From a2734a2d53e4776e25e0b9b6c69f1152001f1456 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 13 Aug 2026 07:16:23 +0200 Subject: [PATCH 7/8] MT-22401: drop remaining mentions of the account-scoped campaigns path Decisions: - "/api/accounts/{account_id}/email_campaigns" does not exist, so comments and tests should not contrast the real path against it - The positive path assertions already cover what these comments described --- tests/unit/api/email_campaigns/test_email_campaigns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/api/email_campaigns/test_email_campaigns.py b/tests/unit/api/email_campaigns/test_email_campaigns.py index 40021aa..b0cd395 100644 --- a/tests/unit/api/email_campaigns/test_email_campaigns.py +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -27,7 +27,6 @@ CAMPAIGN_ID = 4567 DOMAIN_ID = 4321 -# The endpoint is token-scoped, NOT under /api/accounts/{account_id}. BASE_CAMPAIGNS_URL = f"https://{GENERAL_HOST}/api/email_campaigns" From 3bf43d6ad7f1f2697d48e0f7a4ef182cbf0ce76a Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 13 Aug 2026 09:53:41 +0200 Subject: [PATCH 8/8] MT-22401: correct the delete precondition to draft-only Decisions: - The backend allows deleting only a campaign in the draft state (EmailCampaign#validate_soft_delete), not merely a non-sending one - Examples deleted a campaign after start/terminate, which would 422; a started campaign can never return to draft, so they now delete a fresh draft --- examples/email_campaigns/email_campaigns.py | 6 +++++- mailtrap/api/resources/email_campaigns.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/email_campaigns/email_campaigns.py b/examples/email_campaigns/email_campaigns.py index e2c7825..1acb29e 100644 --- a/examples/email_campaigns/email_campaigns.py +++ b/examples/email_campaigns/email_campaigns.py @@ -151,5 +151,9 @@ def delete_email_campaign(email_campaign_id: int) -> DeletedObject: stats = get_email_campaign_stats(created.id) print(stats) - deleted = delete_email_campaign(created.id) + # Only a campaign in the `draft` state can be deleted, and a campaign that + # has been started can never return to `draft` — so delete a fresh draft + # rather than the one started above. + throwaway = create_email_campaign() + deleted = delete_email_campaign(throwaway.id) print(deleted) diff --git a/mailtrap/api/resources/email_campaigns.py b/mailtrap/api/resources/email_campaigns.py index 071a0ea..a0520c9 100644 --- a/mailtrap/api/resources/email_campaigns.py +++ b/mailtrap/api/resources/email_campaigns.py @@ -61,7 +61,8 @@ def update( def delete(self, email_campaign_id: int) -> DeletedObject: """ - Delete an email campaign. The campaign must not be in a sending state. + Delete an email campaign. Only a campaign in the ``draft`` state can be + deleted. """ self._client.delete(self._api_path(email_campaign_id)) return DeletedObject(email_campaign_id)