Skip to content

Commit ac4ca01

Browse files
Merge pull request #31 from Ihor-Bilous/ISSUE-29
Fix issue #29. Add support of Emails Sandbox (Testing) API: Projects
2 parents 709f096 + 0a1a767 commit ac4ca01

22 files changed

+613
-7
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ dist/
88
# IntelliJ's project specific settings
99
.idea/
1010

11+
# VSCode's project specific settings
12+
.vscode/
13+
1114
# mypy
1215
.mypy_cache/
1316

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@
77

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

10-
Quickly add email sending functionality to your Python application with Mailtrap.
10+
Add email sending functionality to your Python application quickly with Mailtrap.
1111

1212
## Compatibility with previous releases
1313

14-
Versions of this package up to 1.0.1 were a different, unrelated project, that is now maintained as [Sendria](https://github.com/msztolcman/sendria). To continue using it, see [instructions](#information-for-version-1-users).
14+
Versions of this package up to 1.0.1 were different, unrelated project, that is now maintained as [Sendria](https://github.com/msztolcman/sendria). To continue using it, see [instructions](#information-for-version-1-users).
1515

1616
## Installation
1717

1818
### Prerequisites
1919

20-
- Python version 3.6+
20+
- Python version 3.9+
2121

2222
### Install package
2323

@@ -150,7 +150,7 @@ To setup virtual environments, run tests and linters use:
150150
tox
151151
```
152152

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

156156
```bash

examples/testing/projects.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from mailtrap import MailtrapClient
2+
from mailtrap.models.projects import Project
3+
4+
API_TOKEN = "YOU_API_TOKEN"
5+
ACCOUNT_ID = "YOU_ACCOUNT_ID"
6+
7+
client = MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID)
8+
projects_api = client.testing_api.projects
9+
10+
11+
def list_projects() -> list[Project]:
12+
return projects_api.get_list()
13+
14+
15+
def create_project(project_name: str) -> Project:
16+
return projects_api.create(project_name=project_name)
17+
18+
19+
def update_project(project_id: str, new_name: str) -> Project:
20+
return projects_api.update(project_id, new_name)
21+
22+
23+
def delete_project(project_id: str):
24+
return projects_api.delete(project_id)
25+
26+
27+
if __name__ == "__main__":
28+
projects = list_projects()
29+
print(projects)

mailtrap/api/__init__.py

Whitespace-only changes.

mailtrap/api/resources/__init__.py

Whitespace-only changes.

mailtrap/api/resources/projects.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from mailtrap.http import HttpClient
2+
from mailtrap.models.common import DeletedObject
3+
from mailtrap.models.projects import Project
4+
5+
6+
class ProjectsApi:
7+
def __init__(self, client: HttpClient, account_id: str) -> None:
8+
self._account_id = account_id
9+
self._client = client
10+
11+
def get_list(self) -> list[Project]:
12+
response = self._client.get(f"/api/accounts/{self._account_id}/projects")
13+
return [Project(**project) for project in response]
14+
15+
def get_by_id(self, project_id: int) -> Project:
16+
response = self._client.get(
17+
f"/api/accounts/{self._account_id}/projects/{project_id}"
18+
)
19+
return Project(**response)
20+
21+
def create(self, project_name: str) -> Project:
22+
response = self._client.post(
23+
f"/api/accounts/{self._account_id}/projects",
24+
json={"project": {"name": project_name}},
25+
)
26+
return Project(**response)
27+
28+
def update(self, project_id: int, project_name: str) -> Project:
29+
response = self._client.patch(
30+
f"/api/accounts/{self._account_id}/projects/{project_id}",
31+
json={"project": {"name": project_name}},
32+
)
33+
return Project(**response)
34+
35+
def delete(self, project_id: int) -> DeletedObject:
36+
response = self._client.delete(
37+
f"/api/accounts/{self._account_id}/projects/{project_id}",
38+
)
39+
return DeletedObject(**response)

mailtrap/api/testing.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import Optional
2+
3+
from mailtrap.api.resources.projects import ProjectsApi
4+
from mailtrap.http import HttpClient
5+
6+
7+
class TestingApi:
8+
def __init__(
9+
self, client: HttpClient, account_id: str, inbox_id: Optional[str] = None
10+
) -> None:
11+
self._account_id = account_id
12+
self._inbox_id = inbox_id
13+
self._client = client
14+
15+
@property
16+
def projects(self) -> ProjectsApi:
17+
return ProjectsApi(account_id=self._account_id, client=self._client)

mailtrap/client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from typing import NoReturn
22
from typing import Optional
33
from typing import Union
4+
from typing import cast
45

56
import requests
67

8+
from mailtrap.api.testing import TestingApi
9+
from mailtrap.config import GENERAL_HOST
710
from mailtrap.exceptions import APIError
811
from mailtrap.exceptions import AuthorizationError
912
from mailtrap.exceptions import ClientConfigurationError
13+
from mailtrap.http import HttpClient
1014
from mailtrap.mail.base import BaseMail
1115

1216

@@ -23,17 +27,28 @@ def __init__(
2327
api_port: int = DEFAULT_PORT,
2428
bulk: bool = False,
2529
sandbox: bool = False,
30+
account_id: Optional[str] = None,
2631
inbox_id: Optional[str] = None,
2732
) -> None:
2833
self.token = token
2934
self.api_host = api_host
3035
self.api_port = api_port
3136
self.bulk = bulk
3237
self.sandbox = sandbox
38+
self.account_id = account_id
3339
self.inbox_id = inbox_id
3440

3541
self._validate_itself()
3642

43+
@property
44+
def testing_api(self) -> TestingApi:
45+
self._validate_account_id()
46+
return TestingApi(
47+
account_id=cast(str, self.account_id),
48+
inbox_id=self.inbox_id,
49+
client=HttpClient(host=GENERAL_HOST, headers=self.headers),
50+
)
51+
3752
def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]:
3853
response = requests.post(
3954
self.api_send_url, headers=self.headers, json=mail.api_data
@@ -98,3 +113,7 @@ def _validate_itself(self) -> None:
98113

99114
if self.bulk and self.sandbox:
100115
raise ClientConfigurationError("bulk mode is not allowed in sandbox mode")
116+
117+
def _validate_account_id(self) -> None:
118+
if not self.account_id:
119+
raise ClientConfigurationError("`account_id` is required for Testing API")

mailtrap/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
GENERAL_HOST = "mailtrap.io"
2+
3+
DEFAULT_REQUEST_TIMEOUT = 30 # in seconds

mailtrap/http.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from typing import Any
2+
from typing import NoReturn
3+
from typing import Optional
4+
5+
from requests import Response
6+
from requests import Session
7+
8+
from mailtrap.config import DEFAULT_REQUEST_TIMEOUT
9+
from mailtrap.exceptions import APIError
10+
from mailtrap.exceptions import AuthorizationError
11+
12+
13+
class HttpClient:
14+
def __init__(
15+
self,
16+
host: str,
17+
headers: Optional[dict[str, str]] = None,
18+
timeout: int = DEFAULT_REQUEST_TIMEOUT,
19+
):
20+
self._host = host
21+
self._session = Session()
22+
self._session.headers.update(headers or {})
23+
self._timeout = timeout
24+
25+
def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
26+
response = self._session.get(
27+
self._url(path), params=params, timeout=self._timeout
28+
)
29+
return self._process_response(response)
30+
31+
def post(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
32+
response = self._session.post(self._url(path), json=json, timeout=self._timeout)
33+
return self._process_response(response)
34+
35+
def put(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
36+
response = self._session.put(self._url(path), json=json, timeout=self._timeout)
37+
return self._process_response(response)
38+
39+
def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> Any:
40+
response = self._session.patch(self._url(path), json=json, timeout=self._timeout)
41+
return self._process_response(response)
42+
43+
def delete(self, path: str) -> Any:
44+
response = self._session.delete(self._url(path), timeout=self._timeout)
45+
return self._process_response(response)
46+
47+
def _url(self, path: str) -> str:
48+
return f"https://{self._host}/{path.lstrip('/')}"
49+
50+
def _process_response(self, response: Response) -> Any:
51+
if not response.ok:
52+
self._handle_failed_response(response)
53+
return response.json()
54+
55+
def _handle_failed_response(self, response: Response) -> NoReturn:
56+
status_code = response.status_code
57+
try:
58+
data = response.json()
59+
except ValueError as exc:
60+
raise APIError(status_code, errors=["Unknown Error"]) from exc
61+
62+
errors = self._extract_errors(data)
63+
64+
if status_code == 401:
65+
raise AuthorizationError(errors=errors)
66+
67+
raise APIError(status_code, errors=errors)
68+
69+
@staticmethod
70+
def _extract_errors(data: dict[str, Any]) -> list[str]:
71+
def flatten_errors(errors: Any) -> list[str]:
72+
if isinstance(errors, list):
73+
return [str(error) for error in errors]
74+
75+
if isinstance(errors, dict):
76+
flat_errors = []
77+
for key, value in errors.items():
78+
if isinstance(value, list):
79+
flat_errors.extend([f"{key}: {v}" for v in value])
80+
else:
81+
flat_errors.append(f"{key}: {value}")
82+
return flat_errors
83+
84+
return [str(errors)]
85+
86+
if "errors" in data:
87+
return flatten_errors(data["errors"])
88+
89+
if "error" in data:
90+
return flatten_errors(data["error"])
91+
92+
return ["Unknown error"]

0 commit comments

Comments
 (0)