diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..8b808bb --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +export MAILTRAP_ACCOUNT_ID="op://Mailtrap Dev/Mailtrap SDK Dev API Key/account_id" +export MAILTRAP_ORGANIZATION_ID="op://Mailtrap Dev/Mailtrap SDK Dev API Key/organization_id" +export MAILTRAP_API_KEY="op://Mailtrap Dev/Mailtrap SDK Dev API Key/account_api_token" +export MAILTRAP_ORGANIZATION_API_KEY="op://Mailtrap Dev/Mailtrap SDK Dev API Key/organization_api_token" diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..20e21fa --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.9.18 diff --git a/README.md b/README.md index 18e54fd..45d0817 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,9 @@ The same situation applies to both `client.batch_send()` and `client.sending_api ### Sending Domains API: - Sending Domains – [`sending_domains/sending_domains.py`](examples/sending_domains/sending_domains.py) +### Webhooks API: +- Webhooks management – [`webhooks/webhooks.py`](examples/webhooks/webhooks.py) + ### Suppressions API: - Suppressions (find & delete) – [`suppressions/suppressions.py`](examples/suppressions/suppressions.py) @@ -256,9 +259,13 @@ The same situation applies to both `client.batch_send()` and `client.sending_api ### General API: - Account Accesses management – [`general/account_accesses.py`](examples/general/account_accesses.py) - Accounts info – [`general/accounts.py`](examples/general/accounts.py) +- API Tokens management – [`general/api_tokens.py`](examples/general/api_tokens.py) - Billing info – [`general/billing.py`](examples/general/billing.py) - Permissions listing – [`general/permissions.py`](examples/general/permissions.py) +### Organizations API: +- Sub-Accounts management – [`organizations/sub_accounts.py`](examples/organizations/sub_accounts.py) + ## Contributing Bug reports and pull requests are welcome on [GitHub](https://github.com/mailtrap/mailtrap-python). This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md). diff --git a/examples/general/api_tokens.py b/examples/general/api_tokens.py new file mode 100644 index 0000000..133cb6d --- /dev/null +++ b/examples/general/api_tokens.py @@ -0,0 +1,61 @@ +import mailtrap as mt +from mailtrap.models.api_tokens import ApiToken +from mailtrap.models.api_tokens import ApiTokenWithToken +from mailtrap.models.common import DeletedObject + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN) +api_tokens_api = client.general_api.api_tokens + + +def list_api_tokens(account_id: int) -> list[ApiToken]: + return api_tokens_api.get_list(account_id=account_id) + + +def get_api_token(account_id: int, api_token_id: int) -> ApiToken: + return api_tokens_api.get_by_id(account_id=account_id, api_token_id=api_token_id) + + +def create_api_token(account_id: int) -> ApiTokenWithToken: + # The full token value is only returned once on the response — store it securely. + return api_tokens_api.create( + account_id=account_id, + token_params=mt.CreateApiTokenParams( + name="My API Token", + resources=[ + mt.ApiTokenResource( + resource_type="account", + resource_id=account_id, + access_level=100, + ) + ], + ), + ) + + +def reset_api_token(account_id: int, api_token_id: int) -> ApiTokenWithToken: + # The reset response includes the new full token value once — store it securely. + return api_tokens_api.reset(account_id=account_id, api_token_id=api_token_id) + + +def delete_api_token(account_id: int, api_token_id: int) -> DeletedObject: + return api_tokens_api.delete(account_id=account_id, api_token_id=api_token_id) + + +if __name__ == "__main__": + tokens = list_api_tokens(ACCOUNT_ID) + print(tokens) + + created = create_api_token(ACCOUNT_ID) + print(created) + + fetched = get_api_token(ACCOUNT_ID, created.id) + print(fetched) + + reset = reset_api_token(ACCOUNT_ID, created.id) + print(reset) + + deleted = delete_api_token(ACCOUNT_ID, reset.id) + print(deleted) diff --git a/examples/organizations/__init__.py b/examples/organizations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/organizations/sub_accounts.py b/examples/organizations/sub_accounts.py new file mode 100644 index 0000000..e6dbea1 --- /dev/null +++ b/examples/organizations/sub_accounts.py @@ -0,0 +1,24 @@ +import mailtrap as mt +from mailtrap.models.organizations import SubAccount + +API_TOKEN = "YOUR_API_TOKEN" +ORGANIZATION_ID = "YOUR_ORGANIZATION_ID" + +client = mt.MailtrapClient(token=API_TOKEN, organization_id=ORGANIZATION_ID) +sub_accounts_api = client.organizations_api.sub_accounts + + +def list_sub_accounts() -> list[SubAccount]: + return sub_accounts_api.get_list() + + +def create_sub_account(name: str) -> SubAccount: + return sub_accounts_api.create(mt.CreateSubAccountParams(name=name)) + + +if __name__ == "__main__": + sub_accounts = list_sub_accounts() + print(sub_accounts) + + created = create_sub_account("New Team Account") + print(created) diff --git a/examples/webhooks/__init__.py b/examples/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/webhooks/webhooks.py b/examples/webhooks/webhooks.py new file mode 100644 index 0000000..61184cd --- /dev/null +++ b/examples/webhooks/webhooks.py @@ -0,0 +1,59 @@ +import mailtrap as mt +from mailtrap.models.common import DeletedObject +from mailtrap.models.webhooks import Webhook +from mailtrap.models.webhooks import WebhookWithSecret + +API_TOKEN = "YOUR_API_TOKEN" +ACCOUNT_ID = "YOUR_ACCOUNT_ID" + +client = mt.MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +webhooks_api = client.webhooks_api.webhooks + + +def list_webhooks() -> list[Webhook]: + return webhooks_api.get_list() + + +def get_webhook(webhook_id: int) -> Webhook: + return webhooks_api.get_by_id(webhook_id=webhook_id) + + +def create_webhook() -> WebhookWithSecret: + # The signing_secret is only returned once on creation — store it + # securely and use it to verify webhook signatures (HMAC SHA-256). + return webhooks_api.create( + mt.CreateWebhookParams( + url="https://example.com/mailtrap/webhooks", + webhook_type="email_sending", + sending_stream="transactional", + event_types=["delivery", "bounce"], + ) + ) + + +def update_webhook(webhook_id: int) -> Webhook: + return webhooks_api.update( + webhook_id=webhook_id, + webhook_params=mt.UpdateWebhookParams(active=False), + ) + + +def delete_webhook(webhook_id: int) -> DeletedObject: + return webhooks_api.delete(webhook_id=webhook_id) + + +if __name__ == "__main__": + webhooks = list_webhooks() + print(webhooks) + + created = create_webhook() + print(created) + + fetched = get_webhook(created.id) + print(fetched) + + updated = update_webhook(created.id) + print(updated) + + deleted = delete_webhook(created.id) + print(deleted) diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 302bae7..ae5f283 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -6,6 +6,8 @@ from .exceptions import ClientConfigurationError from .exceptions import MailtrapError from .models.accounts import AccountAccessFilterParams +from .models.api_tokens import ApiTokenResource +from .models.api_tokens import CreateApiTokenParams from .models.contacts import ContactEventParams from .models.contacts import ContactExportFilter from .models.contacts import ContactListParams @@ -31,6 +33,7 @@ from .models.mail import Mail from .models.mail import MailFromTemplate from .models.messages import UpdateEmailMessageParams +from .models.organizations import CreateSubAccountParams from .models.permissions import PermissionResourceParams from .models.projects import ProjectParams from .models.sending_domains import CreateSendingDomainParams @@ -38,3 +41,5 @@ from .models.stats import StatsFilterParams from .models.templates import CreateEmailTemplateParams from .models.templates import UpdateEmailTemplateParams +from .models.webhooks import CreateWebhookParams +from .models.webhooks import UpdateWebhookParams diff --git a/mailtrap/api/general.py b/mailtrap/api/general.py index b47646e..df6fd30 100644 --- a/mailtrap/api/general.py +++ b/mailtrap/api/general.py @@ -1,5 +1,6 @@ from mailtrap.api.resources.account_accesses import AccountAccessesApi from mailtrap.api.resources.accounts import AccountsApi +from mailtrap.api.resources.api_tokens import ApiTokensApi from mailtrap.api.resources.billing import BillingApi from mailtrap.api.resources.permissions import PermissionsApi from mailtrap.http import HttpClient @@ -17,6 +18,10 @@ def accounts(self) -> AccountsApi: def account_accesses(self) -> AccountAccessesApi: return AccountAccessesApi(client=self._client) + @property + def api_tokens(self) -> ApiTokensApi: + return ApiTokensApi(client=self._client) + @property def billing(self) -> BillingApi: return BillingApi(client=self._client) diff --git a/mailtrap/api/organizations.py b/mailtrap/api/organizations.py new file mode 100644 index 0000000..3b23e08 --- /dev/null +++ b/mailtrap/api/organizations.py @@ -0,0 +1,12 @@ +from mailtrap.api.resources.sub_accounts import SubAccountsApi +from mailtrap.http import HttpClient + + +class OrganizationsBaseApi: + def __init__(self, client: HttpClient, organization_id: str) -> None: + self._organization_id = organization_id + self._client = client + + @property + def sub_accounts(self) -> SubAccountsApi: + return SubAccountsApi(organization_id=self._organization_id, client=self._client) diff --git a/mailtrap/api/resources/api_tokens.py b/mailtrap/api/resources/api_tokens.py new file mode 100644 index 0000000..5e290d0 --- /dev/null +++ b/mailtrap/api/resources/api_tokens.py @@ -0,0 +1,61 @@ +from typing import Optional + +from mailtrap.http import HttpClient +from mailtrap.models.api_tokens import ApiToken +from mailtrap.models.api_tokens import ApiTokenWithToken +from mailtrap.models.api_tokens import CreateApiTokenParams +from mailtrap.models.common import DeletedObject + + +class ApiTokensApi: + def __init__(self, client: HttpClient) -> None: + self._client = client + + def get_list(self, account_id: int) -> list[ApiToken]: + """ + Returns all API tokens visible to the current API token. + """ + response = self._client.get(self._api_path(account_id)) + return [ApiToken(**api_token) for api_token in response] + + def get_by_id(self, account_id: int, api_token_id: int) -> ApiToken: + """ + Get a single API token by id. + """ + response = self._client.get(self._api_path(account_id, api_token_id)) + return ApiToken(**response) + + def create( + self, account_id: int, token_params: CreateApiTokenParams + ) -> ApiTokenWithToken: + """ + Create a new API token. The full token value is only returned once + in the response — store it securely. + """ + response = self._client.post( + self._api_path(account_id), json=token_params.api_data + ) + return ApiTokenWithToken(**response) + + def delete(self, account_id: int, api_token_id: int) -> DeletedObject: + """ + Permanently delete an API token. + """ + self._client.delete(self._api_path(account_id, api_token_id)) + return DeletedObject(id=api_token_id) + + def reset(self, account_id: int, api_token_id: int) -> ApiTokenWithToken: + """ + Expire the requested token and create a new token with the same + permissions. The full new token value is returned once — store it + securely. Only tokens that have not already been reset can be reset. + """ + response = self._client.post(f"{self._api_path(account_id, api_token_id)}/reset") + return ApiTokenWithToken(**response) + + @staticmethod + def _api_path(account_id: int, api_token_id: Optional[int] = None) -> str: + path = f"/api/accounts/{account_id}/api_tokens" + if api_token_id is not None: + return f"{path}/{api_token_id}" + return path diff --git a/mailtrap/api/resources/sub_accounts.py b/mailtrap/api/resources/sub_accounts.py new file mode 100644 index 0000000..2de47f9 --- /dev/null +++ b/mailtrap/api/resources/sub_accounts.py @@ -0,0 +1,31 @@ +from mailtrap.http import HttpClient +from mailtrap.models.organizations import CreateSubAccountParams +from mailtrap.models.organizations import SubAccount + + +class SubAccountsApi: + def __init__(self, client: HttpClient, organization_id: str) -> None: + self._organization_id = organization_id + self._client = client + + def get_list(self) -> list[SubAccount]: + """ + Get a list of sub accounts for the organization. Requires sub + account management permissions for this organization. + """ + response = self._client.get(self._api_path()) + return [SubAccount(**sub_account) for sub_account in response] + + def create(self, sub_account_params: CreateSubAccountParams) -> SubAccount: + """ + Create a new sub account under the organization. Requires sub + account management permissions for this organization. + """ + response = self._client.post( + self._api_path(), + json={"account": sub_account_params.api_data}, + ) + return SubAccount(**response) + + def _api_path(self) -> str: + return f"/api/organizations/{self._organization_id}/sub_accounts" diff --git a/mailtrap/api/resources/webhooks.py b/mailtrap/api/resources/webhooks.py new file mode 100644 index 0000000..d33a1e7 --- /dev/null +++ b/mailtrap/api/resources/webhooks.py @@ -0,0 +1,66 @@ +from typing import Optional + +from mailtrap.http import HttpClient +from mailtrap.models.common import DeletedObject +from mailtrap.models.webhooks import CreateWebhookParams +from mailtrap.models.webhooks import UpdateWebhookParams +from mailtrap.models.webhooks import Webhook +from mailtrap.models.webhooks import WebhookCreateResponse +from mailtrap.models.webhooks import WebhookListResponse +from mailtrap.models.webhooks import WebhookResponse +from mailtrap.models.webhooks import WebhookWithSecret + + +class WebhooksApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + def get_list(self) -> list[Webhook]: + """ + List all webhooks for the account. + """ + response = self._client.get(self._api_path()) + return WebhookListResponse(**response).data + + def get_by_id(self, webhook_id: int) -> Webhook: + """ + Get a single webhook by id. + """ + response = self._client.get(self._api_path(webhook_id)) + return WebhookResponse(**response).data + + def create(self, webhook_params: CreateWebhookParams) -> WebhookWithSecret: + """ + Create a new webhook. The response includes a `signing_secret` used + to verify webhook signatures — store it securely; it is only + returned once on creation. + """ + response = self._client.post( + self._api_path(), json={"webhook": webhook_params.api_data} + ) + return WebhookCreateResponse(**response).data + + def update(self, webhook_id: int, webhook_params: UpdateWebhookParams) -> Webhook: + """ + Update an existing webhook. Only the fields supplied in + `webhook_params` are sent to the API. + """ + response = self._client.patch( + self._api_path(webhook_id), + json={"webhook": webhook_params.api_data}, + ) + return WebhookResponse(**response).data + + def delete(self, webhook_id: int) -> DeletedObject: + """ + Permanently delete a webhook. + """ + self._client.delete(self._api_path(webhook_id)) + return DeletedObject(id=webhook_id) + + def _api_path(self, webhook_id: Optional[int] = None) -> str: + path = f"/api/accounts/{self._account_id}/webhooks" + if webhook_id is not None: + return f"{path}/{webhook_id}" + return path diff --git a/mailtrap/api/webhooks.py b/mailtrap/api/webhooks.py new file mode 100644 index 0000000..ae326d9 --- /dev/null +++ b/mailtrap/api/webhooks.py @@ -0,0 +1,12 @@ +from mailtrap.api.resources.webhooks import WebhooksApi +from mailtrap.http import HttpClient + + +class WebhooksBaseApi: + def __init__(self, client: HttpClient, account_id: str) -> None: + self._account_id = account_id + self._client = client + + @property + def webhooks(self) -> WebhooksApi: + return WebhooksApi(account_id=self._account_id, client=self._client) diff --git a/mailtrap/client.py b/mailtrap/client.py index 1e7f957..0369776 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -9,12 +9,14 @@ from mailtrap.api.contacts import ContactsBaseApi from mailtrap.api.email_logs import EmailLogsBaseApi from mailtrap.api.general import GeneralApi +from mailtrap.api.organizations import OrganizationsBaseApi from mailtrap.api.resources.stats import StatsApi from mailtrap.api.sending import SendingApi from mailtrap.api.sending_domains import SendingDomainsBaseApi from mailtrap.api.suppressions import SuppressionsBaseApi from mailtrap.api.templates import EmailTemplatesApi from mailtrap.api.testing import TestingApi +from mailtrap.api.webhooks import WebhooksBaseApi from mailtrap.config import BULK_HOST from mailtrap.config import GENERAL_HOST from mailtrap.config import SANDBOX_HOST @@ -51,6 +53,7 @@ def __init__( sandbox: bool = False, account_id: Optional[str] = None, inbox_id: Optional[str] = None, + organization_id: Optional[str] = None, user_agent: Optional[str] = None, ) -> None: self.token = token @@ -60,6 +63,7 @@ def __init__( self.sandbox = sandbox self.account_id = account_id self.inbox_id = inbox_id + self.organization_id = organization_id self._user_agent = ( user_agent if user_agent is not None else self.DEFAULT_USER_AGENT ) @@ -121,6 +125,22 @@ def email_logs_api(self) -> EmailLogsBaseApi: client=HttpClient(host=GENERAL_HOST, headers=self.headers), ) + @property + def organizations_api(self) -> OrganizationsBaseApi: + self._validate_organization_id("Organizations API") + return OrganizationsBaseApi( + organization_id=cast(str, self.organization_id), + client=HttpClient(host=GENERAL_HOST, headers=self.headers), + ) + + @property + def webhooks_api(self) -> WebhooksBaseApi: + self._validate_account_id("Webhooks API") + return WebhooksBaseApi( + 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) @@ -189,6 +209,12 @@ def _validate_account_id(self, api_name: str = "Testing API") -> None: if not self.account_id: raise ClientConfigurationError(f"`account_id` is required for {api_name}") + def _validate_organization_id(self, api_name: str) -> None: + if not self.organization_id: + raise ClientConfigurationError( + f"`organization_id` is required for {api_name}" + ) + def _validate_itself(self) -> None: if self.sandbox and not self.inbox_id: raise ClientConfigurationError("`inbox_id` is required for sandbox mode") diff --git a/mailtrap/models/api_tokens.py b/mailtrap/models/api_tokens.py new file mode 100644 index 0000000..31901b5 --- /dev/null +++ b/mailtrap/models/api_tokens.py @@ -0,0 +1,35 @@ +from typing import Optional +from typing import Union + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams + + +@dataclass +class ApiTokenResource: + resource_type: str + resource_id: Union[int, str] + access_level: int + + +@dataclass +class ApiToken: + id: int + name: str + last_4_digits: str + created_by: str + expires_at: Optional[str] + resources: list[ApiTokenResource] + + +@dataclass +class ApiTokenWithToken(ApiToken): + token: str = "" + + +@dataclass +class CreateApiTokenParams(RequestParams): + name: str + resources: list[ApiTokenResource] = Field(default_factory=list) diff --git a/mailtrap/models/organizations.py b/mailtrap/models/organizations.py new file mode 100644 index 0000000..4ebe8b3 --- /dev/null +++ b/mailtrap/models/organizations.py @@ -0,0 +1,14 @@ +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams + + +@dataclass +class SubAccount: + id: int + name: str + + +@dataclass +class CreateSubAccountParams(RequestParams): + name: str diff --git a/mailtrap/models/webhooks.py b/mailtrap/models/webhooks.py new file mode 100644 index 0000000..d4482b8 --- /dev/null +++ b/mailtrap/models/webhooks.py @@ -0,0 +1,57 @@ +from typing import Optional + +from pydantic import Field +from pydantic.dataclasses import dataclass + +from mailtrap.models.common import RequestParams + + +@dataclass +class Webhook: + id: int + url: str + active: bool + webhook_type: str + payload_format: str + sending_stream: Optional[str] = None + domain_id: Optional[int] = None + event_types: list[str] = Field(default_factory=list) + + +@dataclass +class WebhookWithSecret(Webhook): + signing_secret: str = "" + + +@dataclass +class WebhookResponse: + data: Webhook + + +@dataclass +class WebhookCreateResponse: + data: WebhookWithSecret + + +@dataclass +class WebhookListResponse: + data: list[Webhook] = Field(default_factory=list) + + +@dataclass +class CreateWebhookParams(RequestParams): + url: str + webhook_type: str + active: Optional[bool] = None + payload_format: Optional[str] = None + sending_stream: Optional[str] = None + event_types: Optional[list[str]] = None + domain_id: Optional[int] = None + + +@dataclass +class UpdateWebhookParams(RequestParams): + url: Optional[str] = None + active: Optional[bool] = None + payload_format: Optional[str] = None + event_types: Optional[list[str]] = None diff --git a/tests/unit/api/general/test_api_tokens.py b/tests/unit/api/general/test_api_tokens.py new file mode 100644 index 0000000..c76062b --- /dev/null +++ b/tests/unit/api/general/test_api_tokens.py @@ -0,0 +1,330 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.api_tokens import ApiTokensApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.api_tokens import ApiToken +from mailtrap.models.api_tokens import ApiTokenResource +from mailtrap.models.api_tokens import ApiTokenWithToken +from mailtrap.models.api_tokens import CreateApiTokenParams +from mailtrap.models.common import DeletedObject +from tests import conftest + +ACCOUNT_ID = 26730 +API_TOKEN_ID = 12345 +BASE_API_TOKENS_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/api_tokens" + + +@pytest.fixture +def client() -> ApiTokensApi: + return ApiTokensApi(client=HttpClient(GENERAL_HOST)) + + +@pytest.fixture +def sample_api_token_dict() -> dict[str, Any]: + return { + "id": API_TOKEN_ID, + "name": "My API Token", + "last_4_digits": "x7k9", + "created_by": "user@example.com", + "expires_at": None, + "resources": [ + {"resource_type": "account", "resource_id": 3229, "access_level": 100} + ], + } + + +class TestApiTokensApi: + + @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: ApiTokensApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_API_TOKENS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_list(ACCOUNT_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_list_should_return_api_tokens_list( + self, client: ApiTokensApi, sample_api_token_dict: dict + ) -> None: + responses.get( + BASE_API_TOKENS_URL, + json=[sample_api_token_dict], + status=200, + ) + + api_tokens = client.get_list(ACCOUNT_ID) + + assert isinstance(api_tokens, list) + assert all(isinstance(token, ApiToken) for token in api_tokens) + assert len(api_tokens) == 1 + assert api_tokens[0].id == API_TOKEN_ID + assert api_tokens[0].name == "My API Token" + assert api_tokens[0].last_4_digits == "x7k9" + assert api_tokens[0].created_by == "user@example.com" + assert api_tokens[0].expires_at is None + assert len(api_tokens[0].resources) == 1 + assert api_tokens[0].resources[0].resource_type == "account" + assert api_tokens[0].resources[0].resource_id == 3229 + assert api_tokens[0].resources[0].access_level == 100 + + @responses.activate + def test_get_list_should_return_empty_list(self, client: ApiTokensApi) -> None: + responses.get( + BASE_API_TOKENS_URL, + json=[], + status=200, + ) + + api_tokens = client.get_list(ACCOUNT_ID) + + assert isinstance(api_tokens, list) + assert len(api_tokens) == 0 + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_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: ApiTokensApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_API_TOKENS_URL}/{API_TOKEN_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_by_id(ACCOUNT_ID, API_TOKEN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_by_id_should_return_api_token( + self, client: ApiTokensApi, sample_api_token_dict: dict + ) -> None: + responses.get( + f"{BASE_API_TOKENS_URL}/{API_TOKEN_ID}", + json=sample_api_token_dict, + status=200, + ) + + api_token = client.get_by_id(ACCOUNT_ID, API_TOKEN_ID) + + assert isinstance(api_token, ApiToken) + assert api_token.id == API_TOKEN_ID + assert api_token.name == "My API Token" + assert api_token.last_4_digits == "x7k9" + + @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: ApiTokensApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post( + BASE_API_TOKENS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.create(ACCOUNT_ID, CreateApiTokenParams(name="My API Token")) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_return_api_token_with_full_token_value( + self, client: ApiTokensApi, sample_api_token_dict: dict + ) -> None: + responses.post( + BASE_API_TOKENS_URL, + json={**sample_api_token_dict, "token": "a1b2c3d4e5f6"}, + status=200, + ) + + params = CreateApiTokenParams( + name="My API Token", + resources=[ + ApiTokenResource( + resource_type="account", resource_id=3229, access_level=100 + ) + ], + ) + + token = client.create(ACCOUNT_ID, params) + + assert isinstance(token, ApiTokenWithToken) + assert token.id == API_TOKEN_ID + assert token.token == "a1b2c3d4e5f6" + + assert len(responses.calls) == 1 + assert responses.calls[0].request.body == ( + b'{"name": "My API Token", "resources": ' + b'[{"resource_type": "account", "resource_id": 3229, "access_level": 100}]}' + ) + + @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: ApiTokensApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.delete( + f"{BASE_API_TOKENS_URL}/{API_TOKEN_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(ACCOUNT_ID, API_TOKEN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_should_return_deleted_object(self, client: ApiTokensApi) -> None: + responses.delete( + f"{BASE_API_TOKENS_URL}/{API_TOKEN_ID}", + status=204, + ) + + result = client.delete(ACCOUNT_ID, API_TOKEN_ID) + + assert isinstance(result, DeletedObject) + assert result.id == API_TOKEN_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), + ], + ) + @responses.activate + def test_reset_should_raise_api_errors( + self, + client: ApiTokensApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post( + f"{BASE_API_TOKENS_URL}/{API_TOKEN_ID}/reset", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.reset(ACCOUNT_ID, API_TOKEN_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_reset_should_return_api_token_with_full_token_value( + self, client: ApiTokensApi, sample_api_token_dict: dict + ) -> None: + responses.post( + f"{BASE_API_TOKENS_URL}/{API_TOKEN_ID}/reset", + json={**sample_api_token_dict, "token": "new-token-value"}, + status=200, + ) + + token = client.reset(ACCOUNT_ID, API_TOKEN_ID) + + assert isinstance(token, ApiTokenWithToken) + assert token.id == API_TOKEN_ID + assert token.token == "new-token-value" diff --git a/tests/unit/api/organizations/__init__.py b/tests/unit/api/organizations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/organizations/test_sub_accounts.py b/tests/unit/api/organizations/test_sub_accounts.py new file mode 100644 index 0000000..bf90926 --- /dev/null +++ b/tests/unit/api/organizations/test_sub_accounts.py @@ -0,0 +1,154 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.sub_accounts import SubAccountsApi +from mailtrap.config import GENERAL_HOST +from mailtrap.exceptions import APIError +from mailtrap.http import HttpClient +from mailtrap.models.organizations import CreateSubAccountParams +from mailtrap.models.organizations import SubAccount +from tests import conftest + +ORGANIZATION_ID = "1001" +SUB_ACCOUNT_ID = 12345 +BASE_SUB_ACCOUNTS_URL = ( + f"https://{GENERAL_HOST}/api/organizations/{ORGANIZATION_ID}/sub_accounts" +) + + +@pytest.fixture +def client() -> SubAccountsApi: + return SubAccountsApi( + client=HttpClient(GENERAL_HOST), organization_id=ORGANIZATION_ID + ) + + +@pytest.fixture +def sample_sub_account_dict() -> dict[str, Any]: + return {"id": SUB_ACCOUNT_ID, "name": "Development Team Account"} + + +class TestSubAccountsApi: + + @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: SubAccountsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + BASE_SUB_ACCOUNTS_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_sub_accounts_list( + self, client: SubAccountsApi, sample_sub_account_dict: dict + ) -> None: + responses.get( + BASE_SUB_ACCOUNTS_URL, + json=[ + sample_sub_account_dict, + {"id": 12346, "name": "QA Team Account"}, + ], + status=200, + ) + + sub_accounts = client.get_list() + + assert isinstance(sub_accounts, list) + assert all(isinstance(s, SubAccount) for s in sub_accounts) + assert len(sub_accounts) == 2 + assert sub_accounts[0].id == SUB_ACCOUNT_ID + assert sub_accounts[0].name == "Development Team Account" + + @responses.activate + def test_get_list_should_return_empty_list(self, client: SubAccountsApi) -> None: + responses.get(BASE_SUB_ACCOUNTS_URL, json=[], status=200) + + sub_accounts = client.get_list() + + assert isinstance(sub_accounts, list) + assert len(sub_accounts) == 0 + + @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.VALIDATION_ERRORS_STATUS_CODE, + {"errors": "Name is invalid"}, + "Name is invalid", + ), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: SubAccountsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post(BASE_SUB_ACCOUNTS_URL, status=status_code, json=response_json) + + with pytest.raises(APIError) as exc_info: + client.create(CreateSubAccountParams(name="New Team Account")) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_return_sub_account_and_wrap_body_under_account_key( + self, client: SubAccountsApi, sample_sub_account_dict: dict + ) -> None: + responses.post( + BASE_SUB_ACCOUNTS_URL, + json={"id": 12347, "name": "New Team Account"}, + status=200, + ) + + sub_account = client.create(CreateSubAccountParams(name="New Team Account")) + + assert isinstance(sub_account, SubAccount) + assert sub_account.id == 12347 + assert sub_account.name == "New Team Account" + + assert len(responses.calls) == 1 + assert ( + responses.calls[0].request.body + == b'{"account": {"name": "New Team Account"}}' + ) diff --git a/tests/unit/api/webhooks/__init__.py b/tests/unit/api/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/webhooks/test_webhooks.py b/tests/unit/api/webhooks/test_webhooks.py new file mode 100644 index 0000000..8657ebd --- /dev/null +++ b/tests/unit/api/webhooks/test_webhooks.py @@ -0,0 +1,354 @@ +from typing import Any + +import pytest +import responses + +from mailtrap.api.resources.webhooks import WebhooksApi +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.webhooks import CreateWebhookParams +from mailtrap.models.webhooks import UpdateWebhookParams +from mailtrap.models.webhooks import Webhook +from mailtrap.models.webhooks import WebhookWithSecret +from tests import conftest + +ACCOUNT_ID = "26730" +WEBHOOK_ID = 1 +BASE_WEBHOOKS_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/webhooks" + + +@pytest.fixture +def client() -> WebhooksApi: + return WebhooksApi(client=HttpClient(GENERAL_HOST), account_id=ACCOUNT_ID) + + +@pytest.fixture +def sample_webhook_dict() -> dict[str, Any]: + return { + "id": WEBHOOK_ID, + "url": "https://example.com/mailtrap/webhooks", + "active": True, + "webhook_type": "email_sending", + "payload_format": "json", + "sending_stream": "transactional", + "domain_id": 435, + "event_types": ["delivery", "bounce"], + } + + +class TestWebhooksApi: + + @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: WebhooksApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get(BASE_WEBHOOKS_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_webhooks_list( + self, client: WebhooksApi, sample_webhook_dict: dict + ) -> None: + responses.get( + BASE_WEBHOOKS_URL, + json={ + "data": [ + sample_webhook_dict, + { + "id": 2, + "url": "https://example.com/mailtrap/webhooks", + "active": True, + "webhook_type": "audit_log", + "payload_format": "json", + }, + ] + }, + status=200, + ) + + webhooks = client.get_list() + + assert isinstance(webhooks, list) + assert all(isinstance(w, Webhook) for w in webhooks) + assert len(webhooks) == 2 + assert webhooks[0].id == WEBHOOK_ID + assert webhooks[0].sending_stream == "transactional" + assert webhooks[0].domain_id == 435 + assert webhooks[0].event_types == ["delivery", "bounce"] + # audit_log webhook has no sending_stream / domain_id / event_types + assert webhooks[1].webhook_type == "audit_log" + assert webhooks[1].sending_stream is None + assert webhooks[1].domain_id is None + assert webhooks[1].event_types == [] + + @responses.activate + def test_get_list_should_return_empty_list(self, client: WebhooksApi) -> None: + responses.get(BASE_WEBHOOKS_URL, json={"data": []}, status=200) + + webhooks = client.get_list() + + assert webhooks == [] + + @responses.activate + def test_get_list_should_silently_drop_extra_response_fields( + self, client: WebhooksApi, sample_webhook_dict: dict + ) -> None: + responses.get( + BASE_WEBHOOKS_URL, + json={ + "data": [ + { + **sample_webhook_dict, + "domain_name": "example.com", + "permissions": {"can_read": True, "can_update": True}, + } + ] + }, + status=200, + ) + + webhooks = client.get_list() + + assert len(webhooks) == 1 + assert webhooks[0].id == WEBHOOK_ID + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_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: WebhooksApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.get( + f"{BASE_WEBHOOKS_URL}/{WEBHOOK_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_by_id(WEBHOOK_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_get_by_id_should_return_webhook( + self, client: WebhooksApi, sample_webhook_dict: dict + ) -> None: + responses.get( + f"{BASE_WEBHOOKS_URL}/{WEBHOOK_ID}", + json={"data": sample_webhook_dict}, + status=200, + ) + + webhook = client.get_by_id(WEBHOOK_ID) + + assert isinstance(webhook, Webhook) + assert webhook.id == WEBHOOK_ID + assert webhook.url == "https://example.com/mailtrap/webhooks" + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.VALIDATION_ERRORS_STATUS_CODE, + {"errors": "Url is invalid"}, + "Url is invalid", + ), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: WebhooksApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.post(BASE_WEBHOOKS_URL, status=status_code, json=response_json) + + with pytest.raises(APIError) as exc_info: + client.create( + CreateWebhookParams( + url="https://example.com/mailtrap/webhooks", + webhook_type="email_sending", + ) + ) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_create_should_return_webhook_with_signing_secret( + self, client: WebhooksApi, sample_webhook_dict: dict + ) -> None: + responses.post( + BASE_WEBHOOKS_URL, + json={"data": {**sample_webhook_dict, "signing_secret": "a1b2c3d4"}}, + status=200, + ) + + webhook = client.create( + CreateWebhookParams( + url="https://example.com/mailtrap/webhooks", + webhook_type="email_sending", + sending_stream="transactional", + event_types=["delivery", "bounce"], + domain_id=435, + ) + ) + + assert isinstance(webhook, WebhookWithSecret) + assert webhook.id == WEBHOOK_ID + assert webhook.signing_secret == "a1b2c3d4" + + assert len(responses.calls) == 1 + assert responses.calls[0].request.body == ( + b'{"webhook": {"url": "https://example.com/mailtrap/webhooks", ' + b'"webhook_type": "email_sending", "sending_stream": "transactional", ' + b'"event_types": ["delivery", "bounce"], "domain_id": 435}}' + ) + + @responses.activate + def test_update_should_send_only_supplied_fields( + self, client: WebhooksApi, sample_webhook_dict: dict + ) -> None: + responses.patch( + f"{BASE_WEBHOOKS_URL}/{WEBHOOK_ID}", + json={"data": {**sample_webhook_dict, "active": False}}, + status=200, + ) + + webhook = client.update(WEBHOOK_ID, UpdateWebhookParams(active=False)) + + assert isinstance(webhook, Webhook) + assert webhook.active is False + + assert responses.calls[0].request.body == b'{"webhook": {"active": false}}' + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_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: WebhooksApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.patch( + f"{BASE_WEBHOOKS_URL}/{WEBHOOK_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.update(WEBHOOK_ID, UpdateWebhookParams(active=False)) + + assert expected_error_message in str(exc_info.value) + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_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: WebhooksApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.delete( + f"{BASE_WEBHOOKS_URL}/{WEBHOOK_ID}", + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(WEBHOOK_ID) + + assert expected_error_message in str(exc_info.value) + + @responses.activate + def test_delete_should_return_deleted_object( + self, client: WebhooksApi, sample_webhook_dict: dict + ) -> None: + responses.delete( + f"{BASE_WEBHOOKS_URL}/{WEBHOOK_ID}", + json={"data": sample_webhook_dict}, + status=200, + ) + + result = client.delete(WEBHOOK_ID) + + assert isinstance(result, DeletedObject) + assert result.id == WEBHOOK_ID diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 44708d7..952b634 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -47,6 +47,22 @@ def test_get_testing_api_validation(self) -> None: assert "`account_id` is required for Testing API" in str(exc_info.value) + def test_organizations_api_requires_organization_id(self) -> None: + client = self.get_client() + with pytest.raises(mt.ClientConfigurationError) as exc_info: + _ = client.organizations_api + + assert "`organization_id` is required for Organizations API" in str( + exc_info.value + ) + + def test_webhooks_api_requires_account_id(self) -> None: + client = self.get_client() + with pytest.raises(mt.ClientConfigurationError) as exc_info: + _ = client.webhooks_api + + assert "`account_id` is required for Webhooks API" in str(exc_info.value) + @pytest.mark.parametrize( "arguments, expected_url", [