Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 22 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -266,26 +269,21 @@ 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.

**The Simple Variant (Backward Compatible)**
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"})
```

Expand All @@ -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"})
```

Expand All @@ -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).
Expand All @@ -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
Expand Down
50 changes: 48 additions & 2 deletions mailgun/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
Expand Down
39 changes: 39 additions & 0 deletions tests/regression/test_config_url.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Loading