Skip to content

Repository files navigation

Mailgun Python SDK

Welcome to the official Python SDK for Mailgun!

Check out all the resources and Python code examples in the official Mailgun Documentation.

Table of contents

Compatibility

This SDK is compatible with Python 3.11+. It is tested up to 3.14 (including). It guarantees cross-platform compatibility across Linux, macOS, and Windows.

Requirements

Build backend dependencies

To build the mailgun package from the sources you need setuptools (as a build backend) and setuptools-scm.

Runtime dependencies

At runtime the package requires requests >=2.33.0. For async support, it uses httpx2 >=2.7.0. Async client automatically detects and uses httpx2 if available, falling back seamlessly to legacy httpx.

Test dependencies

For running test you need pytest >=9.0.3, pytest-asyncio, and responses at least. Make sure to provide the environment variables from Authentication.

Installation

pip install

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

pip install mailgun

git clone & pip install locally

Use the below code to install it locally by cloning this repository:

git clone https://github.com/mailgun/mailgun-python
cd mailgun-python
pip install .

conda & make

Use the below code to install it locally by conda and make on Unix platforms:

make install

For development

Using conda

on 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-dev

Overview

The Mailgun API is part of the Sinch family and enables you to send, track, and receive email effortlessly.

Base URL

All API calls referenced in our documentation start with a base URL. The Mailgun API has regional endpoints.

If you are using a proxy or a regional endpoint (such as the EU infrastructure), you can configure a custom api_url during initialization.

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

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

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.

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

export APIKEY="API_KEY"# pragma: allowlist secretexport DOMAIN="DOMAIN_NAME"export MESSAGES_FROM="Name Surname <mailgun@domain_name>"export MESSAGES_TO="Name Surname <username@example.com>"export MESSAGES_CC="Name Surname <username2@example.com>"export DOMAINS_DEDICATED_IP="127.0.0.1"export MAILLIST_ADDRESS="everyone@mailgun.domain.com"export VALIDATION_ADDRESS_1="test1@example.com"export VALIDATION_ADDRESS_2="test2@example"export MAILGUN_EMAIL="username@example.com"export USER_ID="123456789012345678901234"export USER_NAME="Name Surname"export ROLE="admin"

Quick Start

Synchronous and Asynchronous Clients.

Synchronous Client

Client Lifecycle & Resource Management

Initialize your Mailgun client.

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.

importosfrommailgun.clientimportClientclient=Client(auth=("api", os.environ["APIKEY"]))
client.messages.create(domain="your-domain.com", 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.

The Recommended Variant (Context Manager)

Initialize your Mailgun client using the with context manager to ensure connection pooling (requests.Session) and underlying socket descriptors are gracefully torn down:

importosfrommailgun.clientimportClient# Sockets are safely managed and closed automaticallywithClient(auth=("api", os.environ["APIKEY"])) asclient:
response=client.messages.create(
domain=os.environ["DOMAIN"],
data={
"from": os.environ["MESSAGES_FROM"],
"to": [os.environ["MESSAGES_TO"]],
"subject": "Hello from Mailgun Python SDK",
"text": "Testing some Mailgun awesomeness!",
},
)
print(response.status_code)
print(response.json())

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.

importasyncioimportosfrommailgun.clientimportAsyncClientasyncdefmain():
# BEST PRACTICE: Use the async context manager for safe connection pooling# and automatic socket teardown.asyncwithAsyncClient(auth=("api", "your-api-key")) asclient:
response=awaitclient.messages.create(
domain=os.environ["DOMAIN"],
data={
"from": "Excited User <mailgun@YOUR_DOMAIN_NAME>",
"to": ["bar@example.com"],
"subject": "Hello from Async!",
"text": "Testing Mailgun asynchronously!",
},
)
print(response.json())
if__name__=="__main__":
asyncio.run(main())

Usage

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.

Logging, Debugging, and Secure Redaction

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

Built-in Security: The SDK includes a native RedactingFilter. You can stream these logs to your centralized monitoring systems (Splunk, Datadog, ELK) knowing that all private api-keys, pubkeys, and webhook signing secrets are automatically scrubbed and replaced with [REDACTED].

To enable detailed logging in your application, configure the logger before initializing the client:

importloggingimportosfrommailgunimportClient# Enable DEBUG level for the Mailgun SDK loggerlogging.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 consolewithClient(auth=("api", "key-super-secret-12345")) asclient:
# API keys will be redacted:# "Sending request to https://api.mailgun.net/v3/messages with auth ('api', 'key-[REDACTED]')"client.domains.get(domain=os.environ["DOMAIN"])

Timeout Configuration

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):

importosfrommailgunimportClient# 3.5 seconds to connect, 15 seconds to wait for the server responsewithClient(auth=("api", "your-key"), timeout=(3.5, 15.0)) asclient:
# Execute safely timed API calls hereclient.domains.get(domain=os.environ["DOMAIN"])

Exactly-Once Delivery & Retry Policies

Configure resilient retries using exponential backoff and jitter alongside IdempotencyGuard to prevent duplicate billing during transient partitions:

importosimportuuidfrommailgun.clientimportClientfrommailgun.configimportRetryPolicy# Configure 3 retries, a 1.0s base delay, a 10.0s max cap, and respect 429 Retry-After headerscustom_retry=RetryPolicy(max_retries=3, base_delay=1.0, max_delay=10.0, respect_retry_after=True)
withClient(auth=("api", "your-api-key"), retry_policy=custom_retry) asclient:
# Generate a unique idempotency key for this specific transactionheaders= {"Idempotency-Key": str(uuid.uuid4())}
# If the network fails, the SDK will safely back off and retry.# IdempotencyGuard ensures retries won't result in duplicate emails to the userclient.messages.create(
domain=os.environ["DOMAIN"],
data={"to": "user@example.com", "subject": "Payment Receipt", "text": "Hello World!"},
headers=headers,
)

API Response Codes

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.

IDE Autocompletion & DX

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).
  • 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.

Zero-Leak Development Mode

During local development and automated CI/CD test runs, you can instantiate the client in dry-run mode to completely intercept and mock outbound network requests:

importosfrommailgun.clientimportClient# dry_run=True intercepts the network call and prevents actual delivery.# Network requests will be skipped, returning a synthetic 200 OK MockResponsewithClient(auth=("api", "API_KEY"), dry_run=True) asclient:
client.messages.create(
domain=os.environ["DOMAIN"],
data={"from": "test@test.com", "to": "user@test.com"},
)

Key Behaviors in dry_run Mode:

  • Local payload checks (like strict minification and JSON serialization) still execute.
  • Security sanitization and path segment rules still execute.
  • Deprecation warnings will still be raised if you use an outdated endpoint.
  • sys.audit events and standard logging messages are still emitted, clearly marked with DRY RUN: Intercepting request....

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.

frommailgunimportClientfrommailgun.typesimportSendMessagePayloadmy_data: SendMessagePayload= {
"from": "admin@domain.com",
"to": ["user@example.com"],
"subject": "Strictly Typed Request",
}
withClient(auth=("api", "key")) asclient:
client.messages.create(domain="domain.com", data=my_data)

Strict Typed Schemas (mailgun.ext)

For enterprise applications using frameworks like FastAPI or Django, you can import strict typed dictionaries and Pydantic models from mailgun.ext.pydantic to validate input payloads at system boundaries:

frommailgun.ext.pydantic.modelsimportSendMessageSchemafrompydanticimportValidationErrortry:
valid_payload=SendMessageSchema(
from_="admin@company.com",
to=["user@example.com"],
subject="Weekly Report",
text="Here is your report.",
)
print("✅ Valid payload passed validation!")
print(f"Data: {valid_payload.to_mailgun_payload()}")
exceptValidationErrorase:
print(f"❌ Valid payload failed: {e}")

Strict Payload Validation (Pydantic & FastAPI):

This enables "Fail-Fast" local validation and perfectly integrates with frameworks like FastAPI.

First, ensure you have installed the optional dependencies: pip install mailgun[fastapi]

fromfastapiimportFastAPI, Dependsfrommailgun.clientimportAsyncClientfrommailgun.ext.pydantic.modelsimportSendMessageSchemaapp=FastAPI()
# Dependency to manage the connection pool safelyasyncdefget_mailgun_client():
asyncwithAsyncClient(auth=("api", "your-api-key")) asclient:
yieldclient@app.post("/send-email")asyncdefsend_email(
payload: SendMessageSchema, # Pydantic instantly validates incoming JSONmailgun_client: AsyncClient=Depends(get_mailgun_client),
):
# .model_dump(by_alias=True) ensures keys like 'from_' map safely to 'from'clean_data=payload.model_dump(by_alias=True, exclude_none=True)
response=awaitmailgun_client.messages.create(domain="your-domain.com", data=clean_data)
returnresponse.json()

Memory-Safe Attachments (ChunkedStreamer)

When sending massive attachments or processing large file exports, use the ChunkedStreamer utility to stream data in 512KB chunks, preventing out-of-memory (OOM) errors in resource-constrained environments or serverless functions:

importosfrommailgun.buildersimportMailgunMessageBuilderfrommailgun.clientimportAsyncClient, ClientAPI_KEY: str=os.environ.get("APIKEY", "")
DOMAIN: str=os.environ.get("DOMAIN", "")
MESSAGES_TO=os.environ.get("MESSAGES_TO") orf"success@{DOMAIN}"test_file="large_report.pdf"withopen(test_file, "wb") asf:
f.write(os.urandom(20*1024*1024))
try:
payload, files= (
MailgunMessageBuilder(f"mailgun@{DOMAIN}")
.add_recipient(MESSAGES_TO)
.set_subject("Monthly Enterprise Report")
.set_text("Here is the 20MB data export.")
.attach_stream(test_file)
.build()
)
# If the network is slow, increase read/write timeout to 300 sec,# but keep 10 sec for connection timeout.custom_timeout= (10.0, 300.0)
withClient(auth=("api", API_KEY), timeout=custom_timeout) asclient:
req=client.messages.create(domain=DOMAIN, data=payload, files=files)
print("Success:", req.json())
finally:
ifos.path.exists(test_file):
os.remove(test_file)

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).

frommailgunimportClientfrommailgun.buildersimportMailgunMessageBuilder# Construct a complex email using the fluent interfacewithClient(auth=("api", "your-api-key")) asclient:
payload, files= (
MailgunMessageBuilder("support@yourdomain.com")
.add_recipient("customer@example.com")
.set_subject("Your Invoice")
.set_text("Please find your invoice attached.")
.add_custom_variable("invoice_id", 1234) # Translates to "v:invoice_id"
.add_custom_header("Reply-To", "billing@...") # Translates to "h:Reply-To"
.attach_file("/tmp/invoice_1234.pdf", safe_base_dir="/tmp/") # Path Traversal guardrail# Define short, human-readable aliases for complex local file paths
.attach_inline("assets/logos/logo_v2_final.png", cid="company_logo")
.attach_inline("assets/signatures/ceo_sign.png", cid="ceo_signature")
.set_html(
""" <html> <body> <img src="cid:company_logo" alt="Company Logo"/><br/> <p>Hello! Thank you for choosing us.</p><br/> <img src="cid:ceo_signature" alt="CEO Signature"/> </body> </html> """
)
.build()
)
client.messages.create(domain="yourdomain.com", data=payload, files=files)

Streaming Pagination

For endpoints that return massive datasets (like Events, Bounces, or Suppressions), loading all pages into memory can cause latency spikes or Out-of-Memory crashes. The .stream() method handles cursor-based pagination invisibly under the hood, yielding one item at a time.

frommailgunimportClientwithClient(auth=("api", "key")) asclient:
# Safely iterate through millions of events with a flat memory footprintforeventinclient.events.stream(domain="yourdomain.com", filters={"event": "bounced"}):
print(f"Bounced: {event['recipient']}")

Readiness Probe

importsysimportosfrommailgunimportClientapi_key=os.environ.get("MAILGUN_API_KEY")
withClient(auth=("api", api_key)) asclient:
ifclient.ping():
print("Status: Healthy")
sys.exit(0) # Exit code 0 indicates successelse:
print("Status: Unhealthy")
sys.exit(1) # Exit code 1 triggers container restart/unready state

API Reference

Full list of examples

Important

This is a full list of mailgun/examples.

Messages

Send an email

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'.

importosfrommailgunimportClientdata= {
"from": "test@test.com",
"to": "recipient@example.com",
"subject": "Hello from python!",
"text": "Hello world!",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.messages.create(domain=os.environ["DOMAIN"], data=data)

Send an email with advanced parameters (Tags, Testmode, STO)

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.

importosfrommailgunimportClientdata= {
"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
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.messages.create(domain=os.environ["DOMAIN"], data=data)

Send an email with attachments

It is strongly recommended that you open files in binary mode (read_bytes()).

importosfrompathlibimportPathfrommailgunimportClientwithClient(auth=("api", os.environ["APIKEY"])) asclient:
files= [("attachment", ("report.pdf", Path("report.pdf").read_bytes()))]
# Assuming `data` is predefined like in the previous examplereq=client.messages.create(domain=os.environ["DOMAIN"], data=data, files=files)

Send a scheduled message

importosfrommailgunimportClientdefpost_scheduled() ->None:
# Scheduled messagedomain: str=os.environ["DOMAIN"]
data= {
"from": os.environ["MESSAGES_FROM"],
"to": os.environ["MESSAGES_TO"],
"cc": os.environ["MESSAGES_CC"],
"subject": "Hello Vasyl Bodaj",
"html": "<html><body>Hello!</body></html>",
"o:deliverytime": "Thu Jan 28 2021 14:00:03 EST",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.messages.create(data=data, domain=domain)
print(req.json())

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.

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
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`withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.mimemessage.create(
domain=domain,
data={"to": "recipient@example.com"},
files={"message": ("message.mime", mime_string)},
)
print(req.json())

Domains

Get domains

importosfrommailgunimportClientwithClient(auth=("api", os.environ["APIKEY"])) asclient:
data=client.domainlist.get()
print(data.json())

Get domains with filters

importosfrommailgunimportClientwithClient(auth=("api", os.environ["APIKEY"])) asclient:
data=client.domainlist.get(filters={"skip": 0, "limit": 10})
print(data.json())

Get domains details

importosfrommailgunimportClientdomain_name="python.test.com"withClient(auth=("api", os.environ["APIKEY"])) asclient:
data=client.domains.get(domain=domain_name)
print(data.json())

Create a domain

importosfrommailgunimportClientdata= {"name": "new.domain.com"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.domains.create(data=data)

Update a domain

importosfrommailgunimportClientdefupdate_simple_domain() ->None:
""" PUT /domains/<domain> :return: """domain_name="python.test.domain5"data= {"name": domain_name, "spam_action": "disabled"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains.put(data=data, domain=domain_name)
print(request.json())

Domain connections

importosfrommailgunimportClientdefget_connections() ->None:
""" GET /domains/<domain>/connection :return: """domain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains_connection.get(domain=domain)
print(request.json())

Domain keys

List keys for all domains

List domain keys, and optionally filter by signing domain or selector. The page & limit data is only required when paging through the data.

importosfrommailgunimportClientdefget_dkim_keys() ->None:
""" GET /v1/dkim/keys :return: """query= {
"page": "string",
"limit": "0",
"signing_domain": os.environ["DOMAIN"],
"selector": "smtp",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.dkim_keys.get(filters=query)
print(request.json())

Create a domain key

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.

importosimportreimportsubprocessfrompathlibimportPathfrommailgunimportClientdefpost_dkim_keys() ->None:
""" POST /v1/dkim/keys :return: """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 2048ifnotALLOWED_FILENAME_RE.match(secret_key_filename):
raiseValueError(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,
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.dkim_keys.create(data=data, files=files)
print(request.json())
Update DKIM authority
importosfrommailgunimportClientdefput_dkim_authority() ->None:
""" PUT /domains/<domain>/dkim_authority :return: """domain: str=os.environ["DOMAIN"]
data= {"self": "false"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains_dkimauthority.put(domain=domain, data=data)
print(request.json())

Domain Tracking

Get tracking settings
importosfrommailgunimportClientdefget_tracking() ->None:
""" GET /domains/<domain>/tracking :return: """domain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains_tracking.get(domain=domain)
print(request.json())

Webhooks

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.

Create a webhook (v4 Multi-Event)

importosfrommailgunimportClientdata= {
"event_types": "clicked,opened,delivered", # Triggers v4 routing"url": "https://my-server.com/webhook",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
client.domains_webhooks.create(domain=os.environ["DOMAIN"], data=data)

Get all webhooks

importosfrommailgunimportClientwithClient(auth=("api", os.environ["APIKEY"])) asclient:
client.domains_webhooks.get(domain=os.environ["DOMAIN"])

Create Account-Level Webhooks (v1)

importosfrommailgunimportClientdata= {"id": "clicked", "url": ["https://my-server.com/webhook"]}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.account_webhooks.create(data=data)

Events

Retrieves a paginated list of events

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.events.get(domain=domain)
print(req.json())

Get events by recipient

importosfrommailgunimportClientparams= {
"begin": "Tue, 24 Nov 2025 09:00:00 -0000",
"limit": 10,
"recipient": "user@example.com",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.events.get(filters=params)

Bounce Classification

API endpoint.

List statistic v2

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

importjsonimportosfrommailgunimportClientdomain: 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"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.bounceclassification_metrics.create(data=json.dumps(payload), headers=headers)
print(req.json())

Tags New

Mailgun allows you to tag your email with unique identifiers. Tags are visible via our analytics tags API endpoint.

Get account tags

importosfrommailgunimportClientdata= {"pagination": {"sort": "lastseen:desc", "limit": 10}}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_tags.create(data=data)

Update account tag

Updates the tag description for an account.

importosfrommailgunimportClientdata= {
"tag": "name-of-tag-to-update",
"description": "updated tag description",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_tags.update(data=data)
print(req.json())

Post query to list account tags or search for single tag

Gets the list of all tags, or filtered by tag prefix, for an account.

importosfrommailgunimportClientdata= {
"pagination": {"sort": "lastseen:desc", "limit": 10},
"include_subaccounts": True,
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_tags.create(data=data)
print(req.json())

Delete account tag

Deletes the tag for an account.

importosfrommailgunimportClientdata= {"tag": "name-of-tag-to-delete"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_tags.delete(data=data)
print(req.json())

Get account tag limit information

Gets the tag limit and current number of unique tags for an account.

importosfrommailgunimportClientwithClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_tags_limits.get()
print(req.json())

Metrics & Logs

List Logs

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.

importjsonimportosfrommailgunimportClientdefpost_analytics_logs() ->None:
""" # Metrics # POST /analytics/logs :return: """domain: str=os.environ["DOMAIN"]
nested_dict= {
"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,
},
}
headers= {"Content-Type": "application/json"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_logs.create(data=json.dumps(nested_dict), headers=headers)
print(req.json())

Get account metrics

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

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
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,
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_metrics.create(data=data)
print(req.json())

Get account usage metrics

importosfrommailgunimportClientdefpost_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,
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.analytics_usage_metrics.create(data=data)
print(req.json())

Suppressions

Bounces

Create bounces
importosfrommailgunimportClientdata= {"address": "test120@gmail.com", "code": 550, "error": "Test error"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.bounces.create(data=data)

Unsubscribe

View all unsubscribes
importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.unsubscribes.get(domain=domain)
print(req.json())
Import list of unsubscribes

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.

importosfrompathlibimportPathfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
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())

Complaints

Add complaints
importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
data= {"address": "bob@gmail.com", "tag": "compl_test_tag"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.complaints.create(data=data, domain=domain)
print(req.json())
Import list of complaints

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.

importosfrompathlibimportPathfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
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())

Whitelists

Delete all whitelists
importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.whitelists.delete(domain=domain)
print(req.json())

Routes

Create a route

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
data= {
"priority": 0,
"description": "Sample route",
"expression": f"match_recipient('.*@{domain}')",
"action": ["forward('http://myhost.com/messages/')", "stop()"],
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.routes.create(data=data)
print(req.json())

Get a route by id

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.routes.get(domain=domain, route_id="xxxxxxxx")
print(req.json())

Mailing Lists

Create a mailing list

importosfrommailgunimportClientdata= {
"address": "developers@my-domain.com",
"description": "Mailgun developers list",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.lists.create(data=data)

Get mailing lists members

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.lists_members_pages.get(domain=domain, address="developers@my-domain.com")
print(req.json())

Delete mailing lists address

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.lists.delete(address=f"python_sdk2@{domain}")
print(req.json())

Templates

Get templates

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
params= {"limit": 1}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.templates.get(domain=domain, filters=params)
print(req.json())

Update a template

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
data= {"description": "new template description"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.templates.put(data=data, domain=domain, template_name="template.name1")
print(req.json())

Create a new template version

importosfrommailgunimportClientdata= {
"tag": "v1",
"template": "{{fname}} {{lname}}",
"engine": "handlebars",
"active": "yes",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.templates.create(data=data, template_name="welcome.email", versions=True)

Get all template's versions

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.templates.get(domain=domain, template_name="template.name1", versions=True)
print(req.json())

IP Pools

Edit DIPP

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
data= {
"name": "test_pool3",
"description": "Test3",
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.ippools.patch(domain=domain, data=data, pool_id="1234567890")
print(req.json())

Link an IP pool

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
data= {"pool_id": "111222333444555666777"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.domains_ips.create(domain=domain, data=data)
print(req.json())

IPs

List account IPs

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.ips.get(domain=domain, filters={"dedicated": "true"})
print(req.json())

Delete a domain's IP

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains_ips.delete(domain=domain, ip="100.111.222.222")
print(request.json())

Keys

The Keys API lets you view and manage api keys.

List Mailgun API keys

importosfrommailgunimportClientquery= {"domain_name": "python.test.domain5", "kind": "web"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.keys.get(filters=query)
print(req.json())

Create Mailgun API key

importosfrommailgun.clientimportClientdefpost_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: """key: str=os.environ["APIKEY"]
mailgun_email=os.environ["MAILGUN_EMAIL"]
role=os.environ["ROLE"]
user_id=os.environ["USER_ID"]
user_name=os.environ["USER_NAME"]
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"}
withClient(auth=("api", key)) asclient:
req=client.keys.create(data=data, headers=headers)
print(req.json())

Credentials

List Mailgun SMTP credential metadata for a given domain

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains_credentials.get(domain=domain)
print(request.json())

Create Mailgun SMTP credentials for a given domain

importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
data= {
"login": f"alice_bob@{domain}",
"password": "test_new_creds123", # pragma: allowlist secret
}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
request=client.domains_credentials.create(domain=domain, data=data)
print(request.json())

Users

Get users on an account

importosfrommailgunimportClientquery= {"role": "admin", "limit": "0", "skip": "0"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.users.get(filters=query)
print(req.json())

Get a user's details

importosfrommailgunimportClientmailgun_email=os.environ["MAILGUN_EMAIL"]
role=os.environ["ROLE"]
query= {"role": role, "limit": "0", "skip": "0"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req1=client.users.get(filters=query)
users=req1.json()["users"]
foruserinusers:
ifmailgun_email==user["email"]:
req2=client.users.get(user_id=user["id"])
print(req2.json())

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.).

Email validation

Create a single validation
importosfrommailgunimportClientdata= {"address": "test2@gmail.com"}
params= {"provider_lookup": "false"}
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.addressvalidate.create(data=data, filters=params)
print(req.json())
Validate an email address
importosfrommailgunimportClient# Note: Requires a paid Mailgun plan.withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.addressvalidate.get(address="suspicious@example.com")

Inbox placement

Get all inbox
importosfrommailgunimportClientdomain: str=os.environ["DOMAIN"]
withClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.inbox_tests.get(domain=domain)
print(req.json())
Fetch InboxReady placement tests
importosfrommailgunimportClientwithClient(auth=("api", os.environ["APIKEY"])) asclient:
req=client.inboxready_domains.get()
print(req.json())

Deprecation Warnings

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.

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.

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.

License

Apache-2.0

Contribute

See for details CONTRIBUTING.md

Security

See SECURITY.md for vulnerability reporting and our security policies.

Enterprise Security Audit Hooks (PEP 578)

For Enterprise and SecOps environments, the Mailgun SDK acts as a security sensor. It emits native Python audit events (sys.audit) for Zero-Trust monitoring, including:

  • Outbound network requests (Egress tracking)
  • CRLF Header Injection attempts
  • 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:

importloggingimportosfrommailgun.clientimportClientfrommailgun.configimportConfiglogging.basicConfig(level=logging.INFO)
# Activate the PEP 578 Audit Listener globally during app startupConfig.enable_security_audit()
# Initialize the client safelywithClient(auth=("api", os.environ.get("APIKEY", "your-api-key"))) asclient:
# The audit hook will now automatically intercept and log events like:# "SECURITY AUDIT: Outbound API call tracked - GET https://api.mailgun.net/v3/domains"response=client.domains.get()

Pre-Flight Delivery Validation (SpamGuard)

The SDK includes a zero-network static analyzer called SpamGuard. It evaluates your payload before making an HTTP request. If your HTML contains known spam triggers (like invalid tags) or if you attempt to send to malformed Internationalized Domain Names (IDN), the SDK fails fast locally.

importosfrommailgun.clientimportClientfrommailgun.handlers.error_handlerimportDeliverabilityErrordomain=os.environ["DOMAIN"]
withClient(auth=("api", "YOUR_API_KEY")) asclient:
try:
client.messages.create(
domain=domain,
data={
"from": "sender@YOUR_DOMAIN_NAME",
"to": ["test@example.com"],
"subject": "Hello",
"html": "<html><script>alert('bad');</script></html>", # Will trigger SpamGuard
},
)
exceptDeliverabilityErrorase:
print(f"Pre-flight check failed! Risk score: {e.score}. Issues: {e.issues}")

Contributors

About

Mailgun's Official SDK for Python

Topics

Resources

Contributing

Security policy

Stars

9 stars

Watchers

9 watching

Forks

Releases

Packages

Used by

Contributors

Languages