Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ repos:
name: "🔒 security · Detect private keys"

- repo: https://github.com/commitizen-tools/commitizen
rev: v4.16.4
rev: v4.17.0
hooks:
- id: commitizen
name: "🌳 git · Validate commit message"
Expand Down Expand Up @@ -134,7 +134,7 @@ repos:
additional_dependencies: [".[toml]"]

- repo: https://github.com/semgrep/pre-commit
rev: 'v1.168.0'
rev: 'v1.171.0'
hooks:
- id: semgrep
name: "🔒 security · Static analysis (semgrep)"
Expand All @@ -159,7 +159,7 @@ repos:
files: ^\.github/workflows/.*\.ya?ml$

- repo: https://github.com/ariebovenberg/slotscheck
rev: v0.20.0
rev: v0.20.1
hooks:
- id: slotscheck
name: "🔍 check · slotscheck"
Expand All @@ -171,7 +171,7 @@ repos:
- responses

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.16.1
hooks:
- id: ruff-check
name: "🐍 lint · Check with Ruff"
Expand All @@ -190,7 +190,7 @@ repos:

# Python type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.1.0
rev: v2.3.0
hooks:
- id: mypy
name: "🐍 types · Check with mypy"
Expand Down
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

We [keep a changelog.](http://keepachangelog.com/)

## [Unreleased] (v1.9.0)
## [Unreleased]

## v1.9.0 - 2026-08-04

### Added

Expand All @@ -19,11 +21,27 @@ We [keep a changelog.](http://keepachangelog.com/)

- **[BREAKING CHANGE]** Dropped support for Python 3.10. The SDK now strictly requires Python 3.11 through 3.14.
- Purged the `typing-extensions` dependency from the package, replacing it with standard library `typing` equivalents (`Self`, `TypedDict`, `NotRequired`).
- **Credential Path Resolution**: Refactored `handle_domains` in `domains_handler.py` to correctly append credential login handles as path segments.
- **Smoke Test Resilience**: Updated `smoke_test.py` and test assertion suites to gracefully accommodate expected `400 Bad Request` responses from sandbox API keys on advanced live endpoints.

### Fixed

- **URL Canonicalization Aliases (`DOMAIN_ALIASES`)**: Fixed strict mapping in `routes.py` and `config.py` to transparently translate camel/hyphenated properties (`dkimauthority`, `dkimselector`, `webprefix`, `sendingqueues`) to their physical underscored Mailgun equivalents (`dkim_authority`, `dkim_selector`, etc.).
- Fixed strict typing issues, circular import risks, and unawaited coroutines in `AsyncClient.ping()`.
- Hardened `SecurityGuard.sanitize_timeout` against infinite/NaN injection (CWE-400) and cleaned up exception handling blocks to eliminate silent failure anti-patterns.
- Cleared all strict Ruff linter, Mypy type-checking, and docstring coverage errors across core handlers and tests.

### Pull Requests Merged

- [PR_51](https://github.com/mailgun/mailgun-python/pull/51) - Phase 2 Modernization: httpx2, Pydantic v2, Retry Engine, and Security Guards.
- [PR_52](https://github.com/mailgun/mailgun-python/pull/52) - Add Scorecard workflow for supply-chain security.
- [PR_53](https://github.com/mailgun/mailgun-python/pull/53) - build(deps): Bump ossf/scorecard-action from 2.4.1 to 2.4.3 in the minor-and-patch group.
- [PR_54](https://github.com/mailgun/mailgun-python/pull/54) - build(deps): Bump actions/checkout from 4.2.2 to 7.0.1.
- [PR_55](https://github.com/mailgun/mailgun-python/pull/55) - build(deps): Bump actions/upload-artifact from 4.6.1 to 7.0.1.
- [PR_56](https://github.com/mailgun/mailgun-python/pull/56) - build(deps): Bump github/codeql-action/upload-sarif from 3.37.2 to 4.37.1
- [PR_57](https://github.com/mailgun/mailgun-python/pull/57) - Release 1.9.0.
- [PR_58](https://github.com/mailgun/mailgun-python/pull/58) - build(deps): Bump the minor-and-patch group with 2 updates.
- [PR_59](https://github.com/mailgun/mailgun-python/pull/59) - build(deps): Bump actions/setup-python from 6.3.0 to 7.0.0.

## v1.8.0 - 2026-07-20

Expand Down
1 change: 0 additions & 1 deletion conda.recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ requirements:
- httpx2 >=2.7.0
- httpx >=0.24
- requests >=2.33.0
- typing-extensions >=4.7.1 # [py<311]

test:
imports:
Expand Down
2 changes: 1 addition & 1 deletion mailgun/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.8.0"
__version__ = "1.9.0"
13 changes: 8 additions & 5 deletions mailgun/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def read(self, size: int) -> bytes:
A byte string containing the read data.
"""
if self._file is None:
self._file = Path(self._file_path).open("rb") # noqa: SIM115
self._file = Path(self._file_path).open("rb") # ruff: ignore[open-file-with-context-handler]

chunk = self._file.read(size)

Expand All @@ -79,7 +79,7 @@ def __iter__(self) -> Generator[bytes, None, None]:
try:
# Sync the iterator with the class-level _file descriptor
if self._file is None:
self._file = Path(self._file_path).open("rb") # noqa: SIM115
self._file = Path(self._file_path).open("rb") # ruff: ignore[open-file-with-context-handler]

while True:
chunk = self._file.read(self.chunk_size)
Expand Down Expand Up @@ -408,13 +408,16 @@ def check_deliverability(self) -> dict[str, float | list[str] | bool] | SpamRepo

return SpamGuard.check_html(html_payload)

def set_idempotency_safe(self, *, enabled: bool) -> Self:
"""Allows you to force-disable the automatic generation of the idempotency key.
def set_idempotency_safe(self, *, safe: bool = True) -> Self:
"""Enable or disable automatic idempotency key generation.

Args:
safe: Whether to automatically generate and attach an X-Idempotency-Key.

Returns:
The builder instance.
"""
self._idempotency_safe = enabled
self._idempotency_safe = safe
return self

def build(self) -> tuple[dict[str, Any], list[tuple[str, FileTuple]] | None]:
Expand Down
4 changes: 2 additions & 2 deletions mailgun/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ def ping(self) -> bool:
try:
# Query the domains endpoint with a strict limit of 1
response = self.domains.get(filters={"limit": 1})
except Exception: # noqa: BLE001 - Explicitly failing closed on readiness probe
except Exception: # ruff: ignore[blind-except] - Explicitly failing closed on readiness probe
return False
else:
if hasattr(response, "status_code"):
Expand Down Expand Up @@ -431,7 +431,7 @@ async def ping(self) -> bool:
try:
# Query the domains endpoint with a strict limit of 1
response = await self.domains.get(filters={"limit": 1})
except Exception: # noqa: BLE001 - Explicitly failing closed on readiness probe
except Exception: # ruff: ignore[blind-except] - Explicitly failing closed on readiness probe
return False
else:
if hasattr(response, "status_code"):
Expand Down
41 changes: 32 additions & 9 deletions mailgun/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def _get_cached_route_data(clean_key: str) -> dict[str, Any]:

Returns:
A dictionary containing versioning and path data for the route.

Raises:
KeyError: If an invalid API endpoint requested.
"""
# Resolve virtual property aliases before processing
clean_key = routes.ROUTE_ALIASES.get(clean_key, clean_key)
Expand All @@ -46,19 +49,39 @@ def _get_cached_route_data(clean_key: str) -> dict[str, Any]:
return {"version": version, "keys": tuple(route_keys)}

route_parts = clean_key.split("_")
primary_resource = route_parts[0]

if primary_resource == "domains":
# Explicitly check for exact matches in PREFIX_ROUTES before splitting
if clean_key in routes.PREFIX_ROUTES:
version, suffix, key_override = routes.PREFIX_ROUTES[clean_key]
return {
"version": version,
"suffix": suffix,
"keys": (key_override or clean_key,),
}

# Intercept domain endpoints BEFORE the prefix router swallows them!
# This ensures Config._resolve_domains_route is triggered to apply DOMAIN_ALIASES.
if route_parts[0] == "domains":
return {"type": "domain", "parts": tuple(route_parts)}

if primary_resource in routes.PREFIX_ROUTES:
version, suffix, key_override = routes.PREFIX_ROUTES[primary_resource]
final_parts = route_parts.copy()
# Fallback to prefix matching
if route_parts[0] in routes.PREFIX_ROUTES:
version, suffix, key_override = routes.PREFIX_ROUTES[route_parts[0]]
keys = list(route_parts)
if key_override:
final_parts[0] = key_override
return {"version": version, "suffix": suffix, "keys": tuple(final_parts)}
keys[0] = key_override
return {
"version": version,
"suffix": suffix,
"keys": tuple(keys),
}

return {"version": APIVersion.V3.value, "keys": tuple(route_parts)}
# Explicitly reject typo'd endpoints and provide available options for DX
available = sorted(list(routes.EXACT_ROUTES.keys()) + list(routes.PREFIX_ROUTES.keys()))
error_msg = (
f"Invalid API endpoint requested: {clean_key}. Available endpoints: {', '.join(available)}"
)
raise KeyError(error_msg)


class APIVersion(StrEnum):
Expand Down Expand Up @@ -107,7 +130,7 @@ def calculate_delay(self, attempt: int) -> float:
A float representing the sleep delay in seconds before the next attempt.
"""
backoff = min(self.max_delay, self.base_delay * (2**attempt))
return random.uniform(0, backoff) # noqa: S311 - Randomness used for network jitter, not crypto.
return random.uniform(0, backoff) # ruff: ignore[suspicious-non-cryptographic-random-usage]


class Config:
Expand Down
Loading
Loading