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 7 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
54 changes: 54 additions & 0 deletions examples/testing/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import logging
from typing import Optional

from mailtrap import MailtrapApiClient
from mailtrap.models.projects import Project

logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)

API_TOKEN = "YOU_API_TOKEN"
ACCOUNT_ID = "YOU_ACCOUNT_ID"


def find_project_by_name(project_name: str, projects: list[Project]) -> Optional[str]:
filtered_projects = [project for project in projects if project.name == project_name]
if filtered_projects:
return filtered_projects[0].id
return None


logging.info("Starting Mailtrap Testing API example...")

client = MailtrapApiClient(token=API_TOKEN)
testing_api = client.get_testing_api(ACCOUNT_ID)
projects_api = testing_api.projects

project_name = "Example-project"
created_project = projects_api.create(project_name=project_name)
logging.info(f"Project created! ID: {created_project.id}, Name: {created_project.name}")

projects = projects_api.get_list()
logging.info(f"Found {len(projects)} projects:")
for project in projects:
logging.info(f" - {project.name} (ID: {project.id})")

project_id = find_project_by_name(project_name, projects)
if project_id:
logging.info(f"Found project with ID: {project_id}")
else:
logging.info("Project not found in the list")

if project_id:
project = projects_api.get_by_id(project_id)
logging.info(f"Project details: {project.name} (ID: {project.id})")

new_name = "Updated-project-name"
updated_project = projects_api.update(project_id, new_name)
logging.info(f"Project updated!ID: {project_id}, New name: {updated_project.name}")

deleted_object = projects_api.delete(project_id)
logging.info(f"Project deleted! Deleted ID: {deleted_object.id}")

logging.info("Example completed successfully!")
1 change: 1 addition & 0 deletions mailtrap/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .client import MailtrapApiClient
from .client import MailtrapClient
from .exceptions import APIError
from .exceptions import AuthorizationError
Expand Down
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.base 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.list(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)
21 changes: 21 additions & 0 deletions mailtrap/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

import requests

from mailtrap.api.testing import TestingApi
from mailtrap.config import GENERAL_ENDPOINT
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 Down Expand Up @@ -98,3 +101,21 @@ def _validate_itself(self) -> None:

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


class MailtrapApiClient:
def __init__(self, token: str) -> None:
self.token = token

def testing_api(self, account_id: str, inbox_id: str) -> TestingApi:
http_client = HttpClient(host=GENERAL_ENDPOINT, headers=self.get_headers())
return TestingApi(account_id=account_id, inbox_id=inbox_id, client=http_client)

def get_headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"User-Agent": (
"mailtrap-python (https://github.com/railsware/mailtrap-python)"
),
}
3 changes: 3 additions & 0 deletions mailtrap/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GENERAL_ENDPOINT = "mailtrap.io"

DEFAULT_REQUEST_TIMEOUT = 30 # in seconds
98 changes: 98 additions & 0 deletions mailtrap/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 _url(self, path: str) -> str:
return f"https://{self._host}/{path.lstrip('/')}"

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 = _extract_errors(data)

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

raise APIError(status_code, errors=errors)

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

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 list(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 _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.
5 changes: 5 additions & 0 deletions mailtrap/models/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel


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

from pydantic import BaseModel

from mailtrap.models.permissions import Permissions


class Inbox(BaseModel):
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
8 changes: 8 additions & 0 deletions mailtrap/models/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from pydantic import BaseModel


class Permissions(BaseModel):
can_read: bool
can_update: bool
can_destroy: bool
can_leave: bool
18 changes: 18 additions & 0 deletions mailtrap/models/projects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pydantic import BaseModel
from pydantic import Field

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


class ShareLinks(BaseModel):
admin: str
viewer: str


class Project(BaseModel):
id: int
name: str
share_links: ShareLinks
inboxes: list[Inbox]
permissions: Permissions
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ classifiers = [
requires-python = ">=3.9"
dependencies = [
"requests>=2.26.0",
"pydantic>=2.11.7",
]

[project.urls]
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
Empty file added tests/unit/api/__init__.py
Empty file.
Loading