Skip to content
Draft
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
14 changes: 12 additions & 2 deletions 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.8.0)
## [Unreleased]

## v1.8.0 - 2026-07-XX

### 🌟 Top Highlights (The "Big Wins")

Expand Down Expand Up @@ -48,6 +50,14 @@ We [keep a changelog.](http://keepachangelog.com/)

- **Legacy Utilities:** Deleted `mailgun/handlers/utils.py`. All path sanitization and validation logic has been strictly centralized into the `SecurityGuard` class in `mailgun/security.py`.

### Pull Requests Merged

- [PR_44](https://github.com/mailgun/mailgun-python/pull/44) - Refactor SDK architecture and harden security guardrails.
- [PR_46](https://github.com/mailgun/mailgun-python/pull/46) - build(deps): Bump actions/github-script from 7.0.1 to 9.0.0.
- [PR_47](https://github.com/mailgun/mailgun-python/pull/47) - build(deps): Bump actions/setup-python from 5.3.0 to 6.3.0.
- [PR_48](https://github.com/mailgun/mailgun-python/pull/48) - build(deps): Bump actions/checkout from 4.1.0 to 7.0.0.
- [PR_49](https://github.com/mailgun/mailgun-python/pull/49) - Release 1.8.0.

## [1.7.1] - 2026-06-10

### Fixed
Expand Down Expand Up @@ -423,4 +433,4 @@ We [keep a changelog.](http://keepachangelog.com/)
[1.6.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.6.0
[1.7.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.7.0
[1.7.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.7.1
[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.7.1...HEAD
[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.8.0...HEAD
51 changes: 25 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
````markdown
# Mailgun Python SDK

Welcome to the official Python SDK for [Mailgun](http://www.mailgun.com/)!
Expand All @@ -25,11 +24,10 @@ Check out all the resources and Python code examples in the official
- [Base URL](#base-url)
- [Authentication](#authentication)
- [Quick Start](#quick-start)
- [Synchronous Client](#client)
- [Synchronous Client](#synchronous-client)
- [AsyncClient](#asyncclient)
- [Usage](#usage)
- [Logging & Debugging](#logging--debugging)
- [Logging & Secure Redaction](#logging--secure-redaction)
- [Logging Debugging and Secure Redaction](#logging-debugging-and-secure-redaction)
- [Timeout Configuration](#timeout-configuration)
- [API Response Codes](#api-response-codes)
- [IDE Autocompletion & DX](#ide-autocompletion--dx)
Expand Down Expand Up @@ -139,7 +137,6 @@ This library `mailgun` officially supports the following Python versions:
It's tested up to 3.14 (including).
It guarantees cross-platform compatibility across Linux, macOS, and Windows.


## Requirements

### Build backend dependencies
Expand All @@ -158,11 +155,11 @@ For running test you need `pytest >=9.0.3`, `pytest-asyncio`, and `responses` at

### pip install

Use the below command to install the Mailgun SDK for Python:
Use the below command to install the Mailgun SDK for Python:

```bash
pip install mailgun
````
```

#### git clone & pip install locally

Expand Down Expand Up @@ -222,15 +219,15 @@ If you are using a proxy or a regional endpoint (such as the EU infrastructure),

Ensure you pass the correct Base URL to your client configuration:

US: `https://api.mailgun.net` (Default)
**US**: `https://api.mailgun.net` (**Default**)

EU: `https://api.eu.mailgun.net`
**EU**: `https://api.eu.mailgun.net`

**⚠️ 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.

### Authentication

Authenticate your Client using a tuple of ("api", "YOUR_API_KEY"). Find your API key in the [Mailgun Control Panel](https://app.mailgun.com/settings/api_security).
Authenticate your Client using a tuple of `("api", "YOUR_API_KEY")`. Find your API key in the [Mailgun Control Panel](https://app.mailgun.com/settings/api_security).

To run tests and examples please use virtualenv or conda environment with next environment variables:

Expand Down Expand Up @@ -276,7 +273,7 @@ client.messages.create(data={"to": "user@example.com"})

> [!WARNING]
> If you are running long-lived applications (like Celery workers, web servers, or high-volume loops), repeatedly initializing the `Client` without closing it can lead to socket leaks (`Too many open files`).
> 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.
> 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.

**The Recommended Variant (Context Manager)**

Expand All @@ -291,7 +288,7 @@ with Client(auth=("api", os.environ["APIKEY"])) as client:

### AsyncClient

SDK provides native async version of the client to use in asynchronous applications. The AsyncClient offers the same functionality as the sync client but with non-blocking I/O, making it ideal for concurrent operations and integration with asyncio-based applications.
SDK provides native async version of the client to use in asynchronous applications. The `AsyncClient` offers the same functionality as the sync client but with non-blocking I/O, making it ideal for concurrent operations and integration with asyncio-based applications.

```python
import asyncio
Expand Down Expand Up @@ -359,7 +356,7 @@ async with AsyncClient(auth=("api", os.environ["APIKEY"])) as client:

For detailed examples of all available methods, parameters, and use cases, refer to the [mailgun/examples](mailgun/examples) section. All examples can be adapted to async by using `AsyncClient` and adding `await` to method calls.

### Logging, Debugging & Secure Redaction
### Logging, Debugging, and Secure Redaction

The Mailgun SDK uses standard Python logging. To aid in debugging, you can enable `DEBUG` or `INFO` logs.

Expand Down Expand Up @@ -450,7 +447,7 @@ Key Behaviors in `dry_run` Mode:
All of Mailgun's HTTP response codes follow standard HTTP definitions. For some additional information and
troubleshooting steps, please see below.

**400** - Bad Request (e.g., missing parameter). Will typically contain a JSON response with a "message" key which contains a human readable message / action
**400** - Bad Request (e.g., missing parameter). Will typically contain a JSON response with a "message" key which contains a human-readable message / action
to interpret.

**401/403** - Auth error or access denied. Please ensure your API key is correct and that you are part of a group that has
Expand Down Expand Up @@ -498,7 +495,7 @@ asyncio.run(main())

### Fluent Message Builder

Constructing complex multipart emails with custom variables (v:), custom headers (h:), and tracking options (o:) can be error-prone. The MailgunMessageBuilder abstracts this away while providing automatic security guardrails against massive file attachments (OOM) and Path Traversal (CWE-22).
Constructing complex multipart emails with custom variables (`v:`), custom headers (`h:`), and tracking options (`o:`) can be error-prone. The `MailgunMessageBuilder` abstracts this away while providing automatic security guardrails against massive file attachments (OOM) and Path Traversal (CWE-22).

```python
from mailgun import Client
Expand Down Expand Up @@ -535,7 +532,7 @@ with Client(auth=("api", "key")) as client:

### Strict Payload Schemas

If you prefer to build your own dictionaries instead of using the builder, you can opt-in to `TypedDict` schemas for full IDE autocomplete and `mypy` compile-time safety.
If you prefer to build your own dictionaries instead of using the builder, you can opt in to `TypedDict` schemas for full IDE autocomplete and `mypy` compile-time safety.

```python
from mailgun import Client
Expand All @@ -562,9 +559,9 @@ with Client(auth=("api", "key")) as client:

#### Send an email

Pass the components of the messages such as To, From, Subject, HTML and text parts, attachments, etc. Mailgun will build
Pass the components of the messages such as `To`, `From`, `Subject`, `HTML` and text parts, attachments, etc. Mailgun will build
a MIME representation of the message and send it. Note: In order to send you must provide one of the following
parameters: 'text', 'html', 'amp-html' or 'template'
parameters: `'text'`, `'html'`, `'amp-html'` or `'template'`.

```python
import os
Expand Down Expand Up @@ -645,7 +642,7 @@ def post_scheduled() -> None:

#### Send a MIME message

When using the .mimemessage endpoint, Mailgun strictly requires the payload to be sent as multipart/form-data. In Python, you trigger this by passing the raw MIME string via the files parameter, assigning it to the "message" key.
When using the `.mimemessage` endpoint, Mailgun strictly requires the payload to be sent as `multipart/form-data`. In Python, you trigger this by passing the raw MIME string via the files parameter, assigning it to the `"message"` key.

```python
import os
Expand Down Expand Up @@ -791,7 +788,7 @@ def get_dkim_keys() -> None:

Create a domain key.
Note that once private keys are created or imported they are never exported.
Alternatively, you can import an existing PEM file containing an RSA private key in PKCS #1, ASn.1 DER format.
Alternatively, you can import an existing PEM file containing an RSA private key in `PKCS #1`, `ASn.1 DER` format.
Note, the pem can be passed as a file attachment or as a form-string parameter.

```python
Expand Down Expand Up @@ -961,7 +958,7 @@ with Client(auth=("api", os.environ["APIKEY"])) as client:

#### List statistic v2

Items that have no bounces and no delays(classified_failures_count==0) are not returned.
Items that have no bounces and no delays (`classified_failures_count==0`) are not returned.

```python
import os
Expand Down Expand Up @@ -1257,7 +1254,8 @@ with Client(auth=("api", os.environ["APIKEY"])) as client:

##### Import list of unsubscribes

> [!IMPORTANT] It is strongly recommended that you open files in binary mode. Because the Content-Length header may be
> [!IMPORTANT]
> It is strongly recommended that you open files in binary mode. Because the Content-Length header may be
> provided for you, and if it does this value will be set to the number of bytes in the file. Errors may occur if you
> open the file in text mode.

Expand Down Expand Up @@ -1294,7 +1292,8 @@ with Client(auth=("api", os.environ["APIKEY"])) as client:

##### Import list of complaints

> [!IMPORTANT] It is strongly recommended that you open files in binary mode. Because the Content-Length header may be
> [!IMPORTANT]
> It is strongly recommended that you open files in binary mode. Because the Content-Length header may be
> provided for you, and if it does this value will be set to the number of bytes in the file. Errors may occur if you
> open the file in text mode.

Expand Down Expand Up @@ -1490,7 +1489,7 @@ import os
from mailgun import Client

domain: str = os.environ["DOMAIN"]
data = {"pool_id": "60140d220859fda7bab8bb6c"}
data = {"pool_id": "111222333444555666777"}

with Client(auth=("api", os.environ["APIKEY"])) as client:
req = client.domains_ips.create(domain=domain, data=data)
Expand Down Expand Up @@ -1660,7 +1659,7 @@ with Client(auth=("api", os.environ["APIKEY"])) as client:

### Validations & Optimize APIs

Thanks to the dynamic routing engine, the SDK natively supports Mailgun's supplementary APIs (like Email Validation, InboxReady, and Send Time Optimization) out of the box, automatically handling the versioning (v4, v5, etc.).
Thanks to the dynamic routing engine, the SDK natively supports Mailgun's supplementary APIs (like Email Validation, InboxReady, and Send Time Optimization) out of the box, automatically handling the versioning (`v4`, `v5`, etc.).

#### Email validation

Expand Down Expand Up @@ -1751,7 +1750,7 @@ For Enterprise and SecOps environments, the Mailgun SDK acts as a security senso
- Control Character Injection attempts (CWE-20)
- Server-Side Request Forgery (SSRF) bypass attempts (CWE-918)

You can globally **opt-in** to have the SDK automatically listen to these events and pipe them to your standard `logging` infrastructure for SIEM integration:
You can globally **opt in** to have the SDK automatically listen to these events and pipe them to your standard `logging` infrastructure for SIEM integration:

```python
import logging
Expand Down
37 changes: 20 additions & 17 deletions mailgun/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,29 +303,32 @@ def __getattr__(self, name: str) -> Any:
raise AttributeError(msg) from None

@property
def _client(self) -> httpx.AsyncClient | None:
def _client(self) -> httpx.AsyncClient:
"""Provide lazy initialization for the underlying httpx.AsyncClient.

Returns:
The active httpx.AsyncClient instance.
"""
if not self._httpx_client or self._httpx_client.is_closed:
if getattr(self, "_httpx_client", None) is None:
# Enforce TLS 1.2+ for httpx (CWE-319)
ssl_context = ssl.create_default_context()
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2

# Check if the user already provided a custom transport (e.g. for mocking)
kwargs = self._client_kwargs.copy()
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
)

self._httpx_client = httpx.AsyncClient(**kwargs)
# Assign to a local variable so Pyright can properly narrow the type
current_client: httpx.AsyncClient | None = getattr(self, "_httpx_client", None)

if current_client is None or current_client.is_closed:
# Enforce TLS 1.2+ for httpx (CWE-319)
ssl_context = ssl.create_default_context()
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2

# Check if the user already provided a custom transport (e.g. for mocking)
kwargs = self._client_kwargs.copy()
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
)

self._httpx_client = httpx.AsyncClient(**kwargs)
return self._httpx_client
return None

return current_client

async def aclose(self) -> None:
"""Close the underlying httpx.AsyncClient and purge memory."""
Expand Down
65 changes: 65 additions & 0 deletions tests/unit/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,71 @@ def test_async_validate_auth_sanitizes_input(self) -> None:
with pytest.raises(ValueError, match="Header Injection risk"):
SecurityGuard.validate_auth(("api", "key\rwithnewline"))

@pytest.mark.asyncio
async def test_async_client_property_lazy_initialization_happy_path(self) -> None:
"""
[Happy Path] Verify that _client lazily initializes and returns the same
active instance on subsequent calls, proving the CWE-400 hotfix works.
"""
client = AsyncClient(auth=("api", "key"))

# First access should initialize the pool
httpx_client_1 = client._client
assert httpx_client_1 is not None
assert httpx_client_1.is_closed is False

# Second access MUST return the exact same instance (not None, and not a new pool)
httpx_client_2 = client._client
assert httpx_client_2 is not None
assert httpx_client_1 is httpx_client_2, "Failed to reuse the active connection pool!"

await client.aclose()

@pytest.mark.asyncio
async def test_async_client_context_manager_retains_connection_edge_case(self) -> None:
"""
[Edge Case] Verify that inside an async context manager, multiple accesses
to _client return the same open connection pool. This was the exact scenario
that triggered the socket leak bug.
"""
async with AsyncClient(auth=("api", "key")) as client:
client1 = client._client
client2 = client._client

# The client must not fall through to returning None
assert client1 is not None
assert client2 is not None

# The context manager must hold the same instance open
assert client1 is client2
assert client1.is_closed is False

@pytest.mark.asyncio
async def test_async_client_property_reinitializes_if_closed_unhappy_path(self) -> None:
"""
[Unhappy Path] Verify that if the underlying httpx client is forcefully closed
(e.g., dropped connection or aggressive GC), accessing the property spins up
a fresh connection pool rather than failing or returning None.
"""
client = AsyncClient(auth=("api", "key"))

first_pool = client._client
assert first_pool is not None

# Simulate a forcefully closed connection pool (unhappy path)
await first_pool.aclose()
assert first_pool.is_closed is True

# Next access MUST detect the closed state and create a new instance
second_pool = client._client
assert second_pool is not None
assert second_pool.is_closed is False

# Validate that a brand new httpx.AsyncClient was generated
assert first_pool is not second_pool, "Failed to regenerate a closed connection pool!"

await client.aclose()


class TestAsyncEndpoint:
@staticmethod
Expand Down
Loading