Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
45a229c
chore(scripts): make manage.sh executable (mode 100755)
skupriienko-mailgun Sep 8, 2026
3bdba9a
fix(security): harden security, URL sanitization, and idempotency has…
skupriienko-mailgun Sep 8, 2026
f10d086
fix(logging): harden RedactingFilter against unhashable types and for…
skupriienko-mailgun Sep 8, 2026
7874686
fix(builders): add seek/tell to ChunkedStreamer and serialize payload…
skupriienko-mailgun Sep 8, 2026
48bd089
fix(handlers): canonicalize domain aliases and block internal client …
skupriienko-mailgun Sep 8, 2026
72ff708
fix(endpoints): prevent parameter type drift and handle malformed str…
skupriienko-mailgun Sep 8, 2026
a58c801
test(integration): stabilize route teardown and assert user region_data
skupriienko-mailgun Sep 8, 2026
b85b530
test(property): add stream pointer, webhook TTL, and stateful lifecyc…
skupriienko-mailgun Sep 8, 2026
61b8eb4
test(fuzz): expand fuzz dictionary and add state transitions to state…
skupriienko-mailgun Sep 8, 2026
6403bd4
build(environment): update environment yaml files and pre-commit config
skupriienko-mailgun Sep 9, 2026
c7599da
fix(filters): prevent mutation during iteration and handle exploding …
skupriienko-mailgun Sep 11, 2026
3432c2a
test(fuzz): add crash corpus payload tokens to fuzz dictionary
skupriienko-mailgun Sep 11, 2026
51ad525
test: add comments to except clause
skupriienko-mailgun Sep 11, 2026
b643ba0
fix(routes): fix, update, and deprecate routes
skupriienko-mailgun Sep 14, 2026
2de90a4
fix(routes): revert changes in domain endpoints
skupriienko-mailgun Sep 15, 2026
0d409c4
test: add comments to except clauses, clean up unused imports
skupriienko-mailgun Sep 15, 2026
ec2222b
test: add comments to except clauses, clean up unused imports
skupriienko-mailgun Sep 15, 2026
7722550
docs(release): update changelog, readme, align runtime pinning across…
skupriienko-mailgun Sep 15, 2026
faaca71
docs(release): update performance benchmarks
skupriienko-mailgun Sep 15, 2026
0ca4341
Merge branch 'main' into fix/v1.9.1-hardening-and-stability
skupriienko-mailgun Sep 15, 2026
4b463eb
fix(security): harden header encoding, pagination error checking, and…
skupriienko-mailgun Sep 16, 2026
012fd04
fix(ext/pydantic): enforce CRLF validation on message subject schema
skupriienko-mailgun Sep 16, 2026
7a5e509
test(fuzz): enhance fuzz harnesses, execution timeouts, and dictionar…
skupriienko-mailgun Sep 16, 2026
67bf178
build(types): configure pyright to ignore missing third-party module …
skupriienko-mailgun Sep 16, 2026
1249eec
docs(release): finalize CHANGELOG for v1.9.1 release
skupriienko-mailgun Sep 16, 2026
d0d3507
Merge branch 'fix/v1.9.1-hardening-and-stability' of id_ed25519_mailw…
skupriienko-mailgun Sep 16, 2026
16cc5d6
test(fuzz): update fuzz.dict, fix timeout issue, add comments to exce…
skupriienko-mailgun Sep 17, 2026
5615524
test(fuzz): add comments to except clause
skupriienko-mailgun Sep 17, 2026
4dc88e6
test(fuzz): clean up unused imports, fix issues, add comments
skupriienko-mailgun Sep 17, 2026
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
3 changes: 2 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,9 @@ repos:
- id: slotscheck
name: "🔍 check · slotscheck"
additional_dependencies:
- requests>=2.32.5
- requests>=2.33.0
- typing-extensions>=4.7.1
- httpx2 >=2.7.0
- httpx>=0.24
- pytest>=9.0.3
- responses
Expand Down
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,40 @@

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

## [Unreleased]
## [Unreleased] (1.9.1)

### Security

- **SSRF and Scheme Whitelisting (CWE-918):** Enforced `ALLOWED_SCHEMES` validation (`https`, `http`) in `SecurityGuard.validate_mailgun_url()` and extended trusted hosts to include `.mailgun.com`.
- **Multi-Pass URL Encoding and Path Traversal (CWE-116 / CWE-22):** Hardened `SecurityGuard.sanitize_domain()` to iterate recursive `unquote()` checks up to 3 passes, apply NFKC Unicode normalization, and strip CRLF/slash sequences before evaluating `..` traversal sequences.
- **Replay Attack Window Verification (CWE-294):** Updated default webhook timestamp TTL in `SecurityGuard.verify_webhook()` to 900 seconds (15 minutes), rejecting expired requests while permitting `<= 0` to selectively bypass clock checks in test environments.
- **Pre-Flight Deliverability & XSS Detection (CWE-79 / CWE-400):** Extended `SpamGuard` with `_BLOCKED_TAGS` (`iframe`, `object`, `embed`, `applet`) and automated regex detection for inline event handlers (`on*`), and added pre-parsing length boundary checks.
- **Log Redaction Hardening (CWE-316 / CWE-117):** Increased `MAX_REDACTION_DEPTH` to 5 in `RedactingFilter` and added `frozenset` support. Preserved original `record.args` types (tuple, dict, list) to prevent string formatting crashes, wrapped object inspection in defensive guards, and added fallback handling for sets containing unhashable elements.
- **Payload Cycle & Recursion Protection:** Added `_deep_sanitize()` with a 50-level depth limit to `IdempotencyGuard` to prevent recursion overflow and handle cyclic data references.
- **Header Injection Boundaries (CWE-113 / RFC 9110):** Enforced ASCII control character checks across header keys and values in `SecurityGuard.sanitize_headers()` and guarded runtime telemetry calls with `sys in sys.modules`.

### Fixed

- **Stream Seek Pointer Displacement in Idempotency Checks:** Fixed `IdempotencyGuard.generate_key()` to record `stream.tell()` before reading file objects and restore the original pointer offset via `seek()` after computing hashes.
- **ChunkedStreamer Seeking & Pointer Handling:** Implemented `seek()` and `tell()` methods on `ChunkedStreamer`, strictly validated positive `chunk_size` values, and introduced explicit `_eof` state tracking to prevent duplicate reads.
- **Stream Pagination Query Parameter Type Drift:** Added `_cast_query_param()` in `BaseEndpoint` to preserve developer filter types (`int`, `float`, `bool`, `list`, `tuple`, `set`) during cursor pagination, and added guardrails for missing next cursors or non-dict payloads.
- **Safe Serialization in Builders:** Added `default=str` to `json.dumps()` across `MailgunMessageBuilder`, `MailgunTemplateBuilder`, and `BaseEndpoint` to safely serialize custom objects (e.g., UUID, datetime) without throwing `TypeError`.
- **Message Builder File Payload Mutation:** Returned a shallow copy of `self._files` in `MailgunMessageBuilder.build()` to prevent external consumers from mutating internal builder state.
- **Protected Attribute Routing Collisions:** Blocked routing fallbacks for `config` and `auth` in `Client.__getattr__()`, explicitly raising `AttributeError` instead of constructing invalid endpoints.
- **Domain Route Alias Canonicalization:** Normalized domain route aliases (`DOMAIN_ALIASES`) directly in `handle_domains()` and preserved literal `@` separators during credentials route generation.
- **Timeout Float Capacity Overflow:** Added `OverflowError` trapping during float conversion in `SecurityGuard.sanitize_timeout()`, mapping out-of-capacity numbers to standard `ValueError` exceptions.
- **Punycode Email Address Normalization:** Updated `SecurityGuard.normalize_domain()` to partition email addresses and apply IDNA Punycode encoding specifically to the host domain.

### Changed

- **Route Registry Definitions:** Registered `routes_match` under the v3 endpoint mapping, added `reputationanalytics_v2` under v2 prefix routes, and mapped `v1/spamtraps` deprecation warnings.
- **Dependency Specifications:** Synchronized `requests >=2.33.0` across environments, removed direct `conda-forge::` channel pinning for `httpx2` in `environment.yaml` and `environment-dev.yaml`, and aligned `.pre-commit-config.yaml` dependency constraints.
- **Linter Rule Alignment:** Removed the deprecated `missing-trailing-comma` selector from `pyproject.toml` to maintain compatibility with modern Ruff formatters.
- **Manage Script Permissions:** Updated `manage.sh` file mode to executable (`100755`).

### Pull Requests Merged

- PR #65: Hardening security and stability.

## v1.9.0 - 2026-08-04

Expand Down
11 changes: 11 additions & 0 deletions PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ String manipulation, dynamic imports (`importlib`), and sequential regex evaluat

______________________________________________________________________

## Benchmarks (v1.9.0 vs. v1.9.1)

| Metric | v1.9.0 (Baseline) | v1.9.1 (Current) | Delta / Notes |
| :-------------------------- | :---------------- | :--------------- | :------------------------------------- |
| **Cold Boot Time** | ~0.220 s | **~0.131 s** | **~40.5% Faster** (Optimized I/O read) |
| **Routing Speed (Mean)** | ~0.94 µs | **~0.95 µs** | **+9.5 ns** (Statistical parity) |
| **Async Throughput (Mean)** | ~3.06 ms | **~3.16 ms** | **+0.10 ms** (Type-cast validation) |
| **Sync Throughput (Mean)** | ~10.47 ms | **~10.15 ms** | **~3.1% Faster** (Tighter variance) |

*Note: Tests were executed on CPython 3.13 (Apple M4 Pro, Darwin ARM64-bit) in an isolated environment.*

## Benchmarks (v1.8.0 vs. v1.9.0)

| Metric | v1.8.0 (Baseline) | v1.9.0 (Current) | Delta / Notes |
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@ with Client(auth=("api", "key-super-secret-12345")) as client:

By default, the SDK relies on the underlying HTTP client's standard timeouts. To prevent uncontrolled resource consumption (CWE-400) in high-throughput production environments, you can enforce strict global timeouts.

Timeouts can be passed as a single `float` (seconds for both connect and read) or a tuple (connect_timeout, read_timeout):
Timeouts can be passed as a single `float` (seconds for both connect and read) or a tuple (connect_timeout, read_timeout).
Timeouts are strictly validated against `float` overflow and capped at 300 seconds to prevent thread starvation.

```python
import os
Expand Down Expand Up @@ -421,7 +422,7 @@ If the issue persists, please reach out to our support team.

The `Client`/`AsyncClient` utilize a dynamic routing engine but is heavily optimized for modern Developer Experience (DX).

- **Introspection**: Calling `dir(client)` or using autocomplete in IDEs like VS Code or PyCharm will automatically expose all available API endpoints (e.g., `client.messages`, `client.domains`, `client.bounces`).
- **Introspection**: Calling `dir(client)` or using autocomplete in IDEs like VS Code or PyCharm will automatically expose all available API endpoints (e.g., `client.messages`, `client.domains`, `client.bounces`). Internal configuration and state properties (`client.config`, `client.auth`) are strictly isolated from dynamic endpoint dispatching.
- **Security Guardrails**: If you accidentally print the client instance or an exception traceback occurs in your CI/CD logs, your API key is strictly redacted from memory dumps: (`'api', '***REDACTED***'`).
- **Performance**: JSON payloads are automatically minified before transit to save bandwidth on large batch requests, and internal route resolution is heavily cached in memory.

Expand Down Expand Up @@ -1806,6 +1807,8 @@ The SDK includes an active Interceptor engine that protects your application fro
If you attempt to call a legacy or deprecated Mailgun endpoint (such as the old `v3` address validation or `v1` bounce classification), the SDK will **not** break your code.
It will successfully execute the request but will emit a non-breaking Python `DeprecationWarning` and print a logger warning with instructions on which modern API endpoint to migrate to.

In `mailgun/routes.py`, `_DEPRECATED_ROUTES_PATTERNS` added explicit deprecation warnings for the legacy `/v1/spamtraps` family (`/v1/spamtraps`, `/v1/spamtraps/totals`, `/v1/spamtraps/filters`), directing users to the `v2` endpoint (`GET /v2/spamtraps`).

## Type Hinting

This SDK is fully type-hinted and complies with PEP 561 (`py.typed` included). Static type checkers (`mypy`, `pyright`) are enforced during CI checks.
Expand Down
4 changes: 2 additions & 2 deletions environment-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ dependencies:
# PyPi only
- python-build
# runtime deps
- requests >=2.32.5
- conda-forge::httpx2 >=2.7.0
- requests >=2.33.0
- httpx2 >=2.7.0
- httpx >=0.24.0
# extras
- fastapi
Expand Down
4 changes: 2 additions & 2 deletions environment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ dependencies:
# build & host deps
- pip
# runtime deps
- requests >=2.32.5
- conda-forge::httpx2 >=2.7.0
- requests >=2.33.0
- httpx2 >=2.7.0
- httpx >=0.24.0
# tests
- pytest >=9.0.3
Expand Down
72 changes: 62 additions & 10 deletions mailgun/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class ChunkedStreamer:
(like Serverless functions).
"""

__slots__ = ("_file", "_file_path", "chunk_size")
__slots__ = ("_eof", "_file", "_file_path", "chunk_size")

def __init__(
self,
Expand All @@ -40,14 +40,27 @@ def __init__(
safe_base_dir: str | Path | None = None,
chunk_size: int = CHUNK_SIZE,
) -> None:
"""Init chunked streamer."""
"""Init chunked streamer.

Args:
file_path: Path to the target attachment file.
safe_base_dir: Sandbox base directory for path validation.
chunk_size: Positive integer size in bytes per chunk.

Raises:
ValueError: If chunk_size is not a strictly positive integer.
"""
if chunk_size <= 0:
raise ValueError("chunk_size must be a strictly positive integer.")

# Provide a secure default base directory (e.g., current working directory) if None is passed
resolved_base_dir = safe_base_dir if safe_base_dir is not None else Path.cwd()
safe_path = SecurityGuard.validate_attachment_path(file_path, resolved_base_dir)

self._file_path = str(safe_path)
self.chunk_size = chunk_size
self._file: IO[bytes] | None = None
self._eof = False

def read(self, size: int) -> bytes:
"""File-like read method required by requests/httpx multipart encoders.
Expand All @@ -58,6 +71,8 @@ def read(self, size: int) -> bytes:
Returns:
A byte string containing the read data.
"""
if self._eof:
return b""
if self._file is None:
self._file = Path(self._file_path).open("rb") # ruff: ignore[open-file-with-context-handler]

Expand All @@ -66,10 +81,37 @@ def read(self, size: int) -> bytes:
# Auto-close the file descriptor as soon as EOF is reached.
# This guarantees teardown even if the HTTP library forgets to call .close().
if not chunk:
self._eof = True
self.close()
return b""

return chunk

def seek(self, offset: int, whence: int = 0) -> int:
"""Change the stream position to the given byte offset.

Args:
offset: The position to seek to relative to whence.
whence: Reference point (0 for start, 1 for current, 2 for end).

Returns:
The new absolute stream position in bytes.
"""
self._eof = False
if self._file is None:
self._file = Path(self._file_path).open("rb") # ruff: ignore[open-file-with-context-handler]
return self._file.seek(offset, whence)

def tell(self) -> int:
"""Return the current stream position in bytes.

Returns:
The current file pointer position.
"""
if self._file is None:
return 0
return self._file.tell()

def __iter__(self) -> Generator[bytes, None, None]:
"""Stream the file natively in chunks.

Expand Down Expand Up @@ -149,7 +191,7 @@ def __init__(self, from_email: str) -> None:
"""Initialize the builder with a sender email."""
self._payload: dict[str, Any] = {"from": from_email, "to": []}
self._files: list[tuple[str, FileTuple]] = []
self._idempotency_safe: bool = True # Enabled dy default
self._idempotency_safe: bool = True # Enabled by default
self._domain: str = from_email.rsplit("@", maxsplit=1)[-1] if "@" in from_email else ""

def add_recipient(self, email: str, recipient_type: str = "to") -> Self:
Expand Down Expand Up @@ -231,7 +273,7 @@ def add_custom_variable(self, key: str, value: Any) -> Self:
"""
# Complex types must be serialized
if isinstance(value, (dict, list)):
safe_val = json.dumps(value, separators=(",", ":"))
safe_val = json.dumps(value, separators=(",", ":"), default=str)
else:
safe_val = str(value)
self._payload[f"v:{key}"] = safe_val
Expand Down Expand Up @@ -323,7 +365,10 @@ def attach_stream(
return self

def attach_inline(
self, file_path: str | Path, cid: str | None = None, safe_base_dir: str | Path | None = None
self,
file_path: str | Path,
cid: str | None = None,
safe_base_dir: str | Path | None = None,
) -> Self:
"""Safely prepare and map an inline image attachment with an explicit Content-ID.

Expand Down Expand Up @@ -381,7 +426,7 @@ def set_template_variables(self, variables: dict[str, Any]) -> Self:
Returns:
The builder instance.
"""
self._payload["t:variables"] = json.dumps(variables, separators=(",", ":"))
self._payload["t:variables"] = json.dumps(variables, separators=(",", ":"), default=str)
return self

def set_recipient_variables(self, variables: dict[str, dict[str, Any]]) -> Self:
Expand All @@ -393,7 +438,11 @@ def set_recipient_variables(self, variables: dict[str, dict[str, Any]]) -> Self:
Returns:
The builder instance.
"""
self._payload["recipient-variables"] = json.dumps(variables, separators=(",", ":"))
self._payload["recipient-variables"] = json.dumps(
variables,
separators=(",", ":"),
default=str,
)
return self

def check_deliverability(self) -> dict[str, float | list[str] | bool] | SpamReport:
Expand Down Expand Up @@ -430,7 +479,9 @@ def build(self) -> tuple[dict[str, Any], list[tuple[str, FileTuple]] | None]:

if self._idempotency_safe and "h:X-Idempotency-Key" not in final_payload:
idempotency_key = IdempotencyGuard.generate_key(
self._domain, final_payload, self._files
self._domain,
final_payload,
self._files,
)
final_payload["h:X-Idempotency-Key"] = idempotency_key

Expand All @@ -442,7 +493,8 @@ def build(self) -> tuple[dict[str, Any], list[tuple[str, FileTuple]] | None]:
else:
del final_payload[key]

return final_payload, self._files or None
files_copy = list(self._files) if self._files else None
return final_payload, files_copy


class MailgunTemplateBuilder:
Expand Down Expand Up @@ -534,7 +586,7 @@ def set_headers(self, headers: dict[str, str]) -> Self:
Returns:
The builder instance.
"""
self._payload["headers"] = json.dumps(headers, separators=(",", ":"))
self._payload["headers"] = json.dumps(headers, separators=(",", ":"), default=str)
return self

def set_copy_requests(self, requests_list: list[dict[str, str]]) -> Self:
Expand Down
6 changes: 4 additions & 2 deletions mailgun/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def __getattr__(self, name: str) -> Any:
AttributeError: If the requested route is unknown or a magic Python method is invoked.
"""
# Protect Data Model: Ignore magic Python methods
if name.startswith("__") and name.endswith("__"):
if (name.startswith("__") and name.endswith("__")) or name in {"config", "auth"}:
msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
raise AttributeError(msg)

Expand Down Expand Up @@ -360,7 +360,9 @@ def _client(self) -> httpx.AsyncClient:
if "transport" not in kwargs:
limits = httpx.Limits(max_keepalive_connections=100, max_connections=100)
kwargs["transport"] = httpx.AsyncHTTPTransport(
retries=3, limits=limits, verify=ssl_context
retries=3,
limits=limits,
verify=ssl_context,
)

self._httpx_client = httpx.AsyncClient(**kwargs)
Expand Down
4 changes: 2 additions & 2 deletions mailgun/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ class Config:
# Use Mapping to denote read-only dictionary-like structures
_HEADERS_BASE: Final[Mapping[str, str]] = MappingProxyType({"User-agent": USER_AGENT})
_HEADERS_JSON: Final[Mapping[str, str]] = MappingProxyType(
{"User-agent": USER_AGENT, "Content-Type": "application/json"}
{"User-agent": USER_AGENT, "Content-Type": "application/json"},
)

# --- ENCAPSULATED ROUTING REGISTRIES ---
Expand All @@ -159,7 +159,7 @@ class Config:
_DOMAIN_ALIASES: Final[Mapping[str, str]] = MappingProxyType(routes.DOMAIN_ALIASES)

_DOMAIN_ENDPOINTS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType(
routes.DOMAIN_ENDPOINTS
routes.DOMAIN_ENDPOINTS,
)
_V1_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS["v1"])
_V3_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS["v3"])
Expand Down
Loading
Loading