From 094ac1f3ba93768ce00b3bd00e621695d7af7bcf Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:19:13 +0300 Subject: [PATCH 1/9] fix(client): Fix #40: Prevent version suffix duplication in api_url during f-string concat --- mailgun/client.py | 7 ++++++- tests/regression/test_config_url.py | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 tests/regression/test_config_url.py diff --git a/mailgun/client.py b/mailgun/client.py index f00d4e2..dacaf0a 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -165,7 +165,12 @@ def sanitize_api_url(cls, raw_url: str) -> str: ) logger.warning(msg) - return raw_url.rstrip("/") + # 1. Strip the trailing slash + safe_url = raw_url.rstrip("/") + + # 2. Bug Fix #40: Strip any trailing version segments (e.g., /v3, /v4) + # to prevent /vX/vX duplication during f-string concatenation + return re.sub(r"/v\d+$", "", safe_url, flags=re.IGNORECASE) @classmethod def validate_auth(cls, auth: tuple[str, str] | None) -> tuple[str, str] | None: diff --git a/tests/regression/test_config_url.py b/tests/regression/test_config_url.py new file mode 100644 index 0000000..1d836d3 --- /dev/null +++ b/tests/regression/test_config_url.py @@ -0,0 +1,22 @@ +import pytest +from mailgun.client import Config + +@pytest.mark.parametrize( + "api_url", + [ + "https://api.eu.mailgun.net/v3", + "https://api.eu.mailgun.net/v3/", + ], + ids=["without_trailing_slash", "with_trailing_slash"] +) +def test_api_url_with_trailing_version(api_url: str) -> None: + """ + Regression test for #40: v1.7.0 silently broke api_url values containing /v3. + Tests that an explicitly passed version segment does not result in duplication. + """ + config = Config(api_url=api_url) + + # Before the fix, this evaluated to 'https://api.eu.mailgun.net/v3/v3' and failed. + assert config._baked_urls["v3"] == "https://api.eu.mailgun.net/v3", ( + f"URL contains duplicated version segments for input: '{api_url}'" + ) From 94722a94c82d42eb42cc076f657954703ea69774 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <61395455+skupriienko@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:20:03 +0300 Subject: [PATCH 2/9] docs: Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2191456..a072bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ We [keep a changelog.](http://keepachangelog.com/) ## [Unreleased] +### Fixed + +- **Config**: Fixed a URL routing regression where explicitly passing a version suffix (like `/v3`) in the `api_url` caused duplicate version paths (`/v3/v3`) resulting in 404s (#40). + ## [1.7.0] - 2026-05-01 ### Added From 5cd693d10956d67120b75324c7a2ab7c9029ee87 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:52:12 +0300 Subject: [PATCH 3/9] fix(client): Fix #40: Prevent version suffix duplication in api_url during f-string concat --- mailgun/client.py | 61 +++++++++++++++++++++++------ tests/regression/test_config_url.py | 25 ++++++++++-- tests/unit/test_config.py | 42 ++++++++++++++++++++ 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/mailgun/client.py b/mailgun/client.py index dacaf0a..e6f9c88 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -165,12 +165,7 @@ def sanitize_api_url(cls, raw_url: str) -> str: ) logger.warning(msg) - # 1. Strip the trailing slash - safe_url = raw_url.rstrip("/") - - # 2. Bug Fix #40: Strip any trailing version segments (e.g., /v3, /v4) - # to prevent /vX/vX duplication during f-string concatenation - return re.sub(r"/v\d+$", "", safe_url, flags=re.IGNORECASE) + return raw_url.rstrip("/") @classmethod def validate_auth(cls, auth: tuple[str, str] | None) -> tuple[str, str] | None: @@ -518,12 +513,56 @@ def __init__(self, api_url: str | None = None) -> None: """ self.ex_handler: bool = True base_url_input: str = api_url or self.DEFAULT_API_URL - self.api_url: str = SecurityGuard.sanitize_api_url(base_url_input) - # PRE-BAKE: Cache base URLs for all versions at once - self._baked_urls: Final[dict[str, str]] = { - ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion - } + self.api_url = self._normalize_api_url(base_url_input) + + self._baked_urls = {ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion} + + @staticmethod + def _normalize_api_url(raw_url: str) -> str: + """Validates and normalizes the base API URL. + + Ensures no explicit versions are embedded in the path that would break + dynamic f-string routing. + + Args: + raw_url: The raw base URL string provided by the user. + + Returns: + The sanitized and normalized API URL string. + + Raises: + ApiError: If an ambiguous API version is found embedded within the custom path. + """ + safe_url = SecurityGuard.sanitize_api_url(raw_url) + + parsed = urlparse(safe_url) + path_segments = [seg for seg in parsed.path.split("/") if seg] + + known_versions = {ver.value for ver in APIVersion} + + # Ambiguity & Backward Compatibility Check + for i, segment in enumerate(path_segments): + if segment in known_versions: + is_last_segment = i == len(path_segments) - 1 + + if is_last_segment: + safe_url = safe_url.removesuffix(f"/{segment}") + logger.warning( + "Semantic Configuration Warning: 'api_url' should be the base domain. The trailing '%s' was stripped to prevent routing duplication.", + segment, + ) + else: + # Fail-Fast: The version is trapped inside a complex path + msg = ( + f"Ambiguous API URL configuration: '{raw_url}'.\n" + f"The SDK automatically handles version routing, but an explicit " + f"version ('{segment}') was found embedded within your custom path. " + f"Please provide only the base host (e.g., 'https://api.mailgun.net')." + ) + raise ApiError(msg) + + return safe_url def _build_base_url(self, version: APIVersion | str, suffix: str = "") -> str: """Construct API URL with precise slash control to prevent 404s. diff --git a/tests/regression/test_config_url.py b/tests/regression/test_config_url.py index 1d836d3..e705c26 100644 --- a/tests/regression/test_config_url.py +++ b/tests/regression/test_config_url.py @@ -6,8 +6,14 @@ [ "https://api.eu.mailgun.net/v3", "https://api.eu.mailgun.net/v3/", + "https://api.eu.mailgun.net/v4", + "https://api.eu.mailgun.net/v4/", ], - ids=["without_trailing_slash", "with_trailing_slash"] + ids=["v3_without_trailing_slash", + "v3_with_trailing_slash", + "v4_without_trailing_slash", + "v4_with_trailing_slash", + ] ) def test_api_url_with_trailing_version(api_url: str) -> None: """ @@ -17,6 +23,17 @@ def test_api_url_with_trailing_version(api_url: str) -> None: config = Config(api_url=api_url) # Before the fix, this evaluated to 'https://api.eu.mailgun.net/v3/v3' and failed. - assert config._baked_urls["v3"] == "https://api.eu.mailgun.net/v3", ( - f"URL contains duplicated version segments for input: '{api_url}'" - ) + if "mailgun" in api_url: + assert config._baked_urls["v3"] == "https://api.eu.mailgun.net/v3" + assert config._baked_urls["v4"] == "https://api.eu.mailgun.net/v4" + + +def test_api_url_emits_semantic_warning_on_version_suffix(caplog: pytest.LogCaptureFixture) -> None: + import logging + + with caplog.at_level(logging.WARNING): + config = Config(api_url="https://api.eu.mailgun.net/v3/") + + assert config._baked_urls["v3"] == "https://api.eu.mailgun.net/v3" + assert "Semantic Configuration Warning" in caplog.text + assert "should be the base domain" in caplog.text diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 44158f3..90816a1 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -3,6 +3,7 @@ import pytest from unittest.mock import MagicMock, patch +from mailgun import ApiError from mailgun.client import Config from mailgun.client import SecurityGuard @@ -211,3 +212,44 @@ def test_build_base_url_prevents_double_slash(self) -> None: assert result_no_suffix == "https://api.mailgun.net/v3/" # The critical check: ensure no double slashes were formed assert "//domains" not in result_with_suffix + + def test_normalize_api_url_clean_url(self) -> None: + """Verify that a clean base URL passes through without modification.""" + clean_url = "https://api.mailgun.net" + result = Config._normalize_api_url(clean_url) + + assert result == "https://api.mailgun.net" + + @patch("mailgun.client.logger.warning") + def test_normalize_api_url_strips_trailing_version(self, mock_warn: MagicMock) -> None: + """ + Verify the backward compatibility branch: + A trailing version is stripped and a developer warning is logged. + """ + trailing_url = "https://api.mailgun.net/v3/" + + result = Config._normalize_api_url(trailing_url) + + # 1. The suffix should be mathematically stripped + assert result == "https://api.mailgun.net" + + # 2. A semantic warning must be emitted for a developer + mock_warn.assert_called_once() + warning_msg = mock_warn.call_args[0][0] + assert "Semantic Configuration Warning" in warning_msg + assert "stripped to prevent routing duplication" in warning_msg + + def test_normalize_api_url_raises_on_embedded_version(self) -> None: + """ + Verify the Fail-Fast branch: + An embedded version (e.g., /v3/sandbox) raises a strict ApiError. + """ + ambiguous_url = "https://api.mailgun.net/v3/sandbox" + + with pytest.raises(ApiError) as exc_info: + Config._normalize_api_url(ambiguous_url) + + error_msg = str(exc_info.value) + assert "Ambiguous API URL configuration" in error_msg + assert "embedded within your custom path" in error_msg + assert "Please provide only the base host" in error_msg From 11865f3591ae5f0f742075f8f45048a377a3ed9f Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:53:43 +0300 Subject: [PATCH 4/9] build: Exclude tests from sdist --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ef183a0..6b36937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,8 @@ urls."Repository" = "https://github.com/mailgun/mailgun-python" py-modules = [ "mailgun._version" ] [tool.setuptools.packages.find] -include = [ "mailgun", "mailgun.handlers", "mailgun.*", "tests", "tests.*" ] +include = [ "mailgun", "mailgun.handlers", "mailgun.*" ] +exclude = [ "tests", "tests.*" ] [tool.setuptools.package-data] mailgun = [ "py.typed", "*.pyi" ] From d68e68271506b3fbff1bd07310eac6a4060d4d47 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:31:47 +0300 Subject: [PATCH 5/9] docs(readme): Update usage examples and address the issue #40 --- README.md | 45 ++++++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index eae1529..46d8728 100644 --- a/README.md +++ b/README.md @@ -210,11 +210,9 @@ The Mailgun API is part of the Sinch family and enables you to send, track, and ### Base URL All API calls referenced in our documentation start with a base URL. Mailgun allows the ability to send and receive -email in both US and EU regions. Be sure to use the appropriate base URL based on which region you have created for your -domain. +email in both US and EU regions. -It is also important to note that Mailgun uses URI versioning for our API endpoints, and some endpoints may have -different versions than others. Please reference the version stated in the URL for each endpoint. +If you are using a proxy or a regional endpoint (such as the EU infrastructure), you can configure a custom `api_url` during initialization. For domains created in our US region the base URL is: @@ -228,11 +226,16 @@ For domains created in our EU region the base URL is: https://api.eu.mailgun.net/ ``` -Your Mailgun account may contain multiple sending domains. To avoid passing the domain name as a query parameter, most -API URLs must include the name of the domain you are interested in: +**⚠️ Important:** The `api_url` parameter must strictly be the **base host only** (e.g., `https://api.eu.mailgun.net`). Do **not** append API version paths (like `/v3` or `/v4`) to this string. The SDK's data-driven routing engine automatically appends the correct, endpoint-specific API version under the hood. -```sh -https://api.mailgun.net/v3/mydomain.com +```python +import os +from mailgun.client import Client + +# Pass ONLY the base domain +with Client(auth=("api", os.environ["APIKEY"]), api_url="https://api.eu.mailgun.net") as client: + # do someshings + pass ``` ### Authentication @@ -266,18 +269,10 @@ Synchronous vs Asynchronous Client. ### Client -Initialize your [Mailgun](http://www.mailgun.com/) client: - -```python -from mailgun.client import Client -import os - -auth = ("api", os.environ["APIKEY"]) -client = Client(auth=auth) -``` - #### Client Lifecycle & Resource Management +Initialize your [Mailgun](http://www.mailgun.com/) client. + > [!TIP] > **New in v1.7.0:** The SDK now utilizes connection pooling (`requests.Session`) under the hood to dramatically improve performance by reusing TLS connections. @@ -285,7 +280,10 @@ client = Client(auth=auth) For simple scripts, lambdas, or single-request apps, you can initialize and use the client directly. Python's garbage collector will eventually clean up the connection. ```python -client = Client(auth=("api", "KEY")) +import os +from mailgun.client import Client + +client = Client(auth=("api", os.environ["APIKEY"])) client.messages.create(data={"to": "user@example.com"}) ``` @@ -298,8 +296,11 @@ If you are running long-lived applications (like Celery workers, web servers, or For production applications, \**always use the client as a Context Manager* (`with`) or explicitly call `client.close()`. This ensures deterministic release of TCP connection pools. ```python +import os +from mailgun.client import Client + # Sockets are safely managed and closed automatically -with Client(auth=("api", "KEY")) as client: +with Client(auth=("api", os.environ["APIKEY"])) as client: client.messages.create(data={"to": "user@example.com"}) ``` @@ -308,7 +309,7 @@ with Client(auth=("api", "KEY")) as client: By default, the SDK routes traffic to the US servers (`https://api.mailgun.net`). If you are operating in the EU, you can override the base URL during initialization: ```python -client = Client(auth=("api", "KEY"), api_url="https://api.eu.mailgun.net") +client = Client(auth=("api", os.environ["APIKEY"]), api_url="https://api.eu.mailgun.net") ``` The SDK also implements Timeouts by default `read=60.0s` (but can take a tuple with connect/read `(10.0, 60.0)` to ensure your application fails-fast during network partitions but remains patient while Mailgun processes heavy analytical queries). @@ -322,8 +323,6 @@ import asyncio import os from mailgun.client import AsyncClient -auth = ("api", os.environ["APIKEY"]) - async def main(): # BEST PRACTICE: Use the async context manager for safe connection pooling From cefb617964cfa8ca8b1a0ce229159b416cb5e7d9 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:54:38 +0300 Subject: [PATCH 6/9] docs: Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a072bd4..c916ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ We [keep a changelog.](http://keepachangelog.com/) ### Fixed - **Config**: Fixed a URL routing regression where explicitly passing a version suffix (like `/v3`) in the `api_url` caused duplicate version paths (`/v3/v3`) resulting in 404s (#40). +- Fixed the usage's example in `README.md` of the `api_url` parameter that must strictly be the **base host only**. ## [1.7.0] - 2026-05-01 From 89a5309b09a6c0a2a55dc1cd3906153bcfbcd686 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:33:37 +0300 Subject: [PATCH 7/9] style: Add type hints --- mailgun/client.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mailgun/client.py b/mailgun/client.py index e6f9c88..6473162 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -514,9 +514,11 @@ def __init__(self, api_url: str | None = None) -> None: self.ex_handler: bool = True base_url_input: str = api_url or self.DEFAULT_API_URL - self.api_url = self._normalize_api_url(base_url_input) + self.api_url: str = self._normalize_api_url(base_url_input) - self._baked_urls = {ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion} + self._baked_urls: Final[dict[str, str]] = { + ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion + } @staticmethod def _normalize_api_url(raw_url: str) -> str: @@ -534,7 +536,7 @@ def _normalize_api_url(raw_url: str) -> str: Raises: ApiError: If an ambiguous API version is found embedded within the custom path. """ - safe_url = SecurityGuard.sanitize_api_url(raw_url) + safe_url: str = SecurityGuard.sanitize_api_url(raw_url) parsed = urlparse(safe_url) path_segments = [seg for seg in parsed.path.split("/") if seg] From e411d554a77803f52335e7a42099af94d1de5b80 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:44:37 +0300 Subject: [PATCH 8/9] chore: trigger CI From 065bd809d2d22f93806f9223692efb7cb9b09900 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:59:09 +0300 Subject: [PATCH 9/9] chore: trigger CI