Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,42 @@
## [2.4.0] - 2025-12-04

* Fix issue #52: Update README.md using new guideline by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/55
* Fix issue #53: Add full usage in all examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/56
* Merge functionality and examples in Readme by @yanchuk in https://github.com/mailtrap/mailtrap-python/pull/57
* Fix issue #54: Add SendingDomainsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/58

## [2.3.0] - 2025-10-24

* Fix issue #24: Add batch_send method to SendingApi, add models by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/47
* Fix issue #42: Add GeneralApi, related models, examples, tests. by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/48
* Fix issue #41: Add ContactExportsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/49
* Fix issue #45: Add ContactEventsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/51

## [2.2.0] - 2025-09-18
- Potential fix for code scanning alert no. 1: Workflow does not contain permissions by @mklocek in https://github.com/railsware/mailtrap-python/pull/15
- Fix issue #29. Add support of Emails Sandbox (Testing) API: Projects by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/31
- Issue 25 by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/33
- Fix issue #18: Add api for EmailTemplates, add tests and examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/34
- Fix issue #19: Add ContactFieldsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/35
- Fix issue #20: Add ContactListsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/36
- Fix issue #21: Add ContactsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/37
- Fix issue #22: Add ContactImportsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/38
- Fix issue #23: Add SuppressionsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/39
- Fix issue #27: Add InboxesApi, related models, tests, examples. by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/40
- Fix issue #26: Add MessagesApi, releated models, examples, tests by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/43
- Fix issue #28: Add AttachmentsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/railsware/mailtrap-python/pull/44

- Potential fix for code scanning alert no. 1: Workflow does not contain permissions by @mklocek in https://github.com/mailtrap/mailtrap-python/pull/15
- Fix issue #29. Add support of Emails Sandbox (Testing) API: Projects by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/31
- Issue 25 by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/33
- Fix issue #18: Add api for EmailTemplates, add tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/34
- Fix issue #19: Add ContactFieldsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/35
- Fix issue #20: Add ContactListsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/36
- Fix issue #21: Add ContactsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/37
- Fix issue #22: Add ContactImportsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/38
- Fix issue #23: Add SuppressionsApi, related models, tests and examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/39
- Fix issue #27: Add InboxesApi, related models, tests, examples. by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/40
- Fix issue #26: Add MessagesApi, releated models, examples, tests by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/43
- Fix issue #28: Add AttachmentsApi, related models, tests, examples by @Ihor-Bilous in https://github.com/mailtrap/mailtrap-python/pull/44

## [2.1.0] - 2025-05-12

- Add sandbox mode support in MailtrapClient
- It requires inbox_id parameter to be set
- Add bulk mode support in MailtrapClient
- Drop support python 3.6 - 3.8
- Add support for python 3.12 - 3.13

## [2.0.1] - 2023-05-18

- Add User-Agent header to all requests

## [2.0.0] - 2023-03-11
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# Official Mailtrap Python client

This Python package offers integration with the [official API](https://api-docs.mailtrap.io/) for [Mailtrap](https://mailtrap.io).
This Python package offers integration with the [official API](https://docs.mailtrap.io/developers) for [Mailtrap](https://mailtrap.io).

Add email sending functionality to your Python application quickly with Mailtrap.

Expand Down Expand Up @@ -247,6 +247,9 @@ The same situation applies to both `client.batch_send()` and `client.sending_api
### Suppressions API:
- Suppressions (find & delete) – [`suppressions/suppressions.py`](examples/suppressions/suppressions.py)

### Email Logs API:
- List email logs (with filters & pagination) and get message by ID – [`email_logs/email_logs.py`](examples/email_logs/email_logs.py)

### General API:
- Account Accesses management – [`general/account_accesses.py`](examples/general/account_accesses.py)
- Accounts info – [`general/accounts.py`](examples/general/accounts.py)
Expand Down
61 changes: 61 additions & 0 deletions examples/email_logs/email_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Example: List email logs and get a single message by ID."""

from datetime import datetime
from datetime import timedelta
from datetime import timezone

import mailtrap as mt
from mailtrap.models.email_logs import EmailLogsListFilters
from mailtrap.models.email_logs import filter_ci_equal
from mailtrap.models.email_logs import filter_string_equal
from mailtrap.models.email_logs import filter_string_not_empty

API_TOKEN = "YOUR_API_TOKEN"
ACCOUNT_ID = "YOUR_ACCOUNT_ID"

client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
email_logs_api = client.email_logs_api.email_logs


def list_email_logs():
"""List email logs (first page)."""
return email_logs_api.get_list()


def list_email_logs_with_filters():
"""List email logs from last 2 days, by category(s), with non-empty subject."""
now = datetime.now(timezone.utc)
two_days_ago = now - timedelta(days=2)
filters = EmailLogsListFilters(
sent_after=two_days_ago.isoformat().replace("+00:00", "Z"),
sent_before=now.isoformat().replace("+00:00", "Z"),
subject=filter_string_not_empty(),
to=filter_ci_equal("recipient@example.com"),
category=filter_string_equal(["Welcome Email", "Password Reset"]),
)
return email_logs_api.get_list(filters=filters)


def get_next_page(previous_response):
"""Fetch next page using cursor from previous response."""
if previous_response.next_page_cursor is None:
return None
return email_logs_api.get_list(search_after=previous_response.next_page_cursor)


def get_message(message_id: str):
"""Get a single email log message by UUID."""
return email_logs_api.get_by_id(message_id)


if __name__ == "__main__":
# List first page
response = list_email_logs()
print(f"Total: {response.total_count}, messages: {len(response.messages)}")
for msg in response.messages:
print(f" {msg.message_id} | {msg.from_} -> {msg.to} | {msg.status}")

# Get single message
if response.messages:
detail = get_message(response.messages[0].message_id)
print(f"Detail: {detail.subject}, events: {len(detail.events)}")
3 changes: 3 additions & 0 deletions mailtrap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from .models.contacts import ImportContactParams
from .models.contacts import UpdateContactFieldParams
from .models.contacts import UpdateContactParams
from .models.email_logs import EmailLogMessage
from .models.email_logs import EmailLogsListFilters
from .models.email_logs import EmailLogsListResponse
from .models.inboxes import CreateInboxParams
from .models.inboxes import UpdateInboxParams
from .models.mail import Address
Expand Down
12 changes: 12 additions & 0 deletions mailtrap/api/email_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from mailtrap.api.resources.email_logs import EmailLogsApi
from mailtrap.http import HttpClient


class EmailLogsBaseApi:
def __init__(self, client: HttpClient, account_id: str) -> None:
self._account_id = account_id
self._client = client

@property
def email_logs(self) -> EmailLogsApi:
return EmailLogsApi(client=self._client, account_id=self._account_id)
48 changes: 48 additions & 0 deletions mailtrap/api/resources/email_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Email Logs API resource – list and get email sending logs."""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace EN DASH in module docstring to satisfy Ruff RUF002.

Line 1 uses ; replace it with standard -.

🧰 Tools
🪛 Ruff (0.15.6)

[warning] 1-1: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mailtrap/api/resources/email_logs.py` at line 1, The module docstring at the
top of the file contains an en dash (–); replace it with a standard hyphen (-)
so the docstring reads "Email Logs API resource - list and get email sending
logs." Update the module-level string (the top-of-file docstring) accordingly to
satisfy Ruff RUF002.


from typing import Optional

from mailtrap.http import HttpClient
from mailtrap.models.email_logs import EmailLogMessage
from mailtrap.models.email_logs import EmailLogsListFilters
from mailtrap.models.email_logs import EmailLogsListResponse


class EmailLogsApi:
def __init__(self, client: HttpClient, account_id: str) -> None:
self._account_id = account_id
self._client = client

def get_list(
self,
filters: Optional[EmailLogsListFilters] = None,
search_after: Optional[str] = None,
) -> EmailLogsListResponse:
"""
List email logs (paginated). Results are ordered by sent_at descending.
Use search_after with next_page_cursor from the previous response for
the next page.
"""
params: dict[str, object] = {}
if filters is not None:
params.update(filters.to_params())
if search_after is not None:
params["search_after"] = search_after
response = self._client.get(self._api_path(), params=params or None)
messages = [EmailLogMessage.from_api(msg) for msg in response.get("messages", [])]
return EmailLogsListResponse(
messages=messages,
total_count=response.get("total_count", 0),
next_page_cursor=response.get("next_page_cursor"),
)
Comment on lines +31 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard response shape before dict access/model parsing.

HttpClient.get() can return non-dict successful payloads (e.g., empty body/text). get_list() and get_by_id() currently assume dicts, which can fail with runtime errors instead of a clear SDK exception.

🛡️ Proposed fix
-"""Email Logs API resource – list and get email sending logs."""
+"""Email Logs API resource - list and get email sending logs."""

-from typing import Optional
+from typing import Any
+from typing import Optional
@@
 class EmailLogsApi:
@@
+    def _expect_dict_response(self, response: Any, operation: str) -> dict[str, Any]:
+        if not isinstance(response, dict):
+            raise TypeError(
+                f"Unexpected response type for {operation}: {type(response).__name__}"
+            )
+        return response
+
     def get_list(
@@
-        response = self._client.get(self._api_path(), params=params or None)
+        response = self._expect_dict_response(
+            self._client.get(self._api_path(), params=params or None),
+            "email logs list",
+        )
         messages = [EmailLogMessage.from_api(msg) for msg in response.get("messages", [])]
@@
     def get_by_id(self, sending_message_id: str) -> EmailLogMessage:
         """Get a single email log message by its UUID."""
-        response = self._client.get(self._api_path(sending_message_id))
+        response = self._expect_dict_response(
+            self._client.get(self._api_path(sending_message_id)),
+            "email log by id",
+        )
         return EmailLogMessage.from_api(response)

Also applies to: 41-42

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mailtrap/api/resources/email_logs.py` around lines 31 - 37, Guard against
non-dict responses from HttpClient.get() in get_list() and get_by_id(): ensure
the value returned from self._client.get(self._api_path(), ...) is a dict before
accessing keys or passing to EmailLogMessage.from_api; if it's not a dict,
convert it to an empty dict or raise a clear SDK error, then proceed to build
EmailLogsListResponse (using response.get(...)) and to call
EmailLogMessage.from_api only for dict items; reference the methods get_list(),
get_by_id(), EmailLogMessage.from_api, EmailLogsListResponse and the call site
self._client.get(...) when making the change.


def get_by_id(self, sending_message_id: str) -> EmailLogMessage:
"""Get a single email log message by its UUID."""
response = self._client.get(self._api_path(sending_message_id))
return EmailLogMessage.from_api(response)

def _api_path(self, sending_message_id: Optional[str] = None) -> str:
path = f"/api/accounts/{self._account_id}/email_logs"
if sending_message_id is not None:
path = f"{path}/{sending_message_id}"
return path
23 changes: 16 additions & 7 deletions mailtrap/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pydantic import TypeAdapter

from mailtrap.api.contacts import ContactsBaseApi
from mailtrap.api.email_logs import EmailLogsBaseApi
from mailtrap.api.general import GeneralApi
from mailtrap.api.sending import SendingApi
from mailtrap.api.sending_domains import SendingDomainsBaseApi
Expand Down Expand Up @@ -37,7 +38,7 @@ class MailtrapClient:
SANDBOX_HOST = SANDBOX_HOST
DEFAULT_USER_AGENT = (
f"mailtrap-python/{importlib.metadata.version('mailtrap')} "
"(https://github.com/railsware/mailtrap-python)"
"(https://github.com/mailtrap/mailtrap-python)"
)

def __init__(
Expand Down Expand Up @@ -81,36 +82,44 @@ def testing_api(self) -> TestingApi:

@property
def email_templates_api(self) -> EmailTemplatesApi:
self._validate_account_id()
self._validate_account_id("Email Templates API")
return EmailTemplatesApi(
account_id=cast(str, self.account_id),
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
)

@property
def contacts_api(self) -> ContactsBaseApi:
self._validate_account_id()
self._validate_account_id("Contacts API")
return ContactsBaseApi(
account_id=cast(str, self.account_id),
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
)

@property
def suppressions_api(self) -> SuppressionsBaseApi:
self._validate_account_id()
self._validate_account_id("Suppressions API")
return SuppressionsBaseApi(
account_id=cast(str, self.account_id),
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
)

@property
def sending_domains_api(self) -> SendingDomainsBaseApi:
self._validate_account_id()
self._validate_account_id("Sending Domains API")
return SendingDomainsBaseApi(
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")
return EmailLogsBaseApi(
account_id=cast(str, self.account_id),
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
)

@property
def sending_api(self) -> SendingApi:
http_client = HttpClient(host=self._sending_api_host, headers=self.headers)
Expand Down Expand Up @@ -169,9 +178,9 @@ def _sending_api_host(self) -> str:
return BULK_HOST
return SENDING_HOST

def _validate_account_id(self) -> None:
def _validate_account_id(self, api_name: str = "Testing API") -> None:
if not self.account_id:
raise ClientConfigurationError("`account_id` is required for Testing API")
raise ClientConfigurationError(f"`account_id` is required for {api_name}")

def _validate_itself(self) -> None:
if self.sandbox and not self.inbox_id:
Expand Down
Loading
Loading