Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 49 additions & 5 deletions mailgun/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,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.
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