Skip to content

Fix issue #29. Add support of Emails Sandbox (Testing) API: Projects #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Aug 14, 2025
Merged
Show file tree
Hide file tree
Changes from 10 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ dist/
# IntelliJ's project specific settings
.idea/

# VSCode's project specific settings
.vscode/

# mypy
.mypy_cache/

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Versions of this package up to 1.0.1 were a different, unrelated project, that i

### Prerequisites

- Python version 3.6+
- Python version 3.9+

### Install package

Expand Down Expand Up @@ -150,7 +150,7 @@ To setup virtual environments, run tests and linters use:
tox
```

It will create virtual environments with all installed dependencies for each available python interpreter (starting from `python3.6`) on your machine.
It will create virtual environments with all installed dependencies for each available python interpreter (starting from `python3.9`) on your machine.
By default, they will be available in `{project}/.tox/` directory. So, for instance, to activate `python3.11` environment, run the following:

```bash
Expand Down
24 changes: 24 additions & 0 deletions examples/testing/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from mailtrap import MailtrapClient
from mailtrap.models.projects import Project

API_TOKEN = "YOU_API_TOKEN"
ACCOUNT_ID = "YOU_ACCOUNT_ID"

client = MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
projects_api = client.testing_api.projects


def list_projects() -> list[Project]:
return projects_api.get_list()


def create_project(project_name: str) -> Project:
return projects_api.create(project_name=project_name)


def update_project(project_id: str, new_name: str) -> Project:
return projects_api.update(project_id, new_name)


def delete_project(project_id: str):
return projects_api.delete(project_id)
Empty file added mailtrap/api/__init__.py
Empty file.
Empty file.
39 changes: 39 additions & 0 deletions mailtrap/api/resources/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from mailtrap.http import HttpClient
from mailtrap.models.common import DeletedObject
from mailtrap.models.projects import Project


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

def get_list(self) -> list[Project]:
response = self.client.get(f"/api/accounts/{self.account_id}/projects")
return [Project(**project) for project in response]

def get_by_id(self, project_id: int) -> Project:
response = self.client.get(
f"/api/accounts/{self.account_id}/projects/{project_id}"
)
return Project(**response)

def create(self, project_name: str) -> Project:
response = self.client.post(
f"/api/accounts/{self.account_id}/projects",
json={"project": {"name": project_name}},
)
return Project(**response)

def update(self, project_id: int, project_name: str) -> Project:
response = self.client.patch(
f"/api/accounts/{self.account_id}/projects/{project_id}",
json={"project": {"name": project_name}},
)
return Project(**response)

def delete(self, project_id: int) -> DeletedObject:
response = self.client.delete(
f"/api/accounts/{self.account_id}/projects/{project_id}",
)
return DeletedObject(**response)
17 changes: 17 additions & 0 deletions mailtrap/api/testing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Optional

from mailtrap.api.resources.projects import ProjectsApi
from mailtrap.http import HttpClient


class TestingApi:
def __init__(
self, client: HttpClient, account_id: str, inbox_id: Optional[str] = None
) -> None:
self.account_id = account_id
self.inbox_id = inbox_id
self.client = client

@property
def projects(self) -> ProjectsApi:
return ProjectsApi(account_id=self.account_id, client=self.client)
19 changes: 19 additions & 0 deletions mailtrap/client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from typing import NoReturn
from typing import Optional
from typing import Union
from typing import cast

import requests

from mailtrap.api.testing import TestingApi
from mailtrap.config import GENERAL_HOST
from mailtrap.exceptions import APIError
from mailtrap.exceptions import AuthorizationError
from mailtrap.exceptions import ClientConfigurationError
from mailtrap.http import HttpClient
from mailtrap.mail.base import BaseMail


Expand All @@ -23,17 +27,28 @@ def __init__(
api_port: int = DEFAULT_PORT,
bulk: bool = False,
sandbox: bool = False,
account_id: Optional[str] = None,
inbox_id: Optional[str] = None,
) -> None:
self.token = token
self.api_host = api_host
self.api_port = api_port
self.bulk = bulk
self.sandbox = sandbox
self.account_id = account_id
self.inbox_id = inbox_id

self._validate_itself()

@property
def testing_api(self) -> TestingApi:
self._validate_account_id()
return TestingApi(
account_id=cast(str, self.account_id),
inbox_id=self.inbox_id,
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
)

def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]:
response = requests.post(
self.api_send_url, headers=self.headers, json=mail.api_data
Expand Down Expand Up @@ -98,3 +113,7 @@ def _validate_itself(self) -> None:

if self.bulk and self.sandbox:
raise ClientConfigurationError("bulk mode is not allowed in sandbox mode")

def _validate_account_id(self) -> None:
if not self.account_id:
raise ClientConfigurationError("`account_id` is required for Testing API")
3 changes: 3 additions & 0 deletions mailtrap/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GENERAL_HOST = "mailtrap.io"

DEFAULT_REQUEST_TIMEOUT = 30 # in seconds
92 changes: 92 additions & 0 deletions mailtrap/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from typing import Any
from typing import NoReturn
from typing import Optional

from requests import Response
from requests import Session

from mailtrap.config import DEFAULT_REQUEST_TIMEOUT
from mailtrap.exceptions import APIError
from mailtrap.exceptions import AuthorizationError


class HttpClient:
def __init__(
self,
host: str,
headers: Optional[dict[str, str]] = None,
timeout: int = DEFAULT_REQUEST_TIMEOUT,
):
self._host = host
self._session = Session()
self._session.headers.update(headers or {})
self._timeout = timeout

def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
response = self._session.get(
self._url(path), params=params, timeout=self._timeout
)
return self._process_response(response)

def post(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
response = self._session.post(self._url(path), json=json, timeout=self._timeout)
return self._process_response(response)

def put(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
response = self._session.put(self._url(path), json=json, timeout=self._timeout)
return self._process_response(response)

def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
response = self._session.patch(self._url(path), json=json, timeout=self._timeout)
return self._process_response(response)

def delete(self, path: str) -> Any:
response = self._session.delete(self._url(path), timeout=self._timeout)
return self._process_response(response)

def _url(self, path: str) -> str:
return f"https://{self._host}/{path.lstrip('/')}"

def _process_response(self, response: Response) -> Any:
if not response.ok:
self._handle_failed_response(response)
return response.json()

def _handle_failed_response(self, response: Response) -> NoReturn:
status_code = response.status_code
try:
data = response.json()
except ValueError as exc:
raise APIError(status_code, errors=["Unknown Error"]) from exc

errors = self._extract_errors(data)

if status_code == 401:
raise AuthorizationError(errors=errors)

raise APIError(status_code, errors=errors)

@staticmethod
def _extract_errors(data: dict[str, Any]) -> list[str]:
def flatten_errors(errors: Any) -> list[str]:
if isinstance(errors, list):
return [str(error) for error in errors]

if isinstance(errors, dict):
flat_errors = []
for key, value in errors.items():
if isinstance(value, list):
flat_errors.extend([f"{key}: {v}" for v in value])
else:
flat_errors.append(f"{key}: {value}")
return flat_errors

return [str(errors)]

if "errors" in data:
return flatten_errors(data["errors"])

if "error" in data:
return flatten_errors(data["error"])

return ["Unknown error"]
Empty file added mailtrap/models/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions mailtrap/models/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from pydantic.dataclasses import dataclass


@dataclass
class DeletedObject:
id: int
34 changes: 34 additions & 0 deletions mailtrap/models/inboxes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Optional

from pydantic.dataclasses import dataclass

from mailtrap.models.permissions import Permissions


@dataclass
class Inbox:
id: int
name: str
username: str
max_size: int
status: str
email_username: str
email_username_enabled: bool
sent_messages_count: int
forwarded_messages_count: int
used: bool
forward_from_email_address: str
project_id: int
domain: str
pop3_domain: str
email_domain: str
emails_count: int
emails_unread_count: int
smtp_ports: list[int]
pop3_ports: list[int]
max_message_size: int
permissions: Permissions
password: Optional[str] = (
None # Password is only available if you have admin permissions for the inbox.
)
last_message_sent_at: Optional[str] = None
9 changes: 9 additions & 0 deletions mailtrap/models/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic.dataclasses import dataclass


@dataclass
class Permissions:
can_read: bool
can_update: bool
can_destroy: bool
can_leave: bool
21 changes: 21 additions & 0 deletions mailtrap/models/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Optional

from pydantic.dataclasses import dataclass

from mailtrap.models.inboxes import Inbox
from mailtrap.models.permissions import Permissions


@dataclass
class ShareLinks:
admin: str
viewer: str


@dataclass
class Project:
id: int
name: str
inboxes: list[Inbox]
permissions: Permissions
share_links: Optional[ShareLinks] = None
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
requires-python = ">=3.9"
dependencies = [
"requests>=2.26.0",
]
dynamic = ["dependencies"]

[project.urls]
Homepage = "https://mailtrap.io/"
Expand All @@ -31,6 +29,9 @@ Repository = "https://github.com/railsware/mailtrap-python.git"
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}

[tool.isort]
profile = "black"
line_length = 90
Expand Down
2 changes: 2 additions & 0 deletions requirements.test.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
-r requirements.txt

pytest>=7.0.1
responses>=0.17.0
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
requests>=2.26.0
pydantic>=2.11.7
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
UNAUTHORIZED_STATUS_CODE = 401
UNAUTHORIZED_ERROR_MESSAGE = "Incorrect API token"
UNAUTHORIZED_RESPONSE = {"error": UNAUTHORIZED_ERROR_MESSAGE}

FORBIDDEN_STATUS_CODE = 403
FORBIDDEN_ERROR_MESSAGE = "Access forbidden"
FORBIDDEN_RESPONSE = {"errors": FORBIDDEN_ERROR_MESSAGE}

NOT_FOUND_STATUS_CODE = 404
NOT_FOUND_ERROR_MESSAGE = "Not Found"
NOT_FOUND_RESPONSE = {"error": NOT_FOUND_ERROR_MESSAGE}
Empty file added tests/unit/api/__init__.py
Empty file.
Loading