diff --git a/.github/workflows/commit_checks.yaml b/.github/workflows/commit_checks.yaml index 8398d04..b85cc8f 100644 --- a/.github/workflows/commit_checks.yaml +++ b/.github/workflows/commit_checks.yaml @@ -3,9 +3,9 @@ name: CI on: push: - branches: - - main + branches: [main] pull_request: + branches: [main] permissions: contents: read @@ -30,8 +30,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - # TODO: Enable Python 3.14 when conda and conda-build will have py314 support. - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] env: APIKEY: ${{ secrets.APIKEY }} DOMAIN: ${{ secrets.DOMAIN }} @@ -46,9 +45,11 @@ jobs: channels: defaults show-channel-urls: true environment-file: environment-dev.yaml + cache: 'pip' # Drastically speeds up CI by caching pip dependencies - - name: Install the package + - name: Install package run: | + python -m pip install --upgrade pip pip install . conda info @@ -60,5 +61,5 @@ jobs: python -m pip install --upgrade pip pip install pytest - - name: Tests + - name: Run Unit Tests run: pytest -v tests/unit/ diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 0497214..6b08bce 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -22,10 +22,12 @@ jobs: - name: Build package run: | - pip install --upgrade build setuptools wheel setuptools-scm + pip install --upgrade build setuptools wheel setuptools-scm twine python -m build + twine check dist/* - name: Test installation run: | + # Install the built wheel to ensure packaging didn't miss files pip install dist/*.whl - python -c "from importlib.metadata import version; print(version('mailgun'))" + python -c "import mailgun; from importlib.metadata import version; print(f'Successfully installed v{version(\"mailgun\")}')" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c3305f1..2ba0d64 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,7 @@ permissions: jobs: publish: + name: Build and Publish to PyPI runs-on: ubuntu-latest permissions: contents: read diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f706721..88b8c30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -170,57 +170,14 @@ repos: name: "🔧 ci/cd · Validate GitHub workflows" files: ^\.github/workflows/.*\.ya?ml$ - # Python code formatting (order matters: autoflake → pyupgrade → darker/ruff) - - repo: https://github.com/PyCQA/autoflake - rev: v2.3.3 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.9 hooks: - - id: autoflake - name: "🐍 format · Remove unused imports" - args: - - --in-place - - --remove-all-unused-imports - - --remove-unused-variable - - --ignore-init-module-imports - - - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 - hooks: - - id: pyupgrade - name: "🐍 format · Modernize syntax" - args: [--py310-plus, --keep-runtime-typing] - - - repo: https://github.com/akaihola/darker - rev: v3.0.0 - hooks: - - id: darker - name: "🐍 format · Format changed lines" - additional_dependencies: [black] - -# - repo: https://github.com/astral-sh/ruff-pre-commit -# rev: v0.15.6 -# hooks: -# - id: ruff-check -# name: "🐍 lint · Check with Ruff" -# args: [--fix, --preview] -# - id: ruff-format -# name: "🐍 format · Format with Ruff" - - # Python linting (comprehensive checks) - - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - name: "🐍 lint · Check style (Flake8)" - args: ["--ignore=E501,C901", --max-complexity=13] # Sets McCabe complexity limit - additional_dependencies: - - radon - - flake8-docstrings - - Flake8-pyproject - - flake8-bugbear - - flake8-comprehensions - - flake8-tidy-imports - - pycodestyle - exclude: ^tests + - id: ruff-check + name: "🐍 lint · Check with Ruff" + args: [--fix, --preview] + - id: ruff-format + name: "🐍 format · Format with Ruff" - repo: https://github.com/PyCQA/pylint rev: v4.0.5 @@ -230,23 +187,6 @@ repos: args: - --exit-zero - - repo: https://github.com/dosisod/refurb - rev: v2.3.0 - hooks: - - id: refurb - name: "🐍 performance · Suggest modernizations" - # TODO: Fix FURB147. - args: ["--enable-all", "--ignore", "FURB147"] - - # Python documentation - - repo: https://github.com/pycqa/pydocstyle - rev: 6.3.0 - hooks: - - id: pydocstyle - name: "🐍 docs · Validate docstrings" - args: [--select=D200,D213,D400,D415] - additional_dependencies: [tomli] - - repo: https://github.com/econchick/interrogate rev: 1.7.0 hooks: @@ -307,16 +247,17 @@ repos: - mdformat-gfm - mdformat-black - mdformat-ruff + # TODO: Enable it for a single check -# - repo: https://github.com/tcort/markdown-link-check -# rev: v3.14.2 -# hooks: -# - id: markdown-link-check -# name: "📝 docs · Check markdown links" + - repo: https://github.com/tcort/markdown-link-check + rev: v3.14.2 + hooks: + - id: markdown-link-check + name: "📝 docs · Check markdown links" # Makefile linting -# - repo: https://github.com/checkmake/checkmake -# rev: v0.3.0 -# hooks: -# - id: checkmake -# name: "🔧 build · Lint Makefile" + - repo: https://github.com/checkmake/checkmake + rev: v0.3.0 + hooks: + - id: checkmake + name: "🔧 build · Lint Makefile" diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e87de..4103524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,26 +6,67 @@ We [keep a changelog.](http://keepachangelog.com/) ### Added +- Explicit `__all__` declaration in `mailgun.client` to cleanly isolate the public API namespace. +- A `__repr__` method to the `Client` and `BaseEndpoint` classes to improve developer experience (DX) during console debugging (showing target routes instead of memory addresses). +- Security guardrail (CWE-319) in `Config` that logs a warning if a cleartext `http://` API URL is configured. +- Python 3.14 support to the GitHub Actions test matrix. - Implemented Smart Logging (telemetry) in `Client` and `AsyncClient` to help users debug API requests, generated URLs, and server errors (`404`, `400`, `429`). -- Added a new "Logging & Debugging" section to `README.md`. +- Smart Webhook Routing: Implemented payload-based routing for domain webhooks. The SDK dynamically routes to `v1`, `v3`, or `v4` endpoints based on the HTTP method and presence of parameters like `event_types` or `url`. +- Deprecation Interceptor: Added a registry and interception hook that emits non-breaking `DeprecationWarning`s and logs when utilizing obsolete Mailgun APIs (e.g., v3 validations, legacy tags, v1 bounce-classification). - Added `build_path_from_keys` utility in `mailgun.handlers.utils` to centralize and dry up URL path generation across handlers. +- Overrode __dir__ in Client and AsyncClient to expose dynamic endpoint routes (e.g., .messages, .domains) directly to IDE autocompletion engines (VS Code, PyCharm). +- Native dynamic routing support for Mailgun Optimize, Validations Service, and Email Preview APIs without requiring new custom handlers. +- Advanced path interpolation in `handle_default` to automatically inject inline URL parameters (e.g., `/v2/x509/{domain}/status`). +- Added a new "Logging & Debugging" section to `README.md`. +- An intelligent live meta-testing suite (`test_routing_meta_live.py`) to strictly verify SDK endpoint aliases against live Mailgun servers. ### Changed - Refactored the `Config` routing engine to use a deterministic, data-driven approach (`EXACT_ROUTES` and `PREFIX_ROUTES`) for better maintainability. - Improved dynamic API version resolution for domain endpoints to gracefully switch between `v1`, `v3`, and `v4` for nested resources, with a safe fallback to `v3`. - Secured internal configuration registries by wrapping them in `MappingProxyType` to prevent accidental mutations of the client state. +- Broadened type hints for `files` (`Any | None`) and `timeout` (`int | float | tuple`) to fully support `requests`/`httpx` capabilities (like multipart lists) without triggering false positives in strict IDEs. +- **Performance**: Implemented automated Payload Minification. The SDK now strips structural spaces from JSON payloads (`separators=(',', ':')`), reducing network overhead by ~15-20% for large batch requests. +- **Performance**: Memoized internal route resolution logic using `@lru_cache` in `_get_cached_route_data`, eliminating redundant string splitting and dictionary lookups during repeated API calls. +- Updated `DOMAIN_ENDPOINTS` mapping to reflect Mailgun's latest architecture, officially moving `tracking`, `click`, `open`, `unsubscribe`, and `webhooks` from `v1` to `v3`. - Modernized the codebase using modern Python idioms (e.g., `contextlib.suppress`) and resolved strict typing errors for `pyright`. +- **Documentation**: Migrated all internal and public docstrings from legacy Sphinx/reST format to modern Google Style for cleaner readability and better IDE hover-hints. - Updated Dependabot configuration to group minor and patch updates and limit open PRs. +- Migrated the fragmented linting and formatting pipeline (Flake8, Black, Pylint, Pyupgrade, etc.) to a unified, high-performance `ruff` setup in `.pre-commit-config.yaml`. +- Refactored `api_call` exception blocks to use the `else` clause for successful returns, adhering to strict Ruff (TRY300) standards. +- Enabled pip dependency caching in GitHub Actions to drastically speed up CI workflows. +- Fixed API versioning collisions in `DOMAIN_ENDPOINTS` (e.g., ensuring `tracking` correctly resolves to `v3` instead of `v1`). +- Corrected the `credentials` route prefix to properly inject the `domains/` path segment. +- Updated `README.md` with new documentation, IDE DX features, and code examples for Validations & Optimize APIs. +- Cleaned up obsolete unit tests that conflicted with the new forgiving dynamic Catch-All routing architecture. ### Fixed +- Fixed a silent data loss bug in `create()` where custom `headers` passed by the user were ignored instead of being merged into the request. +- Fixed a kwargs collision bug in `update()` by using `.pop("headers")` instead of `.get()` to prevent passing duplicate keyword arguments to the underlying request. +- Preserved original tracebacks (PEP 3134) by properly chaining `TimeoutError` and `ApiError` using `from e`. +- Used safely truncating massive HTML error responses to 500 characters (preventing a log-flooding vulnerability (OWASP CWE-532)). +- Replaced a fragile `try/except TypeError` status code check with robust `getattr` and `isinstance` validation to prevent masking unrelated exceptions. - Resolved `httpx` `DeprecationWarning` in `AsyncEndpoint` by properly routing serialized JSON string payloads to the `content` parameter instead of `data`. - Fixed a bug in `domains_handler` where intermediate path segments were sometimes dropped for nested resources like `/credentials` or `/ips`. - Fixed flaky integration tests failing with `429 Too Many Requests` and `403 Limits Exceeded` by adding proper eventual consistency delays and state teardowns. - Fixed DKIM key generation tests to use the `-traditional` OpenSSL flag, ensuring valid PKCS1 format compatibility. - Fixed DKIM selector test names to strictly comply with RFC 6376 formatting (replaced underscores with hyphens). +### Security + +- OWASP Credential Protection: Implemented a `SecretAuth` tuple subclass to securely redact the Mailgun API key from accidental exposure in memory dumps, tracebacks, and `repr()` logs. +- OWASP Input Validation: Added strict sanitization in `Client._validate_auth` to strip trailing whitespace and block HTTP Header Injection attacks (rejecting `\n` and `\r` characters in API keys). + +### Pull Requests Merged + +- [PR_36](https://github.com/mailgun/mailgun-python/pull/36) - Improve client, update & fix tests +- [PR_35](https://github.com/mailgun/mailgun-python/pull/35) - Removed \_prepare_files logic +- [PR_34](https://github.com/mailgun/mailgun-python/pull/34) - Improve the Config class and routes +- [PR_33](https://github.com/mailgun/mailgun-python/pull/32) - Refactored test framework +- [PR_31](https://github.com/mailgun/mailgun-python/pull/31) - Add missing py.typed in module directory +- [PR_30](https://github.com/mailgun/mailgun-python/pull/30) - build(deps): Bump conda-incubator/setup-miniconda from 3.2.0 to 3.3.0 + ## [1.6.0] - 2026-01-08 ### Added diff --git a/Makefile b/Makefile index d4b5da0..f5f04de 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,9 @@ export PRINT_HELP_PYSCRIPT BROWSER := python -c "$$BROWSER_PYSCRIPT" +# Point 'all' to whatever you want to happen when someone just types `make` +all: help + clean: clean-cov clean-build clean-pyc clean-test clean-temp clean-other ## remove all build, test, coverage and Python artifacts clean-cov: @@ -111,22 +114,13 @@ dev-full: clean ## install the package's development version to a fresh environ conda run --name $(CONDA_ENV_NAME)-dev pip install -e . $(CONDA_ACTIVATE) $(CONDA_ENV_NAME)-dev && pre-commit install - pre-commit: ## runs pre-commit against files. NOTE: older files are disabled in the pre-commit config. pre-commit run --all-files check-env: - @missing=0; \ - for v in $(REQUIRED_VARS); do \ - if [ -z "$${!v}" ]; then \ - echo "Missing required env var: $$v"; \ - missing=1; \ - fi; \ - done; \ - if [ $$missing -ne 0 ]; then \ - echo "Aborting tests due to missing env vars."; \ - exit 1; \ - fi + @if [ -z "$(ENV_VAR)" ]; then echo "Missing ENV_VAR"; exit 1; fi; \ + if [ -z "$(OTHER_VAR)" ]; then echo "Missing OTHER_VAR"; exit 1; fi; \ + echo "Environment checks passed." test: test-unit @@ -169,11 +163,8 @@ format-black: @black --line-length=100 $(SRC_DIR) $(TEST_DIR) $(SCRIPTS_DIR) format-isort: @isort --profile black --line-length=88 $(SRC_DIR) $(TEST_DIR) $(SCRIPTS_DIR) -format: format-black format-isort - -format: ## runs the code auto-formatter - isort - black +format: + ruff check --fix . format-docs: ## runs the docstring auto-formatter. Note this requires manually installing `docconvert` with `pip` docconvert --in-place --config .docconvert.json $(SRC_DIR) diff --git a/README.md b/README.md index cf4236f..ac79057 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ Welcome to the official Python SDK for [Mailgun](http://www.mailgun.com/)! Check out all the resources and Python code examples in the official -[Mailgun Documentation](https://documentation.mailgun.com/docs/mailgun/). +[Mailgun Documentation](https://documentation.mailgun.com). + +## Table of contents ## Table of contents @@ -23,44 +25,51 @@ Check out all the resources and Python code examples in the official - [Overview](#overview) - [Base URL](#base-url) - [Authentication](#authentication) + - [Quick Start](#quick-start) - [Client](#client) + - [Advanced Configuration](#advanced-configuration) - [AsyncClient](#asyncclient) + - [Usage](#usage) + - [Logging & Debugging](#logging--debugging) + - [IDE Autocompletion & DX](#ide-autocompletion--dx) - [API Response Codes](#api-response-codes) - [Request examples](#request-examples) - [Full list of supported endpoints](#full-list-of-supported-endpoints) - [Messages](#messages) - [Send an email](#send-an-email) + - [Send an email with advanced parameters (Tags, Testmode, STO)](#send-an-email-with-advanced-parameters-tags-testmode-sto) - [Send an email with attachments](#send-an-email-with-attachments) - [Send a scheduled message](#send-a-scheduled-message) - [Domains](#domains) - [Get domains](#get-domains) + - [Get domains with filters](#get-domains-with-filters) - [Get domains details](#get-domains-details) - [Create a domain](#create-a-domain) - [Update a domain](#update-a-domain) - [Domain connections](#domain-connections) - - [Domain keys](#domain-keys) - - [List keys for all domains](#list-keys-for-all-domains) - - [Create a domain key](#create-a-domain-key) + - [Domain keys](#domain-keys) + - [List keys for all domains](#list-keys-for-all-domains) + - [Create a domain key](#create-a-domain-key) - [Update DKIM authority](#update-dkim-authority) - [Domain Tracking](#domain-tracking) - [Get tracking settings](#get-tracking-settings) - [Webhooks](#webhooks) + - [Create a webhook (v4 Multi-Event)](#create-a-webhook-v4-multi-event) - [Get all webhooks](#get-all-webhooks) - - [Create a webhook](#create-a-webhook) - - [Delete a webhook](#delete-a-webhook) + - [Create Account-Level Webhooks (v1)](#create-account-level-webhooks-v1) - [Events](#events) - [Retrieves a paginated list of events](#retrieves-a-paginated-list-of-events) - [Get events by recipient](#get-events-by-recipient) - [Bounce Classification](#bounce-classification) - [List statistic v2](#list-statistic-v2) - - [Logs](#logs) - - [List logs](#list-logs) - [Tags New](#tags-new) + - [Get account tags](#get-account-tags) - [Update account tag](#update-account-tag) - [Post query to list account tags or search for single tag](#post-query-to-list-account-tags-or-search-for-single-tag) - [Delete account tag](#delete-account-tag) - [Get account tag limit information](#get-account-tag-limit-information) - - [Metrics](#metrics) + - [Metrics & Logs](#metrics--logs) + - [List Logs](#list-logs) - [Get account metrics](#get-account-metrics) - [Get account usage metrics](#get-account-usage-metrics) - [Suppressions](#suppressions) @@ -92,21 +101,27 @@ Check out all the resources and Python code examples in the official - [IPs](#ips) - [List account IPs](#list-account-ips) - [Delete a domain's IP](#delete-a-domains-ip) - - [Tags](#tags) - - [Get tags](#get-tags) - - [Get aggregate countries](#get-aggregate-countries) - - [Email validation](#email-validation) - - [Create a single validation](#create-a-single-validation) - - [Inbox placement](#inbox-placement) - - [Get all inbox](#get-all-inbox) + - [Keys](#keys) + - [List Mailgun API keys](#list-mailgun-api-keys) + - [Create Mailgun API key](#create-mailgun-api-key) - [Credentials](#credentials) - [List Mailgun SMTP credential metadata for a given domain](#list-mailgun-smtp-credential-metadata-for-a-given-domain) - [Create Mailgun SMTP credentials for a given domain](#create-mailgun-smtp-credentials-for-a-given-domain) - [Users](#users) - [Get users on an account](#get-users-on-an-account) - [Get a user's details](#get-a-users-details) + - [Validations & Optimize APIs](#validations--optimize-apis) + - [Email validation](#email-validation) + - [Create a single validation](#create-a-single-validation) + - [Validate an email address](#validate-an-email-address) + - [Inbox placement](#inbox-placement) + - [Get all inbox](#get-all-inbox) + - [Fetch InboxReady placement tests](#fetch-inboxready-placement-tests) + - [Deprecation Warnings](#deprecation-warnings) + - [Type Hinting](#type-hinting) - [License](#license) - [Contribute](#contribute) + - [Security](#security) - [Contributors](#contributors) ## Compatibility @@ -121,12 +136,11 @@ It's tested up to 3.14 (including). ### Build backend dependencies -To build the `mailgun` package from the sources you need `setuptools` (as a build backend), `wheel`, and -`setuptools-scm`. +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 only `requests >=2.32.4`. +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`. ### Test dependencies @@ -244,6 +258,12 @@ export USER_NAME="Name Surname" export ROLE="admin" ``` +## Quick Start + +The Mailgun Send API uses your API key for authentication. + +Synchronous vs Asynchronous Client. + ### Client Initialize your [Mailgun](http://www.mailgun.com/) client: @@ -256,6 +276,16 @@ auth = ("api", os.environ["APIKEY"]) client = Client(auth=auth) ``` +### Advanced Configuration + +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: + +```python +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. + ### AsyncClient 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. @@ -268,6 +298,31 @@ auth = ("api", os.environ["APIKEY"]) client = AsyncClient(auth=auth) ``` +## Usage + +Send a message with a Synchronous Client. + +```python +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 ", + "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: ```python @@ -300,27 +355,6 @@ asyncio.run(main()) 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. -### 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** - Will typically contain a JSON response with a "message" key which contains a human readable message / action -to interpret. - -**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** - Mailgun does have rate limits in place to protect our system. Please retry these requests as defined in the -response. In the unlikely case you encounter them and need them raised, please reach out to our support team. - -**500** - Internal Error on the Mailgun side. Retries are recommended with exponential or logarithmic retry intervals. -If the issue persists, please reach out to our support team. - ### Logging & Debugging 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`). @@ -344,6 +378,34 @@ client = Client(auth=("api", "YOUR_API_KEY")) client.domains.get() ``` +### IDE Autocompletion & DX + +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. + +### 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. + ## Request examples ### Full list of supported endpoints @@ -360,65 +422,42 @@ a MIME representation of the message and send it. Note: In order to send you mus parameters: 'text', 'html', 'amp-html' or 'template' ```python -import os -from mailgun.client import Client - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +data = { + "from": "test@test.com", + "to": "recipient@example.com", + "subject": "Hello from python!", + "text": "Hello world!", +} +req = client.messages.create(data=data) +``` +#### Send an email with advanced parameters (Tags, Testmode, STO) -def post_message() -> None: - # Messages - # POST //messages - data = { - "from": os.getenv("MESSAGES_FROM", "test@test.com"), - "to": os.getenv("MESSAGES_TO", "recipient@example.com"), - "subject": "Hello from python!", - "text": "Hello world!", - "o:tag": "Python test", - } +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. - req = client.messages.create(data=data, domain=domain) - print(req.json()) +```python +data = { + "from": "Excited User ", + "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) ``` #### Send an email with attachments +It is strongly recommended that you open files in binary mode (`read_bytes()`). + ```python -import os from pathlib import Path -from mailgun.client import Client - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) - - -def post_message() -> None: - # Messages - # POST //messages - data = { - "from": os.getenv("MESSAGES_FROM", "test@test.com"), - "to": os.getenv("MESSAGES_TO", "recipient@example.com"), - "subject": "Hello from python!", - "text": "Hello world!", - "o:tag": "Python test", - } - # 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 = [ - ( - "attachment", - ("test1.txt", Path("test1.txt").read_bytes()), - ) - ] - - req = client.messages.create(data=data, files=files, domain=domain) - print(req.json()) +files = [("attachment", ("report.pdf", Path("report.pdf").read_bytes()))] +req = client.messages.create(data=data, files=files) ``` #### Send a scheduled message @@ -444,66 +483,30 @@ def post_scheduled() -> None: #### Get domains ```python -import os -from mailgun.client import Client - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) - - -def get_domains() -> None: - """ - GET /domains - :return: - """ - data = client.domainlist.get() - print(data.json()) +data = client.domainlist.get() +print(data.json()) ``` #### Get domains with filters ```python -def get_domains_with_filters() -> None: - """ - GET /domains - :return: - """ - params = {"skip": 0, "limit": 1} - data = client.domainlist.get(filters=params) - print(data.json()) +data = client.domainlist.get(filters={"skip": 0, "limit": 10}) +print(data.json()) ``` #### Get domains details ```python -def get_simple_domain() -> None: - """ - GET /domains/ - :return: - """ - domain_name = "python.test.domain4" - data = client.domains.get(domain_name=domain_name) - print(data.json()) +domain_name = "python.test.com" +data = client.domains.get(domain_name=domain_name) +print(data.json()) ``` #### Create a domain ```python -def add_domain() -> None: - """ - POST /domains - :return: - """ - # Post domain - data = { - "name": "python.test.domain5", - # "smtp_password": "" - } - - request = client.domains.create(data=data) - print(request.json()) - print(request.status_code) +data = {"name": "new.domain.com"} +req = client.domains.create(data=data) ``` #### Update a domain @@ -532,9 +535,9 @@ def get_connections() -> None: print(request.json()) ``` -#### Domain keys +### Domain keys -### List keys for all domains +#### 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. @@ -635,162 +638,170 @@ def get_tracking() -> None: ### 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) + +```python +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) +``` + #### Get all webhooks ```python -import os +req = client.domains_webhooks.get() +``` -from mailgun.client import Client +#### Create Account-Level Webhooks (v1) +```python +data = {"id": "clicked", "url": ["https://my-server.com/webhook"]} +req = client.account_webhooks.create(data=data) +``` -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] +### Events -client: Client = Client(auth=("api", key)) +#### Retrieves a paginated list of events +```python +domain: str = os.environ["DOMAIN"] -def get_webhooks() -> None: - """ - GET /domains//webhooks - :return: - """ - req = client.domains_webhooks.get(domain=domain) - print(req.json()) +req = client.events.get(domain=domain) +print(req.json()) ``` -#### Create a webhook +#### Get events by recipient ```python -def create_webhook() -> None: - """ - POST /domains//webhooks - :return: - """ - data = {"id": "clicked", "url": ["https://facebook.com"]} - # - req = client.domains_webhooks.create(domain=domain, data=data) - 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) ``` -#### Delete a webhook - -```python -def put_webhook() -> None: - """ - PUT /domains//webhooks/ - :return: - """ - data = {"id": "clicked", "url": ["https://facebook.com", "https://google.com"]} +### Bounce Classification - req = client.domains_webhooks_clicked.put(domain=domain, data=data) - print(req.json()) -``` +[API endpoint](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/bounce-classification). -### Events +#### List statistic v2 -#### Retrieves a paginated list of events +Items that have no bounces and no delays(classified_failures_count==0) are not returned. ```python -import os - -from mailgun.client import Client +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()) +``` -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] +### Tags New -client: Client = Client(auth=("api", key)) +Mailgun allows you to tag your email with unique identifiers. Tags are visible via our analytics tags +[API endpoint](https://documentation.mailgun.com/docs/inboxready/api-reference/optimize/mailgun/tags-new). +#### Get account tags -def get_domain_events() -> None: - """ - GET //events - :return: - """ - req = client.events.get(domain=domain) - print(req.json()) +```python +data = {"pagination": {"sort": "lastseen:desc", "limit": 10}} +req = client.analytics_tags.create(data=data) ``` -#### Get events by recipient +#### Update account tag + +Updates the tag description for an account. ```python -def events_by_recipient() -> None: - """ - GET //events - :return: - """ - params = { - "begin": "Tue, 24 Nov 2020 09:00:00 -0000", - "ascending": "yes", - "limit": 10, - "pretty": "yes", - "recipient": os.environ["VALIDATION_ADDRESS_1"], - } - req = client.events.get(domain=domain, filters=params) - print(req.json()) +data = { + "tag": "name-of-tag-to-update", + "description": "updated tag description", +} + +req = client.analytics_tags.update(data=data) +print(req.json()) ``` -### Bounce Classification +#### Post query to list account tags or search for single tag -[API endpoint](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/bounce-classification). +Gets the list of all tags, or filtered by tag prefix, for an account. -#### List statistic v2 +```python +data = { + "pagination": {"sort": "lastseen:desc", "limit": 10}, + "include_subaccounts": True, +} -Items that have no bounces and no delays(classified_failures_count==0) are not returned. +req = client.analytics_tags.create(data=data) +print(req.json()) +``` + +#### Delete account tag + +Deletes the tag for an account. ```python -def post_list_statistic_v2() -> None: - """ - # Bounce Classification - # POST /v2/bounce-classification/metrics - :return: - """ +data = {"tag": "name-of-tag-to-delete"} - 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}, - } +req = client.analytics_tags.delete(data=data) +print(req.json()) +``` + +#### Get account tag limit information - headers = {"Content-Type": "application/json"} +Gets the tag limit and current number of unique tags for an account. - req = client.bounceclassification_metrics.create(data=payload, headers=headers) - print(req.json()) +```python +req = client.analytics_tags_limits.get() +print(req.json()) ``` -### Logs +### 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](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/logs/post-v1-analytics-logs). -#### List Logs - Gets customer event logs for an account. ```python @@ -824,136 +835,46 @@ def post_analytics_logs() -> None: print(req.json()) ``` -### Tags New - -Mailgun allows you to tag your email with unique identifiers. Tags are visible via our analytics tags -[API endpoint](https://documentation.mailgun.com/docs/inboxready/api-reference/optimize/mailgun/tags-new). +#### Get account metrics -#### Update account tag +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](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/metrics). -Updates the tag description for an account. +Get filtered metrics for an account ```python -def update_analytics_tags() -> None: - """ - # Metrics - # PUT /v1/analytics/tags - :return: - """ - - data = { - "tag": "name-of-tag-to-update", - "description": "updated tag description", - } - - req = client.analytics_tags.update(data=data) - print(req.json()) +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()) ``` -#### 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. +#### Get account usage metrics ```python -def post_analytics_tags() -> None: +def post_analytics_usage_metrics() -> None: """ - # Metrics - # POST /v1/analytics/tags - :return: - """ - - data = { - "pagination": {"sort": "lastseen:desc", "limit": 10}, - "include_subaccounts": True, - } - - req = client.analytics_tags.create(data=data) - print(req.json()) -``` - -#### Delete account tag - -Deletes the tag for an account. - -```python -def delete_analytics_tags() -> None: - """ - # Metrics - # DELETE /v1/analytics/tags - :return: - """ - - data = {"tag": "name-of-tag-to-delete"} - - 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. - -```python -def get_account_analytics_tag_limit_information() -> None: - """ - # Metrics - # GET /v1/analytics/tags/limits - :return: - """ - - req = client.analytics_tags_limits.get() - print(req.json()) -``` - -### 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](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/metrics). - -#### Get account metrics - -Get filtered metrics for an account - -```python -def post_analytics_metrics() -> None: - """ - # Metrics - # POST /analytics/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": ["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()) -``` - -#### Get account usage metrics - -```python -def post_analytics_usage_metrics() -> None: - """ - # Usage Metrics - # POST /analytics/usage/metrics + # Usage Metrics + # POST /analytics/usage/metrics :return: """ data = { @@ -999,24 +920,8 @@ def post_analytics_usage_metrics() -> None: ##### Create bounces ```python -import os - -from mailgun.client import Client - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] - -client: Client = Client(auth=("api", key)) - - -def post_bounces() -> None: - """ - POST //bounces - :return: - """ - data = {"address": "test120@gmail.com", "code": 550, "error": "Test error"} - req = client.bounces.create(data=data, domain=domain) - print(req.json()) +data = {"address": "test120@gmail.com", "code": 550, "error": "Test error"} +req = client.bounces.create(data=data) ``` #### Unsubscribe @@ -1024,13 +929,10 @@ def post_bounces() -> None: ##### View all unsubscribes ```python -def get_unsubs() -> None: - """ - GET //unsubscribes - :return: - """ - req = client.unsubscribes.get(domain=domain) - print(req.json()) +domain: str = os.environ["DOMAIN"] + +req = client.unsubscribes.get(domain=domain) +print(req.json()) ``` ##### Import list of unsubscribes @@ -1040,16 +942,9 @@ def get_unsubs() -> None: > open the file in text mode. ```python -def import_list_unsubs() -> None: - """ - POST //unsubscribes/import, Content-Type: multipart/form-data - :return: - """ - 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()) +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 @@ -1057,14 +952,11 @@ def import_list_unsubs() -> None: ##### Add complaints ```python -def add_complaints() -> None: - """ - POST //complaints - :return: - """ - data = {"address": "bob@gmail.com", "tag": "compl_test_tag"} - req = client.complaints.create(data=data, domain=domain) - 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()) ``` ##### Import list of complaints @@ -1074,14 +966,11 @@ def add_complaints() -> None: > open the file in text mode. ```python -def import_complaint_list() -> None: - """ - POST //complaints/import, Content-Type: multipart/form-data - :return: - """ - 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"] + +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 @@ -1089,13 +978,9 @@ def import_complaint_list() -> None: ##### Delete all whitelists ```python -def delete_all_whitelists() -> None: - """ - DELETE //whitelists - :return: - """ - req = client.whitelists.delete(domain=domain) - print(req.json()) +domain: str = os.environ["DOMAIN"] +req = client.whitelists.delete(domain=domain) +print(req.json()) ``` ### Routes @@ -1103,42 +988,23 @@ def delete_all_whitelists() -> None: #### Create a route ```python -import os - -from mailgun.client import Client - - -key: str = os.environ["APIKEY"] domain: str = os.environ["DOMAIN"] - -client: Client = Client(auth=("api", key)) - - -def post_routes() -> None: - """ - POST /routes - :return: - """ - 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()) +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()) ``` #### Get a route by id ```python -def get_route_by_id() -> None: - """ - GET /routes/ - :return: - """ - req = client.routes.get(domain=domain, route_id="6012d994e8d489e24a127e79") - print(req.json()) +domain: str = os.environ["DOMAIN"] +req = client.routes.get(domain=domain, route_id="6012d994e8d489e24a127e79") +print(req.json()) ``` ### Mailing Lists @@ -1146,53 +1012,27 @@ def get_route_by_id() -> None: #### Create a mailing list ```python -import os - -from mailgun.client import Client - - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] - -client: Client = Client(auth=("api", key)) - - -def post_lists() -> None: - """ - POST /lists - :return: - """ - data = { - "address": f"python_sdk2@{domain}", - "description": "Mailgun developers list", - } - - req = client.lists.create(domain=domain, data=data) - print(req.json()) +data = { + "address": "developers@my-domain.com", + "description": "Mailgun developers list", +} +req = client.lists.create(data=data) ``` #### Get mailing lists members ```python -def get_lists_members() -> None: - """ - GET /lists/
/members/pages - :return: - """ - req = client.lists_members_pages.get(domain=domain, address=mailing_list_address) - print(req.json()) +domain: str = os.environ["DOMAIN"] +req = client.lists_members_pages.get(domain=domain, address=mailing_list_address) +print(req.json()) ``` #### Delete mailing lists address ```python -def delete_lists_address() -> None: - """ - DELETE /lists/
- :return: - """ - req = client.lists.delete(domain=domain, address=f"python_sdk2@{domain}") - print(req.json()) +domain: str = os.environ["DOMAIN"] +req = client.lists.delete(domain=domain, address=f"python_sdk2@{domain}") +print(req.json()) ``` ### Templates @@ -1200,72 +1040,39 @@ def delete_lists_address() -> None: #### Get templates ```python -import os - -from mailgun.client import Client - - -key: str = os.environ["APIKEY"] domain: str = os.environ["DOMAIN"] - -client: Client = Client(auth=("api", key)) - - -def get_domain_templates() -> None: - """ - GET //templates - :return: - """ - params = {"limit": 1} - req = client.templates.get(domain=domain, filters=params) - print(req.json()) +params = {"limit": 1} +req = client.templates.get(domain=domain, filters=params) +print(req.json()) ``` #### Update a template ```python -def update_template() -> None: - """ - PUT //templates/ - :return: - """ - data = {"description": "new template description"} +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()) +req = client.templates.put(data=data, domain=domain, template_name="template.name1") +print(req.json()) ``` #### Create a new template version ```python -def create_new_template_version() -> None: - """ - POST //templates/