diff --git a/.gitignore b/.gitignore index aade00b..a0926f2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist/ # IntelliJ's project specific settings .idea/ +# VSCode's project specific settings +.vscode/ + # mypy .mypy_cache/ diff --git a/README.md b/README.md index c27c5d0..b884138 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,17 @@ This Python package offers integration with the [official API](https://api-docs.mailtrap.io/) for [Mailtrap](https://mailtrap.io). -Quickly add email sending functionality to your Python application with Mailtrap. +Add email sending functionality to your Python application quickly with Mailtrap. ## Compatibility with previous releases -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). +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). ## Installation ### Prerequisites -- Python version 3.6+ +- Python version 3.9+ ### Install package @@ -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 diff --git a/examples/testing/projects.py b/examples/testing/projects.py new file mode 100644 index 0000000..c7d192d --- /dev/null +++ b/examples/testing/projects.py @@ -0,0 +1,29 @@ +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) + + +if __name__ == "__main__": + projects = list_projects() + print(projects) diff --git a/mailtrap/api/__init__.py b/mailtrap/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailtrap/api/resources/__init__.py b/mailtrap/api/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py new file mode 100644 index 0000000..7df38d4 --- /dev/null +++ b/mailtrap/api/resources/projects.py @@ -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) diff --git a/mailtrap/api/testing.py b/mailtrap/api/testing.py new file mode 100644 index 0000000..1f6fda1 --- /dev/null +++ b/mailtrap/api/testing.py @@ -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) diff --git a/mailtrap/client.py b/mailtrap/client.py index 60a1d6a..b2402b2 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -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 @@ -23,6 +27,7 @@ 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 @@ -30,10 +35,20 @@ def __init__( 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 @@ -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") diff --git a/mailtrap/config.py b/mailtrap/config.py new file mode 100644 index 0000000..c8a0527 --- /dev/null +++ b/mailtrap/config.py @@ -0,0 +1,3 @@ +GENERAL_HOST = "mailtrap.io" + +DEFAULT_REQUEST_TIMEOUT = 30 # in seconds diff --git a/mailtrap/http.py b/mailtrap/http.py new file mode 100644 index 0000000..4e0c9bf --- /dev/null +++ b/mailtrap/http.py @@ -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"] diff --git a/mailtrap/models/__init__.py b/mailtrap/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py new file mode 100644 index 0000000..2388974 --- /dev/null +++ b/mailtrap/models/common.py @@ -0,0 +1,6 @@ +from pydantic.dataclasses import dataclass + + +@dataclass +class DeletedObject: + id: int diff --git a/mailtrap/models/inboxes.py b/mailtrap/models/inboxes.py new file mode 100644 index 0000000..06d5374 --- /dev/null +++ b/mailtrap/models/inboxes.py @@ -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 diff --git a/mailtrap/models/permissions.py b/mailtrap/models/permissions.py new file mode 100644 index 0000000..30b1e74 --- /dev/null +++ b/mailtrap/models/permissions.py @@ -0,0 +1,9 @@ +from pydantic.dataclasses import dataclass + + +@dataclass +class Permissions: + can_read: bool + can_update: bool + can_destroy: bool + can_leave: bool diff --git a/mailtrap/models/projects.py b/mailtrap/models/projects.py new file mode 100644 index 0000000..20ecec6 --- /dev/null +++ b/mailtrap/models/projects.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 73834a3..64fe111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/" @@ -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 diff --git a/requirements.test.txt b/requirements.test.txt index 6378454..ac5a9e7 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,2 +1,4 @@ +-r requirements.txt + pytest>=7.0.1 responses>=0.17.0 diff --git a/requirements.txt b/requirements.txt index ee88e26..db5664a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests>=2.26.0 +pydantic>=2.11.7 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3324ff3 --- /dev/null +++ b/tests/conftest.py @@ -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} diff --git a/tests/unit/api/__init__.py b/tests/unit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py new file mode 100644 index 0000000..6ff42c0 --- /dev/null +++ b/tests/unit/api/test_projects.py @@ -0,0 +1,312 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.projects import ProjectsApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.projects import Project +from tests import conftest + +ACCOUNT_ID = "321" +PROJECT_ID = 123 +BASE_PROJECTS_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/projects" + + +@pytest.fixture +def client() -> ProjectsApi: + return ProjectsApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_project_dict() -> dict[str, Any]: + return { + "id": PROJECT_ID, + "name": "Test Project", + "inboxes": [], + "share_links": { + "admin": "https://mailtrap.io/projects/321/admin", + "viewer": "https://mailtrap.io/projects/321/viewer", + }, + "permissions": { + "can_read": True, + "can_update": True, + "can_destroy": True, + "can_leave": True, + }, + } + + +class TestProjectsApi: + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_list_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_PROJECTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_list() + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_list_should_return_project_list( + self, client: ProjectsApi, sample_project_dict: dict + ) -> None: + responses.get( + BASE_PROJECTS_URL, + json=[sample_project_dict], + status=200, + ) + + projects = client.get_list() + + assert isinstance(projects, list) + assert all(isinstance(p, Project) for p in projects) + assert projects[0].id == PROJECT_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_get_by_id_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" + responses.get( + url, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_by_id(PROJECT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_by_id_should_return_single_project( + self, client: ProjectsApi, sample_project_dict: dict + ) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" + responses.get( + url, + json=sample_project_dict, + status=200, + ) + + project = client.get_by_id(PROJECT_ID) + + assert isinstance(project, Project) + assert project.id == PROJECT_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post( + BASE_PROJECTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.create(project_name="New Project") + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_return_new_project( + self, client: ProjectsApi, sample_project_dict: dict + ) -> None: + responses.post( + BASE_PROJECTS_URL, + json=sample_project_dict, + status=201, + ) + + project = client.create(project_name="New Project") + + assert isinstance(project, Project) + assert project.name == "Test Project" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_update_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" + responses.patch( + url, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.update(PROJECT_ID, project_name="Update Project Name") + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_update_should_return_updated_project( + self, client: ProjectsApi, sample_project_dict: dict + ) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" + updated_name = "Updated Project" + updated_project_dict = sample_project_dict.copy() + updated_project_dict["name"] = updated_name + + responses.patch( + url, + json=updated_project_dict, + status=200, + ) + + project = client.update(PROJECT_ID, project_name=updated_name) + + assert isinstance(project, Project) + assert project.name == updated_name + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_delete_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" + responses.delete( + url, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(PROJECT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_should_return_deleted_object(self, client: ProjectsApi) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" + responses.delete( + url, + json={"id": PROJECT_ID}, + status=200, + ) + + result = client.delete(PROJECT_ID) + + assert isinstance(result, DeletedObject) + assert result.id == PROJECT_ID diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 29d3679..cc19c36 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -42,6 +42,13 @@ def test_client_validation(self, arguments: dict[str, Any]) -> None: with pytest.raises(mt.ClientConfigurationError): self.get_client(**arguments) + def test_get_testing_api_validation(self) -> None: + client = self.get_client() + with pytest.raises(mt.ClientConfigurationError) as exc_info: + client.testing_api + + assert "`account_id` is required for Testing API" in str(exc_info.value) + def test_base_url_should_truncate_slash_from_host(self) -> None: client = self.get_client(api_host="example.send.com/", api_port=543)