From f447e0e22f21b466323397773c648282e7dbdf06 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 30 Jul 2026 13:28:22 +0200 Subject: [PATCH 1/2] 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, mailsend_domain_id (UUID string), 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 | 140 ++++ 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 | 224 ++++++ tests/unit/api/email_campaigns/__init__.py | 0 .../email_campaigns/test_email_campaigns.py | 682 ++++++++++++++++++ tests/unit/test_client.py | 7 + 10 files changed, 1230 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 db927e6..38d07f2 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,9 @@ The same situation applies to both `client.batch_send()` and `client.sending_api ### Sending Domains API: - Sending Domains – [`sending_domains/sending_domains.py`](examples/sending_domains/sending_domains.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..c313a38 --- /dev/null +++ b/examples/email_campaigns/email_campaigns.py @@ -0,0 +1,140 @@ +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" +MAILSEND_DOMAIN_ID = "d2313359-acb4-4b87-bce6-f5774f6a1e37" + +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 `mailsend_domain_id` (a UUID string). + return email_campaigns_api.create( + mt.CreateEmailCampaignParams( + name="Spring Sale", + mailsend_domain_id=MAILSEND_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 ca9c7d6..b3de4b2 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..2cef5fa --- /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 ``mailsend_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 0369776..cd74eee 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.organizations import OrganizationsBaseApi @@ -117,6 +118,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..fe407e0 --- /dev/null +++ b/mailtrap/models/email_campaigns.py @@ -0,0 +1,224 @@ +"""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 + message_count: Optional[int] = None + reject_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 + type: Optional[str] = None + mailsend_domain_id: Optional[str] = None + mailsend_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 + mailsend_domain_id: str + 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 + mailsend_domain_id: Optional[str] = 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..468f2bb --- /dev/null +++ b/tests/unit/api/email_campaigns/test_email_campaigns.py @@ -0,0 +1,682 @@ +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 +MAILSEND_DOMAIN_ID = "d2313359-acb4-4b87-bce6-f5774f6a1e37" +# 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, + "message_count": 1500, + "reject_count": 20, + "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, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": MAILSEND_DOMAIN_ID, + "mailsend_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.type == "ContactsEmailCampaign" + assert campaign.mailsend_domain_id == MAILSEND_DOMAIN_ID + 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": {"mailsend_domain_id": ["can't be blank"]}}, + "mailsend_domain_id: can't be blank", + ), + ], + ) + @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", + mailsend_domain_id=MAILSEND_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", + mailsend_domain_id=MAILSEND_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'"mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", ' + 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, + "message_count": 0, + "reject_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 069c3682d13f5b5c834d841e138cc2afd1a03b50 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Fri, 31 Jul 2026 11:20:54 +0200 Subject: [PATCH 2/2] 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 c313a38..e9c20ce 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" MAILSEND_DOMAIN_ID = "d2313359-acb4-4b87-bce6-f5774f6a1e37" -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 @@ -36,7 +40,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" + ), ) ) @@ -67,12 +73,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") ), ) @@ -98,10 +105,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 b3de4b2..5449503 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 2cef5fa..480acc6 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 cd74eee..59168dd 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -120,9 +120,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 fe407e0..72535e9 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: """ @@ -186,7 +199,7 @@ class CreateEmailCampaignParams(RequestParams): name: str mailsend_domain_id: str 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 468f2bb..5127b17 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 MAILSEND_DOMAIN_ID = "d2313359-acb4-4b87-bce6-f5774f6a1e37" # 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 @@ -322,7 +323,7 @@ def test_create_should_raise_api_errors( name="Spring Sale", mailsend_domain_id=MAILSEND_DOMAIN_ID, from_local_part="news", - template_attributes=TemplateAttributes(subject="Spring!"), + template_attributes=CreateTemplateAttributes(subject="Spring!"), ) ) @@ -341,7 +342,7 @@ def test_create_should_send_flat_body_and_unwrap_data_envelope( name="Spring Sale", mailsend_domain_id=MAILSEND_DOMAIN_ID, from_local_part="news", - template_attributes=TemplateAttributes( + template_attributes=CreateTemplateAttributes( subject="Spring is here — 30% off" ), from_display_name="Acme Marketing", @@ -360,16 +361,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'"mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", ' - 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", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "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( @@ -398,14 +402,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", @@ -559,9 +565,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",