Skip to content

Commit 4141195

Browse files
committed
MT-22401: Add Email Campaigns API to Python SDK
Implement the token-scoped /api/email_campaigns resource (list, create, get, update, delete, stats) with pydantic models, params DTOs, client wiring, example, and unit tests. - list returns {data, pagination}; create/get/update/delete return a bare EmailCampaign; stats returns a bare EmailCampaignStats - delete returns HTTP 200 + the deleted entity (not 204/DeletedObject) - request body wraps under the email_campaign key - list name filter serializes to the search wire param - EmailCampaignStats defined once, reused for inline stats + /stats
1 parent ab72eaa commit 4141195

10 files changed

Lines changed: 887 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ The same situation applies to both `client.batch_send()` and `client.sending_api
244244
### Sending Domains API:
245245
- Sending Domains – [`sending_domains/sending_domains.py`](examples/sending_domains/sending_domains.py)
246246

247+
### Email Campaigns API:
248+
- Email Campaigns (list, create, get, update, delete, stats) – [`email_campaigns/email_campaigns.py`](examples/email_campaigns/email_campaigns.py)
249+
247250
### Webhooks API:
248251
- Webhooks management – [`webhooks/webhooks.py`](examples/webhooks/webhooks.py)
249252
- Verifying webhook signatures – [`webhooks/verify_signature.py`](examples/webhooks/verify_signature.py)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import mailtrap as mt
2+
from mailtrap.models.email_campaigns import EmailCampaign
3+
from mailtrap.models.email_campaigns import EmailCampaignListResponse
4+
from mailtrap.models.email_campaigns import EmailCampaignStats
5+
6+
API_TOKEN = "YOUR_API_TOKEN"
7+
ACCOUNT_ID = "YOUR_ACCOUNT_ID"
8+
9+
client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
10+
email_campaigns_api = client.email_campaigns_api.email_campaigns
11+
12+
13+
def list_email_campaigns() -> EmailCampaignListResponse:
14+
# `search` filters by name (case-insensitive partial match); `token` is the
15+
# page number (page-token pagination); `per_page` caps at 100 (default 50).
16+
return email_campaigns_api.get_list(per_page=50, search="Spring", token=1)
17+
18+
19+
def get_email_campaign(email_campaign_id: int) -> EmailCampaign:
20+
return email_campaigns_api.get_by_id(email_campaign_id=email_campaign_id)
21+
22+
23+
def create_email_campaign() -> EmailCampaign:
24+
# A campaign is created in the `draft` state and must reference a verified
25+
# sending domain via `mailsend_domain_id`.
26+
return email_campaigns_api.create(
27+
mt.CreateEmailCampaignParams(
28+
name="Spring Sale",
29+
mailsend_domain_id=123,
30+
from_display_name="Acme Marketing",
31+
from_local_part="news",
32+
reply_to=mt.ReplyTo(
33+
display_name="Acme Support",
34+
local_part="support",
35+
domain="acme.com",
36+
),
37+
template_attributes=mt.CampaignTemplate(subject="Spring is here — 30% off"),
38+
)
39+
)
40+
41+
42+
def update_email_campaign(email_campaign_id: int, template_id: int) -> EmailCampaign:
43+
# Only supplied fields are changed. Pass the existing template `id` to
44+
# update its subject in place instead of creating a new template.
45+
return email_campaigns_api.update(
46+
email_campaign_id=email_campaign_id,
47+
campaign_params=mt.UpdateEmailCampaignParams(
48+
name="Spring Sale (updated)",
49+
delivery_mode="scheduled",
50+
scheduled_for="2026-06-01T09:00:00.000Z",
51+
delivery_options=mt.DeliveryOptions(emails_per_hour=1000),
52+
template_attributes=mt.CampaignTemplate(
53+
id=template_id, subject="New subject"
54+
),
55+
),
56+
)
57+
58+
59+
def delete_email_campaign(email_campaign_id: int) -> EmailCampaign:
60+
# The deleted campaign object is returned (HTTP 200 + body).
61+
return email_campaigns_api.delete(email_campaign_id=email_campaign_id)
62+
63+
64+
def get_email_campaign_stats(email_campaign_id: int) -> EmailCampaignStats:
65+
return email_campaigns_api.get_stats(email_campaign_id=email_campaign_id)
66+
67+
68+
if __name__ == "__main__":
69+
listed = list_email_campaigns()
70+
print(listed.data)
71+
print(listed.pagination)
72+
73+
created = create_email_campaign()
74+
print(created)
75+
76+
fetched = get_email_campaign(created.id)
77+
print(fetched)
78+
79+
template_id = created.template.id if created.template else 0
80+
updated = update_email_campaign(created.id, template_id)
81+
print(updated)
82+
83+
stats = get_email_campaign_stats(created.id)
84+
print(stats)
85+
86+
deleted = delete_email_campaign(created.id)
87+
print(deleted)

mailtrap/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
from .models.contacts import ImportContactParams
1818
from .models.contacts import UpdateContactFieldParams
1919
from .models.contacts import UpdateContactParams
20+
from .models.email_campaigns import CampaignTemplate
21+
from .models.email_campaigns import CreateEmailCampaignParams
22+
from .models.email_campaigns import DeliveryOptions
23+
from .models.email_campaigns import ReplyTo
24+
from .models.email_campaigns import UpdateEmailCampaignParams
2025
from .models.email_logs import EmailLogMessage
2126
from .models.email_logs import EmailLogsListFilters
2227
from .models.email_logs import EmailLogsListResponse

mailtrap/api/email_campaigns.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from mailtrap.api.resources.email_campaigns import EmailCampaignsApi
2+
from mailtrap.http import HttpClient
3+
4+
5+
class EmailCampaignsBaseApi:
6+
def __init__(self, client: HttpClient, account_id: str) -> None:
7+
self._account_id = account_id
8+
self._client = client
9+
10+
@property
11+
def email_campaigns(self) -> EmailCampaignsApi:
12+
return EmailCampaignsApi(account_id=self._account_id, client=self._client)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import Optional
2+
3+
from mailtrap.http import HttpClient
4+
from mailtrap.models.email_campaigns import CreateEmailCampaignParams
5+
from mailtrap.models.email_campaigns import EmailCampaign
6+
from mailtrap.models.email_campaigns import EmailCampaignListParams
7+
from mailtrap.models.email_campaigns import EmailCampaignListResponse
8+
from mailtrap.models.email_campaigns import EmailCampaignStats
9+
from mailtrap.models.email_campaigns import UpdateEmailCampaignParams
10+
11+
12+
class EmailCampaignsApi:
13+
def __init__(self, client: HttpClient, account_id: str) -> None:
14+
self._account_id = account_id
15+
self._client = client
16+
17+
def get_list(
18+
self,
19+
per_page: Optional[int] = None,
20+
search: Optional[str] = None,
21+
token: Optional[int] = None,
22+
) -> EmailCampaignListResponse:
23+
"""
24+
List email campaigns for the account, newest first. ``search`` filters
25+
by name (case-insensitive partial match), ``per_page`` sets the page
26+
size (max 100, default 50), and ``token`` is the page number to
27+
retrieve (default 1).
28+
"""
29+
params = EmailCampaignListParams(
30+
per_page=per_page, search=search, token=token
31+
).api_query_params
32+
response = self._client.get(self._api_path(), params=params or None)
33+
return EmailCampaignListResponse(**response)
34+
35+
def get_by_id(self, email_campaign_id: int) -> EmailCampaign:
36+
"""
37+
Get a single email campaign by id.
38+
"""
39+
response = self._client.get(self._api_path(email_campaign_id))
40+
return EmailCampaign(**response)
41+
42+
def create(self, campaign_params: CreateEmailCampaignParams) -> EmailCampaign:
43+
"""
44+
Create a new email campaign in the ``draft`` state. The campaign must
45+
reference an existing sending domain via ``mailsend_domain_id``.
46+
"""
47+
response = self._client.post(
48+
self._api_path(), json={"email_campaign": campaign_params.api_data}
49+
)
50+
return EmailCampaign(**response)
51+
52+
def update(
53+
self, email_campaign_id: int, campaign_params: UpdateEmailCampaignParams
54+
) -> EmailCampaign:
55+
"""
56+
Update an existing email campaign. The campaign must not be in a
57+
sending state. Only the fields supplied in ``campaign_params`` are sent
58+
to the API.
59+
"""
60+
response = self._client.patch(
61+
self._api_path(email_campaign_id),
62+
json={"email_campaign": campaign_params.api_data},
63+
)
64+
return EmailCampaign(**response)
65+
66+
def delete(self, email_campaign_id: int) -> EmailCampaign:
67+
"""
68+
Delete an email campaign. The campaign must not be in a sending state.
69+
The deleted campaign object is returned (HTTP 200 + body, not 204).
70+
"""
71+
response = self._client.delete(self._api_path(email_campaign_id))
72+
return EmailCampaign(**response)
73+
74+
def get_stats(self, email_campaign_id: int) -> EmailCampaignStats:
75+
"""
76+
Get aggregated performance statistics for a single campaign. If the
77+
campaign has never been started, all counts and rates are ``0``.
78+
"""
79+
response = self._client.get(f"{self._api_path(email_campaign_id)}/stats")
80+
return EmailCampaignStats(**response)
81+
82+
def _api_path(self, email_campaign_id: Optional[int] = None) -> str:
83+
# The Email Campaigns endpoint is token-scoped, NOT account-scoped:
84+
# the account is resolved from the API token server-side.
85+
path = "/api/email_campaigns"
86+
if email_campaign_id is not None:
87+
return f"{path}/{email_campaign_id}"
88+
return path

mailtrap/client.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import TypeAdapter
88

99
from mailtrap.api.contacts import ContactsBaseApi
10+
from mailtrap.api.email_campaigns import EmailCampaignsBaseApi
1011
from mailtrap.api.email_logs import EmailLogsBaseApi
1112
from mailtrap.api.general import GeneralApi
1213
from mailtrap.api.organizations import OrganizationsBaseApi
@@ -117,6 +118,14 @@ def sending_domains_api(self) -> SendingDomainsBaseApi:
117118
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
118119
)
119120

121+
@property
122+
def email_campaigns_api(self) -> EmailCampaignsBaseApi:
123+
self._validate_account_id("Email Campaigns API")
124+
return EmailCampaignsBaseApi(
125+
account_id=cast(str, self.account_id),
126+
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
127+
)
128+
120129
@property
121130
def email_logs_api(self) -> EmailLogsBaseApi:
122131
self._validate_account_id("Email Logs API")

mailtrap/models/email_campaigns.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Models for the Email Campaigns API (campaigns + stats)."""
2+
3+
from typing import Optional
4+
5+
from pydantic import Field
6+
from pydantic.dataclasses import dataclass
7+
8+
from mailtrap.models.common import RequestParams
9+
10+
11+
@dataclass
12+
class ReplyTo:
13+
"""Reply-To address parts."""
14+
15+
display_name: Optional[str] = None
16+
local_part: Optional[str] = None
17+
domain: Optional[str] = None
18+
19+
20+
@dataclass
21+
class EmailCampaignStats:
22+
"""
23+
Aggregated campaign performance metrics. All counts and rates are ``0``
24+
when the campaign has not been started. The same shape is returned both
25+
inline on an ``EmailCampaign`` (its ``stats`` member) and as the standalone
26+
``/stats`` response.
27+
"""
28+
29+
delivery_count: Optional[int] = None
30+
open_count: Optional[int] = None
31+
click_count: Optional[int] = None
32+
bounce_count: Optional[int] = None
33+
unsubscription_count: Optional[int] = None
34+
sent_count: Optional[int] = None
35+
spam_count: Optional[int] = None
36+
message_count: Optional[int] = None
37+
reject_count: Optional[int] = None
38+
delivery_rate: Optional[float] = None
39+
open_rate: Optional[float] = None
40+
click_rate: Optional[float] = None
41+
bounce_rate: Optional[float] = None
42+
spam_rate: Optional[float] = None
43+
unsubscription_rate: Optional[float] = None
44+
45+
46+
@dataclass
47+
class CurrentStateMetadata:
48+
"""Metadata about the most recent campaign state transition."""
49+
50+
reason: Optional[str] = None
51+
error: Optional[str] = None
52+
scheduled_at: Optional[str] = None
53+
errors: list[str] = Field(default_factory=list)
54+
55+
56+
@dataclass
57+
class DeliveryOptions:
58+
"""Delivery throttling options."""
59+
60+
emails_per_hour: Optional[int] = None
61+
62+
63+
@dataclass
64+
class CampaignTemplate:
65+
"""The campaign template reference."""
66+
67+
id: Optional[int] = None
68+
subject: Optional[str] = None
69+
70+
71+
@dataclass
72+
class EmailCampaign:
73+
"""A single email campaign."""
74+
75+
id: int
76+
type: Optional[str] = None
77+
mailsend_domain_id: Optional[int] = None
78+
mailsend_domain_name: Optional[str] = None
79+
name: Optional[str] = None
80+
from_local_part: Optional[str] = None
81+
from_display_name: Optional[str] = None
82+
reply_to: Optional[ReplyTo] = None
83+
current_state: Optional[str] = None
84+
current_state_metadata: Optional[CurrentStateMetadata] = None
85+
created_at: Optional[str] = None
86+
updated_at: Optional[str] = None
87+
last_started_at: Optional[str] = None
88+
last_started_at_date: Optional[str] = None
89+
recipient_total_count: Optional[int] = None
90+
delivery_mode: Optional[str] = None
91+
delivery_options: Optional[DeliveryOptions] = None
92+
scheduled_for: Optional[str] = None
93+
# Omitted from list items.
94+
audience_defined: Optional[bool] = None
95+
# Present only when the campaign carries stats.
96+
stats: Optional[EmailCampaignStats] = None
97+
template: Optional[CampaignTemplate] = None
98+
99+
100+
@dataclass
101+
class Pagination:
102+
"""Page-token pagination metadata."""
103+
104+
token: Optional[int] = None
105+
prev_token: Optional[int] = None
106+
next_token: Optional[int] = None
107+
first_url: Optional[str] = None
108+
prev_url: Optional[str] = None
109+
current_url: Optional[str] = None
110+
next_url: Optional[str] = None
111+
112+
113+
@dataclass
114+
class EmailCampaignListResponse:
115+
"""Paginated response from listing email campaigns."""
116+
117+
data: list[EmailCampaign] = Field(default_factory=list)
118+
pagination: Optional[Pagination] = None
119+
120+
121+
@dataclass
122+
class EmailCampaignListParams(RequestParams):
123+
"""
124+
Query params for listing email campaigns. ``search`` filters by name
125+
(case-insensitive partial match) and serializes to the ``search`` wire
126+
parameter.
127+
"""
128+
129+
per_page: Optional[int] = None
130+
search: Optional[str] = None
131+
token: Optional[int] = None
132+
133+
134+
@dataclass
135+
class CreateEmailCampaignParams(RequestParams):
136+
"""Attributes for creating an email campaign (wrapped under ``email_campaign``)."""
137+
138+
name: str
139+
mailsend_domain_id: int
140+
from_display_name: Optional[str] = None
141+
from_local_part: Optional[str] = None
142+
reply_to: Optional[ReplyTo] = None
143+
template_attributes: Optional[CampaignTemplate] = None
144+
145+
146+
@dataclass
147+
class UpdateEmailCampaignParams(RequestParams):
148+
"""
149+
Attributes for updating an email campaign (wrapped under ``email_campaign``).
150+
All fields are optional; only provided fields are changed.
151+
"""
152+
153+
name: Optional[str] = None
154+
mailsend_domain_id: Optional[int] = None
155+
from_display_name: Optional[str] = None
156+
from_local_part: Optional[str] = None
157+
delivery_mode: Optional[str] = None
158+
scheduled_for: Optional[str] = None
159+
delivery_options: Optional[DeliveryOptions] = None
160+
reply_to: Optional[ReplyTo] = None
161+
template_attributes: Optional[CampaignTemplate] = None

tests/unit/api/email_campaigns/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)