diff --git a/CHANGELOG.md b/CHANGELOG.md index 2191456..c916ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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). +- 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 ### Added 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 diff --git a/mailgun/client.py b/mailgun/client.py index f00d4e2..6473162 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -513,13 +513,59 @@ 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.api_url: str = self._normalize_api_url(base_url_input) + 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: + """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: str = 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/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" ] diff --git a/tests/regression/test_config_url.py b/tests/regression/test_config_url.py new file mode 100644 index 0000000..e705c26 --- /dev/null +++ b/tests/regression/test_config_url.py @@ -0,0 +1,39 @@ +import pytest +from mailgun.client import Config + +@pytest.mark.parametrize( + "api_url", + [ + "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=["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: + """ + 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. + 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