From b536e44ee4fcbc526d6e9be2711d97200cef8913 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:00:15 +0200 Subject: [PATCH 01/23] Add the get routes match endpoint --- mailgun/examples/routes_examples.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mailgun/examples/routes_examples.py b/mailgun/examples/routes_examples.py index 97ea77f..93cff8b 100644 --- a/mailgun/examples/routes_examples.py +++ b/mailgun/examples/routes_examples.py @@ -5,6 +5,7 @@ key: str = os.environ["APIKEY"] domain: str = os.environ["DOMAIN"] +sender: str = os.environ["MESSAGES_FROM"] client: Client = Client(auth=("api", key)) @@ -67,5 +68,15 @@ def delete_route() -> None: print(req.json()) +def get_routes_match() -> None: + """ + GET /routes/match + :return: + """ + query = {"address": sender} + req = client.routes_match.get(domain=domain, filters=query) + print(req.json()) + + if __name__ == "__main__": - delete_route() + get_routes_match() From c395fbf6dc1ae0c2d696ff3d9df4f48f068c5e56 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:04:34 +0200 Subject: [PATCH 02/23] Add the update template version copy endpoint --- mailgun/examples/templates_examples.py | 24 +++++++++++++++++++++++- mailgun/handlers/templates_handler.py | 15 +++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/mailgun/examples/templates_examples.py b/mailgun/examples/templates_examples.py index 4297e96..f9da8c8 100644 --- a/mailgun/examples/templates_examples.py +++ b/mailgun/examples/templates_examples.py @@ -141,5 +141,27 @@ def get_all_versions() -> None: print(req.json()) +def update_template_version_copy() -> None: + """ + PUT /v3/{domain_name}/templates/{template_name}/versions/{version_name}/copy/{new_version_name} + :return: + """ + data = {"comment": "An updated version comment"} + + req = client.templates.put( + domain=domain, + filters=data, + template_name="template.name1", + versions=True, + tag="v2", + copy=True, + new_tag="v3", + ) + print(req.json()) + + if __name__ == "__main__": - get_all_versions() + # get_all_versions() + post_template() + create_new_template_version() + update_template_version_copy() diff --git a/mailgun/handlers/templates_handler.py b/mailgun/handlers/templates_handler.py index 09fc8f7..1a05707 100644 --- a/mailgun/handlers/templates_handler.py +++ b/mailgun/handlers/templates_handler.py @@ -33,7 +33,7 @@ def handle_templates( if "template_name" in kwargs: if "versions" in kwargs: if kwargs["versions"]: - if "tag" in kwargs: + if "tag" in kwargs and "copy" not in kwargs: url = ( url["base"] + domain @@ -43,6 +43,18 @@ def handle_templates( + "/versions/" + kwargs["tag"] ) + elif "tag" in kwargs and "copy" in kwargs and "new_tag" in kwargs: + url = ( + url["base"] + + domain + + final_keys + + "/" + + kwargs["template_name"] + + "/versions/" + + kwargs["tag"] + + "/copy/" + + kwargs["new_tag"] + ) else: url = ( url["base"] @@ -58,5 +70,4 @@ def handle_templates( url = url["base"] + domain + final_keys + "/" + kwargs["template_name"] else: url = url["base"] + domain + final_keys - return url From a97ed10d0777c642c06f99b8e4f22b1f60498a8a Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Thu, 27 Nov 2025 09:56:09 +0200 Subject: [PATCH 03/23] Add the mailboxes credentials endpoint --- Makefile | 28 ++++----- mailgun/client.py | 12 ++++ mailgun/examples/credentials_examples.py | 72 ++++++++++++++++++++++++ mailgun/examples/domain_examples.py | 69 ++++++----------------- mailgun/handlers/domains_handler.py | 35 ++++++++++++ 5 files changed, 151 insertions(+), 65 deletions(-) create mode 100644 mailgun/examples/credentials_examples.py diff --git a/Makefile b/Makefile index ecfe2cf..7dccf3f 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ export PRINT_HELP_PYSCRIPT BROWSER := python -c "$$BROWSER_PYSCRIPT" -clean: clean-cov clean-build clean-pyc clean-test clean-temp clean-other ## remove all build, test, coverage and Python artifacts +clean: clean-cov clean-build clean-pyc clean-test clean-temp clean-other ## remove all build, test, coverage and Python artifacts clean-cov: rm -rf .coverage @@ -47,7 +47,7 @@ clean-cov: rm -rf pytest.xml rm -rf pytest-coverage.txt -clean-build: ## remove build artifacts +clean-build: ## remove build artifacts rm -fr build/ rm -fr dist/ rm -fr .eggs/ @@ -57,19 +57,19 @@ clean-build: ## remove build artifacts clean-env: ## remove conda environment conda remove -y -n $(CONDA_ENV_NAME) --all ; conda info -clean-pyc: ## remove Python file artifacts +clean-pyc: ## remove Python file artifacts find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + find . -name '__pycache__' -exec rm -fr {} + -clean-test: ## remove test and coverage artifacts +clean-test: ## remove test and coverage artifacts rm -fr .tox/ rm -f .coverage rm -fr htmlcov/ rm -fr .pytest_cache -clean-temp: ## remove temp artifacts +clean-temp: ## remove temp artifacts rm -fr temp/tmp.txt rm -fr tmp.txt @@ -83,21 +83,21 @@ clean-other: help: $(PYTHON3) -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) -environment: ## handles environment creation +environment: ## handles environment creation conda env create -f environment.yaml --name $(CONDA_ENV_NAME) --yes conda run --name $(CONDA_ENV_NAME) pip install . -environment-dev: ## Handles environment creation +environment-dev: ## Handles environment creation conda env create -n $(CONDA_ENV_NAME)-dev -y --file environment-dev.yml conda run --name $(CONDA_ENV_NAME)-dev pip install -e . install: clean ## install the package to the active Python's site-packages pip install . -release: dist ## package and upload a release +release: dist ## package and upload a release twine upload dist/* -dist: clean ## builds source and wheel package +dist: clean ## builds source and wheel package python -m build ls -l dist @@ -111,14 +111,14 @@ dev-full: clean ## install the package's development version to a fresh environ $(CONDA_ACTIVATE) $(CONDA_ENV_NAME)-dev && pre-commit install -pre-commit: ## runs pre-commit against files. NOTE: older files are disabled in the pre-commit config. +pre-commit: ## runs pre-commit against files. NOTE: older files are disabled in the pre-commit config. pre-commit run --all-files -test: ## runs test cases - $(PYTHON3) -m pytest -n auto --capture=no $(TEST_DIR) test.py +test: ## runs test cases + $(PYTHON3) -m pytest -v --capture=no $(TEST_DIR)/tests.py test-debug: ## runs test cases with debugging info enabled - $(PYTHON3) -m pytest -n auto -vv --capture=no $(TEST_DIR) test.py + $(PYTHON3) -m pytest -n auto -vv --capture=no $(TEST_DIR)/tests.py test-cov: ## checks test coverage requirements $(PYTHON3) -m pytest -n auto --cov-config=.coveragerc --cov=$(SRC_DIR) \ @@ -127,7 +127,7 @@ test-cov: ## checks test coverage requirements tests-cov-fail: @pytest --cov=$(SRC_DIR) --cov-report term-missing --cov-report=html --cov-fail-under=80 -coverage: ## check code coverage quickly with the default Python +coverage: ## check code coverage quickly with the default Python coverage run --source $(SRC_DIR) -m pytest coverage report -m coverage html diff --git a/mailgun/client.py b/mailgun/client.py index 017900d..c4bc25e 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -23,6 +23,8 @@ from mailgun.handlers.default_handler import handle_default from mailgun.handlers.domains_handler import handle_domainlist from mailgun.handlers.domains_handler import handle_domains +from mailgun.handlers.domains_handler import handle_envelopes +from mailgun.handlers.domains_handler import handle_mailboxes_credentials from mailgun.handlers.domains_handler import handle_sending_queues from mailgun.handlers.email_validation_handler import handle_address_validate from mailgun.handlers.error_handler import ApiError @@ -56,6 +58,8 @@ "dkim_selector": handle_domains, "web_prefix": handle_domains, "sending_queues": handle_sending_queues, + "envelopes": handle_envelopes, + "mailboxes": handle_mailboxes_credentials, "ips": handle_ips, "ip_pools": handle_ippools, "tags": handle_tags, @@ -156,6 +160,14 @@ def __getitem__(self, key: str) -> tuple[Any, dict[str, str]]: "keys": f"{part1}-{part2}".split("_"), }, headers + # TODO: verify if it works! + if "enevelopes" in key: + headers |= {"Content-Type": "application/json"} + return { + "base": v3_base, + "keys": key, + }, headers + # Handle DIPP endpoints if "subaccount" in key: if "ip_pools" in key: diff --git a/mailgun/examples/credentials_examples.py b/mailgun/examples/credentials_examples.py new file mode 100644 index 0000000..57cbaab --- /dev/null +++ b/mailgun/examples/credentials_examples.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import os + +from mailgun.client import Client + + +key: str = os.environ["APIKEY"] +domain: str = os.environ["DOMAIN"] + +client: Client = Client(auth=("api", key)) + + +def get_credentials() -> None: + """ + GET /domains//credentials + :return: + """ + request = client.domains_credentials.get(domain=domain) + print(request.json()) + + +def post_credentials() -> None: + """ + POST /domains//credentials + :return: + """ + data = { + "login": f"alice_bob@{domain}", + "password": "test_new_creds123", # pragma: allowlist secret + } + request = client.domains_credentials.create(domain=domain, data=data) + print(request.json()) + + +def put_credentials() -> None: + """ + PUT /domains//credentials/ + :return: + """ + data = {"password": "test_new_creds12356"} # pragma: allowlist secret + request = client.domains_credentials.put(domain=domain, data=data, login=f"alice_bob@{domain}") + print(request.json()) + + +def put_mailboxes_credentials() -> None: + """ + PUT /v3/{domain_name}/mailboxes/{spec} + :return: + """ + + req = client.mailboxes.put(domain=domain, login=f"alice_bob@{domain}") + print(req) + print(req.json()) + + +def delete_all_domain_credentials() -> None: + """ + DELETE /domains//credentials + :return: + """ + request = client.domains_credentials.delete(domain=domain) + print(request.json()) + + +def delete_credentials() -> None: + """ + DELETE /domains//credentials/ + :return: + """ + request = client.domains_credentials.delete(domain=domain, login=f"alice_bob@{domain}") + print(request.json()) diff --git a/mailgun/examples/domain_examples.py b/mailgun/examples/domain_examples.py index 16f0d66..e2eaa01 100644 --- a/mailgun/examples/domain_examples.py +++ b/mailgun/examples/domain_examples.py @@ -81,56 +81,6 @@ def delete_domain() -> None: print(request.status_code) -def get_credentials() -> None: - """ - GET /domains//credentials - :return: - """ - request = client.domains_credentials.get(domain=domain) - print(request.json()) - - -def post_credentials() -> None: - """ - POST /domains//credentials - :return: - """ - data = { - "login": f"alice_bob@{domain}", - "password": "test_new_creds123", # pragma: allowlist secret - } - request = client.domains_credentials.create(domain=domain, data=data) - print(request.json()) - - -def put_credentials() -> None: - """ - PUT /domains//credentials/ - :return: - """ - data = {"password": "test_new_creds12356"} # pragma: allowlist secret - request = client.domains_credentials.put(domain=domain, data=data, login=f"alice_bob@{domain}") - print(request.json()) - - -def delete_all_domain_credentials() -> None: - """ - DELETE /domains//credentials - :return: - """ - request = client.domains_credentials.delete(domain=domain) - print(request.json()) - - -def delete_credentials() -> None: - """ - DELETE /domains//credentials/ - :return: - """ - request = client.domains_credentials.delete(domain=domain, login=f"alice_bob@{domain}") - print(request.json()) - - def get_connections() -> None: """ GET /domains//connection @@ -237,5 +187,22 @@ def get_sending_queues() -> None: print(request.json()) +# TODO: Verify if it works +def delete_envelopes() -> None: + """ + Messages + GET DELETE /v3//envelopes + :return: + """ + req = client.envelopes.delete(domain=domain) + print(req) + print(req.json()) + + if __name__ == "__main__": - get_domains() + # add_domain() + # get_domains() + + # delete_envelopes() + + put_mailboxes_credentials() diff --git a/mailgun/handlers/domains_handler.py b/mailgun/handlers/domains_handler.py index 30a96ac..35da828 100644 --- a/mailgun/handlers/domains_handler.py +++ b/mailgun/handlers/domains_handler.py @@ -97,3 +97,38 @@ def handle_sending_queues( ) -> str | Any: """Handle sending queues endpoint URL construction.""" return url["base"][:-1] + f"/{domain}/sending_queues" + + +# TODO: Verify if it works! +def handle_envelopes( + url: dict[str, Any], + domain: str | None, + _method: str | None, + **kwargs: Any, +) -> str | Any: + """Handle envelopes endpoint URL construction.""" + return url["base"][:-1] + f"/{domain}/envelopes" + + +def handle_mailboxes_credentials( + url: dict[str, Any], + domain: str | None, + _method: str | None, + **kwargs: Any, +) -> Any: + """Handle Mailboxes credentials. + + :param url: Incoming URL dictionary + :type url: dict + :param domain: Incoming domain + :type domain: str + :param _method: Incoming request method (it's not being used for this handler) + :type _method: str + :param kwargs: kwargs + :return: final url for Mailboxes credentials endpoint + """ + final_keys = path.join("/", *url["keys"]) if url["keys"] else "" + if "login" in kwargs: + url = url["base"] + domain + final_keys + "/" + kwargs["login"] + + return url From 873f96e68bb715dc3dcf21a962516206a7bddfb9 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:11:36 +0200 Subject: [PATCH 04/23] Add users endpoint --- mailgun/client.py | 12 +++++ mailgun/examples/credentials_examples.py | 4 ++ mailgun/examples/domain_examples.py | 6 +-- mailgun/examples/users_examples.py | 62 ++++++++++++++++++++++++ mailgun/handlers/users_handler.py | 49 +++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 mailgun/examples/users_examples.py create mode 100644 mailgun/handlers/users_handler.py diff --git a/mailgun/client.py b/mailgun/client.py index c4bc25e..f2d45db 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -41,6 +41,7 @@ from mailgun.handlers.suppressions_handler import handle_whitelists from mailgun.handlers.tags_handler import handle_tags from mailgun.handlers.templates_handler import handle_templates +from mailgun.handlers.users_handler import handle_users if TYPE_CHECKING: @@ -77,6 +78,7 @@ "events": handle_default, "analytics": handle_metrics, "bounce-classification": handle_bounce_classification, + "users": handle_users, } @@ -139,6 +141,10 @@ def __getitem__(self, key: str) -> tuple[Any, dict[str, str]]: "base": v2_base, "keys": ["bounce-classification", "metrics"], }, + "users": { + "base": v5_base, + "keys": ["users", "me", "org"], + }, } if key in special_cases: @@ -168,6 +174,12 @@ def __getitem__(self, key: str) -> tuple[Any, dict[str, str]]: "keys": key, }, headers + if "users" in key: + return { + "base": v5_base, + "keys": key.split("_"), + }, headers + # Handle DIPP endpoints if "subaccount" in key: if "ip_pools" in key: diff --git a/mailgun/examples/credentials_examples.py b/mailgun/examples/credentials_examples.py index 57cbaab..706a2c4 100644 --- a/mailgun/examples/credentials_examples.py +++ b/mailgun/examples/credentials_examples.py @@ -70,3 +70,7 @@ def delete_credentials() -> None: """ request = client.domains_credentials.delete(domain=domain, login=f"alice_bob@{domain}") print(request.json()) + + +if __name__ == "__main__": + put_mailboxes_credentials() diff --git a/mailgun/examples/domain_examples.py b/mailgun/examples/domain_examples.py index e2eaa01..4e86378 100644 --- a/mailgun/examples/domain_examples.py +++ b/mailgun/examples/domain_examples.py @@ -191,7 +191,7 @@ def get_sending_queues() -> None: def delete_envelopes() -> None: """ Messages - GET DELETE /v3//envelopes + DELETE /v3//envelopes :return: """ req = client.envelopes.delete(domain=domain) @@ -203,6 +203,4 @@ def delete_envelopes() -> None: # add_domain() # get_domains() - # delete_envelopes() - - put_mailboxes_credentials() + delete_envelopes() diff --git a/mailgun/examples/users_examples.py b/mailgun/examples/users_examples.py new file mode 100644 index 0000000..7162db3 --- /dev/null +++ b/mailgun/examples/users_examples.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os + +from mailgun.client import Client + + +key: str = os.environ["APIKEY"] +domain: str = os.environ["DOMAIN"] + +client: Client = Client(auth=("api", key)) + + +def get_users() -> None: + """ + GET /v5/users + :return: + """ + query = {"role": "admin", "limit": "0", "skip": "0"} + req = client.users.get(filters=query) + print(req) + print(req.json()) + + +def get_user_details() -> None: + """ + GET /v5/users/{user_id} + :return: + """ + user_id = "xxxxxxxxxxxxxxxxxxxxxxxx" + req = client.users.get(user_id=user_id) + print(req) + print(req.json()) + + +# TODO: HTTP/2 403 {"message":"Incompatible key for this endpoint"} +# def get_own_user_details() -> None: +# """ +# GET /v5/users/me +# :return: +# """ +# user_id = "me" +# req = client.users.get(user_id=user_id) +# print(req) +# print(req.json()) + + +# TODO: HTTP/2 400 {'message': "User's account USER_ID is not in the organization ORG_ID"} +# def add_user_to_org() -> None: +# """ +# PUT /v5/users/{user_id}/org/{org_id} +# :return: +# """ +# user_id = "xxxxxxxxxxxxxxxxxxxxxxxx" +# org_id = "on.sinch.com" +# req = client.users_org.put(user_id=user_id, org_id=org_id) +# print(req) +# print(req.json()) + +if __name__ == "__main__": + get_users() + get_user_details() diff --git a/mailgun/handlers/users_handler.py b/mailgun/handlers/users_handler.py new file mode 100644 index 0000000..c0b5bb7 --- /dev/null +++ b/mailgun/handlers/users_handler.py @@ -0,0 +1,49 @@ +"""USERS HANDLER. + +Doc: https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/users +""" + +from __future__ import annotations + +from os import path +from typing import Any + + +def handle_users( + url: dict[str, Any], + _domain: str | None, + _method: str | None, + **kwargs: Any, +) -> Any: + """Handle Users. + + :param url: Incoming URL dictionary + :type url: dict + :param _domain: Incoming domain (it's not being used for this handler) + :type _domain: str + :param _method: Incoming request method (it's not being used for this handler) + :type _method: str + :param kwargs: kwargs + :return: final url for Users endpoint + """ + final_keys = path.join("/", *url["keys"]) if url["keys"] else "" + if "org_id" in kwargs: + url = ( + url["base"][:-1] + + "/" + + "users" + + "/" + + kwargs["user_id"] + + "/" + + "org" + + "/" + + kwargs["org_id"] + ) + elif "user_id" in kwargs and kwargs["user_id"] != "me": + url = url["base"][:-1] + "/" + "users" + "/" + kwargs["user_id"] + elif "user_id" in kwargs and kwargs["user_id"] == "me": + url = url["base"][:-1] + final_keys + else: + url = url["base"][:-1] + "/" + "users" + + return url From 664f606c83afea8f99bee67c64c28063a7cdabd7 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:07:59 +0200 Subject: [PATCH 05/23] test: Add test_get_routes_match to RoutesTests and AsyncRoutesTests --- tests/tests.py | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tests/tests.py b/tests/tests.py index 54b115c..f7a8f3b 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -850,6 +850,7 @@ def setUp(self) -> None: ) self.client: Client = Client(auth=self.auth) self.domain: str = os.environ["DOMAIN"] + self.sender: str = os.environ["MESSAGES_FROM"] self.routes_data: dict[str, int | str | list[str]] = { "priority": 0, "description": "Sample route", @@ -864,7 +865,7 @@ def setUp(self) -> None: "priority": 2, } - # 'Routes quota (1) is exceeded for a free plan + # 'Routes quota (1) is exceeded for a free plan' def test_routes_create(self) -> None: params = {"skip": 0, "limit": 1} req1 = self.client.routes.get(domain=self.domain, filters=params) @@ -979,6 +980,21 @@ def test_routes_delete(self) -> None: self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) + def test_get_routes_match(self) -> None: + """Test to match address to route: Happy Path with valid data.""" + + query = {"address": self.sender} + req = self.client.routes_match.get(domain=self.domain, filters=query) + + self.assertEqual(req.status_code, 200) + self.assertIn("route", req.json()) + + expected_keys = ["actions", "created_at", "description", "expression", "id", "priority"] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json()["route"].keys()] # type: ignore[func-returns-value] + class WebhooksTests(unittest.TestCase): """Tests for Mailgun Webhooks API. @@ -2861,6 +2877,7 @@ async def asyncSetUp(self) -> None: ) self.client: AsyncClient = AsyncClient(auth=self.auth) self.domain: str = os.environ["DOMAIN"] + self.sender: str = os.environ["MESSAGES_FROM"] self.routes_data: dict[str, int | str | list[str]] = { "priority": 0, "description": "Sample route", @@ -2979,6 +2996,33 @@ async def test_routes_delete(self) -> None: self.assertEqual(req.status_code, 200) self.assertIn("message", req.json()) + async def test_get_routes_match(self) -> None: + """Test to match address to route: Happy Path with valid data.""" + params = {"skip": 0, "limit": 1} + query = {"address": self.sender} + req1 = await self.client.routes.get(domain=self.domain, filters=params) + print('len(req1.json()["items"]): ', len(req1.json()["items"])) + if len(req1.json()["items"]) > 0: + await self.client.routes.delete( + domain=self.domain, + route_id=req1.json()["items"][0]["id"], + ) + + await self.client.routes.create(domain=self.domain, data=self.routes_data) + req = await self.client.routes_match.get(domain=self.domain, filters=query) + else: + await self.client.routes.create(domain=self.domain, data=self.routes_data) + req = await self.client.routes_match.get(domain=self.domain, filters=query) + + self.assertEqual(req.status_code, 200) + self.assertIn("route", req.json()) + + expected_keys = ["actions", "created_at", "description", "expression", "id", "priority"] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json()["route"].keys()] # type: ignore[func-returns-value] + class AsyncWebhooksTests(unittest.IsolatedAsyncioTestCase): """Async tests for Mailgun Webhooks API using AsyncClient.""" From 021b8b66b250fc870ecd0054dbbadfc1963cc5f3 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:50:20 +0200 Subject: [PATCH 06/23] test: Add test_update_template_version_copy to TemplatesTests and AsyncTemplatesTests --- tests/tests.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/tests.py b/tests/tests.py index f7a8f3b..9796523 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1428,6 +1428,44 @@ def test_delete_version_template(self) -> None: self.assertEqual(req.status_code, 200) + def test_update_template_version_copy(self) -> None: + """Test to copy an existing version into a new version with the provided name: Happy Path with valid data.""" + data = {"comment": "An updated version comment"} + + req = self.client.templates.put( + domain=self.domain, + filters=data, + template_name="template.name1", + versions=True, + tag="v2", + copy=True, + new_tag="v3", + ) + + expected_keys = [ + "message", + "version", + "template", + ] + expected_template_keys = [ + "tag", + "template", + "engine", + "mjml", + "createdAt", + "comment", + "active", + "id", + "headers", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json().keys()] # type: ignore[func-returns-value] + self.assertIn("tag", req.json()["version"]) + self.assertIn("version has been copied", req.json()["message"]) + [self.assertIn(key, expected_template_keys) for key in req.json()["template"]] # type: ignore[func-returns-value] + @pytest.mark.skip( "Email Validation is only available through Mailgun paid plans, see https://www.mailgun.com/pricing/" @@ -3446,6 +3484,53 @@ async def test_delete_version_template(self) -> None: self.assertEqual(req.status_code, 200) + async def test_update_template_version_copy(self) -> None: + """Test to copy an existing version into a new version with the provided name: Happy Path with valid data.""" + await self.client.templates.create(data=self.post_template_data, domain=self.domain) + + await self.client.templates.create( + data=self.post_template_version_data, + domain=self.domain, + template_name=self.post_template_data["name"], + versions=True, + ) + + data = {"comment": "An updated version comment"} + + req = await self.client.templates.put( + domain=self.domain, + filters=data, + template_name="template.name1", + versions=True, + tag="v2", + copy=True, + new_tag="v3", + ) + + expected_keys = [ + "message", + "version", + "template", + ] + expected_template_keys = [ + "tag", + "template", + "engine", + "mjml", + "createdAt", + "comment", + "active", + "id", + "headers", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json().keys()] # type: ignore[func-returns-value] + self.assertIn("tag", req.json()["version"]) + self.assertIn("version has been copied", req.json()["message"]) + [self.assertIn(key, expected_template_keys) for key in req.json()["template"]] # type: ignore[func-returns-value] + @pytest.mark.skip( "Email Validation is only available through Mailgun paid plans, see https://www.mailgun.com/pricing/" From d05a4c7278c7ce32182d50727ebdacb02a2775f6 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:01:46 +0200 Subject: [PATCH 07/23] Remove domain envelopes handler and some users examples --- mailgun/client.py | 12 +----------- mailgun/examples/domain_examples.py | 18 ++---------------- mailgun/examples/users_examples.py | 24 ------------------------ mailgun/handlers/domains_handler.py | 11 ----------- mailgun/handlers/users_handler.py | 14 +------------- 5 files changed, 4 insertions(+), 75 deletions(-) diff --git a/mailgun/client.py b/mailgun/client.py index 981e8db..75c76b8 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -30,7 +30,6 @@ from mailgun.handlers.default_handler import handle_default from mailgun.handlers.domains_handler import handle_domainlist from mailgun.handlers.domains_handler import handle_domains -from mailgun.handlers.domains_handler import handle_envelopes from mailgun.handlers.domains_handler import handle_mailboxes_credentials from mailgun.handlers.domains_handler import handle_sending_queues from mailgun.handlers.email_validation_handler import handle_address_validate @@ -68,7 +67,6 @@ "dkim_selector": handle_domains, "web_prefix": handle_domains, "sending_queues": handle_sending_queues, - "envelopes": handle_envelopes, "mailboxes": handle_mailboxes_credentials, "ips": handle_ips, "ip_pools": handle_ippools, @@ -152,7 +150,7 @@ def __getitem__(self, key: str) -> tuple[Any, dict[str, str]]: }, "users": { "base": v5_base, - "keys": ["users", "me", "org"], + "keys": ["users", "me"], }, } @@ -175,14 +173,6 @@ def __getitem__(self, key: str) -> tuple[Any, dict[str, str]]: "keys": f"{part1}-{part2}".split("_"), }, headers - # TODO: verify if it works! - if "enevelopes" in key: - headers |= {"Content-Type": "application/json"} - return { - "base": v3_base, - "keys": key, - }, headers - if "users" in key: return { "base": v5_base, diff --git a/mailgun/examples/domain_examples.py b/mailgun/examples/domain_examples.py index 4e86378..f703cbe 100644 --- a/mailgun/examples/domain_examples.py +++ b/mailgun/examples/domain_examples.py @@ -187,20 +187,6 @@ def get_sending_queues() -> None: print(request.json()) -# TODO: Verify if it works -def delete_envelopes() -> None: - """ - Messages - DELETE /v3//envelopes - :return: - """ - req = client.envelopes.delete(domain=domain) - print(req) - print(req.json()) - - if __name__ == "__main__": - # add_domain() - # get_domains() - - delete_envelopes() + add_domain() + get_domains() diff --git a/mailgun/examples/users_examples.py b/mailgun/examples/users_examples.py index 7162db3..83e3edf 100644 --- a/mailgun/examples/users_examples.py +++ b/mailgun/examples/users_examples.py @@ -33,30 +33,6 @@ def get_user_details() -> None: print(req.json()) -# TODO: HTTP/2 403 {"message":"Incompatible key for this endpoint"} -# def get_own_user_details() -> None: -# """ -# GET /v5/users/me -# :return: -# """ -# user_id = "me" -# req = client.users.get(user_id=user_id) -# print(req) -# print(req.json()) - - -# TODO: HTTP/2 400 {'message': "User's account USER_ID is not in the organization ORG_ID"} -# def add_user_to_org() -> None: -# """ -# PUT /v5/users/{user_id}/org/{org_id} -# :return: -# """ -# user_id = "xxxxxxxxxxxxxxxxxxxxxxxx" -# org_id = "on.sinch.com" -# req = client.users_org.put(user_id=user_id, org_id=org_id) -# print(req) -# print(req.json()) - if __name__ == "__main__": get_users() get_user_details() diff --git a/mailgun/handlers/domains_handler.py b/mailgun/handlers/domains_handler.py index 35da828..1aa148c 100644 --- a/mailgun/handlers/domains_handler.py +++ b/mailgun/handlers/domains_handler.py @@ -99,17 +99,6 @@ def handle_sending_queues( return url["base"][:-1] + f"/{domain}/sending_queues" -# TODO: Verify if it works! -def handle_envelopes( - url: dict[str, Any], - domain: str | None, - _method: str | None, - **kwargs: Any, -) -> str | Any: - """Handle envelopes endpoint URL construction.""" - return url["base"][:-1] + f"/{domain}/envelopes" - - def handle_mailboxes_credentials( url: dict[str, Any], domain: str | None, diff --git a/mailgun/handlers/users_handler.py b/mailgun/handlers/users_handler.py index c0b5bb7..04b7d10 100644 --- a/mailgun/handlers/users_handler.py +++ b/mailgun/handlers/users_handler.py @@ -27,19 +27,7 @@ def handle_users( :return: final url for Users endpoint """ final_keys = path.join("/", *url["keys"]) if url["keys"] else "" - if "org_id" in kwargs: - url = ( - url["base"][:-1] - + "/" - + "users" - + "/" - + kwargs["user_id"] - + "/" - + "org" - + "/" - + kwargs["org_id"] - ) - elif "user_id" in kwargs and kwargs["user_id"] != "me": + if "user_id" in kwargs and kwargs["user_id"] != "me": url = url["base"][:-1] + "/" + "users" + "/" + kwargs["user_id"] elif "user_id" in kwargs and kwargs["user_id"] == "me": url = url["base"][:-1] + final_keys From 7f3415f4318c08a749ef433de7101cb7e86ffd23 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:00:37 +0200 Subject: [PATCH 08/23] test: Add test_put_mailboxes_credentials to DomainTests and AsyncDomainTests --- tests/tests.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/tests.py b/tests/tests.py index 9796523..eb33869 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -169,6 +169,30 @@ def test_put_domain_creds(self) -> None: self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) + @pytest.mark.order(2) + def test_put_mailboxes_credentials(self) -> None: + self.client.domains_credentials.create( + domain=self.domain, + data=self.post_domain_creds, + ) + name = "alice_bob" + req = self.client.mailboxes.put(domain=self.domain, login=f"{name}@{self.domain}") + + expected_keys = [ + "message", + "note", + "credentials", + ] + expected_credentials_keys = [ + f"{name}@{self.domain}", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json().keys()] # type: ignore[func-returns-value] + self.assertIn("Password changed", req.json()["message"]) + [self.assertIn(key, expected_credentials_keys) for key in req.json()["credentials"]] # type: ignore[func-returns-value] + @pytest.mark.order(3) def test_get_domain_list(self) -> None: req = self.client.domainlist.get() @@ -2266,6 +2290,32 @@ async def test_put_domain_creds(self) -> None: self.assertEqual(request.status_code, 200) self.assertIn("message", request.json()) + @pytest.mark.order(2) + async def test_put_mailboxes_credentials(self) -> None: + await self.client.domains_credentials.create( + domain=self.domain, + data=self.post_domain_creds, + ) + name = "alice_bob" + req = await self.client.mailboxes.put(domain=self.domain, login=f"{name}@{self.domain}") + print(req) + print(req.json()) + + expected_keys = [ + "message", + "note", + "credentials", + ] + expected_credentials_keys = [ + f"{name}@{self.domain}", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json().keys()] # type: ignore[func-returns-value] + self.assertIn("Password changed", req.json()["message"]) + [self.assertIn(key, expected_credentials_keys) for key in req.json()["credentials"]] # type: ignore[func-returns-value] + @pytest.mark.order(3) async def test_get_domain_list(self) -> None: req = await self.client.domainlist.get() From 9a3b739937377c67162ef3b884e37216ef69eb32 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:40:03 +0200 Subject: [PATCH 09/23] Improve users examples --- mailgun/examples/users_examples.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mailgun/examples/users_examples.py b/mailgun/examples/users_examples.py index 83e3edf..abae844 100644 --- a/mailgun/examples/users_examples.py +++ b/mailgun/examples/users_examples.py @@ -7,6 +7,7 @@ key: str = os.environ["APIKEY"] domain: str = os.environ["DOMAIN"] +mailgun_email = os.environ["MAILGUN_EMAIL"] client: Client = Client(auth=("api", key)) @@ -27,10 +28,15 @@ def get_user_details() -> None: GET /v5/users/{user_id} :return: """ - user_id = "xxxxxxxxxxxxxxxxxxxxxxxx" - req = client.users.get(user_id=user_id) - print(req) - print(req.json()) + query = {"role": "admin", "limit": "0", "skip": "0"} + req1 = client.users.get(filters=query) + users = req1.json()["users"] + + for user in users: + if mailgun_email == user["email"]: + req2 = client.users.get(user_id=user["id"]) + print(req2) + print(req2.json()) if __name__ == "__main__": From be8901d3809b69cea8d84a7a7f5e2fc445f16b4b Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:41:30 +0200 Subject: [PATCH 10/23] test: Add UsersTests and AsyncUsersTests --- tests/tests.py | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/tests/tests.py b/tests/tests.py index eb33869..5e89e27 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2151,6 +2151,100 @@ def test_get_account_tag_incorrect_url_without_limits_part(self) -> None: self.assertIn("not found", req.json()["error"]) +class UsersTests(unittest.TestCase): + """Tests for Mailgun Users API, https://api.mailgun.net/v5/users. + + This class provides setup functionality for tests involving + with authentication and client initialization handled + in `setUp`. Each test in this suite operates with the configured Mailgun client + instance to simulate API interactions. + + """ + + def setUp(self) -> None: + self.auth: tuple[str, str] = ( + "api", + os.environ["APIKEY"], + ) + self.client: Client = Client(auth=self.auth) + self.domain: str = os.environ["DOMAIN"] + self.mailgun_email = os.environ["MAILGUN_EMAIL"] + + def test_get_users(self) -> None: + query = {"role": "admin", "limit": "0", "skip": "0"} + req = self.client.users.get(filters=query) + + expected_keys = [ + "total", + "users", + ] + + expected_users_keys = [ + "account_id", + "activated", + "auth", + "email", + "email_details", + "github_user_id", + "id", + "is_disabled", + "is_master", + "metadata", + "migration_status", + "name", + "opened_ip", + "password_updated_at", + "preferences", + "role", + "salesforce_user_id", + "tfa_active", + "tfa_created_at", + "tfa_enabled", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] + [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + + def test_get_user_details(self) -> None: + query = {"role": "admin", "limit": "0", "skip": "0"} + req1 = self.client.users.get(filters=query) + users = req1.json()["users"] + + for user in users: + if self.mailgun_email == user["email"]: + req2 = self.client.users.get(user_id=user["id"]) + + expected_users_keys = [ + "account_id", + "activated", + "auth", + "email", + "email_details", + "github_user_id", + "id", + "is_disabled", + "is_master", + "metadata", + "migration_status", + "name", + "opened_ip", + "password_updated_at", + "preferences", + "role", + "salesforce_user_id", + "tfa_active", + "tfa_created_at", + "tfa_enabled", + ] + + self.assertIsInstance(req2.json(), dict) + self.assertEqual(req2.status_code, 200) + [self.assertIn(key, expected_users_keys) for key in req2.json()] # type: ignore[func-returns-value] + break + + # ============================================================================ # Async Test Classes (using AsyncClient and AsyncEndpoint) # ============================================================================ @@ -4220,6 +4314,100 @@ async def test_get_account_tag_incorrect_url_without_limits_part(self) -> None: self.assertIn("not found", req.json()["error"]) +class AsyncUsersTests(unittest.IsolatedAsyncioTestCase): + """Async tests for Mailgun Users API using AsyncClient.""" + + async def asyncSetUp(self) -> None: + self.auth: tuple[str, str] = ( + "api", + os.environ["APIKEY"], + ) + self.client: AsyncClient = AsyncClient(auth=self.auth) + self.domain: str = os.environ["DOMAIN"] + self.mailgun_email = os.environ["MAILGUN_EMAIL"] + + async def asyncTearDown(self) -> None: + await self.client.aclose() + + async def test_get_users(self) -> None: + query = {"role": "admin", "limit": "0", "skip": "0"} + req = await self.client.users.get(filters=query) + + expected_keys = [ + "total", + "users", + ] + + expected_users_keys = [ + "account_id", + "activated", + "auth", + "email", + "email_details", + "github_user_id", + "id", + "is_disabled", + "is_master", + "metadata", + "migration_status", + "name", + "opened_ip", + "password_updated_at", + "preferences", + "role", + "salesforce_user_id", + "tfa_active", + "tfa_created_at", + "tfa_enabled", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] + [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + + async def test_get_user_details(self) -> None: + """ + GET /v5/users/{user_id} + :return: + """ + query = {"role": "admin", "limit": "0", "skip": "0"} + req1 = await self.client.users.get(filters=query) + users = req1.json()["users"] + + for user in users: + if self.mailgun_email == user["email"]: + req2 = await self.client.users.get(user_id=user["id"]) + + expected_users_keys = [ + "account_id", + "activated", + "auth", + "email", + "email_details", + "github_user_id", + "id", + "is_disabled", + "is_master", + "metadata", + "migration_status", + "name", + "opened_ip", + "password_updated_at", + "preferences", + "role", + "salesforce_user_id", + "tfa_active", + "tfa_created_at", + "tfa_enabled", + ] + + self.assertIsInstance(req2.json(), dict) + self.assertEqual(req2.status_code, 200) + [self.assertIn(key, expected_users_keys) for key in req2.json()] # type: ignore[func-returns-value] + break + + class BounceClassificationTests(unittest.TestCase): """Tests for Mailgun Bounce Classification API, https://api.mailgun.net/v2/bounce-classification/metrics. From 7864cb3a129bb3820237535da3598176ccfa7da0 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:05:34 +0200 Subject: [PATCH 11/23] Improve handle_templates --- mailgun/handlers/templates_handler.py | 62 +++++++++------------------ 1 file changed, 21 insertions(+), 41 deletions(-) diff --git a/mailgun/handlers/templates_handler.py b/mailgun/handlers/templates_handler.py index 1a05707..4bd7437 100644 --- a/mailgun/handlers/templates_handler.py +++ b/mailgun/handlers/templates_handler.py @@ -30,44 +30,24 @@ def handle_templates( :raises: ApiError """ final_keys = path.join("/", *url["keys"]) if url["keys"] else "" - if "template_name" in kwargs: - if "versions" in kwargs: - if kwargs["versions"]: - if "tag" in kwargs and "copy" not in kwargs: - url = ( - url["base"] - + domain - + final_keys - + "/" - + kwargs["template_name"] - + "/versions/" - + kwargs["tag"] - ) - elif "tag" in kwargs and "copy" in kwargs and "new_tag" in kwargs: - url = ( - url["base"] - + domain - + final_keys - + "/" - + kwargs["template_name"] - + "/versions/" - + kwargs["tag"] - + "/copy/" - + kwargs["new_tag"] - ) - else: - url = ( - url["base"] - + domain - + final_keys - + "/" - + kwargs["template_name"] - + "/versions" - ) - else: - raise ApiError("Versions should be True or absent") - else: - url = url["base"] + domain + final_keys + "/" + kwargs["template_name"] - else: - url = url["base"] + domain + final_keys - return url + domain_url = f"{url['base']}{domain}{final_keys}" + + if "template_name" not in kwargs: + return domain_url + + template_url = domain_url + f"/{kwargs['template_name']}" + + if "versions" not in kwargs: + return template_url + + if not kwargs["versions"]: + raise ApiError("Versions should be True or absent") + + versions_url = template_url + "/versions" + + if "tag" in kwargs and "copy" not in kwargs: + return versions_url + f"/{kwargs['tag']}" + if "tag" in kwargs and "copy" in kwargs and "new_tag" in kwargs: + return versions_url + f"/{kwargs['tag']}/copy/{kwargs['new_tag']}" + + return versions_url From 7ce50e2a42c2a0f8465f970f143862969e3d18ac Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:50:01 +0200 Subject: [PATCH 12/23] Add get_own_user_details() to users examples --- mailgun/examples/users_examples.py | 38 ++++++++++++++++++++++++++++++ tests/tests.py | 35 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/mailgun/examples/users_examples.py b/mailgun/examples/users_examples.py index abae844..07a4916 100644 --- a/mailgun/examples/users_examples.py +++ b/mailgun/examples/users_examples.py @@ -23,6 +23,43 @@ def get_users() -> None: print(req.json()) +def get_own_user_details() -> None: + """ + GET /v5/users/me + + Please note, for the command("Get one's own user details") to be successful, you must use a Web type API key for the call. Private type API keys will Not work. + This below Call will generate a Web API key tied to the account user associated with the data inputted for the USER_EMAIL field and USER_ID values. This is returned by the API in the "secret":"API_KEY" key/value pair. This key will authenticate the call(Get one's own user details) made to the /v5/users/me endpoint, and will return the user's data associated with the USER_EMAIL and USER_ID values. + + Generate Web API Key: + curl -i -X POST \ + -u api:API_KEY \ + https://api.mailgun.net/v1/keys \ + -F email=USER_EMAIL \ + -F kind=web \ + -F expiration=SECONDS (Lifetime of the key in seconds) \ + -F role=ROLE \ + -F user_id=USER_ID \ + -F description=DESCRIPTION + + see https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/keys/api.(*keysapi).createkey-fm-7 + + Important Notes: + API_KEY - Private API Key + USER_EMAIL - The user login email address of the user that is trying to make the call to the /v5/users/me endpoint. + SECONDS - How many seconds you want the key to be active before it expires. + ROLE - The role of the API Key. This dictates what permissions the key has (https://help.mailgun.com/hc/en-us/articles/26016288026907-API-Key-Roles) + USER_ID - The internal User ID of the user that is trying to call the /v5/users/me endpoint. This is present in the URL in the address bar when viewing the User details in the GUI or in Admin. Both will show /users/USER_ID in the address. + DESCRIPTION - Description of the key. + + :return: + """ + secret: str = os.environ["SECRET"] + client_with_secret_key: Client = Client(auth=("api", secret)) + req = client_with_secret_key.users.get(user_id="me") + print(req) + print(req.json()) + + def get_user_details() -> None: """ GET /v5/users/{user_id} @@ -41,4 +78,5 @@ def get_user_details() -> None: if __name__ == "__main__": get_users() + get_own_user_details() get_user_details() diff --git a/tests/tests.py b/tests/tests.py index 5e89e27..8f11165 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2166,7 +2166,12 @@ def setUp(self) -> None: "api", os.environ["APIKEY"], ) + self.secret: tuple[str, str] = ( + "api", + os.environ["SECRET"], + ) self.client: Client = Client(auth=self.auth) + self.client_with_secret_key = Client(auth=self.secret) self.domain: str = os.environ["DOMAIN"] self.mailgun_email = os.environ["MAILGUN_EMAIL"] @@ -2207,6 +2212,36 @@ def test_get_users(self) -> None: [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + def test_own_user_details(self) -> None: + req = self.client_with_secret_key.users.get(user_id="me") + + expected_users_keys = [ + "account_id", + "activated", + "auth", + "email", + "email_details", + "github_user_id", + "id", + "is_disabled", + "is_master", + "metadata", + "migration_status", + "name", + "opened_ip", + "password_updated_at", + "preferences", + "role", + "salesforce_user_id", + "tfa_active", + "tfa_created_at", + "tfa_enabled", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_users_keys) for key in req.json()] # type: ignore[func-returns-value] + def test_get_user_details(self) -> None: query = {"role": "admin", "limit": "0", "skip": "0"} req1 = self.client.users.get(filters=query) From 0d653e9407936b76a8ea96672b3fe3dc2bb90bc9 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:42:11 +0200 Subject: [PATCH 13/23] docs: Add credentials and users examples to README --- README.md | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/README.md b/README.md index 178692b..4b479e3 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,12 @@ Check out all the resources and Python code examples in the official - [Create a single validation](#create-a-single-validation) - [Inbox placement](#inbox-placement) - [Get all inbox](#get-all-inbox) + - [Credentials](#credentials) + - [List Mailgun SMTP credential metadata for a given domain](#list-mailgun-smtp-credential-metadata-for-a-given-domain) + - [Create Mailgun SMTP credentials for a given domain](#create-mailgun-smtp-credentials-for-a-given-domain) + - [Users](#users) + - [Get users on an account](#get-users-on-an-account) + - [Get a user's details](#) - [License](#license) - [Contribute](#contribute) - [Contributors](#contributors) @@ -1307,6 +1313,72 @@ def get_all_inbox() -> None: print(req.json()) ``` +### Credentials + +#### List Mailgun SMTP credential metadata for a given domain + +```python +def get_credentials() -> None: + """ + GET /domains//credentials + :return: + """ + request = client.domains_credentials.get(domain=domain) + print(request.json()) +``` + +#### Create Mailgun SMTP credentials for a given domain + +```python +def post_credentials() -> None: + """ + POST /domains//credentials + :return: + """ + data = { + "login": f"alice_bob@{domain}", + "password": "test_new_creds123", # pragma: allowlist secret + } + request = client.domains_credentials.create(domain=domain, data=data) + print(request.json()) +``` + +### Users + +#### Get users on an account + +```python +def get_users() -> None: + """ + GET /v5/users + :return: + """ + query = {"role": "admin", "limit": "0", "skip": "0"} + req = client.users.get(filters=query) + print(req) + print(req.json()) +``` + +#### Get a user's details + +```python +def get_user_details() -> None: + """ + GET /v5/users/{user_id} + :return: + """ + mailgun_email = os.environ["MAILGUN_EMAIL"] + query = {"role": "admin", "limit": "0", "skip": "0"} + req1 = client.users.get(filters=query) + users = req1.json()["users"] + + for user in users: + if mailgun_email == user["email"]: + req2 = client.users.get(user_id=user["id"]) + print(req2) + print(req2.json()) +``` + ## License [Apache-2.0](https://choosealicense.com/licenses/apache-2.0/) From d1bcb1aec43f7cfc33b4504338dbce4dd087aa4f Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:43:33 +0200 Subject: [PATCH 14/23] Remove redundant print statements --- README.md | 2 -- mailgun/examples/credentials_examples.py | 1 - mailgun/examples/users_examples.py | 3 --- 3 files changed, 6 deletions(-) diff --git a/README.md b/README.md index 4b479e3..1b97d3b 100644 --- a/README.md +++ b/README.md @@ -1355,7 +1355,6 @@ def get_users() -> None: """ query = {"role": "admin", "limit": "0", "skip": "0"} req = client.users.get(filters=query) - print(req) print(req.json()) ``` @@ -1375,7 +1374,6 @@ def get_user_details() -> None: for user in users: if mailgun_email == user["email"]: req2 = client.users.get(user_id=user["id"]) - print(req2) print(req2.json()) ``` diff --git a/mailgun/examples/credentials_examples.py b/mailgun/examples/credentials_examples.py index 706a2c4..07b6f25 100644 --- a/mailgun/examples/credentials_examples.py +++ b/mailgun/examples/credentials_examples.py @@ -50,7 +50,6 @@ def put_mailboxes_credentials() -> None: """ req = client.mailboxes.put(domain=domain, login=f"alice_bob@{domain}") - print(req) print(req.json()) diff --git a/mailgun/examples/users_examples.py b/mailgun/examples/users_examples.py index 07a4916..172b122 100644 --- a/mailgun/examples/users_examples.py +++ b/mailgun/examples/users_examples.py @@ -19,7 +19,6 @@ def get_users() -> None: """ query = {"role": "admin", "limit": "0", "skip": "0"} req = client.users.get(filters=query) - print(req) print(req.json()) @@ -56,7 +55,6 @@ def get_own_user_details() -> None: secret: str = os.environ["SECRET"] client_with_secret_key: Client = Client(auth=("api", secret)) req = client_with_secret_key.users.get(user_id="me") - print(req) print(req.json()) @@ -72,7 +70,6 @@ def get_user_details() -> None: for user in users: if mailgun_email == user["email"]: req2 = client.users.get(user_id=user["id"]) - print(req2) print(req2.json()) From b283832ff480376a6ad49ff5005d2a466901cf7d Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:50:48 +0200 Subject: [PATCH 15/23] test: Add test_own_user_details to AsyncUsersTests; mark it xfail because of a dynamic secret env variable --- tests/tests.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/tests.py b/tests/tests.py index 8f11165..472b2d2 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2212,6 +2212,7 @@ def test_get_users(self) -> None: [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + @pytest.mark.xfail def test_own_user_details(self) -> None: req = self.client_with_secret_key.users.get(user_id="me") @@ -4357,7 +4358,12 @@ async def asyncSetUp(self) -> None: "api", os.environ["APIKEY"], ) + self.secret: tuple[str, str] = ( + "api", + os.environ["SECRET"], + ) self.client: AsyncClient = AsyncClient(auth=self.auth) + self.client_with_secret_key: AsyncClient = AsyncClient(auth=self.secret) self.domain: str = os.environ["DOMAIN"] self.mailgun_email = os.environ["MAILGUN_EMAIL"] @@ -4401,6 +4407,37 @@ async def test_get_users(self) -> None: [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + @pytest.mark.xfail + async def test_own_user_details(self) -> None: + req = await self.client_with_secret_key.users.get(user_id="me") + + expected_users_keys = [ + "account_id", + "activated", + "auth", + "email", + "email_details", + "github_user_id", + "id", + "is_disabled", + "is_master", + "metadata", + "migration_status", + "name", + "opened_ip", + "password_updated_at", + "preferences", + "role", + "salesforce_user_id", + "tfa_active", + "tfa_created_at", + "tfa_enabled", + ] + + self.assertIsInstance(req.json(), dict) + self.assertEqual(req.status_code, 200) + [self.assertIn(key, expected_users_keys) for key in req.json()] # type: ignore[func-returns-value] + async def test_get_user_details(self) -> None: """ GET /v5/users/{user_id} From e65b8498561c3a5035fa686ba7714429a36779c8 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:07:23 +0200 Subject: [PATCH 16/23] test: Add docstrings to users tests --- tests/tests.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/tests.py b/tests/tests.py index 472b2d2..a9fd641 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -2176,6 +2176,7 @@ def setUp(self) -> None: self.mailgun_email = os.environ["MAILGUN_EMAIL"] def test_get_users(self) -> None: + """Test to get account's users details: Happy Path with valid data.""" query = {"role": "admin", "limit": "0", "skip": "0"} req = self.client.users.get(filters=query) @@ -2212,6 +2213,13 @@ def test_get_users(self) -> None: [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + def test_get_user_invalid_url(self) -> None: + """Test to get account's users details: expected failure with invalid URL.""" + query = {"role": "admin", "limit": "0", "skip": "0"} + + with self.assertRaises(KeyError) as cm: + self.client.user.get(filters=query) + @pytest.mark.xfail def test_own_user_details(self) -> None: req = self.client_with_secret_key.users.get(user_id="me") @@ -2244,6 +2252,7 @@ def test_own_user_details(self) -> None: [self.assertIn(key, expected_users_keys) for key in req.json()] # type: ignore[func-returns-value] def test_get_user_details(self) -> None: + """Test to get account's users details: happy path.""" query = {"role": "admin", "limit": "0", "skip": "0"} req1 = self.client.users.get(filters=query) users = req1.json()["users"] @@ -2280,6 +2289,18 @@ def test_get_user_details(self) -> None: [self.assertIn(key, expected_users_keys) for key in req2.json()] # type: ignore[func-returns-value] break + def test_get_invalid_user_details(self) -> None: + """Test to get user details: expected failure with invalid user_id.""" + query = {"role": "admin", "limit": "0", "skip": "0"} + req1 = self.client.users.get(filters=query) + users = req1.json()["users"] + + for user in users: + if self.mailgun_email == user["email"]: + req2 = self.client.users.get(user_id="xxxxxxx") + + self.assertIsInstance(req2.json(), dict) + self.assertEqual(req2.status_code, 404) # ============================================================================ # Async Test Classes (using AsyncClient and AsyncEndpoint) @@ -4371,6 +4392,7 @@ async def asyncTearDown(self) -> None: await self.client.aclose() async def test_get_users(self) -> None: + """Test to get account's users: happy path.""" query = {"role": "admin", "limit": "0", "skip": "0"} req = await self.client.users.get(filters=query) @@ -4407,6 +4429,13 @@ async def test_get_users(self) -> None: [self.assertIn(key, expected_keys) for key in req.json()] # type: ignore[func-returns-value] [self.assertIn(key, expected_users_keys) for key in req.json()["users"][0]] # type: ignore[func-returns-value] + async def test_get_user_invalid_url(self) -> None: + """Test to get account's users details: expected failure with invalid URL.""" + query = {"role": "admin", "limit": "0", "skip": "0"} + + with self.assertRaises(KeyError) as cm: + await self.client.user.get(filters=query) + @pytest.mark.xfail async def test_own_user_details(self) -> None: req = await self.client_with_secret_key.users.get(user_id="me") @@ -4439,6 +4468,7 @@ async def test_own_user_details(self) -> None: [self.assertIn(key, expected_users_keys) for key in req.json()] # type: ignore[func-returns-value] async def test_get_user_details(self) -> None: + """Test to get user details: happy path.""" """ GET /v5/users/{user_id} :return: @@ -4479,6 +4509,19 @@ async def test_get_user_details(self) -> None: [self.assertIn(key, expected_users_keys) for key in req2.json()] # type: ignore[func-returns-value] break + async def test_get_invalid_user_details(self) -> None: + """Test to get user details: expected failure with invalid user_id.""" + query = {"role": "admin", "limit": "0", "skip": "0"} + req1 = await self.client.users.get(filters=query) + users = req1.json()["users"] + + for user in users: + if self.mailgun_email == user["email"]: + req2 = await self.client.users.get(user_id="xxxxxxx") + + self.assertIsInstance(req2.json(), dict) + self.assertEqual(req2.status_code, 404) + class BounceClassificationTests(unittest.TestCase): """Tests for Mailgun Bounce Classification API, https://api.mailgun.net/v2/bounce-classification/metrics. From 644ec4cf3a8afc4aa7cb88192f3980c135fdf990 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:11:17 +0200 Subject: [PATCH 17/23] test: Add docstrings to domain credentials tests --- tests/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/tests.py b/tests/tests.py index a9fd641..1f4e471 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -171,6 +171,7 @@ def test_put_domain_creds(self) -> None: @pytest.mark.order(2) def test_put_mailboxes_credentials(self) -> None: + """Test to update Mailgun SMTP credentials: Happy Path with valid data.""" self.client.domains_credentials.create( domain=self.domain, data=self.post_domain_creds, @@ -2443,6 +2444,7 @@ async def test_put_domain_creds(self) -> None: @pytest.mark.order(2) async def test_put_mailboxes_credentials(self) -> None: + """Test to update Mailgun SMTP credentials: Happy Path with valid data.""" await self.client.domains_credentials.create( domain=self.domain, data=self.post_domain_creds, From 8890868dc9d1da7d754dfe712aabe389914b6e62 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:13:56 +0200 Subject: [PATCH 18/23] ci: Update pre-commit hooks --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f2523f..c005201 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -104,21 +104,21 @@ repos: exclude: ^tests - repo: https://github.com/PyCQA/pylint - rev: v4.0.3 + rev: v4.0.4 hooks: - id: pylint args: - --exit-zero - repo: https://github.com/asottile/pyupgrade - rev: v3.21.1 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py310-plus, --keep-runtime-typing] - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: v0.14.5 + rev: v0.14.8 hooks: # Run the linter. - id: ruff-check @@ -139,7 +139,7 @@ repos: - id: refurb - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.18.2 + rev: v1.19.0 hooks: - id: mypy args: [--config-file=./pyproject.toml] @@ -153,7 +153,7 @@ repos: - id: pyright - repo: https://github.com/PyCQA/bandit - rev: 1.8.6 + rev: 1.9.2 hooks: - id: bandit args: ["-c", "pyproject.toml", "-r", "."] From b118d89e93b119c199e996dd3da7aacf3ec4e41d Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:21:25 +0200 Subject: [PATCH 19/23] docs: Update changelog --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e39ee..f01ae74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ We [keep a changelog.](http://keepachangelog.com/) ## [Unreleased] +## [1.5.0] - 2025-12-XX + +### Addedq + +- Add missing endpoints: + + - Add "users", "me" to the `users` key of special cases in the class `Config`. + - Add `handle_users` to `mailgun.handlers.users_handler` for parsing [Users API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/users). + - Add `handle_mailboxes_credentials()` to `mailgun.handlers.domains_handler` for parsing `Update Mailgun SMTP credentials` in [Credentials API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/credentials). + +- Examples: + + - Move credentials examples from `mailgun/examples/domain_examples.py` to `mailgun/examples/credentials_examples.py` and add a new example `put_mailboxes_credentials()`. + - Add the `get_routes_match()` example to `mailgun/examples/routes_examples.py` + - Add the `update_template_version_copy()` example to `mailgun/examples/templates_examples.py` + - Add `mailgun/examples/users_examples.py` + +- Docs: + + - Add `Credentials` and `Users` sections with examples to `README.md`. + - Add docstrings to the test class `UsersTests` & `AsyncUsersTests` and theirs methods. + +- Tests: + + - Add `test_put_mailboxes_credentials` to `DomainTests` and `AsyncDomainTests` + - Add `test_get_routes_match` to `RoutesTests` and `AsyncRoutesTests` + - Add `test_update_template_version_copy` to `TemplatesTests ` and `AsyncTemplatesTests ` + - Add classes `UsersTests` and `AsyncUsersTests` to `tests/tests.py`. + +### Changed + +- Update `handle_templates()` in `mailgun/handlers/templates_handler.py` to handle `new_tag` +- Update CI workflows: update `pre-commit` hooks to the latest versions. +- Replace spaces with tabs in `Makefile` + +### Pull Requests Merged + +- [PR_25](https://github.com/mailgun/mailgun-python/pull/25) - Add missing endpoints + ## [1.4.0] - 2025-11-20 ### Added From 915128c3625584826d5c27bffad7088c4596b898 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:23:33 +0200 Subject: [PATCH 20/23] docs: Update changelog --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c005201..e221f0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -145,6 +145,7 @@ repos: args: [--config-file=./pyproject.toml] additional_dependencies: - types-requests + - pytest-order exclude: ^mailgun/examples/ - repo: https://github.com/RobertCraigie/pyright-python From f9f9d0ac65b49e3b69aef9dedea8f6f5899412e9 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Tue, 9 Dec 2025 22:30:24 +0200 Subject: [PATCH 21/23] docs: Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f01ae74..8dd04c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,11 @@ We [keep a changelog.](http://keepachangelog.com/) ### Changed - Update `handle_templates()` in `mailgun/handlers/templates_handler.py` to handle `new_tag` + - Update CI workflows: update `pre-commit` hooks to the latest versions. + +- Modify `mypy`'s additional_dependencies in `.pre-commit-config.yaml` to suppress `error: Untyped decorator makes function` by adding `pytest-order` + - Replace spaces with tabs in `Makefile` ### Pull Requests Merged From 4d58c461bd674f53c776823176e8596783558040 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:58:52 +0200 Subject: [PATCH 22/23] Fix typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd04c4..fbe2178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ We [keep a changelog.](http://keepachangelog.com/) ## [1.5.0] - 2025-12-XX -### Addedq +### Added - Add missing endpoints: From eae5ffb09c51e6383bd46020b0df29bcb7198584 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:59:55 +0200 Subject: [PATCH 23/23] Go under Unreleased --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe2178..b8d2f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,6 @@ We [keep a changelog.](http://keepachangelog.com/) ## [Unreleased] -## [1.5.0] - 2025-12-XX - ### Added - Add missing endpoints: