Welcome to the official Python SDK for Mailgun!
Check out all the resources and Python code examples in the official Mailgun Documentation.
- Mailgun Python SDK
- Table of contents
- Compatibility
- Requirements
- Installation
- Overview
- Quick Start
- Usage
- Request examples
- Deprecation Warnings
- Type Hinting
- License
- Contribute
- Security
- Contributors
This library mailgun officially supports the following Python versions:
- python >=3.10,<3.15
It's tested up to 3.14 (including).
To build the mailgun package from the sources you need setuptools (as a build backend) and setuptools-scm.
At runtime the package requires only requests >=2.32.5. For async support, it uses httpx and typing-extensions >=4.7.1 for Python <3.11.
For running test you need pytest >=7.0.0 at least. Make sure to provide the environment variables from
Authentication.
Use the below code to install the Mailgun SDK for Python:
pip install mailgunUse the below code to install it locally by cloning this repository:
git clone https://github.com/mailgun/mailgun-python
cd mailgun-pythonpip install .Use the below code to install it locally by conda and make on Unix platforms:
make installon Linux or macOS:
git clone https://github.com/mailgun/mailgun-python
cd mailgun-python- A basic environment with a minimum number of dependencies:
make dev
conda activate mailgun- A full dev environment:
make dev-full
conda activate mailgun-devThe Mailgun API is part of the Sinch family and enables you to send, track, and receive email effortlessly.
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.
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.
For domains created in our US region the base URL is:
https://api.mailgun.net/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:
https://api.mailgun.net/v3/mydomain.comThe Mailgun Send API uses your API key for authentication. Grab and save your Mailgun API credentials.
To run tests and examples please use virtualenv or conda environment with next environment variables:
export APIKEY="API_KEY" # pragma: allowlist secret
export DOMAIN="DOMAIN_NAME"
export MESSAGES_FROM="Name Surname <mailgun@domain_name>"
export MESSAGES_TO="Name Surname <username@gmail.com>"
export MESSAGES_CC="Name Surname <username2@gmail.com>"
export DOMAINS_DEDICATED_IP="127.0.0.1"
export MAILLIST_ADDRESS="everyone@mailgun.domain.com"
export VALIDATION_ADDRESS_1="test1@i.ua"
export VALIDATION_ADDRESS_2="test2@gmail.com"
export MAILGUN_EMAIL="username@example.com"
export USER_ID="123456789012345678901234"
export USER_NAME="Name Surname"
export ROLE="admin"The Mailgun Send API uses your API key for authentication.
Synchronous vs Asynchronous Client.
Initialize your Mailgun client:
from mailgun.client import Client
import os
auth = ("api", os.environ["APIKEY"])
client = Client(auth=auth)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.
client = Client(auth=("api", "KEY"))
client.messages.create(data={"to": "user@example.com"})The Recommended Variant (Context Manager)
[!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.
# Sockets are safely managed and closed automatically
with Client(auth=("api", "KEY")) as client:
client.messages.create(data={"to": "user@example.com"})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:
client = Client(auth=("api", "KEY"), 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).
SDK provides also 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.
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
# and automatic socket teardown.
async with AsyncClient(auth=("api", "your-api-key")) as client:
response = await client.messages.create(
domain="your-domain.com",
data={
"from": "Excited User <mailgun@your-domain.com>",
"to": ["bar@example.com"],
"subject": "Hello",
"text": "Testing some Mailgun awesomeness!",
},
)
print(response.json())
if __name__ == "__main__":
asyncio.run(main())Send a message with a Synchronous Client.
import os
from mailgun.client import Client
# Initialize the client
client = Client(auth=("api", os.environ["APIKEY"]))
# Send an email
response = client.messages.create(
data={
"from": "Excited User <mailgun@sandbox.mailgun.org>",
"to": ["recipient@example.com"],
"subject": "Hello from Mailgun Python SDK",
"text": "Testing some Mailgun awesomeness!",
}
)
print(response.status_code)
print(response.json())The AsyncClient provides async equivalents for all methods available in the sync Client. The method signatures and parameters are identical - simply add await when calling methods:
# Sync version
client = Client(auth=auth)
result = client.domainlist.get()
# Async version
client = AsyncClient(auth=auth)
result = await client.domainlist.get()Additionally AsyncClient can be used as async context manager to automatically close connection when execution is finished:
import asyncio
import os
from mailgun.client import AsyncClient
async def main():
auth = ("api", os.environ["APIKEY"])
async with AsyncClient(auth=auth) as client:
result = await client.domainlist.get()
print(result)
asyncio.run(main())For detailed examples of all available methods, parameters, and use cases, refer to the mailgun/examples section. All examples can be adapted to async by using AsyncClient and adding await to method calls.
The Mailgun SDK includes built-in logging to help you troubleshoot API requests, inspect generated URLs, and read server error messages (like 400 Bad Request or 404 Not Found).
The SDK uses the standard Python logging module under the namespace mailgun.client.
To enable detailed logging in your application, configure the logger before initializing the client:
import logging
from mailgun.client import Client
# Enable DEBUG level for the Mailgun SDK logger
logging.getLogger("mailgun.client").setLevel(logging.DEBUG)
# Configure the basic console output (if not already configured in your app)
logging.basicConfig(format="%(levelname)s - %(name)s - %(message)s")
# Now, any API errors or requests will be printed to your console
client = Client(auth=("api", "YOUR_API_KEY"))
client.domains.get()The Client utilizes 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). - 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.
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 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 access to the desired resource.
404 - Resource not found. NOTE: this one can be temporal as our system is an eventually-consistent system but requires diligence. If a JSON response is missing for a 404 - that's usually a sign that there was a mistake in the API request, such as a non-existing endpoint.
429 - Rate limit exceeded. Mailgun does have rate limits in place to protect our system. The SDK automatically retries these using Exponential Backoff. In the unlikely case you encounter them and need them raised, please reach out to our support team.
500/502/503 - Internal Error on the Mailgun side. The SDK automatically retries these using Exponential Backoff. If the issue persists, please reach out to our support team.
Important
This is a full list of supported endpoints this SDK provides mailgun/examples
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'
data = {
"from": "test@test.com",
"to": "recipient@example.com",
"subject": "Hello from python!",
"text": "Hello world!",
}
req = client.messages.create(data=data)Because the SDK maps kwargs directly to the payload, it inherently supports all advanced Mailgun features without needing SDK updates. You can easily add custom variables (v:), options (o:), and Send Time Optimization (STO) directly to your data dictionary.
data = {
"from": "Excited User <mailgun@my-domain.com>",
"to": ["recipient1@example.com", "recipient2@example.com"],
"subject": "Advanced Mailgun Features",
"text": "Testing out tags, custom variables, and testmode!",
"o:tag": ["newsletter", "python-sdk"], # Multiple tags
"o:testmode": "yes", # Validates payload without actually sending
"o:deliverytime-optimize-period": "24h", # Send Time Optimization
"v:my-custom-id": "USER-12345", # Custom user-defined variable
}
req = client.messages.create(data=data)It is strongly recommended that you open files in binary mode (read_bytes()).
from pathlib import Path
files = [("attachment", ("report.pdf", Path("report.pdf").read_bytes()))]
req = client.messages.create(data=data, files=files)def post_scheduled() -> None:
# Scheduled message
data = {
"from": os.environ["MESSAGES_FROM"],
"to": os.environ["MESSAGES_TO"],
"cc": os.environ["MESSAGES_CC"],
"subject": "Hello Vasyl Bodaj",
"html": html,
"o:deliverytime": "Thu Jan 28 2021 14:00:03 EST",
}
req = client.messages.create(data=data, domain=domain)
print(req.json())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.
mime_string = (
"From: sender@example.com\n"
"To: recipient@example.com\n"
"Subject: MIME Test\n"
"Content-Type: text/plain; charset=utf-8\n\n"
"This is a raw MIME message."
).encode("utf-8")
# Force multipart/form-data by passing `files`
req = client.mimemessage.create(
domain=domain,
data={"to": "recipient@example.com"},
files={"message": ("message.mime", mime_string)},
)
print(req.json())data = client.domainlist.get()
print(data.json())data = client.domainlist.get(filters={"skip": 0, "limit": 10})
print(data.json())domain_name = "python.test.com"
data = client.domains.get(domain_name=domain_name)
print(data.json())data = {"name": "new.domain.com"}
req = client.domains.create(data=data)def update_simple_domain() -> None:
"""
PUT /domains/<domain>
:return:
"""
domain_name = "python.test.domain5"
data = {"name": domain_name, "spam_action": "disabled"}
request = client.domains.put(data=data, domain=domain_name)
print(request.json())def get_connections() -> None:
"""
GET /domains/<domain>/connection
:return:
"""
request = client.domains_connection.get(domain=domain)
print(request.json())List domain keys, and optionally filter by signing domain or selector. The page & limit data is only required when paging through the data.
def get_dkim_keys() -> None:
"""
GET /v1/dkim/keys
:return:
"""
data = {
"page": "string",
"limit": "0",
"signing_domain": "python.test.domain5",
"selector": "smtp",
}
request = client.dkim_keys.get(data=data)
print(request.json())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. Note, the pem can be passed as a file attachment or as a form-string parameter.
def post_dkim_keys() -> None:
"""
POST /v1/dkim/keys
:return:
"""
import os
import re
import subprocess
from pathlib import Path
secret_key_filename: str = os.environ["SECRET_KEY_FILENAME"]
secret_key_path: Path = Path(secret_key_filename)
ALLOWED_FILENAME_RE = re.compile(r"^[a-zA-Z0-9._-]{1,255}$")
# Private key PEM file must be generated in PKCS1 format. You need 'openssl' on your machine
# example:
# openssl genrsa -traditional -out .server.key 2048
if not ALLOWED_FILENAME_RE.match(secret_key_filename):
raise ValueError(f"Invalid filename: {secret_key_filename!r}")
subprocess.run(
["openssl", "genrsa", "-traditional", "-out", secret_key_filename, "--", "2048"], check=True
)
files = [
(
"pem",
("server.key", secret_key_path.read_bytes()),
)
]
data = {
"signing_domain": "python.test.domain5",
"selector": "smtp",
"bits": "2048",
"pem": files,
}
headers = {"Content-Type": "multipart/form-data"}
request = client.dkim_keys.create(data=data, headers=headers, files=files)
print(request.json())def put_dkim_authority() -> None:
"""
PUT /domains/<domain>/dkim_authority
:return:
"""
data = {"self": "false"}
request = client.domains_dkimauthority.put(domain=domain, data=data)
print(request.json())def get_tracking() -> None:
"""
GET /domains/<domain>/tracking
:return:
"""
request = client.domains_tracking.get(domain=domain)
print(request.json())The SDK utilizes Payload-Based Routing. You do not need to worry about calling /v1, /v3, or /v4 APIs.
Simply use client.domains_webhooks and the SDK will automatically analyze your payload (e.g., looking for event_types) and upgrade the request to the modern v4 multi-event API if applicable.
data = {
"event_types": "clicked,opened,delivered", # Triggers v4 routing
"url": "[https://my-server.com/webhook](https://my-server.com/webhook)",
}
req = client.domains_webhooks.create(data=data)req = client.domains_webhooks.get()data = {"id": "clicked", "url": ["https://my-server.com/webhook"]}
req = client.account_webhooks.create(data=data)domain: str = os.environ["DOMAIN"]
req = client.events.get(domain=domain)
print(req.json())params = {
"begin": "Tue, 24 Nov 2025 09:00:00 -0000",
"limit": 10,
"recipient": "user@example.com",
}
req = client.events.get(filters=params)Items that have no bounces and no delays(classified_failures_count==0) are not returned.
domain: str = os.environ["DOMAIN"]
payload = {
"start": "Wed, 12 Nov 2025 23:00:00 UTC",
"end": "Thu, 13 Nov 2025 23:00:00 UTC",
"resolution": "day",
"duration": "24h0m0s",
"dimensions": ["entity-name", "domain.name"],
"metrics": [
"critical_bounce_count",
"non_critical_bounce_count",
"critical_delay_count",
"non_critical_delay_count",
"delivered_smtp_count",
"classified_failures_count",
"critical_bounce_rate",
"non_critical_bounce_rate",
"critical_delay_rate",
"non_critical_delay_rate",
],
"filter": {
"AND": [
{
"attribute": "domain.name",
"comparator": "=",
"values": [{"value": domain}],
}
]
},
"include_subaccounts": True,
"pagination": {"sort": "entity-name:asc", "limit": 10},
}
headers = {"Content-Type": "application/json"}
req = client.bounceclassification_metrics.create(data=payload, headers=headers)
print(req.json())Mailgun allows you to tag your email with unique identifiers. Tags are visible via our analytics tags API endpoint.
data = {"pagination": {"sort": "lastseen:desc", "limit": 10}}
req = client.analytics_tags.create(data=data)Updates the tag description for an account.
data = {
"tag": "name-of-tag-to-update",
"description": "updated tag description",
}
req = client.analytics_tags.update(data=data)
print(req.json())Gets the list of all tags, or filtered by tag prefix, for an account.
data = {
"pagination": {"sort": "lastseen:desc", "limit": 10},
"include_subaccounts": True,
}
req = client.analytics_tags.create(data=data)
print(req.json())Deletes the tag for an account.
data = {"tag": "name-of-tag-to-delete"}
req = client.analytics_tags.delete(data=data)
print(req.json())Gets the tag limit and current number of unique tags for an account.
req = client.analytics_tags_limits.get()
print(req.json())Mailgun keeps track of every inbound and outbound message event and stores this log data. This data can be queried and filtered to provide insights into the health of your email infrastructure API endpoint.
Gets customer event logs for an account.
def post_analytics_logs() -> None:
"""
# Metrics
# POST /analytics/logs
:return:
"""
data = {
"start": "Wed, 24 Sep 2025 00:00:00 +0000",
"end": "Thu, 25 Sep 2025 00:00:00 +0000",
"filter": {
"AND": [
{
"attribute": "domain",
"comparator": "=",
"values": [{"label": domain, "value": domain}],
}
]
},
"include_subaccounts": True,
"pagination": {
"sort": "timestamp:asc",
"limit": 50,
},
}
req = client.analytics_logs.create(data=data)
print(req.json())Mailgun collects many different events and generates event metrics which are available in your Control Panel. This data is also available via our analytics metrics API endpoint.
Get filtered metrics for an account
data = {
"start": "Sun, 08 Jun 2025 00:00:00 +0000",
"end": "Tue, 08 Jul 2025 00:00:00 +0000",
"resolution": "day",
"duration": "1m",
"dimensions": ["time"],
"metrics": ["accepted_count", "delivered_count", "clicked_rate", "opened_rate"],
"filter": {
"AND": [
{
"attribute": "domain",
"comparator": "=",
"values": [{"label": domain, "value": domain}],
}
]
},
"include_subaccounts": True,
"include_aggregates": True,
}
req = client.analytics_metrics.create(data=data)
print(req.json())def post_analytics_usage_metrics() -> None:
"""
# Usage Metrics
# POST /analytics/usage/metrics
:return:
"""
data = {
"start": "Sun, 08 Jun 2025 00:00:00 +0000",
"end": "Tue, 08 Jul 2025 00:00:00 +0000",
"resolution": "day",
"duration": "1m",
"dimensions": ["time"],
"metrics": [
"accessibility_count",
"accessibility_failed_count",
"domain_blocklist_monitoring_count",
"email_preview_count",
"email_preview_failed_count",
"email_validation_bulk_count",
"email_validation_count",
"email_validation_list_count",
"email_validation_mailgun_count",
"email_validation_mailjet_count",
"email_validation_public_count",
"email_validation_single_count",
"email_validation_valid_count",
"image_validation_count",
"image_validation_failed_count",
"ip_blocklist_monitoring_count",
"link_validation_count",
"link_validation_failed_count",
"processed_count",
"seed_test_count",
],
"include_subaccounts": True,
"include_aggregates": True,
}
req = client.analytics_usage_metrics.create(data=data)
print(req.json())data = {"address": "test120@gmail.com", "code": 550, "error": "Test error"}
req = client.bounces.create(data=data)domain: str = os.environ["DOMAIN"]
req = client.unsubscribes.get(domain=domain)
print(req.json())[!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.
files = {"unsubscribe2_csv": Path("mailgun/doc_tests/files/mailgun_unsubscribes.csv").read_bytes()}
req = client.unsubscribes_import.create(domain=domain, files=files)
print(req.json())domain: str = os.environ["DOMAIN"]
data = {"address": "bob@gmail.com", "tag": "compl_test_tag"}
req = client.complaints.create(data=data, domain=domain)
print(req.json())[!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.
domain: str = os.environ["DOMAIN"]
files = {"complaints_csv": Path("mailgun/doc_tests/files/mailgun_complaints.csv").read_bytes()}
req = client.complaints_import.create(domain=domain, files=files)
print(req.json())domain: str = os.environ["DOMAIN"]
req = client.whitelists.delete(domain=domain)
print(req.json())domain: str = os.environ["DOMAIN"]
data = {
"priority": 0,
"description": "Sample route",
"expression": f"match_recipient('.*@{domain}')",
"action": ["forward('http://myhost.com/messages/')", "stop()"],
}
req = client.routes.create(domain=domain, data=data)
print(req.json())domain: str = os.environ["DOMAIN"]
req = client.routes.get(domain=domain, route_id="xxxxxxxx")
print(req.json())data = {
"address": "developers@my-domain.com",
"description": "Mailgun developers list",
}
req = client.lists.create(data=data)domain: str = os.environ["DOMAIN"]
req = client.lists_members_pages.get(domain=domain, address=mailing_list_address)
print(req.json())domain: str = os.environ["DOMAIN"]
req = client.lists.delete(domain=domain, address=f"python_sdk2@{domain}")
print(req.json())domain: str = os.environ["DOMAIN"]
params = {"limit": 1}
req = client.templates.get(domain=domain, filters=params)
print(req.json())domain: str = os.environ["DOMAIN"]
data = {"description": "new template description"}
req = client.templates.put(data=data, domain=domain, template_name="template.name1")
print(req.json())data = {
"tag": "v1",
"template": "{{fname}} {{lname}}",
"engine": "handlebars",
"active": "yes",
}
req = client.templates.create(data=data, template_name="welcome.email", versions=True)req = client.templates.get(domain=domain, template_name="template.name1", versions=True)
print(req.json())domain: str = os.environ["DOMAIN"]
data = {
"name": "test_pool3",
"description": "Test3",
}
req = client.ippools.patch(domain=domain, data=data, pool_id="60140bc1fee3e84dec5abeeb")
print(req.json())data = {"pool_id": "60140d220859fda7bab8bb6c"}
req = client.domains_ips.create(domain=domain, data=data)
print(req.json())domain: str = os.environ["DOMAIN"]
req = client.ips.get(domain=domain, filters={"dedicated": "true"})
print(req.json())request = client.domains_ips.delete(domain=domain, ip="161.38.194.10")
print(request.json())The Keys API lets you view and manage api keys.
query = {"domain_name": "python.test.domain5", "kind": "web"}
req = client.keys.get(filters=query)
print(req.json())import os
from mailgun.client import Client
key: str = os.environ["APIKEY"]
domain: str = os.environ["DOMAIN"]
mailgun_email = os.environ["MAILGUN_EMAIL"]
role = os.environ["ROLE"]
user_id = os.environ["USER_ID"]
user_name = os.environ["USER_NAME"]
client: Client = Client(auth=("api", key))
def post_keys() -> None:
"""
POST /v1/keys
This code generate a Web API key tied to the account user associated with the data inputted for the USER_EMAIL field and USER_ID values.
This is returned by the API in the "secret":"API_KEY" key/value pair. This key will authenticate the call (Get one's own user details) made to the /v5/users/me endpoint, # pragma: allowlist secret
and will return the user's data associated with the USER_EMAIL and USER_ID values.
Important Notes:
USER_EMAIL - The user login email address of the user that is trying to make the call to the /v5/users/me endpoint.
SECONDS - How many seconds you want the key to be active before it expires.
ROLE - The role of the API Key. This dictates what permissions the key has (https://help.mailgun.com/hc/en-us/articles/26016288026907-API-Key-Roles)
USER_ID - The internal User ID of the user that is trying to call the /v5/users/me endpoint. This is present in the URL in the address bar when viewing the User details in the GUI or in Admin. Both will show /users/USER_ID in the address.
DESCRIPTION - Description of the key.
:return:
"""
data = {
"email": mailgun_email,
"domain_name": "python.test.domain5",
"kind": "web",
"expiration": "3600",
"role": role,
"user_id": user_id,
"user_name": user_name,
"description": "a new key",
}
headers = {"Content-Type": "multipart/form-data"}
req = client.keys.create(data=data, headers=headers)
print(req.json())request = client.domains_credentials.get(domain=domain)
print(request.json())data = {
"login": f"alice_bob@{domain}",
"password": "test_new_creds123", # pragma: allowlist secret
}
request = client.domains_credentials.create(domain=domain, data=data)
print(request.json())query = {"role": "admin", "limit": "0", "skip": "0"}
req = client.users.get(filters=query)
print(req.json())mailgun_email = os.environ["MAILGUN_EMAIL"]
role = os.environ["ROLE"]
user_name = os.environ["USER_NAME"]
query = {"role": role, "limit": "0", "skip": "0"}
req1 = client.users.get(filters=query)
users = req1.json()["users"]
for user in users:
if mailgun_email == user["email"]:
req2 = client.users.get(user_id=user["id"])
print(req2.json())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.).
data = {"address": "test2@gmail.com"}
params = {"provider_lookup": "false"}
req = client.addressvalidate.create(domain=domain, data=data, filters=params)
print(req.json())# Note: Requires a paid Mailgun plan.
req = client.addressvalidate.get(address="suspicious@example.com")req = client.inbox_tests.get(domain=domain)
print(req.json())req = client.inboxready_domains.get()
print(req.json())The SDK includes an active Interceptor engine that protects your application from API drift.
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.
This SDK is fully type-hinted and compatible with static type checkers like mypy and pyright.
Because of the dynamic URL dispatch engine (__getattr__), IDEs may flag endpoints like client.messages.create as Any.
If you enforce strict typing in your application, you may safely ignore these specific dynamically dispatched calls.
See for details CONTRIBUTING.md
See for details SECURITY.md