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..e9c20ce
--- /dev/null
+++ b/examples/email_campaigns/email_campaigns.py
@@ -0,0 +1,148 @@
+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
+from mailtrap.models.email_campaigns import EmailCampaignListResponse
+from mailtrap.models.email_campaigns import EmailCampaignStats
+
+API_TOKEN = "YOUR_API_TOKEN"
+MAILSEND_DOMAIN_ID = "d2313359-acb4-4b87-bce6-f5774f6a1e37"
+
+# 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
+
+
+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.CreateTemplateAttributes(
+ 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 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=send_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
+ ),
+ )
+
+
+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:
+ 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(),
+ )
+
+
+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..5449503 100644
--- a/mailtrap/__init__.py
+++ b/mailtrap/__init__.py
@@ -17,6 +17,17 @@
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 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..612bf90
--- /dev/null
+++ b/mailtrap/api/email_campaigns.py
@@ -0,0 +1,11 @@
+from mailtrap.api.resources.email_campaigns import EmailCampaignsApi
+from mailtrap.http import HttpClient
+
+
+class EmailCampaignsBaseApi:
+ def __init__(self, client: HttpClient) -> None:
+ self._client = client
+
+ @property
+ def email_campaigns(self) -> EmailCampaignsApi:
+ return EmailCampaignsApi(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..480acc6
--- /dev/null
+++ b/mailtrap/api/resources/email_campaigns.py
@@ -0,0 +1,142 @@
+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) -> None:
+ 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..59168dd 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:
+ # Token-scoped (`/api/email_campaigns`) — the account is resolved
+ # server-side from the token, so no `account_id` is required.
+ return EmailCampaignsBaseApi(
+ 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..72535e9
--- /dev/null
+++ b/mailtrap/models/email_campaigns.py
@@ -0,0 +1,237 @@
+"""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. 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 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:
+ """
+ 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: CreateTemplateAttributes
+ 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..5127b17
--- /dev/null
+++ b/tests/unit/api/email_campaigns/test_email_campaigns.py
@@ -0,0 +1,688 @@
+import json
+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 CreateTemplateAttributes
+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
+
+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))
+
+
+@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=CreateTemplateAttributes(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=CreateTemplateAttributes(
+ 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 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(
+ 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 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",
+ [
+ (
+ 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 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(
+ 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..02fb138 100644
--- a/tests/unit/test_client.py
+++ b/tests/unit/test_client.py
@@ -63,6 +63,11 @@ 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_does_not_require_account_id(self) -> None:
+ client = self.get_client()
+
+ assert client.email_campaigns_api.email_campaigns is not None
+
@pytest.mark.parametrize(
"arguments, expected_url",
[