diff --git a/.github/workflows/commit_checks.yaml b/.github/workflows/commit_checks.yaml index 8b785c3..61e60d0 100644 --- a/.github/workflows/commit_checks.yaml +++ b/.github/workflows/commit_checks.yaml @@ -14,7 +14,7 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-python@v6 with: python-version: '3.13' # Specify a Python version explicitly @@ -35,7 +35,7 @@ jobs: APIKEY: ${{ secrets.APIKEY }} DOMAIN: ${{ secrets.DOMAIN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Required for setuptools-scm @@ -45,7 +45,6 @@ jobs: channels: defaults show-channel-urls: true environment-file: environment-dev.yaml - cache: 'pip' # Drastically speeds up CI by caching pip dependencies - name: Install package run: | diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 6b08bce..62ed115 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -11,7 +11,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fb82d3d..3b548d1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..4de8d9a --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,64 @@ +name: Continuous Security Verification + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '37 3 * * 0' # Run weekly on Sundays to catch newly published CVEs + +permissions: + contents: read + +jobs: + static-analysis: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: 'pip' + - run: python -m pip install --upgrade pip + - run: pip install ruff bandit mypy pip-audit + # Fast checks + - run: ruff check . + - run: bandit -c pyproject.toml -r mailgun + - run: mypy --strict mailgun + + semgrep: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: returntocorp/semgrep-action@v1 + with: + config: >- + p/python + p/owasp-top-ten + p/supply-chain + p/command-injection + p/insecure-transport + error: true # Fails CI if issues found + + pip-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: { python-version: "3.13" } + - run: python -m pip install --upgrade pip + - run: pip install pip-audit + - run: pip-audit --strict + + osv-scan: + permissions: + actions: read + security-events: write # For Security Tab + contents: read + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.3.8" + with: + # Explicit root scanning + scan-args: |- + --recursive + ./ diff --git a/.gitignore b/.gitignore index 65eb3cf..ad08cff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,172 +1,149 @@ -# Byte-compiled / optimized / DLL files +# ============================================================================== +# 1. PYTHON CORE, CACHES & RUNTIME +# ============================================================================== +__pycache__ __pycache__/ -*.py[cod] +**/__pycache__/*.pyc +*/__pycache__/ +*/__pycache__/*.pyc +*/*/__pycache__/ +*/*/*/__pycache__/ *$py.class - -# C extensions +*.pyc +*.py[cod] +*.py,cover *.so - -# Distribution / packaging .Python + +# ============================================================================== +# 2. VIRTUAL ENVIRONMENTS +# ============================================================================== +.venv +venv +venv/ +venv.bak/ +venv.bak +pythonenv* +myvenv +env +env/ +ENV/ +env.bak/ +env.bak +.env +.env-mysql + +# ============================================================================== +# 3. PACKAGING, PYTHON BUILDERS & DISTRIBUTIONS +# ============================================================================== +build build/ -develop-eggs/ +dist dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ +sdist sdist/ -var/ wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg +wheels +out/ +/out/ MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder +*.egg-info/ +*.egg +*.gem +develop-eggs +develop-eggs/ +eggs +eggs/ +.eggs/ +.installed.cfg +.mr.developer.cfg .pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock +share/python-wheels/ +pip-delete-this-directory.txt +pip-wheel-metadata/ -# PEP 582; used by e.g. github.com/David-OConnor/pyflow +# ============================================================================== +# 4. DEPENDENCY MANAGERS & COMPILERS +# ============================================================================== +.pdm.toml +poetry.toml __pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -3.8venv/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation +node_modules +node_modules/ +target/ +cmake-build-*/ +cython_debug/ +/bin +bin +/include +/lib +lib +lib/ +lib64 +lib64/ /site +/src -# mypy +# ============================================================================== +# 5. TESTING FRAMEWORKS, FUZZING & CACHES +# ============================================================================== +.ruff_cache/ .mypy_cache/ .dmypy.json dmypy.json - -# Pyre type checker +.pytest_cache/ +.hypothesis/ +.nox/ +.tox/ +.cache .pyre/ - -# pytype static type analyzer .pytype/ +.webassets-cache +pytestdebug.log +pyrightconfig.json +.overcommit.yml -# Cython debug symbols -cython_debug/ +# Atheris / Fuzzing / Profiling Artifacts +tests/fuzz/corpus/ +tests/fuzz/corpus/*/ +tests/fuzz/corpus/fuzz_*/golden_*.json +crash-* +leak-* +slow-* +fuzz-*.log +.clusterfuzzlite/ +*.prof +profile.html +profile.json + + +# ============================================================================== +# 6. TESTING METRICS & CODE COVERAGE +# ============================================================================== +.coverage +.coverage.* +.coverage* +cover/ +htmlcov +htmlcov/ +coverage.xml +nosetests.xml +junit* +*.cover +tdd +reports/ -#PyCharm +# ============================================================================== +# 7. IDEs & TEXT EDITORS +# ============================================================================== +# JetBrains / PyCharm / CLion .idea/ - - -# Generic -__pycache__ -!.elasticbeanstalk/*.cfg.yml -!.elasticbeanstalk/*.global.yml -.anvil/* -.elasticbeanstalk/* -.env-mysql -.history -.mr.developer.cfg -.pdm.toml -.prof -.project -.pydevproject -.tox -.vagrant/ -*.code-workspace -*.gz -*.iml -*.iws -*.lock -*.pyc -*.rar -*.sqlite -*.zip -**/__pycache__/*.pyc +.idea_modules/ +.idea/* +.idea/*.iml +.idea/caches/build_file_checksums.ser **/.idea/dataSources.ids **/.idea/dataSources.local.xml **/.idea/dataSources.xml @@ -178,78 +155,6 @@ __pycache__ **/.idea/uiDesigner.xml **/.idea/vcs.xml **/.idea/workspace.xml -**/staticfiles/ -*/__pycache__/ -*/__pycache__/*.pyc -*/*/__pycache__/ -*/*/*/__pycache__/ -*/staticfiles/ -/bin -/include -/lib -/out/ -/src - -atlassian-ide-plugin.xml -bin -build -cmake-build-*/ -com_crashlytics_export_strings.xml -crashlytics-build.properties -crashlytics.properties -develop-eggs -dist -eggs -fabric.properties -lib -lib64 -media -myvenv -node_modules -node_modules/ -parts -pip-wheel-metadata/ -poetry.toml -projects/static/ -pyrightconfig.json -pythonenv* -sdist -secret_key.txt -static/build/ -static/local/ -static/media -static/rev-manifest.json -staticfiles/ -tdd -temp/ -Thumbs.db -tmp/ -uploads/ -var -venv - -*~ -\#*\# -/.emacs.desktop -/.emacs.desktop.lock -*.elc -auto-save-list -tramp -.\#* - -.projectile -.overcommit.yml - -junit* - -# Coverage Files -htmlcov -.coverage* - -# IDEs -.idea_modules/ -.idea/* -.idea/*.iml .idea/**/contentModel.xml .idea/**/dataSources.ids .idea/**/dataSources.local.xml @@ -266,7 +171,6 @@ htmlcov .idea/**/uiDesigner.xml .idea/**/usage.statistics.xml .idea/**/workspace.xml -.idea/caches/build_file_checksums.ser .idea/dataSources.ids .idea/dataSources.local.xml .idea/dataSources.xml @@ -285,27 +189,91 @@ htmlcov .idea/uiDesigner.xml .idea/vcs.xml .idea/workspace.xml -# VS Code -.vscode/ -# pycharm -queue.json dev/ +queue.json -# Operating Systems -.DS_Store - -# ruff cache -.ruff_cache/ - +# Visual Studio Code & Microsoft Ecosystem +.vscode/ +*.code-workspace +.history +.project +.pydevproject +.ropeproject +.spyderproject +.spyproject +atlassian-ide-plugin.xml +profile_default/ +ipython_config.py +.projectile -# pytest cache -pytestdebug.log +# GNU Emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* -*/_version.py +# ============================================================================== +# 8. SYSTEM GARBAGE, TEMPORARY FILES & COMPRESSION +# ============================================================================== +.DS_Store +Thumbs.db +downloads/ +temp/ +tmp/ +tmp.txt +wget-log +Downloads/ +*.gz +*.rar +*.zip -# local temp files -.server.key +# ============================================================================== +# 9. CLOUD INFRASTRUCTURE & BACKEND WORKFLOWS (AWS & Celery) +# ============================================================================== +.elasticbeanstalk/* +!.elasticbeanstalk/*.cfg.yml +!.elasticbeanstalk/*.global.yml +.anvil/* +celerybeat-schedule +celerybeat.pid +instance/ +var +var/ +.scrapy +# ============================================================================== +# 10. METADATA, LOGS, DATABASES & SECRETS +# ============================================================================== +*.log +pip-log.txt +db.sqlite3 +db.sqlite3-journal +*.sqlite +local_settings.py +secret_key.txt +*.key +*.mo +*.pot +*.sage.py -# Benchmarking -.benchmarks/ +# ============================================================================== +# 11. STATIC ASSETS, MEDIA, DOCS & INTEGRATIONS +# ============================================================================== +media +uploads/ +**/staticfiles/ +*/staticfiles/ +projects/static/ +static/build/ +static/local/ +static/media +static/rev-manifest.json +com_crashlytics_export_strings.xml +crashlytics-build.properties +crashlytics.properties +fabric.properties +docs/_build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5e88b0..dac465c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -102,7 +102,7 @@ repos: name: "🔒 security · Detect private keys" - repo: https://github.com/commitizen-tools/commitizen - rev: v4.13.10 + rev: v4.16.4 hooks: - id: commitizen name: "🌳 git · Validate commit message" @@ -130,16 +130,8 @@ repos: exclude: ^tests/ additional_dependencies: [".[toml]"] -# TODO: Enable it for a single check -# - repo: https://github.com/pypa/pip-audit -# rev: v2.10.0 -# hooks: -# - id: pip-audit -# name: "🔒 security · Audit Python dependencies" -# args: ['--desc', 'on'] - - repo: https://github.com/semgrep/pre-commit - rev: 'v1.159.0' + rev: 'v1.167.0' hooks: - id: semgrep name: "🔒 security · Static analysis (semgrep)" @@ -148,14 +140,14 @@ repos: # Spelling and typos - repo: https://github.com/crate-ci/typos - rev: v1.45.2 + rev: v1.47.2 hooks: - id: typos name: "📝 spelling · Check typos" # CI/CD validation - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.37.1 + rev: 0.37.3 hooks: - id: check-dependabot name: "🔧 ci/cd · Validate Dependabot config" @@ -164,7 +156,7 @@ repos: files: ^\.github/workflows/.*\.ya?ml$ - repo: https://github.com/ariebovenberg/slotscheck - rev: v0.19.1 + rev: v0.20.0 hooks: - id: slotscheck name: "🔍 check · slotscheck" @@ -172,11 +164,11 @@ repos: - requests>=2.32.5 - typing-extensions>=4.7.1 - httpx>=0.24 - - pytest>=7.0.0 + - pytest>=9.0.3 - responses - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.12 + rev: v0.15.18 hooks: - id: ruff-check name: "🐍 lint · Check with Ruff" @@ -184,14 +176,6 @@ repos: - id: ruff-format name: "🐍 format · Format with Ruff" - - repo: https://github.com/PyCQA/pylint - rev: v4.0.5 - hooks: - - id: pylint - name: "🐍 lint · Check code quality" - args: - - --exit-zero - - repo: https://github.com/econchick/interrogate rev: 1.7.0 hooks: @@ -203,7 +187,7 @@ repos: # Python type checking - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.2 + rev: v2.1.0 hooks: - id: mypy name: "🐍 types · Check with mypy" @@ -214,7 +198,7 @@ repos: exclude: ^mailgun/examples/ - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.409 + rev: v1.1.410 hooks: - id: pyright name: "🐍 types · Check with pyright" @@ -252,16 +236,16 @@ repos: - mdformat-gfm - mdformat-ruff + # Makefile linting + - repo: https://github.com/checkmake/checkmake + rev: v0.3.0 + hooks: + - id: checkmake + name: "🔧 build · Lint Makefile" + # 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" - - # Makefile linting -# - 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 4fead23..3ddaa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,51 @@ We [keep a changelog.](http://keepachangelog.com/) -## [Unreleased] +## [Unreleased] (v1.8.0) + +### 🌟 Top Highlights (The "Big Wins") + +- **Decoupled Architecture:** The monolithic `client.py` has been split into highly cohesive, single-responsibility modules (`builders.py`, `config.py`, `endpoints.py`, `security.py`, etc.), making the codebase vastly easier to maintain and contribute to. +- **Fluent Payload Builders:** Developers no longer have to manually memorize API prefixes (`v:`, `h:`, `o:`). The new `MailgunMessageBuilder` provides an intuitive, autocomplete-friendly way to construct complex emails. +- **Leak-Free Context Managers:** Native support for `with` and `async with` statements guarantees that underlying TCP connection pools (`requests.Session` and `httpx.AsyncClient`) are cleanly closed, eliminating memory/socket leaks. +- **Enterprise-Grade Security:** Addition of `sys.audit` hooks, automatic log redaction for API keys (CWE-316), and strict TLS 1.2+ minimums (CWE-319) makes this SDK compliant with strict enterprise security postures. + +### Added + +- **Fluent Payload Builders:** Introduced `MailgunMessageBuilder` in `mailgun.builders` to intuitively construct complex multipart emails (handling `v:`, `h:`, and `o:` prefixes automatically). +- **Context Managers:** Implemented `__enter__`/`__exit__` for `Client` and `AsyncClient` to support the `with` statement, ensuring TCP connection pools are gracefully closed. +- **Strict Payload Schemas:** Added `TypedDict` contracts (e.g., `SendMessagePayload`) to `mailgun.types` to enable IDE autocomplete and compile-time validation via `mypy`. +- **Streaming Pagination:** Added a `.stream()` generator to `Endpoint` and `AsyncEndpoint` for automatic, memory-safe traversal of cursor-based pagination URLs. +- **Enterprise Security Audit Hooks:** Implemented native Python PEP 578 `sys.audit` events (e.g., `mailgun.api.request`) for Zero-Trust enterprise observability. +- **Zero-Leak Sandbox Mode:** Added `dry_run=True` to Client configuration to safely intercept and mock network requests during local development and CI pipelines. +- **Defense-in-Depth Testing Suite:** Vastly expanded SDK test coverage by introducing rigorous unit, integration, regression, Hypothesis (property-based), and Atheris fuzzing tests. +- **Automated Security Scanning:** Integrated `osv-scanner`, CodeQL, and `pip-audit` into GitHub actions to block insecure transitive dependencies. + +### Changed + +- **Architectural Refactoring:** Completely decoupled the monolithic `client.py` into highly cohesive, single-responsibility modules (`builders.py`, `client.py`, `config.py`, `endpoints.py`, `filters.py`, `logger.py`, `routes.py`, `security.py`, `types.py`), massively reducing technical debt. +- **Resource Management:** Added explicit `.close()` and `.aclose()` methods to the sync and async clients to cleanly terminate internal `requests.Session` and `httpx.AsyncClient` instances. +- **O(1) Dynamic Routing:** Replaced the legacy hardcoded if/else endpoint resolution with a high-performance, immutable O(1) prefix-routing dictionary (`PREFIX_ROUTES`). +- **Modernized Examples:** Completely refactored the `mailgun/examples/` directory. Examples now group synchronous and asynchronous equivalents side-by-side and strictly utilize context managers (`with` / `async with`) to demonstrate leak-free execution. + +### Security + +- **CWE-319 (Protocol Downgrade):** Enforced a strict minimum TLS 1.2+ protocol context via `SecureHTTPAdapter` to prevent Man-in-the-Middle (MITM) downgrade attacks. +- **CWE-316 (Cleartext Storage of Sensitive Information):** Integrated a centralized `RedactingFilter` to automatically scrub Mailgun API credentials and secrets from all standard log outputs. +- **Path Segment Sanitization:** Hardened `SecurityGuard.sanitize_path_segment()` to strictly canonicalize and URL-encode inputs, effectively neutralizing edge-case Path Traversal and Injection attacks during dynamic route building. +- **CWE-22 & CWE-400 (Path Traversal & Resource Exhaustion):** Implemented strict local attachment guardrails (`SecurityGuard.validate_attachment_path` and `check_file_size`) to block LFI attempts and fail-fast on files exceeding Mailgun's 25MB limit. +- **CWE-400 (Uncontrolled Resource Consumption):** Hardened `sanitize_timeout` to strictly enforce finite, positive integer/float boundaries against malicious timeout injection. +- **CWE-316 (Cleartext Memory Purging):** The `Client.close()` and `AsyncClient.aclose()` methods now explicitly zero-out the `auth` tuples and HTTP headers in memory upon teardown. + +### Fixed + +- **Routing Regression (Bug #40):** Fixed a critical v1.7.0 regression where custom `api_url` configurations containing trailing versions (e.g., `/v3/`) triggered 404 errors. The SDK now safely sanitizes and strips trailing version segments before evaluating the `O(1)` routing dictionary (PR #41). +- **Request Data Formatting:** Fixed internal bugs with request data formatting and dynamic routing resolution across various API handlers. +- **Type Hinting & Linting:** Resolved outstanding strict-mode MyPy errors and Ruff violations (e.g., `F401`, `PLC0415`) across the core package and test suites. + +### Removed + +- **Legacy Utilities:** Deleted `mailgun/handlers/utils.py`. All path sanitization and validation logic has been strictly centralized into the `SecurityGuard` class in `mailgun/security.py`. ## [1.7.1] - 2026-06-10 diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 78e1331..0eb1ddf 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -22,6 +22,26 @@ String manipulation, dynamic imports (`importlib`), and sequential regex evaluat - **Deferred Regex Compilation:** Legacy SDK versions compiled multiple `re.Pattern` objects upon module import. By wrapping these in `@functools.lru_cache(maxsize=1)` and returning an immutable `MappingProxyType`, the SDK defers expensive AST parsing until the exact moment it is needed, shaving ~15-30ms off the initial application startup time. +### 4. Zero-Regression Security & Context Generation (v1.8.0+) + +- **Centralized Pre-computation:** The `Config` object and unified `_prepare_request` architecture consolidate header merging, authentication resolution, and schema logging into a single invariant block, dropping per-request CPU cycles. +- **Enterprise-Grade TLS Latency Tradeoff:** The SDK explicitly generates a hardened `ssl.SSLContext()` to enforce `TLSv1.2+` and mitigate MITM downgrade attacks. This introduces a strict, one-time boot cost (loading OS certificates via `set_default_verify_paths`), but ensures the active event loop and hot path remain exceptionally fast and safe. + +______________________________________________________________________ + +## Benchmarks (v1.7.0 vs. v1.8.0) + +This suite proves that the introduction of enterprise-grade security layers (`SecurityGuard`, strict payload schemas) introduced virtually **zero performance regressions** in the active hot path. + +| Metric | v1.7.0 (Baseline) | v1.8.0 (Current) | Delta / Notes | +| :-------------------------- | :---------------- | :--------------- | :-------------------------------------- | +| **Cold Boot Time** | ~0.130 s | **~0.126 s** | **~3.0% Faster** | +| **Routing Speed (Mean)** | ~1.22 µs | **~1.20 µs** | **Flat** (Statistical noise) | +| **Async Throughput (Mean)** | **~0.11 ms**\* | ~15.76 ms | **Fixed Pipeline** (See note) | +| **Sync Throughput (Mean)** | **~0.25 ms** | ~0.28 ms | **+ 0.03 ms** (Security validation tax) | + +*\* The v1.7.0 Async Throughput time reflects a deprecated test state where the mock transport was accidentally bypassed, resulting in an instant loop crash rather than a full HTTP pipeline execution. The v1.8.0 metric reflects the true, successfully mocked execution.* + ______________________________________________________________________ ## Benchmarks (v1.6.0 vs. v1.7.0) @@ -32,7 +52,7 @@ Our internal `pytest-benchmark` and `cProfile` suites verify these architectural | :-------------------------- | :---------------- | :--------------- | :---------------- | | **Cold Boot Time** | ~0.232 s | **~0.201 s** | **~13% Faster** | | **Routing Speed (Mean)** | ~17.98 µs | **~1.39 µs** | **~12.9x Faster** | -| **Async Throughput (Mean)** | ~6.49 ms | **~5.88 ms** | **~9.4% Faster** | +| **Async Throughput (Mean)** | ~6.49 ms | **~5.88 ms**\* | **~9.4% Faster** | | **Sync Throughput (Mean)** | ~18.29 ms | **~16.82 ms** | **~8.0% Faster** | *Note: Benchmarks measure network-isolated internal overhead. Routing operations per second (OPS) jumped from ~55k to over **718k**.* diff --git a/README.md b/README.md index 46d8728..8ef96d7 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,16 @@ Check out all the resources and Python code examples in the official - [AsyncClient](#asyncclient) - [Usage](#usage) - [Logging & Debugging](#logging--debugging) + - [Logging & Secure Redaction](#logging--secure-redaction) + - [Timeout Configuration](#timeout-configuration) + - [API Response Codes](#api-response-codes) - [IDE Autocompletion & DX](#ide-autocompletion--dx) + - [Zero-Leak Sandbox Mode](#zero-leak-sandbox-mode) - [API Response Codes](#api-response-codes) + - [Context Managers (Safe Resource Teardown)](#context-managers-safe-resource-teardown) + - [Fluent Message Builder](#fluent-message-builder) + - [Streaming Pagination](#streaming-pagination) + - [Strict Payload Schemas](#strict-payload-schemas) - [Request examples](#request-examples) - [Full list of supported endpoints](#full-list-of-supported-endpoints) - [Messages](#messages) @@ -209,22 +217,15 @@ The Mailgun API is part of the Sinch family and enables you to send, track, and ### Base URL -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. +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. -For domains created in our US region the base URL is: - -```sh -https://api.mailgun.net/ -``` +Ensure you pass the correct Base URL to your client configuration: -For domains created in our EU region the base URL is: +US: `https://api.mailgun.net` (Default) -```sh -https://api.eu.mailgun.net/ -``` +EU: `https://api.eu.mailgun.net` **⚠️ Important:** The `api_url` parameter must strictly be the **base host only** (e.g., `https://api.eu.mailgun.net`). Do **not** append API version paths (like `/v3` or `/v4`) to this string. The SDK's data-driven routing engine automatically appends the correct, endpoint-specific API version under the hood. @@ -240,8 +241,7 @@ with Client(auth=("api", os.environ["APIKEY"]), api_url="https://api.eu.mailgun. ### Authentication -The Mailgun Send API uses your API key for authentication. [Grab](https://app.mailgun.com/settings/api_security) and -save your Mailgun API credentials. +Authenticate your Client using a tuple of ("api", "YOUR_API_KEY"). Find your API key in the [Mailgun Control Panel](https://app.mailgun.com/settings/api_security). To run tests and examples please use virtualenv or conda environment with next environment variables: @@ -249,12 +249,12 @@ To run tests and examples please use virtualenv or conda environment with next e export APIKEY="API_KEY" # pragma: allowlist secret export DOMAIN="DOMAIN_NAME" export MESSAGES_FROM="Name Surname " -export MESSAGES_TO="Name Surname " -export MESSAGES_CC="Name Surname " +export MESSAGES_TO="Name Surname " +export MESSAGES_CC="Name Surname " 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 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" @@ -263,8 +263,6 @@ export ROLE="admin" ## Quick Start -The Mailgun Send API uses your API key for authentication. - Synchronous vs Asynchronous Client. ### Client @@ -306,13 +304,7 @@ with Client(auth=("api", os.environ["APIKEY"])) as client: #### 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", os.environ["APIKEY"]), 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). +The SDK 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 @@ -329,12 +321,12 @@ async def main(): # and automatic socket teardown. async with AsyncClient(auth=("api", "your-api-key")) as client: response = await client.messages.create( - domain="your-domain.com", + domain="YOUR_DOMAIN_NAME", data={ - "from": "Excited User ", + "from": "Excited User ", "to": ["bar@example.com"], - "subject": "Hello", - "text": "Testing some Mailgun awesomeness!", + "subject": "Hello from Async!", + "text": "Testing Mailgun asynchronously!", }, ) print(response.json()) @@ -381,37 +373,20 @@ 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: - -```python -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](mailgun/examples) section. All examples can be adapted to async by using `AsyncClient` and adding `await` to method calls. -### Logging & Debugging +### Logging, Debugging & Secure Redaction -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 Mailgun SDK uses standard Python logging. To aid in debugging, you can enable `DEBUG` or `INFO` logs. -The SDK uses the standard Python `logging` module under the namespace `mailgun.client`. +**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: ```python import logging -from mailgun.client import Client +from mailgun import Client # Enable DEBUG level for the Mailgun SDK logger logging.getLogger("mailgun.client").setLevel(logging.DEBUG) @@ -420,8 +395,24 @@ logging.getLogger("mailgun.client").setLevel(logging.DEBUG) 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 = Client(auth=("api", "key-super-secret-12345")) +# API keys will be redacted: +# "Sending request to https://api.mailgun.net/v3/messages with auth ('api', 'key-[REDACTED]')" client.domains.get() +client.close() +``` + +### 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): + +```python +from mailgun import Client + +# 3.5 seconds to connect, 15 seconds to wait for the server response +client = Client(auth=("api", "your-key"), timeout=(3.5, 15.0)) ``` ### IDE Autocompletion & DX @@ -432,6 +423,44 @@ The `Client` utilizes a dynamic routing engine but is heavily optimized for mode - **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 Sandbox Mode + +For local development and CI/CD pipelines, the Mailgun SDK offers a native **Zero-Leak Sandbox Mode**. By initializing the client with `dry_run=True`, the SDK will safely intercept all network traffic locally. + +This allows you to fully validate your SDK initialization, dynamic routing, and payload building without dispatching real HTTP requests to Mailgun servers. This prevents accidental spam, list mutations, or billing charges during testing. + +```python +from mailgun.client import Client + +# 1. Initialize the client in strict Sandbox Mode +client = Client(auth=("api", "your-api-key"), dry_run=True) + +# 2. Execute a state-changing API call +response = client.messages.create( + domain="yourdomain.com", + data={ + "from": "sender@example.com", + "to": "test@example.com", + "subject": "Testing Sandbox", + "text": "This will not actually send!", + }, +) + +# 3. The SDK intercepts the I/O layer and returns a mock 200 OK response +print(response.status_code) +# Outputs: 200 + +print(response.json()) +# Outputs: {"message": "Dry run successful - request intercepted", "id": ""} +``` + +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...`. + ### API Response Codes All of Mailgun's HTTP response codes follow standard HTTP definitions. For some additional information and @@ -452,6 +481,90 @@ request, such as a non-existing endpoint. **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. +### Context Managers (Safe Resource Teardown) + +Always use the `Client` or `AsyncClient` inside a `with` statement. This ensures that underlying TCP connection pools are safely closed and sensitive API keys are immediately purged from memory once the block exits, preventing resource leaks. + +**Synchronous:** + +```python +from mailgun import Client + +with Client(auth=("api", "your-api-key")) as client: + response = client.domains.get() + print(response.json()) +# Connection pool is closed and credentials are wiped from memory here. +``` + +**Asynchronous:** + +``` +import asyncio +from mailgun import AsyncClient + +async def main(): + async with AsyncClient(auth=("api", "your-api-key")) as client: + response = await client.domains.get() + print(response.json()) + +asyncio.run(main()) +``` + +### Fluent Message Builder + +Constructing complex multipart emails with custom variables (v:), custom headers (h:), and tracking options (o:) can be error-prone. The MailgunMessageBuilder abstracts this away while providing automatic security guardrails against massive file attachments (OOM) and Path Traversal (CWE-22). + +```python +from mailgun import Client +from mailgun.builders import MailgunMessageBuilder + +with Client(auth=("api", "your-api-key")) as client: + 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 + .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 crash your application. +The `.stream()` method handles cursor-based pagination invisibly under the hood, yielding one item at a time. + +```python +from mailgun import Client + +with Client(auth=("api", "key")) as client: + # Safely iterate through millions of events with a flat memory footprint + for event in client.events.stream(domain="yourdomain.com", filters={"event": "bounced"}): + print(f"Bounced: {event['recipient']}") +``` + +### 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. + +```python +from mailgun import Client +from mailgun.types import SendMessagePayload + +my_data: SendMessagePayload = { + "from": "admin@domain.com", + "to": ["user@example.com"], + "subject": "Strictly Typed Request", +} + +with Client(auth=("api", "key")) as client: + client.messages.create(domain="domain.com", data=my_data) +``` + ## Request examples ### Full list of supported endpoints @@ -1358,7 +1471,36 @@ See for details [CONTRIBUTING.md](CONTRIBUTING.md) ## Security -See for details [SECURITY.md](SECURITY.md) +See [SECURITY.md](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: + +```python +import logging +from mailgun.client import Client +from mailgun.config import Config + +logging.basicConfig(level=logging.INFO) + +# Activate the PEP 578 Audit Listener globally during app startup +Config.enable_security_audit() + +# Initialize the client normally +client = Client(auth=("api", "your-api-key")) + +# 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() +``` ## Contributors diff --git a/SECURITY.md b/SECURITY.md index e2404d9..3f9ead5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| 1.7.x | :white_check_mark: | -| < 1.7.0 | :x: | +| 1.8.x | :white_check_mark: | +| < 1.8.0 | :x: | # Vulnerability Disclosure diff --git a/environment-dev.yaml b/environment-dev.yaml index aed4821..ae1411a 100644 --- a/environment-dev.yaml +++ b/environment-dev.yaml @@ -16,11 +16,12 @@ dependencies: # tests - conda-forge::pyfakefs - coverage >=4.5.4 + - hypothesis - openssl - pytest >=9.0.3 - pytest-asyncio - pytest-benchmark - - pytest-cov + - pytest-cov >=5.0.0 - pytest-xdist - responses # linters, formatters & typing @@ -29,7 +30,7 @@ dependencies: - pyright - ruff - toml - - types-requests + - types-requests >=2.33.0 # other - conda - jsonschema @@ -37,6 +38,8 @@ dependencies: - python-dotenv >=0.19.2 - types-jsonschema - pip: + # Requires llvm; disabled on CI + #- atheris >=2.3.0 - bandit - codecov >=2.0.16 - docconvert diff --git a/mailgun/__init__.py b/mailgun/__init__.py index 882e53c..c7b15ab 100644 --- a/mailgun/__init__.py +++ b/mailgun/__init__.py @@ -1,11 +1,20 @@ """Provide a Python SDK for interacting with the Mailgun API. -This package exposes the primary client classes and custom exceptions -needed to integrate with Mailgun's services. +This package exposes the primary client classes, fluent builders, and +custom exceptions needed to securely integrate with Mailgun's services. """ +from __future__ import annotations + +from mailgun._version import __version__ +from mailgun.builders import MailgunMessageBuilder, MailgunTemplateBuilder from mailgun.client import AsyncClient, Client -from mailgun.handlers.error_handler import ApiError, RouteNotFoundError, UploadError +from mailgun.handlers.error_handler import ( + ApiError, + MailgunTimeoutError, + RouteNotFoundError, + UploadError, +) # Defines the root public API of the Mailgun SDK @@ -13,6 +22,10 @@ "ApiError", "AsyncClient", "Client", + "MailgunMessageBuilder", + "MailgunTemplateBuilder", + "MailgunTimeoutError", "RouteNotFoundError", "UploadError", + "__version__", ] diff --git a/mailgun/_version.py b/mailgun/_version.py index 3c1e9cb..16b5d47 100644 --- a/mailgun/_version.py +++ b/mailgun/_version.py @@ -1 +1 @@ -__version__ = "1.7.1" +__version__ = "1.7.1.post1.dev1" diff --git a/mailgun/builders.py b/mailgun/builders.py new file mode 100644 index 0000000..1c5953c --- /dev/null +++ b/mailgun/builders.py @@ -0,0 +1,344 @@ +"""Fluent message builders for the Mailgun API to improve Developer Experience (DX).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +from mailgun.security import SecurityGuard + + +class MailgunMessageBuilder: + """Fluent builder for constructing Mailgun Send API payloads. + + Mitigates configuration errors by abstracting Mailgun's custom prefix + syntax (h:, v:, o:) and handling attachment structures. + """ + + def __init__(self, from_email: str) -> None: + """Initialize the builder with a sender email.""" + self._payload: dict[str, Any] = {"from": from_email, "to": []} + self._files: list[tuple[str, tuple[str, bytes]]] = [] + + def add_recipient(self, email: str, recipient_type: str = "to") -> Self: + """Add a recipient (to, cc, bcc). + + Returns: + The builder instance. + + Raises: + ValueError: If an invalid recipient type is provided. + """ + if recipient_type not in {"to", "cc", "bcc"}: + msg = f"Invalid recipient type: {recipient_type}" + raise ValueError(msg) + + if recipient_type not in self._payload: + self._payload[recipient_type] = [] + + # If it's a list, append. If it was converted to string, convert back to list + if isinstance(self._payload[recipient_type], str): + self._payload[recipient_type] = [self._payload[recipient_type]] + + self._payload[recipient_type].append(email) + return self + + def set_subject(self, subject: str) -> Self: + """Set the subject of the email. + + Returns: + The builder instance. + """ + self._payload["subject"] = subject + return self + + def set_text(self, text: str) -> Self: + """Set the plain text body of the email. + + Returns: + The builder instance. + """ + self._payload["text"] = text + return self + + def set_html(self, html: str) -> Self: + """Set the HTML body of the email. + + Returns: + The builder instance. + """ + self._payload["html"] = html + return self + + def set_amp_html(self, amp_html: str) -> Self: + """Set the AMP HTML content of the message. + + AMP part of the message. Please follow Google guidelines to compose and send AMP emails. + + Returns: + The builder instance. + """ + self._payload["amp-html"] = amp_html + return self + + def set_template(self, template: str) -> Self: + """Set the template name to be used for the message. + + Returns: + The builder instance. + """ + self._payload["template"] = template + return self + + def add_custom_variable(self, key: str, value: Any) -> Self: + """Add a custom v: variable to the email. + + Returns: + The builder instance. + """ + # Complex types must be serialized + if isinstance(value, (dict, list)): + safe_val = json.dumps(value, separators=(",", ":")) + else: + safe_val = str(value) + self._payload[f"v:{key}"] = safe_val + return self + + def add_custom_header(self, key: str, value: str) -> Self: + """Add a custom h: header to the email. + + Returns: + The builder instance. + """ + self._payload[f"h:{key}"] = value + return self + + def add_option(self, key: str, *, value: bool | str) -> Self: + """Adds an o:tracking or similar option. + + Returns: + The builder instance. + """ + safe_val = "yes" if value is True else "no" if value is False else value + self._payload[f"o:{key}"] = safe_val + return self + + def attach_file(self, file_path: str | Path, safe_base_dir: str | Path | None = None) -> Self: + """Safely attach a file to the email, protected against Path Traversal and OOM. + + Returns: + The builder instance. + """ + path = Path(file_path) + + # 1. Apply CWE-22 Path Traversal Guardrail + if safe_base_dir: + path = SecurityGuard.validate_attachment_path(path, safe_base_dir) + + # 2. Apply CWE-400 Memory Guardrail (Fail-fast if > 25MB) + SecurityGuard.check_file_size(path) + + # 3. Read into memory for the multipart payload + file_data = path.read_bytes() + self._files.append(("attachment", (path.name, file_data))) + + return self + + def attach_inline(self, file_path: str | Path, safe_base_dir: str | Path | None = None) -> Self: + """Safely attach an inline image/file, protected against Path Traversal and OOM. + + Returns: + The builder instance. + """ + path = Path(file_path) + + if safe_base_dir: + path = SecurityGuard.validate_attachment_path(path, safe_base_dir) + SecurityGuard.check_file_size(path) + + self._files.append(("inline", (path.name, path.read_bytes()))) + return self + + def set_template_version(self, version: str) -> Self: + """Set the template version to use. + + Returns: + The builder instance. + """ + self._payload["t:version"] = version + return self + + def set_template_text(self, *, enable: bool) -> Self: + """Enable or disable template text. + + Returns: + The builder instance. + """ + self._payload["t:text"] = "yes" if enable else "no" + return self + + def set_template_variables(self, variables: dict[str, Any]) -> Self: + """Set the variables for the template. + + Returns: + The builder instance. + """ + self._payload["t:variables"] = json.dumps(variables, separators=(",", ":")) + return self + + def set_recipient_variables(self, variables: dict[str, dict[str, Any]]) -> Self: + """Set recipient variables for batch sending. + + Maximum 1,000 recipients per batch. + See Batch Sending https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/batch-sending. + + Returns: + The builder instance. + """ + self._payload["recipient-variables"] = json.dumps(variables, separators=(",", ":")) + return self + + def build(self) -> tuple[dict[str, Any], list[tuple[str, tuple[str, bytes]]] | None]: + """Finalize the payload for the sync and async clients. + + Returns: + A tuple containing the payload dictionary and the list of files to be attached. + """ + final_payload = self._payload.copy() + + for key in ["to", "cc", "bcc"]: + if key in final_payload and isinstance(final_payload[key], list): + # Only collapse into a string if the list actually has items + if final_payload[key]: + final_payload[key] = ",".join(final_payload[key]) + else: + del final_payload[key] + + return final_payload, self._files or None + + +class MailgunTemplateBuilder: + """Fluent builder for constructing Mailgun Template creation/update payloads. + + Works identically for both Domain Templates (v3) and Account Templates (v4) + as the underlying multipart/form-data payload schema is exactly the same. + """ + + def __init__(self, name: str | None = None) -> None: + """Initialize the builder. + + Args: + name: Required for creating a new template, but optional for PUT/Updates. + + Raises: + ValueError: If an invalid configuration is detected. + """ + self._payload: dict[str, Any] = {} + if name is not None: + if not name: + raise ValueError("Template name cannot be empty.") + self._payload["name"] = name + + def set_description(self, description: str) -> Self: + """Set an optional description for the template. + + Returns: + The builder instance. + """ + self._payload["description"] = description + return self + + def set_template_content(self, content: str) -> Self: + """Set the raw HTML/text content of the template. + + Returns: + The builder instance. + + Raises: + ValueError: If the content is empty. + """ + if not content: + raise ValueError("Template content cannot be empty.") + self._payload["template"] = content + return self + + def set_engine(self, engine: str = "handlebars") -> Self: + """Set the template engine. Mailgun currently defaults to 'handlebars'. + + Returns: + The builder instance. + """ + self._payload["engine"] = engine + return self + + def set_tag(self, tag: str) -> Self: + """Set the specific version tag (e.g. 'v1', 'initial'). + + Returns: + The builder instance. + """ + self._payload["tag"] = tag + return self + + def set_version_comment(self, comment: str) -> Self: + """Add a comment for the specific version being created or copied. + + Returns: + The builder instance. + """ + self._payload["comment"] = comment + return self + + def set_active(self, *, active: bool) -> Self: + """Define if this specific version should be set as active. + + Returns: + The builder instance. + """ + self._payload["active"] = "yes" if active else "no" + return self + + def set_headers(self, headers: dict[str, str]) -> Self: + """Set default email headers (From, Subject, Reply-To) for the template. + + These will be overridden if the same headers are provided during send. + + Returns: + The builder instance. + """ + self._payload["headers"] = json.dumps(headers, separators=(",", ":")) + return self + + def set_copy_requests(self, requests_list: list[dict[str, str]]) -> Self: + """Set the JSON payload for copying a template to multiple domains/accounts. + + Example: [{"account_id": "acc-1", "name": "new-name"}] + Note: This is used for the /copy endpoint, which expects application/json. + + Returns: + The builder instance. + """ + self._payload["requests"] = requests_list + return self + + def build(self) -> dict[str, Any]: + """Finalize the payload for the sync and async clients. + + Returns: + The template payload dictionary. + + Raises: + ValueError: If the payload is empty. + """ + if not self._payload: + raise ValueError("Cannot build an empty template payload.") + + return self._payload.copy() diff --git a/mailgun/client.py b/mailgun/client.py index 6473162..7ecb4e5 100644 --- a/mailgun/client.py +++ b/mailgun/client.py @@ -16,27 +16,19 @@ from __future__ import annotations -import json -import logging -import re +import ssl import sys import warnings -from enum import Enum -from functools import lru_cache -from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, TypeAlias -from urllib.parse import unquote, urlparse +from typing import TYPE_CHECKING, Any, Final import httpx import requests # pyright: ignore[reportMissingModuleSource] -from requests.adapters import HTTPAdapter # pyright: ignore[reportMissingModuleSource] -from requests.exceptions import ( - ConnectionError as RequestsConnectionError, # pyright: ignore[reportMissingModuleSource] -) from urllib3.util.retry import Retry -from mailgun import routes -from mailgun.handlers.error_handler import ApiError, MailgunTimeoutError +from mailgun.config import Config +from mailgun.endpoints import AsyncEndpoint, BaseEndpoint, Endpoint +from mailgun.filters import RedactingFilter +from mailgun.security import SecretAuth, SecureHTTPAdapter, SecurityGuard if sys.version_info >= (3, 11): @@ -44,19 +36,9 @@ else: from typing_extensions import Self -try: - from mailgun._version import __version__ -except ImportError: - __version__ = "0.0.0-unknown" - if TYPE_CHECKING: import types - from collections.abc import Callable, Mapping - - from httpx import Response as HttpxResponse - from requests.models import Response # pyright: ignore[reportMissingModuleSource] - # ============================================================================== # 1. PUBLIC API & GLOBALS @@ -66,610 +48,26 @@ "AsyncClient", "AsyncEndpoint", "BaseClient", + "BaseEndpoint", "Client", + "Config", "Endpoint", + "RedactingFilter", + "SecretAuth", + "SecureHTTPAdapter", + "SecurityGuard", ] -logger = logging.getLogger("mailgun.client") -if not logger.hasHandlers(): - logger.addHandler(logging.NullHandler()) # Constants for API error handling and logging (fixes Ruff PLR2004) -_HTTP_ERROR_THRESHOLD: Final[int] = 400 _MAX_LOG_LENGTH: Final[int] = 500 _AUTH_TUPLE_LEN: Final = 2 _TIMEOUT_TUPLE_LEN: Final[int] = 2 _DEFAULT_TIMEOUT = 60.0 -# Type Aliases for SDK Signatures -TimeoutType: TypeAlias = float | tuple[float, float] | None - -# ============================================================================== -# 2. CORE TYPES & SECURITY GUARDRAILS -# ============================================================================== - - -class APIVersion(str, Enum): - """Constants for Mailgun API versions.""" - - V1 = "v1" - V2 = "v2" - V3 = "v3" - V4 = "v4" - V5 = "v5" - - -class SecretAuth(tuple): - """OWASP: Obfuscate credentials in memory dumps and tracebacks.""" - - __slots__ = () # DX & Performance: Prevent __dict__ creation to optimize memory usage. - - def __repr__(self) -> str: - return "('api', '***REDACTED***')" - - -class SecurityGuard: - """Centralized security validation and sanitization (Defense in Depth). - - This class isolates all Zero-Trust guardrails, enforcing SRP and making it - easy to extract into a dedicated security module in future releases. - """ - - ALLOWED_HTTP_METHODS: Final[frozenset[str]] = frozenset( - {"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"} - ) - ALLOWED_API_HOSTS: Final[tuple[str, ...]] = ( - "mailgun.net", - "mailgun.org", - "localhost", - "127.0.0.1", - ) - ALLOWED_KWARGS: Final[frozenset[str]] = frozenset({"proxies", "cert"}) - SAFE_KEY_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[a-z0-9_]+$") - CRLF_SLASH_PATTERN: Final[re.Pattern[str]] = re.compile(r"[\r\n/\\]+") - - @classmethod - def sanitize_api_url(cls, raw_url: str) -> str: - """Sanitize and validate the base API URL to prevent SSRF and Cleartext transmission. - - Args: - raw_url: The raw URL string to sanitize. - - Returns: - The sanitized URL string without a trailing slash. - - Raises: - ValueError: If the URL uses prohibited cleartext HTTP (CWE-319). - """ - raw_url = raw_url.strip().replace("\r", "").replace("\n", "") - parsed = urlparse(raw_url) - - if not parsed.scheme: - raw_url = f"https://{raw_url}" - parsed = urlparse(raw_url) - - if parsed.scheme == "http" and parsed.hostname not in {"localhost", "127.0.0.1"}: - msg = ( - "CRITICAL SECURITY: Cleartext HTTP transmission is prohibited (CWE-319). Use HTTPS." - ) - raise ValueError(msg) # Fail Closed - - hostname = parsed.hostname or "" - is_valid_host = any( - hostname == allowed or hostname.endswith(f".{allowed}") - for allowed in cls.ALLOWED_API_HOSTS - ) - if not is_valid_host: - msg = ( - f"SECURITY WARNING: Invalid API host '{hostname}'. Ensure this is a trusted proxy." - ) - logger.warning(msg) - - return raw_url.rstrip("/") - - @classmethod - def validate_auth(cls, auth: tuple[str, str] | None) -> tuple[str, str] | None: - """Sanitize and validate credentials against Header Injection vulnerabilities. - - Args: - auth: A tuple containing the API user and API key, or None. - - Returns: - A SecretAuth tuple with cleaned credentials, or None if no auth was provided. - - Raises: - ValueError: If the API key contains invalid characters (e.g., newlines). - """ - if auth and isinstance(auth, tuple) and len(auth) == _AUTH_TUPLE_LEN: - clean_user = str(auth[0]).strip() - clean_key = str(auth[1]).strip() - - if "\n" in clean_key or "\r" in clean_key: - raise ValueError("API Key contains invalid characters (Header Injection risk).") - - return SecretAuth((clean_user, clean_key)) - return auth - - @classmethod - def sanitize_key(cls, key: str) -> str: - """Normalize and validate the endpoint key from IDE Introspection. - - Args: - key: The raw endpoint key to sanitize. - - Returns: - The sanitized and validated endpoint key. - - Raises: - KeyError: If the resulting key is invalid or empty. - """ - clean_key: str = key.lower() - if not cls.SAFE_KEY_PATTERN.fullmatch(clean_key): - clean_key = re.sub(r"[^a-z0-9_]", "", clean_key) - if not clean_key: - msg = f"Invalid endpoint key: {key}" - raise KeyError(msg) - return clean_key - - @classmethod - def sanitize_domain(cls, domain: str | None) -> str | None: - """Protect against Path Traversal in URL construction. - - Args: - domain: Target domain name to sanitize. - - Returns: - The sanitized domain name or None. - - Raises: - ValueError: If path traversal characters are detected. - """ - if not domain: - return None - - decoded_domain = unquote(domain) - - # Poka-yoke: Actively strip all slashes and newlines (Advanced Traversal & CRLF) - safe_domain = cls.CRLF_SLASH_PATTERN.sub("", decoded_domain).strip() - - if ".." in safe_domain: - raise ValueError( - "CRITICAL SECURITY: Path traversal characters detected in domain parameter." - ) - return safe_domain - - @classmethod - def sanitize_http_method(cls, method: str) -> str: - """Prevent HTTP Verb Tampering and Attribute Injection. - - Args: - method: The HTTP method requested. - - Returns: - A safely formatted HTTP method string. - - Raises: - ValueError: If the method is not in the allowed list. - """ - safe_method = str(method).strip().upper() - if safe_method not in cls.ALLOWED_HTTP_METHODS: - msg = f"CRITICAL SECURITY: HTTP method '{safe_method}' is prohibited." - raise ValueError(msg) - return safe_method - - @classmethod - def sanitize_timeout(cls, timeout: TimeoutType) -> TimeoutType: - """Prevent Infinite Timeout Thread Exhaustion (DoS). - - Args: - timeout: The requested timeout value. - - Returns: - The safely verified timeout value. - """ - if timeout is None: - # Soft Deprecation - warnings.warn( - "Passing 'timeout=None' allows infinite socket blocking (CWE-400). " - "This will be removed in a future major release. Please provide an explicit timeout.", - DeprecationWarning, - stacklevel=3, - ) - return None - - def _ensure_positive(val: Any) -> float: - f_val = float(val) - if f_val <= 0: - raise ValueError("Timeout values must be strictly positive.") - return f_val - - if isinstance(timeout, tuple) and len(timeout) == _TIMEOUT_TUPLE_LEN: - return _ensure_positive(timeout[0]), _ensure_positive(timeout[1]) - return _ensure_positive(timeout) - - @classmethod - def filter_safe_kwargs(cls, kwargs: dict[str, Any]) -> dict[str, Any]: - """Prevent Mass Assignment of internal HTTP client states. - - Args: - kwargs: Dictionary of keyword arguments passed to the network layer. - - Returns: - A filtered dictionary containing only allowed low-level HTTP settings. - """ - return {k: v for k, v in kwargs.items() if k in cls.ALLOWED_KWARGS} - - @staticmethod - def sanitize_headers(headers: dict[str, str] | None) -> dict[str, str] | None: - """Poka-yoke: Prevent HTTP Header Injection (CWE-113). - - Returns: - The sanitized headers dictionary, or None if no headers were provided. - - Raises: - ValueError: If a CRLF injection pattern is detected in any header key or value. - """ - if not headers: - return headers - for key, value in headers.items(): - # Check both key and value - if "\n" in str(key) or "\r" in str(key) or "\n" in str(value) or "\r" in str(value): - msg = f"CRLF injection detected in header: {key}" - raise ValueError(msg) - return headers - - -# ============================================================================== -# 3. ROUTING ENGINE & CONFIGURATION -# ============================================================================== - - -@lru_cache(maxsize=32) -def _load_handler(endpoint_key: str) -> Callable[..., str]: # noqa: PLR0911, PLR0912 - """Lazy load the API URL handler for a specific endpoint using SAST-safe literal imports. - - This maintains zero-I/O startup performance. The lru_cache ensures this branching logic - is executed exactly once per route type. - - Returns: - Callable: The specific handler function for the requested endpoint. - """ - # Group 1: Domains Handler (Most common aliases grouped for speed) - if endpoint_key in {"domains", "dkim_authority", "dkim_selector", "web_prefix"}: - from mailgun.handlers.domains_handler import handle_domains # noqa: PLC0415 - - return handle_domains - if endpoint_key == "domainlist": - from mailgun.handlers.domains_handler import handle_domainlist # noqa: PLC0415 - - return handle_domainlist - if endpoint_key == "dkim": - from mailgun.handlers.domains_handler import handle_dkimkeys # noqa: PLC0415 - - return handle_dkimkeys - if endpoint_key == "sending_queues": - from mailgun.handlers.domains_handler import handle_sending_queues # noqa: PLC0415 - - return handle_sending_queues - if endpoint_key == "mailboxes": - from mailgun.handlers.domains_handler import handle_mailboxes_credentials # noqa: PLC0415 - - return handle_mailboxes_credentials - if endpoint_key == "webhooks": - from mailgun.handlers.domains_handler import handle_webhooks # noqa: PLC0415 - - return handle_webhooks - - # Group 2: Suppressions - if endpoint_key == "bounces": - from mailgun.handlers.suppressions_handler import handle_bounces # noqa: PLC0415 - - return handle_bounces - if endpoint_key == "unsubscribes": - from mailgun.handlers.suppressions_handler import handle_unsubscribes # noqa: PLC0415 - - return handle_unsubscribes - if endpoint_key == "whitelists": - from mailgun.handlers.suppressions_handler import handle_whitelists # noqa: PLC0415 - - return handle_whitelists - if endpoint_key == "complaints": - from mailgun.handlers.suppressions_handler import handle_complaints # noqa: PLC0415 - - return handle_complaints - - # Group 3: Specific Services - if endpoint_key == "resendmessage": - from mailgun.handlers.messages_handler import handle_resend_message # noqa: PLC0415 - - return handle_resend_message - if endpoint_key == "ips": - from mailgun.handlers.ips_handler import handle_ips # noqa: PLC0415 - - return handle_ips - if endpoint_key == "ip_pools": - from mailgun.handlers.ip_pools_handler import handle_ippools # noqa: PLC0415 - - return handle_ippools - if endpoint_key == "tags": - from mailgun.handlers.tags_handler import handle_tags # noqa: PLC0415 - - return handle_tags - if endpoint_key == "routes": - from mailgun.handlers.routes_handler import handle_routes # noqa: PLC0415 - - return handle_routes - if endpoint_key == "lists": - from mailgun.handlers.mailinglists_handler import handle_lists # noqa: PLC0415 - - return handle_lists - if endpoint_key == "templates": - from mailgun.handlers.templates_handler import handle_templates # noqa: PLC0415 - - return handle_templates - if endpoint_key == "addressvalidate": - from mailgun.handlers import email_validation_handler as evh # noqa: PLC0415 - - return evh.handle_address_validate - if endpoint_key == "inbox": - from mailgun.handlers.inbox_placement_handler import handle_inbox # noqa: PLC0415 - - return handle_inbox - if endpoint_key == "analytics": - from mailgun.handlers.metrics_handler import handle_metrics # noqa: PLC0415 - - return handle_metrics - if endpoint_key == "bounce-classification": - from mailgun.handlers import bounce_classification_handler as bch # noqa: PLC0415 - - return bch.handle_bounce_classification - if endpoint_key == "users": - from mailgun.handlers.users_handler import handle_users # noqa: PLC0415 - - return handle_users - if endpoint_key == "keys": - from mailgun.handlers.keys_handler import handle_keys # noqa: PLC0415 - - return handle_keys - - # Group 4: Fallback for "messages", "messages.mime", "events", and unknown routes - from mailgun.handlers.default_handler import handle_default # noqa: PLC0415 - - return handle_default - - -@lru_cache -def _get_cached_route_data(clean_key: str) -> dict[str, Any]: - """Apply internal cached routing logic. - - Uses only hashable types (str) as arguments to avoid TypeError. - - Args: - clean_key: The sanitized endpoint key. - - Returns: - A dictionary containing versioning and path data for the route. - """ - # Resolve virtual property aliases before processing - clean_key = routes.ROUTE_ALIASES.get(clean_key, clean_key) - - if clean_key in routes.EXACT_ROUTES: - version, route_keys = routes.EXACT_ROUTES[clean_key] - return {"version": version, "keys": tuple(route_keys)} - - route_parts = clean_key.split("_") - primary_resource = route_parts[0] - - if primary_resource == "domains": - return {"type": "domain", "parts": tuple(route_parts)} - - if primary_resource in routes.PREFIX_ROUTES: - version, suffix, key_override = routes.PREFIX_ROUTES[primary_resource] - final_parts = route_parts.copy() - if key_override: - final_parts[0] = key_override - return {"version": version, "suffix": suffix, "keys": tuple(final_parts)} - - return {"version": APIVersion.V3.value, "keys": tuple(route_parts)} - - -class Config: - """Configuration engine for the Mailgun API client. - - Using a data-driven routing approach. - """ - - __slots__ = ("_baked_urls", "api_url", "ex_handler") - - DEFAULT_API_URL: Final[str] = "https://api.mailgun.net" - USER_AGENT: Final[str] = f"mailgun-api-python/{__version__}" - - # Use Mapping to denote read-only dictionary-like structures - _HEADERS_BASE: Final[Mapping[str, str]] = MappingProxyType({"User-agent": USER_AGENT}) - _HEADERS_JSON: Final[Mapping[str, str]] = MappingProxyType( - {"User-agent": USER_AGENT, "Content-Type": "application/json"} - ) - - # --- ENCAPSULATED ROUTING REGISTRIES --- - _DOMAINS_RESOURCE: Final[str] = "domains" - - # Mapping[str, Any] is used because the values in routes vary in structure - _EXACT_ROUTES: Final[Mapping[str, Any]] = MappingProxyType(routes.EXACT_ROUTES) - _PREFIX_ROUTES: Final[Mapping[str, Any]] = MappingProxyType(routes.PREFIX_ROUTES) - _DOMAIN_ALIASES: Final[Mapping[str, str]] = MappingProxyType(routes.DOMAIN_ALIASES) - - _DOMAIN_ENDPOINTS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( - routes.DOMAIN_ENDPOINTS - ) - _V1_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS["v1"]) - _V3_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS["v3"]) - _V4_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS.get("v4", [])) - - def __init__(self, api_url: str | None = None) -> None: - """Initialize the configuration engine. - - Args: - api_url: Optional custom base URL for the Mailgun API. - """ - self.ex_handler: bool = True - base_url_input: str = api_url or self.DEFAULT_API_URL - - self.api_url: str = self._normalize_api_url(base_url_input) - - self._baked_urls: Final[dict[str, str]] = { - ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion - } - - @staticmethod - def _normalize_api_url(raw_url: str) -> str: - """Validates and normalizes the base API URL. - - Ensures no explicit versions are embedded in the path that would break - dynamic f-string routing. - - Args: - raw_url: The raw base URL string provided by the user. - - Returns: - The sanitized and normalized API URL string. - - Raises: - ApiError: If an ambiguous API version is found embedded within the custom path. - """ - safe_url: str = SecurityGuard.sanitize_api_url(raw_url) - - parsed = urlparse(safe_url) - path_segments = [seg for seg in parsed.path.split("/") if seg] - - known_versions = {ver.value for ver in APIVersion} - - # Ambiguity & Backward Compatibility Check - for i, segment in enumerate(path_segments): - if segment in known_versions: - is_last_segment = i == len(path_segments) - 1 - - if is_last_segment: - safe_url = safe_url.removesuffix(f"/{segment}") - logger.warning( - "Semantic Configuration Warning: 'api_url' should be the base domain. The trailing '%s' was stripped to prevent routing duplication.", - segment, - ) - else: - # Fail-Fast: The version is trapped inside a complex path - msg = ( - f"Ambiguous API URL configuration: '{raw_url}'.\n" - f"The SDK automatically handles version routing, but an explicit " - f"version ('{segment}') was found embedded within your custom path. " - f"Please provide only the base host (e.g., 'https://api.mailgun.net')." - ) - raise ApiError(msg) - - return safe_url - - def _build_base_url(self, version: APIVersion | str, suffix: str = "") -> str: - """Construct API URL with precise slash control to prevent 404s. - - Args: - version: The API version to use. - suffix: An optional suffix to append to the base URL. - - Returns: - The fully constructed base URL string. - """ - ver_str: str = version.value if isinstance(version, APIVersion) else version - # O(1) access instead of dynamic concatenation, ensuring no trailing slash - base: str = self._baked_urls.get(ver_str, f"{self.api_url}/{ver_str}").rstrip("/") - - if suffix: - path: str = f"{suffix}/" if suffix == self._DOMAINS_RESOURCE else suffix - return f"{base}/{path}" - - return f"{base}/" - - def _resolve_domains_route(self, route_parts: list[str]) -> dict[str, Any]: - """Handle context-aware versioning for domain-related endpoints. - - Args: - route_parts: The components of the route requested. - - Returns: - A dictionary containing a string base URL and a tuple of keys. - """ - if any(action in route_parts for action in ("activate", "deactivate")): - return { - "base": self._build_base_url(APIVersion.V4), - "keys": ( - self._DOMAINS_RESOURCE, - "{authority_name}", - "keys", - "{selector}", - route_parts[-1], - ), - } - - mapped_parts: list[str] = [self._DOMAIN_ALIASES.get(p, p) for p in route_parts] - - if not mapped_parts or mapped_parts[0] != self._DOMAINS_RESOURCE: - mapped_parts.insert(0, self._DOMAINS_RESOURCE) - - version: APIVersion = APIVersion.V3 - - if len(mapped_parts) > 1: - for part in reversed(mapped_parts[1:]): - if part in self._V1_ENDPOINTS: - version = APIVersion.V1 - break - if part in self._V4_ENDPOINTS: - version = APIVersion.V4 - break - if part in self._V3_ENDPOINTS: - version = APIVersion.V3 - break - - return { - "base": self._build_base_url(version, self._DOMAINS_RESOURCE), - "keys": mapped_parts.copy(), - } - - def __getitem__(self, key: str) -> tuple[dict[str, Any], dict[str, str]]: - """Retrieve the URL configuration and headers for a specific endpoint. - - Args: - key: The name of the endpoint route (e.g., 'messages', 'bounces'). - - Returns: - A tuple containing the URL configuration dictionary and the headers dictionary. - """ - clean_key = SecurityGuard.sanitize_key(key) - - route_data = _get_cached_route_data(clean_key) - - # HTTP header mapping based on endpoint naming conventions - requires_json_headers = "analytics" in clean_key or "bounceclassification" in clean_key - - # Prepare headers - headers_map = self._HEADERS_JSON if requires_json_headers else self._HEADERS_BASE - headers = dict(headers_map) - - # Reconstruct result - if route_data.get("type") == "domain": - return self._resolve_domains_route(list(route_data["parts"])), headers - - safe_url = { - "base": self._build_base_url(route_data["version"], route_data.get("suffix", "")), - "keys": list(route_data["keys"]), - } - - return safe_url, headers - - @property - def available_endpoints(self) -> set[str]: - """Provide public access to valid route keys for IDE introspection.""" - return set(self._EXACT_ROUTES.keys()) | set(self._PREFIX_ROUTES.keys()) - # ============================================================================== -# 4. BASE CLASSES (Abstract Interfaces) +# 1. BASE CLASS (Abstract Interface) # ============================================================================== @@ -735,148 +133,8 @@ def __dir__(self) -> list[str]: return sorted(set(super().__dir__()) | self.config.available_endpoints) -class BaseEndpoint: - """Base class for endpoints. Contains methods common for Endpoint and AsyncEndpoint.""" - - __slots__ = ("_auth", "_timeout", "_url", "headers") - - def __init__( - self, - url: dict[str, Any], - headers: dict[str, str], - auth: tuple[str, str] | None, - timeout: TimeoutType = 60, - ) -> None: - """Initialize a new BaseEndpoint instance. - - Args: - url: URL dictionary with pairs {"base": "keys"}. - headers: Headers dictionary. - auth: Authentication tuple or None. - """ - self._url = url - self.headers = headers - self._auth = auth - self._timeout = timeout - - @staticmethod - def _warn_if_deprecated(method: str, target_url: str) -> None: - """Check the formulated URL against the registry of deprecated endpoints. - - Issues both a standard Python DeprecationWarning and an SDK logger warning. - - Args: - method: Requested HTTP method. - target_url: Formulated destination URL. - """ - path = urlparse(target_url).path - - # Iterate over the dynamically compiled, cached regexes - for pattern, msg in routes.get_deprecated_regexes().items(): - if pattern.search(path): - warning_message = f"DEPRECATED API CALL ({method.upper()} {path}): {msg}" - warnings.warn(warning_message, DeprecationWarning, stacklevel=3) - logger.warning(warning_message) - break - - def __repr__(self) -> str: - """DX: Show the actual resolved target route instead of memory address. - - Returns: - A string representation of the Endpoint and its target route. - """ - route_path = "/".join(self._url.get("keys", ["unknown"])) - return f"<{self.__class__.__name__} target='/{route_path}'>" - - @staticmethod - def build_url( - url: dict[str, Any], - domain: str | None = None, - method: str | None = None, - **kwargs: Any, - ) -> str: - """Build the final request URL using predefined handlers. - - Note: Some URLs are built in the Config class as they cannot be generated dynamically. - - Args: - url: Incoming URL structure containing base and keys. - domain: Target domain name. - method: Requested HTTP method. - **kwargs: Additional arguments required by specific handlers. - - Returns: - The fully constructed target URL. - - Raises: - ApiError: If the domain is required but missing. - """ - keys = url.get("keys", []) - endpoint_key = keys[0] if keys else "" - - if not domain and endpoint_key == "messages": - raise ApiError("Domain is required") - - # Load the handler function dynamically via the cached lazy loader - handler = _load_handler(endpoint_key) - - return handler(url, domain, method, **kwargs) # type: ignore[no-untyped-call] - - def _merge_headers(self, kwargs: dict[str, Any]) -> dict[str, str]: - """Safely extract and merge custom headers from kwargs. - - Returns: - A dictionary containing the safely merged headers. - """ - custom_headers = kwargs.pop("headers", {}) - req_headers = self.headers.copy() - - if custom_headers and isinstance(custom_headers, dict): - req_headers.update(custom_headers) - - return req_headers - - def _prepare_request( - self, - method: str, - url: dict[str, Any], - domain: str | None, - timeout: TimeoutType, - headers: dict[str, str], - kwargs: dict[str, Any], - ) -> tuple[str, str, str, TimeoutType, dict[str, str], dict[str, Any]]: - """Security and routing preparation logic. - - Args: - method: The requested HTTP method. - url: Incoming URL structure containing base and keys. - domain: Target domain name to sanitize. - timeout: Request timeout duration. - headers: Headers dictionary. - kwargs: Additional keyword arguments. - - Returns: - A tuple containing safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, and safe_kwargs. - """ - safe_method = SecurityGuard.sanitize_http_method(method) - safe_kwargs = SecurityGuard.filter_safe_kwargs(kwargs) - safe_headers = SecurityGuard.sanitize_headers(headers) or {} - target_domain = SecurityGuard.sanitize_domain(domain) - - actual_timeout = timeout if timeout is not None else self._timeout - safe_timeout = SecurityGuard.sanitize_timeout(actual_timeout) - - target_url = self.build_url(url, domain=target_domain, method=safe_method, **kwargs) - self._warn_if_deprecated(safe_method, target_url) - - # PEP 578 and protection against Log Forging (CWE-117) - safe_url_for_log = target_url.replace("\n", "_").replace("\r", "_") - - return safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, safe_kwargs - - # ============================================================================== -# 5. SYNCHRONOUS IMPLEMENTATION +# 2. SYNCHRONOUS IMPLEMENTATION # ============================================================================== @@ -916,7 +174,9 @@ def _build_resilient_session() -> requests.Session: status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "OPTIONS", "HEAD"], ) - adapter = HTTPAdapter(max_retries=retry_strategy, pool_connections=100, pool_maxsize=100) + adapter = SecureHTTPAdapter( + max_retries=retry_strategy, pool_connections=100, pool_maxsize=100 + ) session.mount("https://", adapter) session.mount("http://", adapter) return session @@ -956,9 +216,14 @@ def __getattr__(self, name: str) -> Any: def close(self) -> None: """Close the underlying requests.Session connection pool and purge memory.""" - self._session.auth = None - self._session.headers.clear() - self._session.close() + if self._session: + try: + # CWE-316: Clear session resources + self._session.auth = None + self._session.headers.clear() + self._session.close() + finally: + self._session = None self.auth = None def __enter__(self) -> Self: @@ -979,566 +244,11 @@ def __exit__( self.close() -class Endpoint(BaseEndpoint): - """Generate synchronous requests and return responses.""" - - __slots__ = ("_session",) - - def __init__( - self, - url: dict[str, Any], - headers: dict[str, str], - auth: tuple[str, str] | None = None, - session: requests.Session | None = None, - timeout: TimeoutType = 60, - ) -> None: - """Initialize a new Endpoint instance for synchronous API interaction. - - Args: - url: URL dictionary with pairs {"base": "keys"}. - headers: Headers dictionary. - auth: requests auth tuple or None. - session: Optional pre-configured requests.Session instance. - """ - super().__init__(url, headers, auth, timeout=timeout) - self._session = session or requests.Session() - - def api_call( - self, - auth: tuple[str, str] | None, - method: str, - url: dict[str, Any], - headers: dict[str, str], - data: Any | None = None, - filters: Mapping[str, str | Any] | None = None, - timeout: TimeoutType = None, - files: Any | None = None, - domain: str | None = None, - **kwargs: Any, - ) -> Response | Any: - """Execute the HTTP request to the Mailgun API. - - Args: - auth: Authentication tuple. - method: The HTTP method to use (e.g., 'GET', 'POST', 'PUT', 'DELETE'). - url: The final formulated endpoint URL dictionary. - headers: Request headers. - data: Payload data (form data or JSON). - filters: Query parameters. - timeout: Request timeout duration in seconds. - files: Files to upload. - domain: Target domain name. - **kwargs: Additional parameters to be passed to the underlying HTTP client. - - Returns: - The HTTP response object from the server. - - Raises: - MailgunTimeoutError: If the request times out. - ApiError: If the server returns a 4xx or 5xx status code or a network error occurs. - """ - safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, safe_kwargs = ( - self._prepare_request(method, url, domain, timeout, headers, kwargs) - ) - - # Case-insensitive validation for Content-Type to conform with RFC 7230 - is_json_request = any( - k.lower() == "content-type" and "application/json" in str(v).lower() - for k, v in safe_headers.items() - ) - - if is_json_request and data is not None and not isinstance(data, (str, bytes)): - data = json.dumps(data, separators=(",", ":")) - - req_method = getattr(self._session, safe_method.lower()) - - sys.audit("mailgun.api.request", safe_method.upper(), safe_url_for_log) - logger.debug("Sending Request: %s %s", safe_method.upper(), safe_url_for_log) - - try: - response = req_method( - target_url, - data=data, - params=filters, - headers=safe_headers, - auth=auth, - timeout=safe_timeout, - files=files, - verify=True, - stream=False, - allow_redirects=False, - **safe_kwargs, - ) - - status_code = getattr(response, "status_code", 200) - is_error = isinstance(status_code, int) and status_code >= _HTTP_ERROR_THRESHOLD - if is_error: - logger.error( - "API Error %s | %s %s", status_code, safe_method.upper(), safe_url_for_log - ) - else: - logger.debug( - "API Success %s | %s %s", - getattr(response, "status_code", 200), - safe_method.upper(), - target_url, - ) - - except requests.exceptions.Timeout as e: - logger.exception("Timeout Error: %s %s", safe_method.upper(), safe_url_for_log) - raise MailgunTimeoutError("Request timed out") from e - except RequestsConnectionError as e: - logger.critical("Connection Failed (DNS/Network): %s | URL: %s", e, safe_url_for_log) - msg = f"Network routing failed: {e}" - raise ApiError(msg) from e - except requests.RequestException as e: - logger.critical("Request Exception: %s | URL: %s", e, safe_url_for_log) - raise ApiError(e) from e - else: - return response - - def get( - self, - filters: Mapping[str, str | Any] | None = None, - domain: str | None = None, - **kwargs: Any, - ) -> Response: - """Send a GET request to retrieve resources. - - Args: - filters: Query parameters to include in the request. - domain: Target domain name. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return self.api_call( - self._auth, - "get", - self._url, - domain=domain, - headers=merged_headers, - filters=filters, - **kwargs, - ) - - def create( - self, - data: Any | None = None, - filters: Mapping[str, str | Any] | None = None, - domain: str | None = None, - headers: Any = None, - files: Any | None = None, - **kwargs: Any, - ) -> Response: - """Send a POST request to create a new resource or execute an action. - - Args: - data: Payload data (form data or JSON) to include in the request. - filters: Query parameters to include in the request. - domain: Target domain name. - headers: Additional headers to merge with the default headers. - files: Files to upload in the request. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - if headers is not None: - kwargs["headers"] = headers - merged_headers = self._merge_headers(kwargs) - - return self.api_call( - self._auth, - "post", - self._url, - files=files, - domain=domain, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - def put( - self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any - ) -> Response: - """Send a PUT request to update or replace a resource. - - Args: - data: Payload data to include in the request. - filters: Query parameters to include in the request. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return self.api_call( - self._auth, - "put", - self._url, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - def patch( - self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any - ) -> Response: - """Send a PATCH request to partially update a resource. - - Args: - data: Payload data to include in the request. - filters: Query parameters to include in the request. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return self.api_call( - self._auth, - "patch", - self._url, - data=data, - headers=merged_headers, - filters=filters, - **kwargs, - ) - - def update( - self, data: Any | None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any - ) -> Response: - """Send a PUT request specifically structured for updating resources with dynamic headers. - - Args: - data: Payload data (form data or JSON). - filters: Query parameters to include in the request. - **kwargs: Additional arguments, including custom 'headers', to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return self.api_call( - self._auth, - "put", - self._url, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - def delete(self, domain: str | None = None, **kwargs: Any) -> Response: - """Send a DELETE request to remove a resource. - - Args: - domain: Target domain name. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return self.api_call( - self._auth, "delete", self._url, headers=merged_headers, domain=domain, **kwargs - ) - - # ============================================================================== -# 6. ASYNCHRONOUS IMPLEMENTATION +# 2. ASYNC IMPLEMENTATION # ============================================================================== -class AsyncEndpoint(BaseEndpoint): - """Generate async requests and return responses using httpx.""" - - __slots__ = ("_client",) - - def __init__( - self, - url: dict[str, Any], - headers: dict[str, str], - auth: tuple[str, str] | None, - client: httpx.AsyncClient | None = None, - timeout: TimeoutType = 60, - ) -> None: - """Initialize a new AsyncEndpoint instance for asynchronous API interaction. - - Args: - url: URL dictionary with pairs {"base": "keys"}. - headers: Headers dictionary. - auth: httpx auth tuple or None. - client: Optional httpx.AsyncClient instance to reuse. - """ - super().__init__(url, headers, auth, timeout=timeout) - self._client = client or httpx.AsyncClient() - - async def api_call( - self, - auth: tuple[str, str] | None, - method: str, - url: dict[str, Any], - headers: dict[str, str], - data: Any | None = None, - filters: Mapping[str, str | Any] | None = None, - timeout: TimeoutType = None, - files: Any | None = None, - domain: str | None = None, - **kwargs: Any, - ) -> HttpxResponse: - """Execute the asynchronous HTTP request to the Mailgun API. - - Args: - auth: Authentication tuple. - method: The HTTP method to use (e.g., 'GET', 'POST', 'PUT', 'DELETE'). - url: The final formulated endpoint URL dictionary. - headers: Request headers. - data: Payload data (form data or JSON). - filters: Query parameters. - timeout: Request timeout duration in seconds. - files: Files to upload. - domain: Target domain name. - **kwargs: Additional parameters to be passed to the underlying HTTP client. - - Returns: - The HTTP response object from the server. - - Raises: - MailgunTimeoutError: If the request times out. - ApiError: If the server returns a 4xx or 5xx status code or a network error occurs. - """ - safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, safe_kwargs = ( - self._prepare_request(method, url, domain, timeout, headers, kwargs) - ) - - if isinstance(safe_timeout, tuple): - safe_timeout = httpx.Timeout(safe_timeout[1], connect=safe_timeout[0]) - - # Case-insensitive validation for Content-Type to conform with RFC 7230 - is_json_request = any( - k.lower() == "content-type" and "application/json" in str(v).lower() - for k, v in safe_headers.items() - ) - - if is_json_request and data is not None and not isinstance(data, (str, bytes)): - data = json.dumps(data, separators=(",", ":")) - - request_kwargs: dict[str, Any] = { - "method": safe_method.upper(), - "url": target_url, - "params": filters, - "files": files, - "headers": safe_headers, - "auth": auth, - "timeout": safe_timeout, - "follow_redirects": False, - } - - # Safe kwargs passthrough (e.g., allow_redirects) - request_kwargs.update(safe_kwargs) - - if isinstance(data, (str, bytes)): - request_kwargs["content"] = data - else: - request_kwargs["data"] = data - - # PEP 578 and protection against Log Forging (CWE-117) - safe_url_for_log = target_url.replace("\n", "_").replace("\r", "_") - sys.audit("mailgun.api.request", safe_method.upper(), safe_url_for_log) - logger.debug("Sending Async Request: %s %s", safe_method.upper(), safe_url_for_log) - - try: - response = await self._client.request(**request_kwargs) - - status_code = getattr(response, "status_code", 200) - is_error = isinstance(status_code, int) and status_code >= _HTTP_ERROR_THRESHOLD - if is_error: - logger.error( - "API Error %s | %s %s", status_code, safe_method.upper(), safe_url_for_log - ) - else: - logger.debug( - "API Success %s | %s %s", - getattr(response, "status_code", 200), - safe_method.upper(), - target_url, - ) - - except httpx.TimeoutException as e: - logger.exception("Timeout Error: %s %s", safe_method.upper(), safe_url_for_log) - raise MailgunTimeoutError("Request timed out") from e - except httpx.ConnectError as e: - logger.critical( - "Async Connection Failed (DNS/Network): %s | URL: %s", e, safe_url_for_log - ) - msg = f"Network routing failed: {e}" - raise ApiError(msg) from e - except httpx.RequestError as e: - logger.critical("Request Exception: %s | URL: %s", e, safe_url_for_log) - raise ApiError(e) from e - else: - return response - - async def get( - self, - filters: Mapping[str, str | Any] | None = None, - domain: str | None = None, - **kwargs: Any, - ) -> HttpxResponse: - """Send an asynchronous GET request to retrieve resources. - - Args: - filters: Query parameters to include in the request. - domain: Target domain name. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return await self.api_call( - self._auth, - "get", - self._url, - domain=domain, - headers=merged_headers, - filters=filters, - **kwargs, - ) - - async def create( - self, - data: Any | None = None, - filters: Mapping[str, str | Any] | None = None, - domain: str | None = None, - headers: Any = None, - files: Any | None = None, - **kwargs: Any, - ) -> HttpxResponse: - """Send an asynchronous POST request to create a new resource or execute an action. - - Args: - data: Payload data (form data or JSON) to include in the request. - filters: Query parameters to include in the request. - domain: Target domain name. - headers: Additional headers to merge with the default headers. - files: Files to upload in the request. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - if headers is not None: - kwargs["headers"] = headers - merged_headers = self._merge_headers(kwargs) - - return await self.api_call( - self._auth, - "post", - self._url, - files=files, - domain=domain, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - async def put( - self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any - ) -> HttpxResponse: - """Send an asynchronous PUT request to update or replace a resource. - - Args: - data: Payload data to include in the request. - filters: Query parameters to include in the request. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return await self.api_call( - self._auth, - "put", - self._url, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - async def patch( - self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any - ) -> HttpxResponse: - """Send an asynchronous PATCH request to partially update a resource. - - Args: - data: Payload data to include in the request. - filters: Query parameters to include in the request. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return await self.api_call( - self._auth, - "patch", - self._url, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - async def update( - self, data: Any | None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any - ) -> HttpxResponse: - """Send an asynchronous PUT request specifically structured for updating resources with dynamic headers. - - Args: - data: Payload data (form data or JSON). - filters: Query parameters to include in the request. - **kwargs: Additional arguments, including custom 'headers', to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - - return await self.api_call( - self._auth, - "put", - self._url, - headers=merged_headers, - data=data, - filters=filters, - **kwargs, - ) - - async def delete(self, domain: str | None = None, **kwargs: Any) -> httpx.Response: - """Send an asynchronous DELETE request to remove a resource. - - Args: - domain: Target domain name. - **kwargs: Additional arguments to pass to the HTTP client. - - Returns: - The HTTP response object. - """ - merged_headers = self._merge_headers(kwargs) - return await self.api_call( - self._auth, "delete", self._url, headers=merged_headers, domain=domain, **kwargs - ) - - class AsyncClient(BaseClient): """Async client class using httpx.""" @@ -1593,21 +303,29 @@ def __getattr__(self, name: str) -> Any: raise AttributeError(msg) from None @property - def _client(self) -> httpx.AsyncClient: + def _client(self) -> httpx.AsyncClient | None: """Provide lazy initialization for the underlying httpx.AsyncClient. Returns: The active httpx.AsyncClient instance. """ if not self._httpx_client or self._httpx_client.is_closed: - # Check if the user already provided a custom transport (e.g. for mocking) - kwargs = self._client_kwargs.copy() - if "transport" not in kwargs: - limits = httpx.Limits(max_keepalive_connections=100, max_connections=100) - kwargs["transport"] = httpx.AsyncHTTPTransport(retries=3, limits=limits) - - self._httpx_client = httpx.AsyncClient(**kwargs) - return self._httpx_client + if getattr(self, "_httpx_client", None) is None: + # Enforce TLS 1.2+ for httpx (CWE-319) + ssl_context = ssl.create_default_context() + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 + + # Check if the user already provided a custom transport (e.g. for mocking) + kwargs = self._client_kwargs.copy() + if "transport" not in kwargs: + limits = httpx.Limits(max_keepalive_connections=100, max_connections=100) + kwargs["transport"] = httpx.AsyncHTTPTransport( + retries=3, limits=limits, verify=ssl_context + ) + + self._httpx_client = httpx.AsyncClient(**kwargs) + return self._httpx_client + return None async def aclose(self) -> None: """Close the underlying httpx.AsyncClient and purge memory.""" diff --git a/mailgun/config.py b/mailgun/config.py new file mode 100644 index 0000000..718fc6d --- /dev/null +++ b/mailgun/config.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import sys +from enum import Enum +from functools import lru_cache +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Final +from urllib.parse import urlparse + +from mailgun import routes +from mailgun.logger import get_logger +from mailgun.security import SecurityGuard + + +try: + from mailgun._version import __version__ +except ImportError: + __version__ = "0.0.0-unknown" + + +if TYPE_CHECKING: + from collections.abc import Mapping + + +logger = get_logger(__name__) + + +@lru_cache +def _get_cached_route_data(clean_key: str) -> dict[str, Any]: + """Apply internal cached routing logic. + + Uses only hashable types (str) as arguments to avoid TypeError. + + Args: + clean_key: The sanitized endpoint key. + + Returns: + A dictionary containing versioning and path data for the route. + """ + # Resolve virtual property aliases before processing + clean_key = routes.ROUTE_ALIASES.get(clean_key, clean_key) + + if clean_key in routes.EXACT_ROUTES: + version, route_keys = routes.EXACT_ROUTES[clean_key] + return {"version": version, "keys": tuple(route_keys)} + + route_parts = clean_key.split("_") + primary_resource = route_parts[0] + + if primary_resource == "domains": + return {"type": "domain", "parts": tuple(route_parts)} + + if primary_resource in routes.PREFIX_ROUTES: + version, suffix, key_override = routes.PREFIX_ROUTES[primary_resource] + final_parts = route_parts.copy() + if key_override: + final_parts[0] = key_override + return {"version": version, "suffix": suffix, "keys": tuple(final_parts)} + + return {"version": APIVersion.V3.value, "keys": tuple(route_parts)} + + +class APIVersion(str, Enum): + """Constants for Mailgun API versions.""" + + V1 = "v1" + V2 = "v2" + V3 = "v3" + V4 = "v4" + V5 = "v5" + + +class Config: + """Configuration engine for the Mailgun API client. + + Using a data-driven routing approach. + """ + + __slots__ = ("_baked_urls", "api_url", "dry_run", "ex_handler") + + DEFAULT_API_URL: Final[str] = "https://api.mailgun.net" + USER_AGENT: Final[str] = f"mailgun-api-python/{__version__}" + + # Use Mapping to denote read-only dictionary-like structures + _HEADERS_BASE: Final[Mapping[str, str]] = MappingProxyType({"User-agent": USER_AGENT}) + _HEADERS_JSON: Final[Mapping[str, str]] = MappingProxyType( + {"User-agent": USER_AGENT, "Content-Type": "application/json"} + ) + + # --- ENCAPSULATED ROUTING REGISTRIES --- + _DOMAINS_RESOURCE: Final[str] = "domains" + + # Mapping[str, Any] is used because the values in routes vary in structure + _EXACT_ROUTES: Final[Mapping[str, Any]] = MappingProxyType(routes.EXACT_ROUTES) + _PREFIX_ROUTES: Final[Mapping[str, Any]] = MappingProxyType(routes.PREFIX_ROUTES) + _DOMAIN_ALIASES: Final[Mapping[str, str]] = MappingProxyType(routes.DOMAIN_ALIASES) + + _DOMAIN_ENDPOINTS: Final[Mapping[str, tuple[str, ...]]] = MappingProxyType( + routes.DOMAIN_ENDPOINTS + ) + _V1_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS["v1"]) + _V3_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS["v3"]) + _V4_ENDPOINTS: Final[frozenset[str]] = frozenset(routes.DOMAIN_ENDPOINTS.get("v4", [])) + + def __init__(self, api_url: str | None = None, *, dry_run: bool = False) -> None: + """Initialize the configuration engine. + + Args: + api_url: Optional custom base URL for the Mailgun API. + dry_run: Prevents network execution and intercepts requests locally. + """ + self.ex_handler: bool = True + self.dry_run: bool = dry_run + base_url_input: str = api_url or self.DEFAULT_API_URL + + self.api_url: str = self._normalize_api_url(base_url_input) + + self._baked_urls: Final[dict[str, str]] = { + ver.value: f"{self.api_url}/{ver.value}" for ver in APIVersion + } + + @staticmethod + def _normalize_api_url(raw_url: str) -> str: + """Validates and normalizes the base API URL. + + Ensures no explicit versions are embedded in the path that would break + dynamic f-string routing. + + Args: + raw_url: The raw base URL string provided by the user. + + Returns: + The sanitized and normalized API URL string. + + Raises: + ValueError: If an ambiguous API version is found embedded within the custom path. + """ + safe_url: str = SecurityGuard.sanitize_api_url(raw_url) + + parsed = urlparse(safe_url) + path_segments = [seg for seg in parsed.path.split("/") if seg] + + known_versions = {v.value for v in APIVersion} + + # Ambiguity & Backward Compatibility Check + for i, segment in enumerate(path_segments): + if segment in known_versions: + is_last_segment = i == len(path_segments) - 1 + + if is_last_segment: + safe_url = safe_url.removesuffix(f"/{segment}") + logger.warning( + "Semantic Configuration Warning: 'api_url' should be the base domain. The trailing '%s' was stripped to prevent routing duplication.", + segment, + ) + else: + # Fail-Fast: The version is trapped inside a complex path + msg = ( + f"Ambiguous API URL configuration: '{raw_url}'.\n" + f"The SDK automatically handles version routing, but an explicit " + f"version ('{segment}') was found embedded within your custom path. " + f"Please provide only the base host (e.g., 'https://api.mailgun.net')." + ) + # Raised ValueError instead of ApiError + raise ValueError(msg) + + return safe_url.rstrip("/") + + def _build_base_url(self, version: APIVersion | str, suffix: str = "") -> str: + """Construct API URL with precise slash control to prevent 404s. + + Args: + version: The API version to use. + suffix: An optional suffix to append to the base URL. + + Returns: + The fully constructed base URL string. + """ + ver_str: str = version.value if isinstance(version, APIVersion) else version + # O(1) access instead of dynamic concatenation, ensuring no trailing slash + base: str = self._baked_urls.get(ver_str, f"{self.api_url}/{ver_str}").rstrip("/") + + if suffix: + path: str = f"{suffix}/" if suffix == self._DOMAINS_RESOURCE else suffix + return f"{base}/{path}" + + return f"{base}/" + + def _resolve_domains_route(self, route_parts: list[str]) -> dict[str, Any]: + """Handle context-aware versioning for domain-related endpoints. + + Args: + route_parts: The components of the route requested. + + Returns: + A dictionary containing a string base URL and a tuple of keys. + """ + if any(action in route_parts for action in ("activate", "deactivate")): + return { + "base": self._build_base_url(APIVersion.V4), + "keys": ( + self._DOMAINS_RESOURCE, + "{authority_name}", + "keys", + "{selector}", + route_parts[-1], + ), + } + + mapped_parts: list[str] = [self._DOMAIN_ALIASES.get(p, p) for p in route_parts] + + if not mapped_parts or mapped_parts[0] != self._DOMAINS_RESOURCE: + mapped_parts.insert(0, self._DOMAINS_RESOURCE) + + version: APIVersion = APIVersion.V3 + + if len(mapped_parts) > 1: + for part in reversed(mapped_parts[1:]): + if part in self._V1_ENDPOINTS: + version = APIVersion.V1 + break + if part in self._V4_ENDPOINTS: + version = APIVersion.V4 + break + if part in self._V3_ENDPOINTS: + version = APIVersion.V3 + break + + return { + "base": self._build_base_url(version, self._DOMAINS_RESOURCE), + "keys": mapped_parts.copy(), + } + + def __getitem__(self, key: str) -> tuple[dict[str, Any], dict[str, str]]: + """Retrieve the URL configuration and headers for a specific endpoint. + + Args: + key: The name of the endpoint route (e.g., 'messages', 'bounces'). + + Returns: + A tuple containing the URL configuration dictionary and the headers dictionary. + """ + clean_key = SecurityGuard.sanitize_key(key) + + route_data = _get_cached_route_data(clean_key) + + # HTTP header mapping based on endpoint naming conventions + requires_json_headers = "analytics" in clean_key or "bounceclassification" in clean_key + + # Prepare headers + headers_map = self._HEADERS_JSON if requires_json_headers else self._HEADERS_BASE + headers = dict(headers_map) + + # Reconstruct result + if route_data.get("type") == "domain": + return self._resolve_domains_route(list(route_data["parts"])), headers + + safe_url = { + "base": self._build_base_url(route_data["version"], route_data.get("suffix", "")), + "keys": list(route_data["keys"]), + } + + return safe_url, headers + + @property + def available_endpoints(self) -> set[str]: + """Provide public access to valid route keys for IDE introspection.""" + return set(self._EXACT_ROUTES.keys()) | set(self._PREFIX_ROUTES.keys()) + + @classmethod + def enable_security_audit(cls) -> None: + """Opt-in PEP 578 Audit Hook to track and log runtime network events. + + Enterprise security teams can enable this during SDK boot to gain instant + visibility into API requests sent via the SDK without altering standard logs. + """ + + def audit_hook(event: str, args: tuple[Any, ...]) -> None: + if event == "mailgun.api.request": + method, url = args + logger.info("SECURITY AUDIT: Outbound API call tracked - %s %s", method, url) + + sys.addaudithook(audit_hook) + logger.info("Mailgun Security Audit Hooks Enabled.") diff --git a/mailgun/endpoints.py b/mailgun/endpoints.py new file mode 100644 index 0000000..b39484f --- /dev/null +++ b/mailgun/endpoints.py @@ -0,0 +1,977 @@ +from __future__ import annotations + +import json +import sys +import warnings +from functools import lru_cache +from typing import TYPE_CHECKING, Any, Final +from urllib.parse import parse_qs, urlparse + +import httpx +import requests +from requests.exceptions import ( + ConnectionError as RequestsConnectionError, # pyright: ignore[reportMissingModuleSource] +) +from requests.models import Response # pyright: ignore[reportMissingModuleSource] + +from mailgun import routes +from mailgun.handlers.error_handler import ApiError, MailgunTimeoutError +from mailgun.logger import get_logger +from mailgun.security import SecurityGuard + + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Mapping + + from httpx import Response as HttpxResponse + + from mailgun.types import TimeoutType + + +logger = get_logger(__name__) + +_HTTP_ERROR_THRESHOLD: Final[int] = 400 + + +def build_path_from_keys(keys: Iterable[str]) -> str: + """Convert a sequence of endpoint keys into a URL path string. + + Args: + keys: An iterable of string components for the URL path. + + Returns: + A formatted path string starting with a slash, or an empty string if the iterable is empty. + """ + if not keys: + return "" + keys_seq = keys if isinstance(keys, (list, tuple)) else list(keys) + return "".join(f"/{SecurityGuard.sanitize_path_segment(k)}" for k in keys_seq if k) + + +@lru_cache(maxsize=32) +def _load_handler(endpoint_key: str) -> Callable[..., str]: # noqa: PLR0911, PLR0912 + """Lazy load the API URL handler for a specific endpoint using SAST-safe literal imports. + + This maintains zero-I/O startup performance. The lru_cache ensures this branching logic + is executed exactly once per route type. + + Returns: + Callable: The specific handler function for the requested endpoint. + """ + # Group 1: Domains Handler (Most common aliases grouped for speed) + if endpoint_key in {"domains", "dkim_authority", "dkim_selector", "web_prefix"}: + from mailgun.handlers.domains_handler import handle_domains # noqa: PLC0415 + + return handle_domains + if endpoint_key == "domainlist": + from mailgun.handlers.domains_handler import handle_domainlist # noqa: PLC0415 + + return handle_domainlist + if endpoint_key == "dkim": + from mailgun.handlers.domains_handler import handle_dkimkeys # noqa: PLC0415 + + return handle_dkimkeys + if endpoint_key == "sending_queues": + from mailgun.handlers.domains_handler import handle_sending_queues # noqa: PLC0415 + + return handle_sending_queues + if endpoint_key == "mailboxes": + from mailgun.handlers.domains_handler import handle_mailboxes_credentials # noqa: PLC0415 + + return handle_mailboxes_credentials + if endpoint_key == "webhooks": + from mailgun.handlers.domains_handler import handle_webhooks # noqa: PLC0415 + + return handle_webhooks + + # Group 2: Suppressions + if endpoint_key == "bounces": + from mailgun.handlers.suppressions_handler import handle_bounces # noqa: PLC0415 + + return handle_bounces + if endpoint_key == "unsubscribes": + from mailgun.handlers.suppressions_handler import handle_unsubscribes # noqa: PLC0415 + + return handle_unsubscribes + if endpoint_key == "whitelists": + from mailgun.handlers.suppressions_handler import handle_whitelists # noqa: PLC0415 + + return handle_whitelists + if endpoint_key == "complaints": + from mailgun.handlers.suppressions_handler import handle_complaints # noqa: PLC0415 + + return handle_complaints + + # Group 3: Specific Services + if endpoint_key == "resendmessage": + from mailgun.handlers.messages_handler import handle_resend_message # noqa: PLC0415 + + return handle_resend_message + if endpoint_key == "ips": + from mailgun.handlers.ips_handler import handle_ips # noqa: PLC0415 + + return handle_ips + if endpoint_key == "ip_pools": + from mailgun.handlers.ip_pools_handler import handle_ippools # noqa: PLC0415 + + return handle_ippools + if endpoint_key == "tags": + from mailgun.handlers.tags_handler import handle_tags # noqa: PLC0415 + + return handle_tags + if endpoint_key == "routes": + from mailgun.handlers.routes_handler import handle_routes # noqa: PLC0415 + + return handle_routes + if endpoint_key == "lists": + from mailgun.handlers.mailinglists_handler import handle_lists # noqa: PLC0415 + + return handle_lists + if endpoint_key == "templates": + from mailgun.handlers.templates_handler import handle_templates # noqa: PLC0415 + + return handle_templates + if endpoint_key == "addressvalidate": + from mailgun.handlers import email_validation_handler as evh # noqa: PLC0415 + + return evh.handle_address_validate + if endpoint_key == "inbox": + from mailgun.handlers.inbox_placement_handler import handle_inbox # noqa: PLC0415 + + return handle_inbox + if endpoint_key == "analytics": + from mailgun.handlers.metrics_handler import handle_metrics # noqa: PLC0415 + + return handle_metrics + if endpoint_key == "bounce-classification": + from mailgun.handlers import bounce_classification_handler as bch # noqa: PLC0415 + + return bch.handle_bounce_classification + if endpoint_key == "users": + from mailgun.handlers.users_handler import handle_users # noqa: PLC0415 + + return handle_users + if endpoint_key == "keys": + from mailgun.handlers.keys_handler import handle_keys # noqa: PLC0415 + + return handle_keys + + # Group 4: Fallback for "messages", "messages.mime", "events", and unknown routes + from mailgun.handlers.default_handler import handle_default # noqa: PLC0415 + + return handle_default + + +class BaseEndpoint: + """Base class for endpoints. Contains methods common for Endpoint and AsyncEndpoint.""" + + __slots__ = ("_auth", "_timeout", "_url", "dry_run", "headers") + + def __init__( + self, + url: dict[str, Any], + headers: dict[str, str], + auth: tuple[str, str] | None, + timeout: TimeoutType = 60, + *, + dry_run: bool = False, + ) -> None: + """Initialize a new BaseEndpoint instance. + + Args: + url: URL dictionary with pairs {"base": "keys"}. + headers: Headers dictionary. + auth: Authentication tuple or None. + timeout: Base request timeout. + dry_run: Execution sandbox flag to prevent I/O. + """ + self._url = url + self.headers = headers + self._auth = auth + self._timeout = timeout + self.dry_run = dry_run + + @staticmethod + def _warn_if_deprecated(method: str, target_url: str) -> None: + """Check the formulated URL against the registry of deprecated endpoints. + + Issues both a standard Python DeprecationWarning and an SDK logger warning. + + Args: + method: Requested HTTP method. + target_url: Formulated destination URL. + """ + path = urlparse(target_url).path + + # Iterate over the dynamically compiled, cached regexes + for pattern, msg in routes.get_deprecated_regexes().items(): + if pattern.search(path): + warning_message = f"DEPRECATED API CALL ({method.upper()} {path}): {msg}" + warnings.warn(warning_message, DeprecationWarning, stacklevel=3) + logger.warning(warning_message) + break + + def __repr__(self) -> str: + """DX: Show the actual resolved target route instead of memory address. + + Returns: + A string representation of the Endpoint and its target route. + """ + route_path = "/".join(self._url.get("keys", ["unknown"])) + return f"<{self.__class__.__name__} target='/{route_path}'>" + + @staticmethod + def build_url( + url: dict[str, Any], + domain: str | None = None, + method: str | None = None, + **kwargs: Any, + ) -> str: + """Build the final request URL using predefined handlers. + + Note: Some URLs are built in the Config class as they cannot be generated dynamically. + + Args: + url: Incoming URL structure containing base and keys. + domain: Target domain name. + method: Requested HTTP method. + **kwargs: Additional arguments required by specific handlers. + + Returns: + The fully constructed target URL. + + Raises: + ApiError: If the domain is required but missing. + """ + keys = url.get("keys", []) + endpoint_key = keys[0] if keys else "" + + if not domain and endpoint_key == "messages": + raise ApiError("Domain is required") + + # Load the handler function dynamically via the cached lazy loader + handler = _load_handler(endpoint_key) + + return handler(url, domain, method, **kwargs) + + def _merge_headers(self, kwargs: dict[str, Any]) -> dict[str, str]: + """Safely extract and merge custom headers from kwargs. + + Returns: + A dictionary containing the safely merged headers. + """ + custom_headers = kwargs.pop("headers", {}) + req_headers = self.headers.copy() + + if custom_headers and isinstance(custom_headers, dict): + req_headers.update(custom_headers) + + return req_headers + + def _prepare_request( + self, + method: str, + url: dict[str, Any], + domain: str | None, + timeout: TimeoutType, + headers: dict[str, str], + kwargs: dict[str, Any], + ) -> tuple[str, str, str, TimeoutType, dict[str, str], dict[str, Any]]: + """Security and routing preparation logic. + + Args: + method: The requested HTTP method. + url: Incoming URL structure containing base and keys. + domain: Target domain name to sanitize. + timeout: Request timeout duration. + headers: Headers dictionary. + kwargs: Additional keyword arguments. + + Returns: + A tuple containing safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, and safe_kwargs. + """ + safe_method = SecurityGuard.sanitize_http_method(method) + safe_kwargs = SecurityGuard.filter_safe_kwargs(kwargs) + safe_headers = SecurityGuard.sanitize_headers(headers) or {} + target_domain = SecurityGuard.sanitize_domain(domain) + + actual_timeout = timeout if timeout is not None else self._timeout + safe_timeout = SecurityGuard.sanitize_timeout(actual_timeout) + + target_url = self.build_url(url, domain=target_domain, method=safe_method, **kwargs) + self._warn_if_deprecated(safe_method, target_url) + + # PEP 578 and protection against Log Forging (CWE-117) + safe_url_for_log = SecurityGuard.sanitize_log_trace(target_url) + + return safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, safe_kwargs + + +class Endpoint(BaseEndpoint): + """Generate synchronous requests and return responses.""" + + __slots__ = ("_session",) + + def __init__( + self, + url: dict[str, Any], + headers: dict[str, str], + auth: tuple[str, str] | None = None, + session: requests.Session | None = None, + timeout: TimeoutType = 60, + *, + dry_run: bool = False, + ) -> None: + """Initialize a new Endpoint instance for synchronous API interaction. + + Args: + url: URL dictionary with pairs {"base": "keys"}. + headers: Headers dictionary. + auth: requests auth tuple or None. + session: Optional pre-configured requests.Session instance. + timeout: Base request timeout. + dry_run: Execution sandbox flag. + """ + super().__init__(url, headers, auth, timeout=timeout, dry_run=dry_run) + self._session = session or requests.Session() + + def api_call( + self, + auth: tuple[str, str] | None, + method: str, + url: dict[str, Any], + headers: dict[str, str], + data: Any | None = None, + filters: Mapping[str, str | Any] | None = None, + timeout: TimeoutType = None, + files: Any | None = None, + domain: str | None = None, + **kwargs: Any, + ) -> Response | Any: + """Execute the HTTP request to the Mailgun API. + + Args: + auth: Authentication tuple. + method: The HTTP method to use (e.g., 'GET', 'POST', 'PUT', 'DELETE'). + url: The final formulated endpoint URL dictionary. + headers: Request headers. + data: Payload data (form data or JSON). + filters: Query parameters. + timeout: Request timeout duration in seconds. + files: Files to upload. + domain: Target domain name. + **kwargs: Additional parameters to be passed to the underlying HTTP client. + + Returns: + The HTTP response object from the server. + + Raises: + MailgunTimeoutError: If the request times out. + ApiError: If the server returns a 4xx or 5xx status code or a network error occurs. + """ + safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, safe_kwargs = ( + self._prepare_request(method, url, domain, timeout, headers, kwargs) + ) + + SecurityGuard.validate_no_control_characters(target_url, context="Endpoint URL") + + # Zero-Leak Sandbox Mode Interception + if self.dry_run: + logger.info( + "DRY RUN: Intercepting %s request to %s", safe_method.upper(), safe_url_for_log + ) + mock_resp = Response() + mock_resp.status_code = 200 + mock_resp.encoding = "utf-8" + mock_resp._content = b'{"message": "Dry run successful - request intercepted", "id": ""}' # noqa: SLF001 + return mock_resp + + # Case-insensitive validation for Content-Type to conform with RFC 7230 + is_json_request = any( + k.lower() == "content-type" and "application/json" in str(v).lower() + for k, v in safe_headers.items() + ) + + if is_json_request and data is not None and not isinstance(data, (str, bytes)): + data = json.dumps(data, separators=(",", ":")) + + req_method = getattr(self._session, safe_method.lower()) + + sys.audit("mailgun.api.request", safe_method.upper(), safe_url_for_log) + logger.debug("Sending Request: %s %s", safe_method.upper(), safe_url_for_log) + + try: + response = req_method( + target_url, + data=data, + params=filters, + headers=safe_headers, + auth=auth, + timeout=safe_timeout, + files=files, + verify=True, + stream=False, + allow_redirects=False, + **safe_kwargs, + ) + + status_code = getattr(response, "status_code", 200) + is_error = isinstance(status_code, int) and status_code >= _HTTP_ERROR_THRESHOLD + if is_error: + logger.error( + "API Error %s | %s %s", status_code, safe_method.upper(), safe_url_for_log + ) + else: + logger.debug( + "API Success %s | %s %s", + getattr(response, "status_code", 200), + safe_method.upper(), + target_url, + ) + + except requests.exceptions.Timeout as e: + logger.exception("Timeout Error: %s %s", safe_method.upper(), safe_url_for_log) + raise MailgunTimeoutError("Request timed out") from e + except RequestsConnectionError as e: + logger.critical("Connection Failed (DNS/Network): %s | URL: %s", e, safe_url_for_log) + msg = f"Network routing failed: {e}" + raise ApiError(msg) from e + except requests.RequestException as e: + logger.critical("Request Exception: %s | URL: %s", e, safe_url_for_log) + raise ApiError(e) from e + else: + return response + + def get( + self, + filters: Mapping[str, str | Any] | None = None, + domain: str | None = None, + **kwargs: Any, + ) -> Response: + """Send a GET request to retrieve resources. + + Args: + filters: Query parameters to include in the request. + domain: Target domain name. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return self.api_call( + self._auth, + "get", + self._url, + domain=domain, + headers=merged_headers, + filters=filters, + **kwargs, + ) + + def create( + self, + data: Any | None = None, + filters: Mapping[str, str | Any] | None = None, + domain: str | None = None, + headers: Any = None, + files: Any | None = None, + **kwargs: Any, + ) -> Response: + """Send a POST request to create a new resource or execute an action. + + Args: + data: Payload data (form data or JSON) to include in the request. + filters: Query parameters to include in the request. + domain: Target domain name. + headers: Additional headers to merge with the default headers. + files: Files to upload in the request. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + if headers is not None: + kwargs["headers"] = headers + merged_headers = self._merge_headers(kwargs) + + return self.api_call( + self._auth, + "post", + self._url, + files=files, + domain=domain, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + def put( + self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any + ) -> Response: + """Send a PUT request to update or replace a resource. + + Args: + data: Payload data to include in the request. + filters: Query parameters to include in the request. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return self.api_call( + self._auth, + "put", + self._url, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + def patch( + self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any + ) -> Response: + """Send a PATCH request to partially update a resource. + + Args: + data: Payload data to include in the request. + filters: Query parameters to include in the request. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return self.api_call( + self._auth, + "patch", + self._url, + data=data, + headers=merged_headers, + filters=filters, + **kwargs, + ) + + def update( + self, data: Any | None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any + ) -> Response: + """Send a PUT request specifically structured for updating resources with dynamic headers. + + Args: + data: Payload data (form data or JSON). + filters: Query parameters to include in the request. + **kwargs: Additional arguments, including custom 'headers', to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return self.api_call( + self._auth, + "put", + self._url, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + def delete(self, domain: str | None = None, **kwargs: Any) -> Response: + """Send a DELETE request to remove a resource. + + Args: + domain: Target domain name. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return self.api_call( + self._auth, "delete", self._url, headers=merged_headers, domain=domain, **kwargs + ) + + def stream( + self, + filters: Mapping[str, str | Any] | None = None, + domain: str | None = None, + **kwargs: Any, + ) -> Any: + """Lazy pagination: yield records one by one without loading all into memory. + + Automatically traverses the 'paging' links returned by the Mailgun API. + + Yields: + Individual records from the paginated API response. + """ + current_filters = dict(filters) if filters else {} + + while True: + # Pass a copy of the dictionary so the mock (and the underlying request layer) + # receives a frozen snapshot of the state for this specific loop iteration. + response = self.get(filters=current_filters.copy(), domain=domain, **kwargs) + + if hasattr(response, "raise_for_status"): + response.raise_for_status() + + data = response.json() + items = data.get("items", []) + + # Yield items one by one (Lazy Evaluation) + yield from items + + # Check for the next page cursor + next_url = data.get("paging", {}).get("next") + + # Stop if there's no next URL or the current page was empty + if not next_url or not items: + break + + # Mailgun returns a full URL. Parse it to extract just the new pagination parameters + # (like 'page' or 'url') so the next self.get() call works correctly. + query_params = parse_qs(urlparse(next_url).query) + current_filters.update({k: v[0] for k, v in query_params.items()}) + + +# ============================================================================== +# 6. ASYNCHRONOUS IMPLEMENTATION +# ============================================================================== + + +class AsyncEndpoint(BaseEndpoint): + """Generate async requests and return responses using httpx.""" + + __slots__ = ("_client",) + + def __init__( + self, + url: dict[str, Any], + headers: dict[str, str], + auth: tuple[str, str] | None, + client: httpx.AsyncClient | None = None, + timeout: TimeoutType = 60, + *, + dry_run: bool = False, + ) -> None: + """Initialize a new AsyncEndpoint instance for asynchronous API interaction. + + Args: + url: URL dictionary with pairs {"base": "keys"}. + headers: Headers dictionary. + auth: httpx auth tuple or None. + client: Optional httpx.AsyncClient instance to reuse. + timeout: Base request timeout. + dry_run: Execution sandbox flag. + """ + super().__init__(url, headers, auth, timeout=timeout, dry_run=dry_run) + self._client = client or httpx.AsyncClient() + + async def api_call( + self, + auth: tuple[str, str] | None, + method: str, + url: dict[str, Any], + headers: dict[str, str], + data: Any | None = None, + filters: Mapping[str, str | Any] | None = None, + timeout: TimeoutType = None, + files: Any | None = None, + domain: str | None = None, + **kwargs: Any, + ) -> HttpxResponse: + """Execute the asynchronous HTTP request to the Mailgun API. + + Args: + auth: Authentication tuple. + method: The HTTP method to use (e.g., 'GET', 'POST', 'PUT', 'DELETE'). + url: The final formulated endpoint URL dictionary. + headers: Request headers. + data: Payload data (form data or JSON). + filters: Query parameters. + timeout: Request timeout duration in seconds. + files: Files to upload. + domain: Target domain name. + **kwargs: Additional parameters to be passed to the underlying HTTP client. + + Returns: + The HTTP response object from the server. + + Raises: + MailgunTimeoutError: If the request times out. + ApiError: If the server returns a 4xx or 5xx status code or a network error occurs. + """ + safe_method, target_url, safe_url_for_log, safe_timeout, safe_headers, safe_kwargs = ( + self._prepare_request(method, url, domain, timeout, headers, kwargs) + ) + + SecurityGuard.validate_no_control_characters(target_url, context="Endpoint URL") + + # Zero-Leak Sandbox Mode Interception + if self.dry_run: + logger.info( + "DRY RUN: Intercepting async %s request to %s", + safe_method.upper(), + safe_url_for_log, + ) + return httpx.Response( + status_code=200, + json={ + "message": "Dry run successful - request intercepted", + "id": "", + }, + request=httpx.Request(method=safe_method.upper(), url=target_url), + ) + + if isinstance(safe_timeout, tuple): + safe_timeout = httpx.Timeout(safe_timeout[1], connect=safe_timeout[0]) + + # Case-insensitive validation for Content-Type to conform with RFC 7230 + is_json_request = any( + k.lower() == "content-type" and "application/json" in str(v).lower() + for k, v in safe_headers.items() + ) + + if is_json_request and data is not None and not isinstance(data, (str, bytes)): + data = json.dumps(data, separators=(",", ":")) + + request_kwargs: dict[str, Any] = { + "method": safe_method.upper(), + "url": target_url, + "params": filters, + "files": files, + "headers": safe_headers, + "auth": auth, + "timeout": safe_timeout, + "follow_redirects": False, + } + + # Safe kwargs passthrough (e.g., allow_redirects) + request_kwargs.update(safe_kwargs) + + if isinstance(data, (str, bytes)): + request_kwargs["content"] = data + else: + request_kwargs["data"] = data + + # PEP 578 and protection against Log Forging (CWE-117) + sys.audit("mailgun.api.request", safe_method.upper(), safe_url_for_log) + logger.debug("Sending Async Request: %s %s", safe_method.upper(), safe_url_for_log) + + try: + response = await self._client.request(**request_kwargs) + + status_code = getattr(response, "status_code", 200) + is_error = isinstance(status_code, int) and status_code >= _HTTP_ERROR_THRESHOLD + if is_error: + logger.error( + "API Error %s | %s %s", status_code, safe_method.upper(), safe_url_for_log + ) + else: + logger.debug( + "API Success %s | %s %s", + getattr(response, "status_code", 200), + safe_method.upper(), + target_url, + ) + + except httpx.TimeoutException as e: + logger.exception("Timeout Error: %s %s", safe_method.upper(), safe_url_for_log) + raise MailgunTimeoutError("Request timed out") from e + except httpx.ConnectError as e: + logger.critical( + "Async Connection Failed (DNS/Network): %s | URL: %s", e, safe_url_for_log + ) + msg = f"Network routing failed: {e}" + raise ApiError(msg) from e + except httpx.RequestError as e: + logger.critical("Request Exception: %s | URL: %s", e, safe_url_for_log) + raise ApiError(e) from e + else: + return response + + async def get( + self, + filters: Mapping[str, str | Any] | None = None, + domain: str | None = None, + **kwargs: Any, + ) -> HttpxResponse: + """Send an asynchronous GET request to retrieve resources. + + Args: + filters: Query parameters to include in the request. + domain: Target domain name. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return await self.api_call( + self._auth, + "get", + self._url, + domain=domain, + headers=merged_headers, + filters=filters, + **kwargs, + ) + + async def create( + self, + data: Any | None = None, + filters: Mapping[str, str | Any] | None = None, + domain: str | None = None, + headers: Any = None, + files: Any | None = None, + **kwargs: Any, + ) -> HttpxResponse: + """Send an asynchronous POST request to create a new resource or execute an action. + + Args: + data: Payload data (form data or JSON) to include in the request. + filters: Query parameters to include in the request. + domain: Target domain name. + headers: Additional headers to merge with the default headers. + files: Files to upload in the request. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + if headers is not None: + kwargs["headers"] = headers + merged_headers = self._merge_headers(kwargs) + + return await self.api_call( + self._auth, + "post", + self._url, + files=files, + domain=domain, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + async def put( + self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any + ) -> HttpxResponse: + """Send an asynchronous PUT request to update or replace a resource. + + Args: + data: Payload data to include in the request. + filters: Query parameters to include in the request. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return await self.api_call( + self._auth, + "put", + self._url, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + async def patch( + self, data: Any | None = None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any + ) -> HttpxResponse: + """Send an asynchronous PATCH request to partially update a resource. + + Args: + data: Payload data to include in the request. + filters: Query parameters to include in the request. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return await self.api_call( + self._auth, + "patch", + self._url, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + async def update( + self, data: Any | None, filters: Mapping[str, str | Any] | None = None, **kwargs: Any + ) -> HttpxResponse: + """Send an asynchronous PUT request specifically structured for updating resources with dynamic headers. + + Args: + data: Payload data (form data or JSON). + filters: Query parameters to include in the request. + **kwargs: Additional arguments, including custom 'headers', to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + + return await self.api_call( + self._auth, + "put", + self._url, + headers=merged_headers, + data=data, + filters=filters, + **kwargs, + ) + + async def delete(self, domain: str | None = None, **kwargs: Any) -> httpx.Response: + """Send an asynchronous DELETE request to remove a resource. + + Args: + domain: Target domain name. + **kwargs: Additional arguments to pass to the HTTP client. + + Returns: + The HTTP response object. + """ + merged_headers = self._merge_headers(kwargs) + return await self.api_call( + self._auth, "delete", self._url, headers=merged_headers, domain=domain, **kwargs + ) + + async def stream( + self, + filters: Mapping[str, str | Any] | None = None, + domain: str | None = None, + **kwargs: Any, + ) -> Any: + """Lazy pagination: yield records asynchronously one by one. + + Yields: + Individual records from the paginated API response. + """ + current_filters = dict(filters) if filters else {} + + while True: + response = await self.get(filters=current_filters.copy(), domain=domain, **kwargs) + + if hasattr(response, "raise_for_status"): + response.raise_for_status() + + data = response.json() + items = data.get("items", []) + for item in items: + yield item + + next_url = data.get("paging", {}).get("next") + if not next_url or not items: + break + + query_params = parse_qs(urlparse(next_url).query) + current_filters.update({k: v[0] for k, v in query_params.items()}) diff --git a/mailgun/examples/async_client_examples.py b/mailgun/examples/async_client_examples.py index d74c7e0..00c38e8 100644 --- a/mailgun/examples/async_client_examples.py +++ b/mailgun/examples/async_client_examples.py @@ -1,15 +1,15 @@ +"""Asynchronous examples for the Mailgun Python SDK.""" + from __future__ import annotations import asyncio import os from pathlib import Path +from typing import Any from mailgun.client import AsyncClient - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -html: str = """ +_HTML_CONTENT: str = """
@@ -19,66 +19,91 @@
""" -client: AsyncClient = AsyncClient(auth=("api", key)) +# ============================================================================== +# Domain Examples +# ============================================================================== -async def get_domains() -> None: + +async def get_domains_async(api_key: str) -> None: """ GET /domains - :return: + :return: None """ - data = await client.domainlist.get() - print(data.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domainlist.get() + print("GET Domains:", response.json()) + +# ============================================================================== +# Messaging Examples +# ============================================================================== -async def post_message() -> None: - # Messages - # POST //messages - data = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], - "cc": os.environ["MESSAGES_CC"], + +async def post_message_async( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str +) -> None: + """ + POST //messages + :return: None + """ + data: dict[str, str] = { + "from": from_email, + "to": to_email, + "cc": cc_email, "subject": "Hello World", - "html": html, + "html": _HTML_CONTENT, "o:tag": "Python test", } + + path1 = Path("mailgun/doc_tests/files/test1.txt") + path2 = Path("mailgun/doc_tests/files/test2.txt") + + if not path1.exists() or not path2.exists(): + print(f"Files not found: {path1} or {path2}. Skipping message attachment upload.") + return + # 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("mailgun/doc_tests/files/test1.txt").read_bytes()), - ), - ( - "attachment", - ("test2.txt", Path("mailgun/doc_tests/files/test2.txt").read_bytes()), - ), + files: list[tuple[str, tuple[str, bytes]]] = [ + ("attachment", ("test1.txt", path1.read_bytes())), + ("attachment", ("test2.txt", path2.read_bytes())), ] - async with AsyncClient(auth=("api", key)) as _client: - req = await _client.messages.create(data=data, files=files, domain=domain) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.messages.create(data=data, files=files, domain=domain) + print("POST Message:", response.json()) + +# ============================================================================== +# Events Examples +# ============================================================================== -async def events_rejected_or_failed() -> None: + +async def events_rejected_or_failed_async(api_key: str, domain: str) -> None: """ GET //events - :return: + :return: None """ - params = {"event": "rejected OR failed"} - req = await client.events.get(domain=domain, filters=params) - print(req.json()) + params: dict[str, str] = {"event": "rejected OR failed"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.events.get(domain=domain, filters=params) + print("GET Events (Rejected/Failed):", response.json()) + + +# ============================================================================== +# Template Examples +# ============================================================================== -# context manager approach examples: -async def post_template() -> None: +async def post_template_async(api_key: str, domain: str) -> None: """ POST //templates - :return: + :return: None """ - data = { + data: dict[str, str] = { "name": "template.name1", "description": "template description", "template": "{{fname}} {{lname}}", @@ -86,19 +111,23 @@ async def post_template() -> None: "comment": "version comment", } - async with AsyncClient(auth=("api", key)) as _client: - req = await _client.templates.create(data=data, domain=domain) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.templates.create(data=data, domain=domain) + print("POST Template:", response.json()) -async def post_analytics_logs() -> None: +# ============================================================================== +# Analytics Examples +# ============================================================================== + + +async def post_analytics_logs_async(api_key: str, domain: str) -> None: """ # Metrics # POST /v1/analytics/logs - :return: + :return: None """ - - data = { + data: dict[str, Any] = { "start": "Wed, 24 Sep 2025 00:00:00 +0000", "end": "Thu, 25 Sep 2025 00:00:00 +0000", "filter": { @@ -117,26 +146,45 @@ async def post_analytics_logs() -> None: }, } - async with AsyncClient(auth=("api", key)) as _client: - req = await _client.analytics_logs.create(data=data) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_logs.create(data=data) + print("POST Analytics Logs:", response.json()) -async def main(): +# ============================================================================== +# Execution +# ============================================================================== + + +async def main() -> None: """Main coroutine that orchestrates the execution of other coroutines.""" + api_key: str = os.environ.get("APIKEY", "") + domain: str = os.environ.get("DOMAIN", "") + + # Fallbacks to prevent instant crashes if only partially configured + msg_from: str = os.environ.get("MESSAGES_FROM", f"test_from@{domain}") + msg_to: str = os.environ.get("MESSAGES_TO", f"test_to@{domain}") + msg_cc: str = os.environ.get("MESSAGES_CC", f"test_cc@{domain}") + + if not api_key or not domain: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + return + print("=== Starting async operations ===\n") - # # Example 1: Running coroutines sequentially + # Example 1: Running coroutines sequentially print("Example 1: Sequential execution") - await get_domains() - await events_rejected_or_failed() + await get_domains_async(api_key=api_key) + await events_rejected_or_failed_async(api_key=api_key, domain=domain) # Example 2: Running coroutines concurrently with gather - print("Example 2: Concurrent execution with gather()") + print("\nExample 2: Concurrent execution with gather()") await asyncio.gather( - post_message(), - post_template(), - post_analytics_logs(), + post_message_async( + api_key=api_key, domain=domain, from_email=msg_from, to_email=msg_to, cc_email=msg_cc + ), + post_template_async(api_key=api_key, domain=domain), + post_analytics_logs_async(api_key=api_key, domain=domain), ) print("\n=== All async operations completed ===") diff --git a/mailgun/examples/bounce_classification_examples.py b/mailgun/examples/bounce_classification_examples.py index 503d3c8..6619ee0 100644 --- a/mailgun/examples/bounce_classification_examples.py +++ b/mailgun/examples/bounce_classification_examples.py @@ -1,21 +1,63 @@ +"""Examples for Mailgun Bounce Classification API.""" + +import asyncio import os +from typing import Any + +from mailgun.client import AsyncClient, Client + -from mailgun.client import Client +def post_list_statistic_v2_sync(api_key: str, domain: str) -> None: + """ + # Bounce Classification (Synchronous) + # POST /v2/bounce-classification/metrics + :return: None + """ + payload: dict[str, Any] = { + "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: dict[str, str] = {"Content-Type": "application/json"} -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) + with Client(auth=("api", api_key)) as client: + req = client.bounce_classification.create(data=payload, headers=headers) + print(req.json()) -def post_list_statistic_v2() -> None: +async def post_list_statistic_v2_async(api_key: str, domain: str) -> None: """ - # Bounce Classification + # Bounce Classification (Asynchronous) # POST /v2/bounce-classification/metrics - :return: + :return: None """ - - payload = { + payload: dict[str, Any] = { "start": "Wed, 12 Nov 2025 23:00:00 UTC", "end": "Thu, 13 Nov 2025 23:00:00 UTC", "resolution": "day", @@ -46,11 +88,22 @@ def post_list_statistic_v2() -> None: "pagination": {"sort": "entity-name:asc", "limit": 10}, } - headers = {"Content-Type": "application/json"} + headers: dict[str, str] = {"Content-Type": "application/json"} - req = client.bounceclassification_metrics.create(data=payload, headers=headers) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + req = await client.bounce_classification.create(data=payload, headers=headers) + print(req.json()) if __name__ == "__main__": - post_list_statistic_v2() + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables.") + else: + print("--- Running Synchronous Bounce Classification Example ---") + post_list_statistic_v2_sync(api_key=API_KEY, domain=DOMAIN) + + print("\n--- Running Asynchronous Bounce Classification Example ---") + asyncio.run(post_list_statistic_v2_async(api_key=API_KEY, domain=DOMAIN)) diff --git a/mailgun/examples/builder_examples.py b/mailgun/examples/builder_examples.py new file mode 100644 index 0000000..7f24905 --- /dev/null +++ b/mailgun/examples/builder_examples.py @@ -0,0 +1,134 @@ +"""Examples for Mailgun Message Builders and Clients.""" + +import asyncio +import os + +from mailgun.builders import MailgunMessageBuilder +from mailgun.client import AsyncClient, Client + + +def send_standard_email_sync(api_key: str, domain: str) -> None: + """ + Example 1: Sending a standard email with text, HTML, and an attachment. + (Synchronous Execution) + """ + print("\n--- Sending Standard Email (Sync) ---") + payload, files = ( + MailgunMessageBuilder(f"support@{domain}") + .add_recipient("user1@example.com") + .set_subject("Your Monthly Invoice") + .set_text("Please find your invoice attached.") + .set_html("

Please find your invoice attached.

") + .add_custom_header("Reply-To", f"billing@{domain}") + # Note: file must exist locally to attach + # .attach_file("/tmp/invoice.pdf", safe_base_dir="/tmp/") + .build() + ) + print(f"Payload: {payload}") + + # Use the synchronous context manager to prevent requests.Session socket leaks + with Client(auth=("api", api_key)) as client: + req = client.messages.create(domain=domain, data=payload, files=files) + print(req.json()) + + +async def send_template_email_async(api_key: str, domain: str) -> None: + """ + Example 2: Sending an email using Mailgun Templates with variables and fallbacks. + (Asynchronous Execution) + """ + print("\n--- Sending Template Email (Async) ---") + payload, files = ( + MailgunMessageBuilder(f"marketing@{domain}") + .add_recipient("user2@example.com") + .set_subject("Special Offer Inside!") + .set_template("promo-template") + .set_template_version("v2") + .set_template_text(enable=True) # Auto-generate text fallback for deliverability + .set_template_variables({"discount": "20%", "code": "SAVE20"}) + .build() + ) + print(f"Payload: {payload}") + + async with AsyncClient(auth=("api", api_key)) as client: + req = await client.messages.create(domain=domain, data=payload, files=files) + print(req.json()) + + +def send_batch_email_sync(api_key: str, domain: str) -> None: + """ + Example 3: Batch sending to up to 1,000 users with personalized variables. + (Synchronous Execution) + """ + print("\n--- Sending Batch Email (Sync) ---") + payload, files = ( + MailgunMessageBuilder(f"newsletter@{domain}") + .add_recipient("alice@example.com") + .add_recipient("bob@example.com") + .set_subject("Hey %recipient.name%, your weekly update!") + .set_text("Hi %recipient.name%, your user ID is %recipient.id%.") + .set_recipient_variables( + { + "alice@example.com": {"name": "Alice", "id": 101}, + "bob@example.com": {"name": "Bob", "id": 102}, + } + ) + .build() + ) + print(f"Payload: {payload}") + + with Client(auth=("api", api_key)) as client: + req = client.messages.create(domain=domain, data=payload, files=files) + print(req.json()) + + +async def send_amp_and_inline_images_async(api_key: str, domain: str) -> None: + """ + Example 4: Sending interactive AMP HTML with inline CID images. + (Asynchronous Execution) + """ + print("\n--- Sending AMP Email with Inline Image (Async) ---") + + dummy_image_path: str = "/tmp/logo.png" + + # Create a dummy image for this example to work + try: + with open(dummy_image_path, "wb") as f: + f.write(b"dummy image data") + + payload, files = ( + MailgunMessageBuilder(f"hello@{domain}") + .add_recipient("user3@example.com") + .set_subject("Interactive Email") + .set_html('') + .set_amp_html("AMP Content") + .attach_inline(dummy_image_path, safe_base_dir="/tmp/") + .build() + ) + print(f"Payload: {payload}") + print(f"Files: {[(f[0], f[1][0]) for f in files] if files else None}") + + async with AsyncClient(auth=("api", api_key)) as client: + req = await client.messages.create(domain=domain, data=payload, files=files) + print(req.json()) + + finally: + # Clean up the dummy image to avoid polluting the host's /tmp/ directory + if os.path.exists(dummy_image_path): + os.remove(dummy_image_path) + + +if __name__ == "__main__": + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + # 1. Run Synchronous Examples + send_standard_email_sync(api_key=API_KEY, domain=DOMAIN) + send_batch_email_sync(api_key=API_KEY, domain=DOMAIN) + + # 2. Run Asynchronous Examples + asyncio.run(send_template_email_async(api_key=API_KEY, domain=DOMAIN)) + asyncio.run(send_amp_and_inline_images_async(api_key=API_KEY, domain=DOMAIN)) diff --git a/mailgun/examples/credentials_examples.py b/mailgun/examples/credentials_examples.py index b49be50..f07e72f 100644 --- a/mailgun/examples/credentials_examples.py +++ b/mailgun/examples/credentials_examples.py @@ -1,68 +1,164 @@ +"""Examples for managing Mailgun domain SMTP credentials.""" + from __future__ import annotations +import asyncio import os -from mailgun.client import Client +from mailgun.client import AsyncClient, Client + +# ============================================================================== +# Synchronous Examples +# ============================================================================== -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +def post_credentials_sync(api_key: str, domain: str) -> None: + """ + POST /domains//credentials + :return: None + """ + data: dict[str, str] = { + "login": f"alice_bob@{domain}", + "password": "test_new_creds123", # pragma: allowlist secret + } + with Client(auth=("api", api_key)) as client: + response = client.domains_credentials.create(domain=domain, data=data) + print("POST (Sync):", response.json()) -def get_credentials() -> None: +def get_credentials_sync(api_key: str, domain: str) -> None: """ GET /domains//credentials - :return: + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.domains_credentials.get(domain=domain) + print("GET (Sync):", response.json()) + + +def put_credentials_sync(api_key: str, domain: str) -> None: + """ + PUT /domains//credentials/ + :return: None + """ + data: dict[str, str] = { + "password": "test_new_creds12356" # pragma: allowlist secret + } + with Client(auth=("api", api_key)) as client: + response = client.domains_credentials.put( + domain=domain, data=data, login=f"alice_bob@{domain}" + ) + print("PUT (Sync):", response.json()) + + +def delete_credentials_sync(api_key: str, domain: str) -> None: """ - request = client.domains_credentials.get(domain=domain) - print(request.json()) + DELETE /domains//credentials/ + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.domains_credentials.delete(domain=domain, login=f"alice_bob@{domain}") + print("DELETE Single (Sync):", response.json()) -def post_credentials() -> None: +def delete_all_domain_credentials_sync(api_key: str, domain: str) -> None: + """ + DELETE /domains//credentials + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.domains_credentials.delete(domain=domain) + print("DELETE All (Sync):", response.json()) + + +# ============================================================================== +# Asynchronous Examples +# ============================================================================== + + +async def post_credentials_async(api_key: str, domain: str) -> None: """ POST /domains//credentials - :return: + :return: None """ - data = { - "login": f"alice_bob@{domain}", + data: dict[str, str] = { + "login": f"async_alice_bob@{domain}", "password": "test_new_creds123", # pragma: allowlist secret } - request = client.domains_credentials.create(domain=domain, data=data) - print(request.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_credentials.create(domain=domain, data=data) + print("POST (Async):", response.json()) -def put_credentials() -> None: +async def get_credentials_async(api_key: str, domain: str) -> None: """ - PUT /domains//credentials/ - :return: + GET /domains//credentials + :return: None """ - data = {"password": "test_new_creds12356"} # pragma: allowlist secret - request = client.domains_credentials.put(domain=domain, data=data, login=f"alice_bob@{domain}") - print(request.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_credentials.get(domain=domain) + print("GET (Async):", response.json()) -def delete_all_domain_credentials() -> None: +async def put_credentials_async(api_key: str, domain: str) -> None: """ - DELETE /domains//credentials - :return: + PUT /domains//credentials/ + :return: None """ - request = client.domains_credentials.delete(domain=domain) - print(request.json()) + data: dict[str, str] = { + "password": "test_new_creds12356" # pragma: allowlist secret + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_credentials.put( + domain=domain, data=data, login=f"async_alice_bob@{domain}" + ) + print("PUT (Async):", response.json()) -def delete_credentials() -> None: +async def delete_credentials_async(api_key: str, domain: str) -> None: """ DELETE /domains//credentials/ - :return: + :return: None """ - request = client.domains_credentials.delete(domain=domain, login=f"alice_bob@{domain}") - print(request.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_credentials.delete( + domain=domain, login=f"async_alice_bob@{domain}" + ) + print("DELETE Single (Async):", response.json()) -if __name__ == "__main__": - post_credentials() - get_credentials() +async def delete_all_domain_credentials_async(api_key: str, domain: str) -> None: + """ + DELETE /domains//credentials + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_credentials.delete(domain=domain) + print("DELETE All (Async):", response.json()) + - # put_mailboxes_credentials() +# ============================================================================== +# Execution +# ============================================================================== + +if __name__ == "__main__": + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Credentials Examples ---") + post_credentials_sync(api_key=API_KEY, domain=DOMAIN) + get_credentials_sync(api_key=API_KEY, domain=DOMAIN) + # put_credentials_sync(api_key=API_KEY, domain=DOMAIN) + # delete_credentials_sync(api_key=API_KEY, domain=DOMAIN) + # delete_all_domain_credentials_sync(api_key=API_KEY, domain=DOMAIN) + + print("\n--- Running Asynchronous Credentials Examples ---") + asyncio.run(post_credentials_async(api_key=API_KEY, domain=DOMAIN)) + asyncio.run(get_credentials_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(put_credentials_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(delete_credentials_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(delete_all_domain_credentials_async(api_key=API_KEY, domain=DOMAIN)) diff --git a/mailgun/examples/domain_examples.py b/mailgun/examples/domain_examples.py index e3e3496..9da29b0 100644 --- a/mailgun/examples/domain_examples.py +++ b/mailgun/examples/domain_examples.py @@ -1,225 +1,249 @@ +"""Examples for managing Mailgun Domains, Connections, Tracking, and DKIM.""" + from __future__ import annotations +import asyncio import os import re import subprocess from pathlib import Path +from typing import Any -from mailgun.client import Client - +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -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}$") -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Domain Management (Synchronous) +# ============================================================================== -def get_domains() -> None: + +def get_domains_sync(api_key: str) -> None: """ GET /domains - :return: + :return: None """ - data = client.domainlist.get() - print(data.json()) + with Client(auth=("api", api_key)) as client: + response = client.domainlist.get() + print("GET Domains:", response.json()) -def add_domain() -> None: +def add_domain_sync(api_key: str, domain_name: str) -> None: """ POST /domains - :return: + :return: None """ - # Post domain - data = { - "name": "python.test.com", + data: dict[str, str] = { + "name": domain_name, } - # Problem with smtp_password!!!! - - request = client.domains.create(data=data) - print(request.json()) - print(request.status_code) + with Client(auth=("api", api_key)) as client: + response = client.domains.create(data=data) + print("POST Domain:", response.json()) + print("Status Code:", response.status_code) -# Get domain - -def get_simple_domain() -> None: +def get_simple_domain_sync(api_key: str, domain_name: str) -> None: """ GET /domains/ - :return: + :return: None """ - domain_name = "python.test.com" - request = client.domains.get(domain_name=domain_name) - print(request.json()) + with Client(auth=("api", api_key)) as client: + response = client.domains.get(domain_name=domain_name) + print("GET Simple Domain:", response.json()) -def update_simple_domain() -> None: +def update_simple_domain_sync(api_key: str, domain_name: str) -> None: """ PUT /domains/ - :return: + :return: None """ - domain_name = "python.test.com" - data = {"name": domain_name, "spam_action": "disabled"} - request = client.domains.put(data=data, domain=domain_name) - print(request.json()) + data: dict[str, str] = {"name": domain_name, "spam_action": "disabled"} + with Client(auth=("api", api_key)) as client: + response = client.domains.put(data=data, domain=domain_name) + print("PUT Simple Domain:", response.json()) -def verify_domain() -> None: +def verify_domain_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//verify - :return: + :return: None """ - domain_name = "python.test.com" - request = client.domains.put(domain=domain_name, verify=True) - print(request.json()) + with Client(auth=("api", api_key)) as client: + response = client.domains.put(domain=domain_name, verify=True) + print("PUT Verify Domain:", response.json()) -def delete_domain() -> None: +def delete_domain_sync(api_key: str, domain_name: str) -> None: """ DELETE /domains/ - :return: + :return: None """ # Delete domain - request = client.domains.delete(domain="python.test.com") - print(request.text) - print(request.status_code) + with Client(auth=("api", api_key)) as client: + response = client.domains.delete(domain=domain_name) + print("DELETE Domain:", response.text) + print("Status Code:", response.status_code) + + +# ============================================================================== +# Connection Settings (Synchronous) +# ============================================================================== -def get_connections() -> None: +def get_connections_sync(api_key: str, domain_name: str) -> None: """ GET /domains//connection - :return: + :return: None """ - request = client.domains_connection.get(domain=domain) - print(request.json()) + with Client(auth=("api", api_key)) as client: + response = client.domains_connection.get(domain=domain_name) + print("GET Connections:", response.json()) -def put_connections() -> None: +def put_connections_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//connection - :return: + :return: None """ - data = {"require_tls": "true", "skip_verification": "false"} - request = client.domains_connection.put(domain=domain, data=data) - print(request.json()) + data: dict[str, str] = {"require_tls": "true", "skip_verification": "false"} + with Client(auth=("api", api_key)) as client: + response = client.domains_connection.put(domain=domain_name, data=data) + print("PUT Connections:", response.json()) -def get_tracking() -> None: +# ============================================================================== +# Tracking Settings (Synchronous) +# ============================================================================== + + +def get_tracking_sync(api_key: str, domain_name: str) -> None: """ GET /domains//tracking - :return: + :return: None """ - request = client.domains_tracking.get(domain=domain) - print(request.json()) + with Client(auth=("api", api_key)) as client: + response = client.domains_tracking.get(domain=domain_name) + print("GET Tracking:", response.json()) -def put_open_tracking() -> None: +def put_open_tracking_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//tracking/open - :return: + :return: None """ - data = {"active": "yes", "skip_verification": "false"} - request = client.domains_tracking_open.put(domain=domain, data=data) - print(request.json()) + data: dict[str, str] = {"active": "yes", "skip_verification": "false"} + with Client(auth=("api", api_key)) as client: + response = client.domains_tracking_open.put(domain=domain_name, data=data) + print("PUT Open Tracking:", response.json()) -def put_click_tracking() -> None: +def put_click_tracking_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//tracking/click - :return: + :return: None """ - data = { - "active": "yes", - } - request = client.domains_tracking_click.put(domain=domain, data=data) - print(request.json()) + data: dict[str, str] = {"active": "yes"} + with Client(auth=("api", api_key)) as client: + response = client.domains_tracking_click.put(domain=domain_name, data=data) + print("PUT Click Tracking:", response.json()) -def put_unsub_tracking() -> None: +def put_unsub_tracking_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//tracking/unsubscribe - :return: + :return: None """ # fmt: off - data = { + data: dict[str, str] = { "active": "yes", "html_footer": "\n
\n

UnSuBsCrIbE

\n", "text_footer": "\n\nTo unsubscribe here click: <%unsubscribe_url%>\n\n" } # fmt: on - request = client.domains_tracking_unsubscribe.put(domain=domain, data=data) - print(request.json()) + with Client(auth=("api", api_key)) as client: + response = client.domains_tracking_unsubscribe.put(domain=domain_name, data=data) + print("PUT Unsub Tracking:", response.json()) + + +# ============================================================================== +# DKIM & Web Prefix (Synchronous) +# ============================================================================== -def put_dkim_authority() -> None: +def put_dkim_authority_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//dkim_authority - :return: + :return: None """ - data = {"self": "true"} - request = client.domains_dkimauthority.put(domain=domain, data=data) - print(request.json()) + data: dict[str, str] = {"self": "true"} + with Client(auth=("api", api_key)) as client: + response = client.domains_dkimauthority.put(domain=domain_name, data=data) + print("PUT DKIM Authority:", response.json()) -def put_dkim_selector() -> None: +def put_dkim_selector_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//dkim_selector - :return: + :return: None """ - data = {"dkim_selector": "s"} - request = client.domains_dkimselector.put(domain="python.test.com", data=data) - print(request.json()) + data: dict[str, str] = {"dkim_selector": "s"} + with Client(auth=("api", api_key)) as client: + response = client.domains_dkimselector.put(domain=domain_name, data=data) + print("PUT DKIM Selector:", response.json()) -def put_web_prefix() -> None: +def put_web_prefix_sync(api_key: str, domain_name: str) -> None: """ PUT /domains//web_prefix - :return: + :return: None """ - data = {"web_prefix": "python"} - request = client.domains_webprefix.put(domain="python.test.com", data=data) - print(request.json()) + data: dict[str, str] = {"web_prefix": "python"} + with Client(auth=("api", api_key)) as client: + response = client.domains_webprefix.put(domain=domain_name, data=data) + print("PUT Web Prefix:", response.json()) -def get_sending_queues() -> None: +def get_sending_queues_sync(api_key: str, domain_name: str) -> None: """ GET /domains//sending_queues - :return: + :return: None """ - request = client.domains_sendingqueues.get(domain="python.test.com") - print(request.json()) - print(request.status_code) + with Client(auth=("api", api_key)) as client: + response = client.domains_sendingqueues.get(domain=domain_name) + print("GET Sending Queues:", response.json()) + print("Status Code:", response.status_code) -def get_dkim_keys() -> None: +def get_dkim_keys_sync(api_key: str, domain_name: str) -> None: """ GET /v1/dkim/keys - :return: + :return: None """ - data = { + data: dict[str, str] = { "page": "string", "limit": "0", - "signing_domain": "python.test.com", + "signing_domain": domain_name, "selector": "smtp", } + with Client(auth=("api", api_key)) as client: + response = client.dkim_keys.get(data=data) + print("GET DKIM Keys:", response.json()) - request = client.dkim_keys.get(data=data) - print(request.json()) - -def post_dkim_keys() -> None: +def post_dkim_keys_sync(api_key: str, domain_name: str, secret_key_filename: str) -> None: """ POST /v1/dkim/keys - :return: + :return: None """ - # 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}") + + secret_key_path = Path(secret_key_filename) subprocess.run( ["openssl", "genrsa", "-traditional", "-out", secret_key_filename, "--", "2048"], check=True ) @@ -231,52 +255,95 @@ def post_dkim_keys() -> None: ) ] - data = { - "signing_domain": "python.test.com", + data: dict[str, Any] = { + "signing_domain": domain_name, "selector": "smtp", "bits": "2048", - "pem": files, } - headers = {"Content-Type": "multipart/form-data"} + # Note: Explicitly providing {"Content-Type": "multipart/form-data"} here breaks `requests` + # and `httpx` because they auto-generate the necessary multipart boundary string. + with Client(auth=("api", api_key)) as client: + response = client.dkim_keys.create(data=data, files=files) + print("POST DKIM Keys:", response.json()) - request = client.dkim_keys.create(data=data, headers=headers, files=files) - print(request.json()) + # Safely clean up the generated key + if secret_key_path.exists(): + secret_key_path.unlink() -def delete_dkim_keys() -> None: +def delete_dkim_keys_sync(api_key: str, domain_name: str) -> None: """ - GET /v1/dkim/keys - :return: + DELETE /v1/dkim/keys + :return: None + """ + query: dict[str, str] = {"signing_domain": domain_name, "selector": "smtp"} + with Client(auth=("api", api_key)) as client: + response = client.dkim_keys.delete(filters=query) + print("DELETE DKIM Keys:", response.json()) + + +# ============================================================================== +# Domain Management (Asynchronous Example) +# ============================================================================== + + +async def get_domains_async(api_key: str) -> None: """ - query = {"signing_domain": "python.test.com", "selector": "smtp"} + GET /domains (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domainlist.get() + print("GET Domains (Async):", response.json()) - request = client.dkim_keys.delete(filters=query) - print(request.json()) +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - add_domain() - get_domains() - get_simple_domain() - update_simple_domain() - verify_domain() - delete_domain() - - get_connections() - put_connections() - get_tracking() - put_open_tracking() - put_click_tracking() - put_unsub_tracking() - - put_dkim_authority() - - put_dkim_selector() - put_web_prefix() - - get_sending_queues() - - post_dkim_keys() - get_dkim_keys() - delete_dkim_keys() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + TARGET_DOMAIN: str = "python.test.com" + SECRET_KEY_FILE: str = os.environ.get("SECRET_KEY_FILENAME", "server.key") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + # Domain Management + add_domain_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + get_domains_sync(api_key=API_KEY) + get_simple_domain_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + update_simple_domain_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + verify_domain_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + delete_domain_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + + # Connection Settings + get_connections_sync(api_key=API_KEY, domain_name=DOMAIN) + put_connections_sync(api_key=API_KEY, domain_name=DOMAIN) + + # Tracking Settings + get_tracking_sync(api_key=API_KEY, domain_name=DOMAIN) + put_open_tracking_sync(api_key=API_KEY, domain_name=DOMAIN) + put_click_tracking_sync(api_key=API_KEY, domain_name=DOMAIN) + put_unsub_tracking_sync(api_key=API_KEY, domain_name=DOMAIN) + + # DKIM & Web Prefix + put_dkim_authority_sync(api_key=API_KEY, domain_name=DOMAIN) + put_dkim_selector_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + put_web_prefix_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + + # Sending Queues + get_sending_queues_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + + # DKIM Keys Lifecycle + post_dkim_keys_sync( + api_key=API_KEY, domain_name=TARGET_DOMAIN, secret_key_filename=SECRET_KEY_FILE + ) + get_dkim_keys_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + delete_dkim_keys_sync(api_key=API_KEY, domain_name=TARGET_DOMAIN) + + # Run Async Example + asyncio.run(get_domains_async(api_key=API_KEY)) diff --git a/mailgun/examples/email_validation_examples.py b/mailgun/examples/email_validation_examples.py index c4554bc..d2f1464 100644 --- a/mailgun/examples/email_validation_examples.py +++ b/mailgun/examples/email_validation_examples.py @@ -1,126 +1,224 @@ +"""Examples for Mailgun Address Validation API.""" + +from __future__ import annotations + +import asyncio import os from pathlib import Path +from typing import Any -from mailgun.client import Client +from mailgun.client import AsyncClient, Client from mailgun.handlers.error_handler import UploadError -# The maximum message size Mailgun supports is 25MB, -# see https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-http#send-via-http -MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB +# The maximum message size Mailgun supports is 25MB +MAX_FILE_SIZE: int = 25 * 1024 * 1024 + -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] +def _print_response(prefix: str, response: Any) -> None: + """Helper to safely print response JSON, falling back to raw text on Free/Sandbox errors.""" + try: + print(f"{prefix}:", response.json()) + except Exception: + print(f"{prefix}: HTTP {response.status_code} - {response.text}") -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Single Validation Examples +# ============================================================================== -def get_single_validate() -> None: + +def get_single_validate_sync(api_key: str) -> None: """ GET /v4/address/validate - :return: + :return: None """ - params = {"address": "test@gmail.com", "provider_lookup": "false"} - req = client.addressvalidate.get(domain=domain, filters=params) - print(req.json()) + params: dict[str, str] = {"address": "test@gmail.com", "provider_lookup": "false"} + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate.get(filters=params) + _print_response("GET Single Validate (Sync)", response) -def post_single_validate() -> None: +def post_single_validate_sync(api_key: str) -> None: """ POST /v4/address/validate - :return: + :return: None + """ + data: dict[str, str] = {"address": "test2@gmail.com"} + params: dict[str, str] = {"provider_lookup": "false"} + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate.create(data=data, filters=params) + _print_response("POST Single Validate (Sync)", response) + + +async def post_single_validate_async(api_key: str) -> None: + """ + POST /v4/address/validate (Asynchronous) + :return: None + """ + data: dict[str, str] = {"address": "test_async@gmail.com"} + params: dict[str, str] = {"provider_lookup": "false"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.addressvalidate.create(data=data, filters=params) + _print_response("POST Single Validate (Async)", response) + + +# ============================================================================== +# Bulk List Validation Examples +# ============================================================================== + + +def delete_bulk_list_validate_sync(api_key: str) -> None: + """ + DELETE /v4/address/validate/bulk/ + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_bulk.delete(list_name="python2_list") + _print_response("DELETE Bulk List Validate (Sync)", response) + + +def get_bulk_list_validate_sync(api_key: str) -> None: + """ + GET /v4/address/validate/bulk/ + :return: None """ - data = {"address": "test2@gmail.com"} - params = {"provider_lookup": "false"} - req = client.addressvalidate.create(domain=domain, data=data, filters=params) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_bulk.get(list_name="python2_list") + _print_response("GET Specific Bulk List Validate (Sync)", response) -def get_bulk_validate() -> None: +def get_bulk_validate_sync(api_key: str) -> None: """ GET /v4/address/validate/bulk - :return: + :return: None """ - params = {"limit": 2} - req = client.addressvalidate_bulk.get(domain=domain, filters=params) - print(req.json()) + params: dict[str, int] = {"limit": 2} + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_bulk.get(filters=params) + _print_response("GET Bulk Validate (Sync)", response) -def post_bulk_list_validate() -> None: +def post_bulk_list_validate_sync(api_key: str, csv_filepath: Path) -> None: """ POST /v4/address/validate/bulk/ - :return: + :return: None """ - csv_filepath = Path("mailgun/doc_tests/files/email_validation.csv") - - if not csv_filepath: - raise FileNotFoundError(f"File {csv_filepath} not found.") + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping bulk validation upload.") + return if csv_filepath.stat().st_size > MAX_FILE_SIZE: - raise UploadError(f"File too large and exceeds the limit of {MAX_FILE_SIZE}") + raise UploadError(f"File too large and exceeds the limit of {MAX_FILE_SIZE} bytes") - # 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. - csv_data = csv_filepath.read_bytes() + csv_data: bytes = csv_filepath.read_bytes() - if not csv_data.startswith(b"") and not csv_data: + if not csv_data: raise ValueError("File is empty.") - files = {"file": csv_data} - req = client.addressvalidate_bulk.create(domain=domain, files=files, list_name="python2_list") - print(req.json()) + # Using the tuple format ensures requests/httpx correctly flags multipart/form-data + files: dict[str, tuple[str, bytes, str]] = {"file": (csv_filepath.name, csv_data, "text/csv")} + + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_bulk.create(files=files, list_name="python2_list") + _print_response("POST Bulk List Validate (Sync)", response) -def get_bulk_list_validate() -> None: + +async def post_bulk_list_validate_async(api_key: str, csv_filepath: Path) -> None: """ - GET /v4/address/validate/bulk/ - :return: + POST /v4/address/validate/bulk/ (Asynchronous) + :return: None """ - req = client.addressvalidate_bulk.get(domain=domain, list_name="python2_list") - print(req.json()) + if not csv_filepath.exists(): + return + + csv_data: bytes = csv_filepath.read_bytes() + files: dict[str, tuple[str, bytes, str]] = {"file": (csv_filepath.name, csv_data, "text/csv")} + + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.addressvalidate_bulk.create( + files=files, list_name="async_python_list" + ) + _print_response("POST Bulk List Validate (Async)", response) -def delete_bulk_list_validate() -> None: +# ============================================================================== +# Validation Preview Examples +# ============================================================================== + + +def delete_preview_sync(api_key: str) -> None: """ - DELETE /v4/address/validate/bulk/ - :return: + DELETE /v4/address/validate/preview/ + :return: None """ - req = client.addressvalidate_bulk.delete(domain=domain, list_name="python2_list") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_preview.delete(list_name="python_list") + _print_response("DELETE Preview (Sync)", response) -def get_preview() -> None: +def get_preview_sync(api_key: str) -> None: """ GET /v4/address/validate/preview - :return: + :return: None """ - req = client.addressvalidate_preview.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_preview.get() + _print_response("GET Preview (Sync)", response) -def post_preview() -> None: +def post_preview_sync(api_key: str, csv_filepath: Path) -> None: """ POST /v4/address/validate/preview/ - :return: + :return: None """ - # 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 = {"file": Path("mailgun/doc_tests/files/email_previews.csv").read_bytes()} - req = client.addressvalidate_preview.create(domain=domain, files=files, list_name="python_list") - print(req.json()) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping preview upload.") + return + csv_data: bytes = csv_filepath.read_bytes() + files: dict[str, tuple[str, bytes, str]] = {"file": (csv_filepath.name, csv_data, "text/csv")} + + with Client(auth=("api", api_key)) as client: + response = client.addressvalidate_preview.create(files=files, list_name="python_list") + _print_response("POST Preview (Sync)", response) -def delete_preview() -> None: - """ - DELETE /v4/address/validate/preview/ - :return: - """ - req = client.addressvalidate_preview.delete(domain=domain, list_name="python_list") - print(req.text) +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - delete_preview() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + + # Define target files + VALIDATION_CSV = Path("mailgun/doc_tests/files/email_validation.csv") + PREVIEW_CSV = Path("mailgun/doc_tests/files/email_previews.csv") + + if not API_KEY: + print("Please set the 'APIKEY' environment variable to run examples.") + else: + # Pre-seed files so the script doesn't crash on the first run if the directory is missing + VALIDATION_CSV.parent.mkdir(parents=True, exist_ok=True) + if not VALIDATION_CSV.exists(): + VALIDATION_CSV.write_bytes(b"email\ntest1@example.com\n") + if not PREVIEW_CSV.exists(): + PREVIEW_CSV.write_bytes(b"email\npreview1@example.com\n") + + print("--- Running Synchronous Examples ---") + get_single_validate_sync(api_key=API_KEY) + post_single_validate_sync(api_key=API_KEY) + + get_bulk_validate_sync(api_key=API_KEY) + post_bulk_list_validate_sync(api_key=API_KEY, csv_filepath=VALIDATION_CSV) + get_bulk_list_validate_sync(api_key=API_KEY) + # delete_bulk_list_validate_sync(api_key=API_KEY, domain=DOMAIN) + + get_preview_sync(api_key=API_KEY) + post_preview_sync(api_key=API_KEY, csv_filepath=PREVIEW_CSV) + # delete_preview_sync(api_key=API_KEY, domain=DOMAIN) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(post_single_validate_async(api_key=API_KEY)) + asyncio.run(post_bulk_list_validate_async(api_key=API_KEY, csv_filepath=VALIDATION_CSV)) diff --git a/mailgun/examples/events_examples.py b/mailgun/examples/events_examples.py index f25e83f..125e1e5 100644 --- a/mailgun/examples/events_examples.py +++ b/mailgun/examples/events_examples.py @@ -1,62 +1,175 @@ +"""Examples for querying and retrieving Mailgun routing events and stored messages.""" + +from __future__ import annotations + +import asyncio import os +from typing import Any + +from mailgun.client import AsyncClient, Client -from mailgun.client import Client +# ============================================================================== +# Synchronous Examples +# ============================================================================== -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +def get_domain_events_sync(api_key: str, domain: str) -> None: + """ + GET //events + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.events.get(domain=domain) + print("GET Domain Events (Sync):", response.json()) + + +def events_by_recipient_sync(api_key: str, domain: str, recipient: str) -> None: + """ + GET //events + :return: None + """ + params: dict[str, Any] = { + "begin": "Tue, 24 Nov 2020 09:00:00 -0000", + "ascending": "yes", + "limit": 10, + "pretty": "yes", + "recipient": recipient, + } + with Client(auth=("api", api_key)) as client: + response = client.events.get(domain=domain, filters=params) + print("Events by Recipient (Sync):", response.json()) -def get_domain_events() -> None: +def events_rejected_or_failed_sync(api_key: str, domain: str) -> None: """ GET //events - :return: + :return: None """ - req = client.events.get(domain=domain) - print(req.json()) + params: dict[str, str] = {"event": "rejected OR failed"} + with Client(auth=("api", api_key)) as client: + response = client.events.get(domain=domain, filters=params) + print("Rejected or Failed Events (Sync):", response.json()) -def view_message_with_storage_url() -> None: +def view_message_with_storage_url_sync(api_key: str, domain: str) -> None: """ - /v3/domains/2048.zeefarmer.com/messages/{storage_url} - :return: + /v3/domains//messages/{storage_url} + :return: None """ - params = {"limit": 1} + params: dict[str, int] = {"limit": 1} + + with Client(auth=("api", api_key)) as client: + events_response = client.events.get(domain=domain, filters=params) + items = events_response.json().get("items", []) + + if items and "storage" in items[0]: + storage_info = items[0].get("storage", {}) + storage_url = storage_info.get("url") - storage_url = client.events.get(domain=domain, filters=params).json()["items"][0]["storage"][ - "url" - ] - req = client.domains_messages.get(domain=domain, api_storage_url=storage_url) - print(req.json()) + if storage_url: + # Proceed with storage_url + print(f"Found storage URL: {storage_url}") + else: + # Handle the case where no URL is found + print("No storage URL available for this event.") + # Retrieve the full message using the exact storage URL + message_response = client.domains_messages.get( + domain=domain, api_storage_url=storage_url + ) + print("View Stored Message (Sync):", message_response.json()) + else: + print("No stored messages found in recent events.") -def events_by_recipient() -> None: +# ============================================================================== +# Asynchronous Examples +# ============================================================================== + + +async def get_domain_events_async(api_key: str, domain: str) -> None: """ - GET //events - :return: + GET //events (Asynchronous) + :return: None """ - params = { + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.events.get(domain=domain) + print("GET Domain Events (Async):", response.json()) + + +async def events_by_recipient_async(api_key: str, domain: str, recipient: str) -> None: + """ + GET //events (Asynchronous) + :return: None + """ + params: dict[str, Any] = { "begin": "Tue, 24 Nov 2020 09:00:00 -0000", "ascending": "yes", "limit": 10, "pretty": "yes", - "recipient": os.environ["VALIDATION_ADDRESS_1"], + "recipient": recipient, } - req = client.events.get(domain=domain, filters=params) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.events.get(domain=domain, filters=params) + print("Events by Recipient (Async):", response.json()) -def events_rejected_or_failed() -> None: +async def events_rejected_or_failed_async(api_key: str, domain: str) -> None: """ - GET //events - :return: + GET //events (Asynchronous) + :return: None + """ + params: dict[str, str] = {"event": "rejected OR failed"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.events.get(domain=domain, filters=params) + print("Rejected or Failed Events (Async):", response.json()) + + +async def view_message_with_storage_url_async(api_key: str, domain: str) -> None: + """ + /v3/domains//messages/{storage_url} (Asynchronous) + :return: None """ - params = {"event": "rejected OR failed"} - req = client.events.get(domain=domain, filters=params) - print(req.json()) + params: dict[str, int] = {"limit": 1} + + async with AsyncClient(auth=("api", api_key)) as client: + events_response = await client.events.get(domain=domain, filters=params) + items = events_response.json().get("items", []) + + # Check if storage exists AND contains the 'url' key + if items and "storage" in items[0] and "url" in items[0]["storage"]: + storage_url: str = items[0]["storage"]["url"] + # Retrieve the full message asynchronously + message_response = await client.domains_messages.get( + domain=domain, api_storage_url=storage_url + ) + print("View Stored Message (Async):", message_response.json()) + else: + print("No stored messages found in recent events.") + + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - events_rejected_or_failed() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + RECIPIENT: str = os.environ.get("VALIDATION_ADDRESS_1", f"test@{DOMAIN}") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + get_domain_events_sync(api_key=API_KEY, domain=DOMAIN) + events_by_recipient_sync(api_key=API_KEY, domain=DOMAIN, recipient=RECIPIENT) + events_rejected_or_failed_sync(api_key=API_KEY, domain=DOMAIN) + view_message_with_storage_url_sync(api_key=API_KEY, domain=DOMAIN) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(get_domain_events_async(api_key=API_KEY, domain=DOMAIN)) + asyncio.run(events_by_recipient_async(api_key=API_KEY, domain=DOMAIN, recipient=RECIPIENT)) + asyncio.run(events_rejected_or_failed_async(api_key=API_KEY, domain=DOMAIN)) + asyncio.run(view_message_with_storage_url_async(api_key=API_KEY, domain=DOMAIN)) diff --git a/mailgun/examples/inbox_placement_examples.py b/mailgun/examples/inbox_placement_examples.py index 6366bf4..349b25f 100644 --- a/mailgun/examples/inbox_placement_examples.py +++ b/mailgun/examples/inbox_placement_examples.py @@ -1,88 +1,213 @@ -import os +"""Examples for Mailgun Inbox Placement (Optimize) API.""" + +from __future__ import annotations -from mailgun.client import Client +import asyncio +import os +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Synchronous Examples (requests) +# ============================================================================== -def post_inbox() -> None: +def post_inbox_sync(api_key: str, domain: str) -> None: """ POST /v3/inbox/tests - :return: + :return: None """ - data = { - "domain": "domain.com", - "from": "user@sending_domain.com", + data: dict[str, str] = { + "domain": domain, + "from": f"user@{domain}", "subject": "testSubject", "html": "HTML version of the body", } - req = client.inbox_tests.create(domain=domain, data=data) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.create(data=data) + print("POST Inbox Test (Sync):", response.json()) -def get_all_inbox() -> None: +def get_all_inbox_sync(api_key: str) -> None: """ GET /v3/inbox/tests - :return: + :return: None """ - req = client.inbox_tests.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.get() + print("GET All Inbox Tests (Sync):", response.json()) -def get_inbox_placement_test() -> None: +def get_inbox_placement_test_sync(api_key: str, test_id: str) -> None: """ GET /v3/inbox/tests/ - :return: + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.get(test_id=test_id) + print("GET Single Inbox Test (Sync):", response.json()) + + +def inbox_placement_test_counters_sync(api_key: str, test_id: str) -> None: + """ + GET /v3/inbox/tests//counters + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.get(test_id=test_id, counters=True) + print("GET Inbox Test Counters (Sync):", response.json()) + + +def get_inbox_placement_test_checks_sync(api_key: str, test_id: str) -> None: """ - req = client.inbox_tests.get(domain=domain, test_id="6017b5cf3c92d93bd1f810ea") - print(req.json()) + GET /v3/inbox/tests//checks + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.get(test_id=test_id, checks=True) + print("GET Inbox Test Checks (Sync):", response.json()) -def delete_inbox_placement_test() -> None: +def get_single_placement_check_test_sync(api_key: str, test_id: str, address: str) -> None: + """ + GET /v3/inbox/tests//checks/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.get(test_id=test_id, checks=True, address=address) + print("GET Single Placement Check Test (Sync):", response.json()) + + +def delete_inbox_placement_test_sync(api_key: str, test_id: str) -> None: """ DELETE /v3/inbox/tests/ - :return: + :return: None """ - req = client.inbox_tests.delete(domain=domain, test_id="6017b5cf3c92d93bd1f810ea") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.inbox_tests.delete(test_id=test_id) + print("DELETE Inbox Test (Sync):", response.json()) + + +# ============================================================================== +# Asynchronous Examples (httpx) +# ============================================================================== -def inbox_placement_test_counters() -> None: +async def post_inbox_async(api_key: str, domain: str) -> None: """ - GET /v3/inbox/tests//counters - :return: + POST /v3/inbox/tests (Asynchronous) + :return: None """ - req = client.inbox_tests.get(domain=domain, test_id="6017b5cf3c92d93bd1f810ea", counters=True) - print(req.json()) + data: dict[str, str] = { + "domain": domain, + "from": f"async_user@{domain}", + "subject": "testSubject Async", + "html": "HTML version of the body", + } + + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.create(data=data) + print("POST Inbox Test (Async):", response.json()) -def get_inbox_placement_test_checks() -> None: +async def get_all_inbox_async( + api_key: str, +) -> None: """ - GET /v3/inbox/tests//checks - :return: + GET /v3/inbox/tests (Asynchronous) + :return: None """ - req = client.inbox_tests.get(domain=domain, test_id="6017b5cf3c92d93bd1f810ea", checks=True) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.get() + print("GET All Inbox Tests (Async):", response.json()) -def get_single_placement_check_test() -> None: +async def get_inbox_placement_test_async(api_key: str, test_id: str) -> None: """ - GET /v3/inbox/tests//checks/
- :return: + GET /v3/inbox/tests/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.get(test_id=test_id) + print("GET Single Inbox Test (Async):", response.json()) + + +async def inbox_placement_test_counters_async(api_key: str, test_id: str) -> None: + """ + GET /v3/inbox/tests//counters (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.get(test_id=test_id, counters=True) + print("GET Inbox Test Counters (Async):", response.json()) + + +async def get_inbox_placement_test_checks_async(api_key: str, test_id: str) -> None: """ - req = client.inbox_tests.get( - domain=domain, - test_id="6017b5cf3c92d93bd1f810ea", - checks=True, - address="aa_ext_test03mg@comcast.net", - ) - print(req.json()) + GET /v3/inbox/tests//checks (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.get(test_id=test_id, checks=True) + print("GET Inbox Test Checks (Async):", response.json()) + + +async def get_single_placement_check_test_async(api_key: str, test_id: str, address: str) -> None: + """ + GET /v3/inbox/tests//checks/
(Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.get(test_id=test_id, checks=True, address=address) + print("GET Single Placement Check Test (Async):", response.json()) + + +async def delete_inbox_placement_test_async(api_key: str, test_id: str) -> None: + """ + DELETE /v3/inbox/tests/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.inbox_tests.delete(test_id=test_id) + print("DELETE Inbox Test (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - get_single_placement_check_test() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + # Dummy identifiers for example purposes + TEST_ID: str = "123456789" + TEST_ADDRESS: str = "user@example.com" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + post_inbox_sync(api_key=API_KEY, domain=DOMAIN) + get_all_inbox_sync(api_key=API_KEY) + get_inbox_placement_test_sync(api_key=API_KEY, test_id=TEST_ID) + inbox_placement_test_counters_sync(api_key=API_KEY, test_id=TEST_ID) + get_inbox_placement_test_checks_sync(api_key=API_KEY, test_id=TEST_ID) + get_single_placement_check_test_sync(api_key=API_KEY, test_id=TEST_ID, address=TEST_ADDRESS) + # delete_inbox_placement_test_sync(api_key=API_KEY, test_id=TEST_ID) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(post_inbox_async(api_key=API_KEY, domain=DOMAIN)) + asyncio.run(get_all_inbox_async(api_key=API_KEY)) + asyncio.run(get_inbox_placement_test_async(api_key=API_KEY, test_id=TEST_ID)) + asyncio.run(inbox_placement_test_counters_async(api_key=API_KEY, test_id=TEST_ID)) + asyncio.run(get_inbox_placement_test_checks_async(api_key=API_KEY, test_id=TEST_ID)) + asyncio.run( + get_single_placement_check_test_async( + api_key=API_KEY, test_id=TEST_ID, address=TEST_ADDRESS + ) + ) + # asyncio.run(delete_inbox_placement_test_async(api_key=API_KEY, test_id=TEST_ID)) diff --git a/mailgun/examples/ip_pools_examples.py b/mailgun/examples/ip_pools_examples.py index 64aab5d..c60afbd 100644 --- a/mailgun/examples/ip_pools_examples.py +++ b/mailgun/examples/ip_pools_examples.py @@ -1,75 +1,207 @@ -import os +"""Examples for managing Mailgun IP Pools and Domain Linkage.""" + +from __future__ import annotations -from mailgun.client import Client +import asyncio +import os +from typing import Any +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# IP Pool Management (Synchronous) +# ============================================================================== -def get_ippools() -> None: +def get_ippools_sync(api_key: str, domain: str) -> None: """ GET /v1/ip_pools - :return: + :return: None """ - req = client.ippools.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.ippools.get(domain=domain) + print("GET IP Pools (Sync):", response.json()) -def create_ippool() -> None: +def create_ippool_sync(api_key: str, domain: str) -> None: """ POST /v1/ip_pools - :return: + :return: None """ - post_data = {"name": "test_pool1", "description": "Test", "ips": ["166.78.68.186"]} - req_post = client.ippools.create(domain=domain, data=post_data) - print(req_post.json()) + post_data: dict[str, Any] = { + "name": "test_pool1", + "description": "Test", + "ips": ["1.2.3.4"], + } + with Client(auth=("api", api_key)) as client: + response = client.ippools.create(domain=domain, data=post_data) + print("POST Create IP Pool (Sync):", response.json()) -def update_ippool() -> None: +def update_ippool_sync(api_key: str, domain: str, pool_id: str) -> None: """ PATCH /v1/ip_pools/{pool_id} - :return: + :return: None """ - data = { + data: dict[str, str] = { "name": "test_pool3", "description": "Test3", } - req = client.ippools.patch(domain=domain, data=data, pool_id="60140bc1fee3e84dec5abeeb") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.ippools.patch(domain=domain, data=data, pool_id=pool_id) + print("PATCH Update IP Pool (Sync):", response.json()) -def delete_ippool() -> None: +def delete_ippool_sync(api_key: str, domain: str, pool_id: str) -> None: """ DELETE /v1/ip_pools/{pool_id} - :return: + :return: None """ - req = client.ippools.delete(domain=domain, pool_id="60140bc1fee3e84dec5abeeb") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.ippools.delete(domain=domain, pool_id=pool_id) + print("DELETE IP Pool (Sync):", response.json()) + +# ============================================================================== +# Domain IP Pool Linkage (Synchronous) +# ============================================================================== -def link_ippool() -> None: + +def link_ippool_sync(api_key: str, domain: str, pool_id: str) -> None: """ POST /v3/domains/{domain_name}/ips - :return: + :return: None """ - data = {"pool_id": "60140d220859fda7bab8bb6c"} - req = client.domains_ips.create(domain=domain, data=data) - print(req.json()) + data: dict[str, str] = {"pool_id": pool_id} + with Client(auth=("api", api_key)) as client: + response = client.domains_ips.create(domain=domain, data=data) + print("POST Link IP Pool (Sync):", response.json()) -def unlink_ippool() -> None: +def unlink_ippool_sync(api_key: str, domain: str, pool_id: str) -> None: """ DELETE /v3/domains/{domain_name}/ips/ip_pool - :return: + :return: None + """ + filters: dict[str, str] = {"pool_id": pool_id} + with Client(auth=("api", api_key)) as client: + response = client.domains_ips.delete(domain=domain, filters=filters, unlink_pool=True) + print("DELETE Unlink IP Pool (Sync):", response.json()) + + +# ============================================================================== +# IP Pool Management (Asynchronous) +# ============================================================================== + + +async def get_ippools_async(api_key: str, domain: str) -> None: + """ + GET /v1/ip_pools (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.ippools.get(domain=domain) + print("GET IP Pools (Async):", response.json()) + + +async def create_ippool_async(api_key: str, domain: str) -> None: + """ + POST /v1/ip_pools (Asynchronous) + :return: None + """ + post_data: dict[str, Any] = { + "name": "test_pool1_async", + "description": "Test Async", + "ips": ["1.2.3.4"], + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.ippools.create(domain=domain, data=post_data) + print("POST Create IP Pool (Async):", response.json()) + + +async def update_ippool_async(api_key: str, domain: str, pool_id: str) -> None: + """ + PATCH /v1/ip_pools/{pool_id} (Asynchronous) + :return: None + """ + data: dict[str, str] = { + "name": "test_pool3_async", + "description": "Test3 Async", + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.ippools.patch(domain=domain, data=data, pool_id=pool_id) + print("PATCH Update IP Pool (Async):", response.json()) + + +async def delete_ippool_async(api_key: str, domain: str, pool_id: str) -> None: + """ + DELETE /v1/ip_pools/{pool_id} (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.ippools.delete(domain=domain, pool_id=pool_id) + print("DELETE IP Pool (Async):", response.json()) + + +# ============================================================================== +# Domain IP Pool Linkage (Asynchronous) +# ============================================================================== + + +async def link_ippool_async(api_key: str, domain: str, pool_id: str) -> None: + """ + POST /v3/domains/{domain_name}/ips (Asynchronous) + :return: None + """ + data: dict[str, str] = {"pool_id": pool_id} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_ips.create(domain=domain, data=data) + print("POST Link IP Pool (Async):", response.json()) + + +async def unlink_ippool_async(api_key: str, domain: str, pool_id: str) -> None: + """ + DELETE /v3/domains/{domain_name}/ips/ip_pool (Asynchronous) + :return: None """ - data = {"pool_id": "5ff37204e5eb74149462c375"} + filters: dict[str, str] = {"pool_id": pool_id} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_ips.delete(domain=domain, filters=filters, unlink_pool=True) + print("DELETE Unlink IP Pool (Async):", response.json()) - req = client.domains_ips.delete(domain=domain, filters=data, unlink_pool=True) - print(req.json()) +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - get_ippools() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + # Dummy identifiers for example purposes + POOL_ID: str = "123456789" + LINK_POOL_ID: str = "987654321" + UNLINK_POOL_ID: str = "000111222333444555" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + get_ippools_sync(api_key=API_KEY, domain=DOMAIN) + # create_ippool_sync(api_key=API_KEY, domain=DOMAIN) + # update_ippool_sync(api_key=API_KEY, domain=DOMAIN, pool_id=POOL_ID) + # delete_ippool_sync(api_key=API_KEY, domain=DOMAIN, pool_id=POOL_ID) + + # link_ippool_sync(api_key=API_KEY, domain=DOMAIN, pool_id=LINK_POOL_ID) + # unlink_ippool_sync(api_key=API_KEY, domain=DOMAIN, pool_id=UNLINK_POOL_ID) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(get_ippools_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(create_ippool_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(update_ippool_async(api_key=API_KEY, domain=DOMAIN, pool_id=POOL_ID)) + # asyncio.run(delete_ippool_async(api_key=API_KEY, domain=DOMAIN, pool_id=POOL_ID)) + + # asyncio.run(link_ippool_async(api_key=API_KEY, domain=DOMAIN, pool_id=LINK_POOL_ID)) + # asyncio.run(unlink_ippool_async(api_key=API_KEY, domain=DOMAIN, pool_id=UNLINK_POOL_ID)) diff --git a/mailgun/examples/ips_examples.py b/mailgun/examples/ips_examples.py index 77578ac..06f4ccf 100644 --- a/mailgun/examples/ips_examples.py +++ b/mailgun/examples/ips_examples.py @@ -1,59 +1,162 @@ -import os +"""Examples for managing Mailgun Dedicated IPs and Domain IP assignments.""" + +from __future__ import annotations -from mailgun.client import Client +import asyncio +import os +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Dedicated IP Management (Synchronous) +# ============================================================================== -def get_ips() -> None: +def get_ips_sync(api_key: str, domain: str) -> None: """ GET /ips - :return: + :return: None """ - req = client.ips.get(domain=domain, filters={"dedicated": "true"}) - print(req.json()) + filters: dict[str, str] = {"dedicated": "true"} + with Client(auth=("api", api_key)) as client: + response = client.ips.get(domain=domain, filters=filters) + print("GET IPs (Sync):", response.json()) -def get_single_ip() -> None: +def get_single_ip_sync(api_key: str, domain: str, target_ip: str) -> None: """ GET /ips/ - :return: + :return: None """ - req = client.ips.get(domain=domain, ip="161.38.194.10") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.ips.get(domain=domain, ip=target_ip) + print("GET Single IP (Sync):", response.json()) + +# ============================================================================== +# Domain IP Linkage (Synchronous) +# ============================================================================== -def get_domain_ips() -> None: + +def get_domain_ips_sync(api_key: str, domain: str) -> None: """ GET /domains//ips - :return: + :return: None """ - request = client.domains_ips.get(domain=domain) - print(request.json()) + with Client(auth=("api", api_key)) as client: + response = client.domains_ips.get(domain=domain) + print("GET Domain IPs (Sync):", response.json()) -def post_domains_ip() -> None: +def post_domains_ip_sync(api_key: str, domain: str, target_ip: str) -> None: """ POST /domains//ips - :return: + :return: None """ - ip_data = {"ip": "161.38.194.10"} - request = client.domains_ips.create(domain=domain, data=ip_data) - print(request.json()) + data: dict[str, str] = {"ip": target_ip} + with Client(auth=("api", api_key)) as client: + response = client.domains_ips.create(domain=domain, data=data) + print("POST Domain IP (Sync):", response.json()) -def delete_domain_ip() -> None: +def delete_domain_ip_sync(api_key: str, domain: str, target_ip: str) -> None: """ DELETE /domains//ips/ - :return: + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.domains_ips.delete(domain=domain, ip=target_ip) + print("DELETE Domain IP (Sync):", response.json()) + + +# ============================================================================== +# Dedicated IP Management (Asynchronous) +# ============================================================================== + + +async def get_ips_async(api_key: str, domain: str) -> None: + """ + GET /ips (Asynchronous) + :return: None + """ + filters: dict[str, str] = {"dedicated": "true"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.ips.get(domain=domain, filters=filters) + print("GET IPs (Async):", response.json()) + + +async def get_single_ip_async(api_key: str, domain: str, target_ip: str) -> None: + """ + GET /ips/ (Asynchronous) + :return: None """ - request = client.domains_ips.delete(domain=domain, ip="161.38.194.10") - print(request.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.ips.get(domain=domain, ip=target_ip) + print("GET Single IP (Async):", response.json()) + + +# ============================================================================== +# Domain IP Linkage (Asynchronous) +# ============================================================================== + + +async def get_domain_ips_async(api_key: str, domain: str) -> None: + """ + GET /domains//ips (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_ips.get(domain=domain) + print("GET Domain IPs (Async):", response.json()) + + +async def post_domains_ip_async(api_key: str, domain: str, target_ip: str) -> None: + """ + POST /domains//ips (Asynchronous) + :return: None + """ + data: dict[str, str] = {"ip": target_ip} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_ips.create(domain=domain, data=data) + print("POST Domain IP (Async):", response.json()) + + +async def delete_domain_ip_async(api_key: str, domain: str, target_ip: str) -> None: + """ + DELETE /domains//ips/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.domains_ips.delete(domain=domain, ip=target_ip) + print("DELETE Domain IP (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - get_ips() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + # Dummy identifier for example purposes + TARGET_IP: str = "1.2.3.4" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + get_ips_sync(api_key=API_KEY, domain=DOMAIN) + # get_single_ip_sync(api_key=API_KEY, domain=DOMAIN, target_ip=TARGET_IP) + # get_domain_ips_sync(api_key=API_KEY, domain=DOMAIN) + # post_domains_ip_sync(api_key=API_KEY, domain=DOMAIN, target_ip=TARGET_IP) + # delete_domain_ip_sync(api_key=API_KEY, domain=DOMAIN, target_ip=TARGET_IP) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(get_ips_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(get_single_ip_async(api_key=API_KEY, domain=DOMAIN, target_ip=TARGET_IP)) + # asyncio.run(get_domain_ips_async(api_key=API_KEY, domain=DOMAIN)) + # asyncio.run(post_domains_ip_async(api_key=API_KEY, domain=DOMAIN, target_ip=TARGET_IP)) + # asyncio.run(delete_domain_ip_async(api_key=API_KEY, domain=DOMAIN, target_ip=TARGET_IP)) diff --git a/mailgun/examples/keys_examples.py b/mailgun/examples/keys_examples.py index 00492a0..ac900dd 100644 --- a/mailgun/examples/keys_examples.py +++ b/mailgun/examples/keys_examples.py @@ -1,35 +1,53 @@ +"""Examples for managing Mailgun API Keys.""" + from __future__ import annotations +import asyncio import os +from typing import Any + +from mailgun.client import AsyncClient, Client + -from mailgun.client import Client +# ============================================================================== +# API Key Management (Synchronous) +# ============================================================================== -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"] +def delete_key_sync(api_key: str, mailgun_email: str) -> None: + """ + DELETE /v1/keys/{key_id} + :return: None + """ + filters: dict[str, str] = {"domain_name": "python.test.domain5", "kind": "web"} + with Client(auth=("api", api_key)) as client: + response1 = client.keys.get(filters=filters) + items: list[dict[str, Any]] = response1.json().get("items", []) -client: Client = Client(auth=("api", key)) + for item in items: + if mailgun_email == item.get("requestor"): # codespell:disable-line + response2 = client.keys.delete(key_id=item["id"]) + print("DELETE Key (Sync):", response2.json()) -def get_keys() -> None: +def get_keys_sync(api_key: str) -> None: """ GET /v1/keys - :return: + :return: None """ - query = {"domain_name": "python.test.domain5", "kind": "web"} - req = client.keys.get(filters=query) - print(req.json()) + filters: dict[str, str] = {"domain_name": "python.test.domain5", "kind": "web"} + with Client(auth=("api", api_key)) as client: + response = client.keys.get(filters=filters) + print("GET Keys (Sync):", response.json()) -def post_keys() -> None: +def post_keys_sync( + api_key: str, mailgun_email: str, role: str, user_id: str, user_name: str +) -> 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 code generates 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. @@ -40,10 +58,9 @@ def post_keys() -> None: 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: + :return: None """ - - data = { + data: dict[str, str] = { "email": mailgun_email, "domain_name": "python.test.domain5", "kind": "web", @@ -54,41 +71,148 @@ def post_keys() -> None: "description": "a new key", } - headers = {"Content-Type": "multipart/form-data"} + headers: dict[str, str] = {"Content-Type": "multipart/form-data"} - req = client.keys.create(data=data, headers=headers) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.keys.create(data=data, headers=headers) + print("POST Keys (Sync):", response.json()) -def delete_key() -> None: +def regenerate_key_sync(api_key: str) -> None: """ - DELETE /vq/keys/{key_id} - :return: + POST /v1/keys/public + :return: None """ - query = {"domain_name": "python.test.domain5", "kind": "web"} - req1 = client.keys.get(filters=query) - items = req1.json()["items"] + with Client(auth=("api", api_key)) as client: + response = client.keys_public.create() + print("Regenerate Key (Sync):", response.json()) + - for item in items: - if mailgun_email == item["requestor"]: # codespell:disable-line - req2 = client.keys.delete(key_id=item["id"]) - print(req2.json()) +# ============================================================================== +# API Key Management (Asynchronous) +# ============================================================================== -def regenerate_key() -> None: +async def delete_key_async(api_key: str, mailgun_email: str) -> None: """ - POST /v1/keys/public - :return: + DELETE /v1/keys/{key_id} (Asynchronous) + :return: None """ - req = client.keys_public.create() - print(req.json()) + filters: dict[str, str] = {"domain_name": "python.test.domain5", "kind": "web"} + async with AsyncClient(auth=("api", api_key)) as client: + response1 = await client.keys.get(filters=filters) + items: list[dict[str, Any]] = response1.json().get("items", []) + + for item in items: + if mailgun_email == item.get("requestor"): # codespell:disable-line + response2 = await client.keys.delete(key_id=item["id"]) + print("DELETE Key (Async):", response2.json()) + + +async def get_keys_async(api_key: str) -> None: + """ + GET /v1/keys (Asynchronous) + :return: None + """ + filters: dict[str, str] = {"domain_name": "python.test.domain5", "kind": "web"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.keys.get(filters=filters) + print("GET Keys (Async):", response.json()) + + +async def post_keys_async( + api_key: str, mailgun_email: str, role: str, user_id: str, user_name: str +) -> None: + """ + POST /v1/keys (Asynchronous) + + This code generates 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: None + """ + data: dict[str, str] = { + "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: dict[str, str] = {"Content-Type": "multipart/form-data"} + + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.keys.create(data=data, headers=headers) + print("POST Keys (Async):", response.json()) + + +async def regenerate_key_async(api_key: str) -> None: + """ + POST /v1/keys/public (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.keys_public.create() + print("Regenerate Key (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - # get_keys() - post_keys() - get_keys() - delete_key() - get_keys() - regenerate_key() - get_keys() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + MAILGUN_EMAIL: str = os.environ.get("MAILGUN_EMAIL", "test@example.com") + ROLE: str = os.environ.get("ROLE", "admin") + USER_ID: str = os.environ.get("USER_ID", "12345") + USER_NAME: str = os.environ.get("USER_NAME", "Test User") + + if not API_KEY or not MAILGUN_EMAIL or not ROLE or not USER_ID or not USER_NAME: + print( + "Please set the 'APIKEY', 'MAILGUN_EMAIL', 'ROLE', 'USER_ID', and " + "'USER_NAME' environment variables to run examples." + ) + else: + print("--- Running Synchronous Examples ---") + # get_keys_sync(api_key=API_KEY) + post_keys_sync( + api_key=API_KEY, + mailgun_email=MAILGUN_EMAIL, + role=ROLE, + user_id=USER_ID, + user_name=USER_NAME, + ) + get_keys_sync(api_key=API_KEY) + delete_key_sync(api_key=API_KEY, mailgun_email=MAILGUN_EMAIL) + get_keys_sync(api_key=API_KEY) + regenerate_key_sync(api_key=API_KEY) + get_keys_sync(api_key=API_KEY) + + print("\n--- Running Asynchronous Examples ---") + # asyncio.run(get_keys_async(api_key=API_KEY)) + asyncio.run( + post_keys_async( + api_key=API_KEY, + mailgun_email=MAILGUN_EMAIL, + role=ROLE, + user_id=USER_ID, + user_name=USER_NAME, + ) + ) + asyncio.run(get_keys_async(api_key=API_KEY)) + asyncio.run(delete_key_async(api_key=API_KEY, mailgun_email=MAILGUN_EMAIL)) + asyncio.run(get_keys_async(api_key=API_KEY)) + asyncio.run(regenerate_key_async(api_key=API_KEY)) + asyncio.run(get_keys_async(api_key=API_KEY)) diff --git a/mailgun/examples/logs_examples.py b/mailgun/examples/logs_examples.py index 07c0566..0a4a586 100644 --- a/mailgun/examples/logs_examples.py +++ b/mailgun/examples/logs_examples.py @@ -1,21 +1,61 @@ +"""Examples for fetching Mailgun Analytics Logs.""" + +from __future__ import annotations + +import asyncio import os +from typing import Any -from mailgun.client import Client +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Analytics Logs Management (Synchronous) +# ============================================================================== -def post_analytics_logs() -> None: +def post_analytics_logs_sync(api_key: str, domain: str) -> None: """ # Metrics # POST /v1/analytics/logs - :return: + :return: None """ + data: dict[str, Any] = { + "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, + }, + } + + with Client(auth=("api", api_key)) as client: + response = client.analytics_logs.create(data=data) + print("POST Analytics Logs (Sync):", response.json()) + + +# ============================================================================== +# Analytics Logs Management (Asynchronous) +# ============================================================================== - data = { + +async def post_analytics_logs_async(api_key: str, domain: str) -> None: + """ + # Metrics (Asynchronous) + # POST /v1/analytics/logs + :return: None + """ + data: dict[str, Any] = { "start": "Wed, 24 Sep 2025 00:00:00 +0000", "end": "Thu, 25 Sep 2025 00:00:00 +0000", "filter": { @@ -34,9 +74,25 @@ def post_analytics_logs() -> None: }, } - req = client.analytics_logs.create(data=data) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_logs.create(data=data) + print("POST Analytics Logs (Async):", response.json()) +# ============================================================================== +# Execution +# ============================================================================== + if __name__ == "__main__": - post_analytics_logs() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + post_analytics_logs_sync(api_key=API_KEY, domain=DOMAIN) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(post_analytics_logs_async(api_key=API_KEY, domain=DOMAIN)) diff --git a/mailgun/examples/mailing_lists_examples.py b/mailgun/examples/mailing_lists_examples.py index 34451cb..d542579 100644 --- a/mailgun/examples/mailing_lists_examples.py +++ b/mailgun/examples/mailing_lists_examples.py @@ -1,209 +1,481 @@ +"""Examples for managing Mailgun Mailing Lists and their Members.""" + +from __future__ import annotations + import os +from typing import Any + +from mailgun.client import AsyncClient, Client + -from mailgun.client import Client +# ============================================================================== +# Mailing Lists Management (Synchronous) +# ============================================================================== -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -mailing_list_address: str = os.environ["MAILLIST_ADDRESS"] +def delete_list_sync(api_key: str, domain: str, list_address: str) -> None: + """ + DELETE /lists/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.lists.delete(domain=domain, address=list_address) + print("DELETE List (Sync):", response.json()) + -client: Client = Client(auth=("api", key)) +def get_list_pages_sync(api_key: str, domain: str) -> None: + """ + GET /lists/pages + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.lists_pages.get(domain=domain) + print("GET List Pages (Sync):", response.json()) + + +def get_list_sync(api_key: str, domain: str, list_address: str) -> None: + """ + GET /lists/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.lists.get(domain=domain, address=list_address) + print("GET List (Sync):", response.json()) -def post_lists() -> None: +def post_list_sync(api_key: str, domain: str, list_address: str) -> None: """ POST /lists - :return: + :return: None """ - data = { - "address": f"python_sdk2@{domain}", + data: dict[str, str] = { + "address": list_address, "description": "Mailgun developers list", } + with Client(auth=("api", api_key)) as client: + response = client.lists.create(domain=domain, data=data) + print("POST List (Sync):", response.json()) + + +def put_list_sync(api_key: str, domain: str, list_address: str) -> None: + """ + PUT /lists/
+ :return: None + """ + data: dict[str, str] = {"description": "Mailgun developers list 121212"} + with Client(auth=("api", api_key)) as client: + response = client.lists.put(domain=domain, data=data, address=list_address) + print("PUT List (Sync):", response.json()) - req = client.lists.create(domain=domain, data=data) - print(req.json()) +# ============================================================================== +# Mailing List Validation (Synchronous) +# ============================================================================== +# Note: Email Validations are only available for paid accounts. -def get_pages() -> None: + +def delete_list_validation_sync(api_key: str, domain: str, list_address: str) -> None: """ - GET /lists/pages - :return: + DELETE /lists/
/validate + :return: None """ - req = client.lists_pages.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.lists.delete(domain=domain, address=list_address, validate=True) + print("DELETE List Validation (Sync):", response.json()) -def put_lists() -> None: +def get_list_validation_sync(api_key: str, domain: str, list_address: str) -> None: """ - PUT /lists/
- :return: + GET /lists/
/validate + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.lists.get(domain=domain, address=list_address, validate=True) + print("GET List Validation (Sync):", response.json()) + + +def post_list_validation_sync(api_key: str, domain: str, list_address: str) -> None: + """ + POST /lists/
/validate + :return: None """ - data = {"description": "Mailgun developers list 121212"} + with Client(auth=("api", api_key)) as client: + response = client.lists.create(domain=domain, address=list_address, validate=True) + print("POST List Validation (Sync):", response.json()) + - req = client.lists.put(domain=domain, data=data, address=f"python_sdk2@{domain}") - print(req.json()) +# ============================================================================== +# Mailing List Members Management (Synchronous) +# ============================================================================== -def get_lists() -> None: +def delete_list_member_sync( + api_key: str, domain: str, list_address: str, member_address: str +) -> None: """ - GEt /lists - :return: + DELETE /lists/
/members/ + :return: None """ - req = client.lists.get(domain=domain, address=f"python_sdk2@{domain}") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.lists_members.delete( + domain=domain, + address=list_address, + member_address=member_address, + ) + print("DELETE List Member (Sync):", response.json()) -# Email Validations are only available for paid accounts. -def post_address_validate() -> None: +def get_list_member_sync(api_key: str, domain: str, list_address: str, member_address: str) -> None: """ - POST /lists/
/validate - :return: + GET /lists/
/members/ + :return: None """ - req = client.lists.create(domain=domain, address=f"python_sdk2@{domain}", validate=True) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.lists_members.get( + domain=domain, + address=list_address, + member_address=member_address, + ) + print("GET List Member (Sync):", response.json()) -# Email Validations are only available for paid accounts. -def get_validate_address() -> None: +def get_list_members_pages_sync(api_key: str, domain: str, list_address: str) -> None: """ - GET /lists/
/validate - :return: + GET /lists/
/members/pages + :return: None """ - req = client.lists.get(domain=domain, address=f"python_sdk2@{domain}", validate=True) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.lists_members_pages.get(domain=domain, address=list_address) + print("GET List Members Pages (Sync):", response.json()) -# Email Validations are only available for paid accounts. -def delete_validate_job() -> None: +def get_list_members_sync(api_key: str, domain: str, list_address: str) -> None: """ - DELETE /lists/
/validate - :return: + GET /lists/
/members + :return: None """ - req = client.lists.delete(domain=domain, address=f"python_sdk2@{domain}", validate=True) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.lists_members.get(domain=domain, address=list_address) + print("GET List Members (Sync):", response.json()) -def post_member_list() -> None: +def post_list_member_sync( + api_key: str, domain: str, list_address: str, member_address: str +) -> None: """ POST /lists/
/members - :return: + :return: None """ - data = { + data: dict[str, Any] = { "subscribed": True, - "address": "bar2@example.com", + "address": member_address, "name": "Bob Bar", "description": "Developer", "vars": '{"age": 26}', } - req = client.lists_members.create(domain=domain, address=mailing_list_address, data=data) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.lists_members.create(domain=domain, address=list_address, data=data) + print("POST List Member (Sync):", response.json()) -def get_member_list() -> None: +def post_list_members_json_sync(api_key: str, domain: str, list_address: str) -> None: """ - GET /lists/
/members - :return: + POST /lists/
/members.json + :return: None """ - req = client.lists_members.get(domain=domain, address=mailing_list_address) - print(req.json()) + data: dict[str, Any] = { + "upsert": True, + "members": '[{"address": "Alice ", "vars": {"age": 26}},' + '{"name": "Bob1", "address": "bob2@example.com", "vars": {"age": 34}}]', + } + with Client(auth=("api", api_key)) as client: + response = client.lists_members.create( + domain=domain, + address=list_address, + data=data, + multiple=True, + ) + print("POST List Members JSON (Sync):", response.json()) -def get_lists_address() -> None: +def put_list_member_sync(api_key: str, domain: str, list_address: str, member_address: str) -> None: """ - GET /lists/
- :return: + PUT /lists/
/members/ + :return: None """ - req = client.lists.get(domain=domain, address=f"python_sdk2@{domain}") - print(req.json()) + data: dict[str, Any] = { + "subscribed": True, + "address": member_address, + "name": "Bob Bar 2", + "description": "Developer", + "vars": '{"age": 28}', + } + with Client(auth=("api", api_key)) as client: + response = client.lists_members.put( + domain=domain, + address=list_address, + data=data, + member_address=member_address, + ) + print("PUT List Member (Sync):", response.json()) + +# ============================================================================== +# Mailing Lists Management (Asynchronous) +# ============================================================================== -def get_lists_members() -> None: + +async def delete_list_async(api_key: str, domain: str, list_address: str) -> None: """ - GET /lists/
/members/pages - :return: + DELETE /lists/
(Asynchronous) + :return: None """ - req = client.lists_members_pages.get(domain=domain, address=mailing_list_address) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.delete(domain=domain, address=list_address) + print("DELETE List (Async):", response.json()) -def get_member_from_list() -> None: +async def get_list_async(api_key: str, domain: str, list_address: str) -> None: """ - GET /lists/
/members/ - :return: + GET /lists/
(Asynchronous) + :return: None """ - req = client.lists_members.get( - domain=domain, - address=mailing_list_address, - member_address="bar2@example.com", - ) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.get(domain=domain, address=list_address) + print("GET List (Async):", response.json()) - print(req.json()) + +async def get_list_pages_async(api_key: str, domain: str) -> None: + """ + GET /lists/pages (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_pages.get(domain=domain) + print("GET List Pages (Async):", response.json()) -def put_member_list() -> None: +async def post_list_async(api_key: str, domain: str, list_address: str) -> None: """ - PUT /lists/
/members/ - :return: + POST /lists (Asynchronous) + :return: None """ - data = { - "subscribed": True, - "address": "bar2@example.com", - "name": "Bob Bar 2", - "description": "Developer", - "vars": '{"age": 28}', + data: dict[str, str] = { + "address": list_address, + "description": "Mailgun developers list", } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.create(domain=domain, data=data) + print("POST List (Async):", response.json()) + - req = client.lists_members.put( - domain=domain, - address=mailing_list_address, - data=data, - member_address="bar2@example.com", - ) +async def put_list_async(api_key: str, domain: str, list_address: str) -> None: + """ + PUT /lists/
(Asynchronous) + :return: None + """ + data: dict[str, str] = {"description": "Mailgun developers list 121212"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.put(domain=domain, data=data, address=list_address) + print("PUT List (Async):", response.json()) - print(req.json()) +# ============================================================================== +# Mailing List Validation (Asynchronous) +# ============================================================================== -def post_members_json() -> None: + +async def delete_list_validation_async(api_key: str, domain: str, list_address: str) -> None: """ - POST /lists/
/members.json - :return: + DELETE /lists/
/validate (Asynchronous) + :return: None """ - data = { - "upsert": True, - "members": '[{"address": "Alice ", "vars": {"age": 26}},' - '{"name": "Bob1", "address": "bob2@example.com", "vars": {"age": 34}}]', - } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.delete(domain=domain, address=list_address, validate=True) + print("DELETE List Validation (Async):", response.json()) + - req = client.lists_members.create( - domain=domain, - address=mailing_list_address, - data=data, - multiple=True, - ) - print(req.json()) +async def get_list_validation_async(api_key: str, domain: str, list_address: str) -> None: + """ + GET /lists/
/validate (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.get(domain=domain, address=list_address, validate=True) + print("GET List Validation (Async):", response.json()) -def delete_mailing_list_member() -> None: +async def post_list_validation_async(api_key: str, domain: str, list_address: str) -> None: """ - DELETE /lists/
/members/ - :return: + POST /lists/
/validate (Asynchronous) + :return: None """ - req = client.lists_members.delete( - domain=domain, - address=mailing_list_address, - member_address="bob2@example.com", - ) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists.create(domain=domain, address=list_address, validate=True) + print("POST List Validation (Async):", response.json()) + +# ============================================================================== +# Mailing List Members Management (Asynchronous) +# ============================================================================== -def delete_lists_address() -> None: + +async def delete_list_member_async( + api_key: str, domain: str, list_address: str, member_address: str +) -> None: """ - DELETE /lists/
- :return: + DELETE /lists/
/members/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members.delete( + domain=domain, + address=list_address, + member_address=member_address, + ) + print("DELETE List Member (Async):", response.json()) + + +async def get_list_member_async( + api_key: str, domain: str, list_address: str, member_address: str +) -> None: + """ + GET /lists/
/members/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members.get( + domain=domain, + address=list_address, + member_address=member_address, + ) + print("GET List Member (Async):", response.json()) + + +async def get_list_members_async(api_key: str, domain: str, list_address: str) -> None: + """ + GET /lists/
/members (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members.get(domain=domain, address=list_address) + print("GET List Members (Async):", response.json()) + + +async def get_list_members_pages_async(api_key: str, domain: str, list_address: str) -> None: + """ + GET /lists/
/members/pages (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members_pages.get(domain=domain, address=list_address) + print("GET List Members Pages (Async):", response.json()) + + +async def post_list_member_async( + api_key: str, domain: str, list_address: str, member_address: str +) -> None: + """ + POST /lists/
/members (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "subscribed": True, + "address": member_address, + "name": "Bob Bar", + "description": "Developer", + "vars": '{"age": 26}', + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members.create(domain=domain, address=list_address, data=data) + print("POST List Member (Async):", response.json()) + + +async def post_list_members_json_async(api_key: str, domain: str, list_address: str) -> None: """ - req = client.lists.delete(domain=domain, address=f"python_sdk2@{domain}") - print(req.json()) + POST /lists/
/members.json (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "upsert": True, + "members": '[{"address": "Alice ", "vars": {"age": 26}},' + '{"name": "Bob1", "address": "bob2@example.com", "vars": {"age": 34}}]', + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members.create( + domain=domain, + address=list_address, + data=data, + multiple=True, + ) + print("POST List Members JSON (Async):", response.json()) + + +async def put_list_member_async( + api_key: str, domain: str, list_address: str, member_address: str +) -> None: + """ + PUT /lists/
/members/ (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "subscribed": True, + "address": member_address, + "name": "Bob Bar 2", + "description": "Developer", + "vars": '{"age": 28}', + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.lists_members.put( + domain=domain, + address=list_address, + data=data, + member_address=member_address, + ) + print("PUT List Member (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - delete_lists_address() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + # Identifiers mapping to your original business logic + LIST_ADDRESS_1: str = f"python_sdk2@{DOMAIN}" + LIST_ADDRESS_2: str = os.environ.get("MAILLIST_ADDRESS", f"my_list@{DOMAIN}") + + MEMBER_ADDRESS_1: str = "bar2@example.com" + MEMBER_ADDRESS_2: str = "bob2@example.com" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + # Lists Management + # post_list_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + # put_list_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + # get_list_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + # get_list_pages_sync(api_key=API_KEY, domain=DOMAIN) + delete_list_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + + # Lists Validation + # post_list_validation_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + # get_list_validation_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + # delete_list_validation_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1) + + # Members Management + # post_list_member_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2, member_address=MEMBER_ADDRESS_1) + # post_list_members_json_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2) + # put_list_member_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2, member_address=MEMBER_ADDRESS_1) + # get_list_member_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2, member_address=MEMBER_ADDRESS_1) + # get_list_members_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2) + # get_list_members_pages_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2) + # delete_list_member_sync(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2, member_address=MEMBER_ADDRESS_2) + + print("\n--- Running Asynchronous Examples ---") + # asyncio.run(delete_list_async(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_1)) + # asyncio.run(get_list_member_async(api_key=API_KEY, domain=DOMAIN, list_address=LIST_ADDRESS_2, member_address=MEMBER_ADDRESS_1)) diff --git a/mailgun/examples/messages_examples.py b/mailgun/examples/messages_examples.py index 7d8150c..0b735db 100644 --- a/mailgun/examples/messages_examples.py +++ b/mailgun/examples/messages_examples.py @@ -1,68 +1,101 @@ +"""Examples for sending and managing Mailgun Messages.""" + +from __future__ import annotations + import os from pathlib import Path +from typing import Any -from mailgun.client import Client +from mailgun.client import AsyncClient, Client from mailgun.handlers.error_handler import UploadError # The maximum message size Mailgun supports is 25MB, # see https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-http#send-via-http -MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB - -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -html: str = """ - - - - -
- Hello! -
-""" - -client: Client = Client(auth=("api", key)) - - -def post_message() -> None: - # Messages - # POST //messages - data = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], - "cc": os.environ["MESSAGES_CC"], +MAX_FILE_SIZE: int = 25 * 1024 * 1024 # 25 MB + + +# ============================================================================== +# Message Management (Synchronous) +# ============================================================================== + + +def post_message_sync( + api_key: str, + domain: str, + from_email: str, + to_email: str, + cc_email: str, + html_body: str, + file_path_1: str, + file_path_2: str, +) -> None: + """ + POST //messages + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, "subject": "Hello Vasyl Bodaj", - "html": html, + "html": html_body, "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. + file_bytes_1: bytes = Path(file_path_1).read_bytes() + file_bytes_2: bytes = Path(file_path_2).read_bytes() - file_bytes_1 = Path("mailgun/doc_tests/files/test1.txt").read_bytes() - file_bytes_2 = Path("mailgun/doc_tests/files/test2.txt").read_bytes() - - for file in {file_bytes_1, file_bytes_2}: - if len(file) > MAX_FILE_SIZE: + for file_content in (file_bytes_1, file_bytes_2): + if len(file_content) > MAX_FILE_SIZE: raise UploadError("File too large") - files = [ - ("attachment", ("test1.txt", file_bytes_1)), - ("attachment", ("test2.txt", file_bytes_2)), + files: list[tuple[str, tuple[str, bytes]]] = [ + ("attachment", (Path(file_path_1).name, file_bytes_1)), + ("attachment", (Path(file_path_2).name, file_bytes_2)), ] - req = client.messages.create(data=data, files=files, domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.messages.create(data=data, files=files, domain=domain) + print("POST Message (Sync):", response.json()) -def post_mime() -> None: - # Mime messages - # POST //messages.mime - mime_data = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], - "cc": os.environ["MESSAGES_CC"], +def post_message_tags_sync( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, html_body: str +) -> None: + """ + Message Tags + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, + "subject": "Hello Vasyl Bodaj", + "html": html_body, + "o:tag": ["September newsletter", "newsletters"], + } + with Client(auth=("api", api_key)) as client: + response = client.messages.create(data=data, domain=domain) + print("POST Message Tags (Sync):", response.json()) + + +def post_mime_sync( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, mime_file_path: str +) -> None: + """ + Mime messages + POST //messages.mime + :return: None + """ + mime_data: dict[str, str] = { + "from": from_email, + "to": to_email, + "cc": cc_email, "subject": "Hello HELLO", } # It is strongly recommended that you open files in binary mode. @@ -71,75 +104,302 @@ def post_mime() -> None: # Errors may occur if you open the file in text mode. # Mailgun requires the MIME string to be uploaded as a file # . Passing 'files' forces multipart/form-data. - files = {"message": Path("mailgun/doc_tests/files/test_mime.mime").read_bytes()} + files: dict[str, bytes] = {"message": Path(mime_file_path).read_bytes()} - req = client.mimemessage.create(data=mime_data, files=files, domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.mimemessage.create(data=mime_data, files=files, domain=domain) + print("POST MIME (Sync):", response.json()) -def post_no_tracking() -> None: - # Message no tracking - data = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], - "cc": os.environ["MESSAGES_CC"], +def post_no_tracking_sync( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, html_body: str +) -> None: + """ + Message no tracking + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, "subject": "Hello Vasyl Bodaj", - "html": html, + "html": html_body, "o:tracking": False, } - - req = client.messages.create(data=data, domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.messages.create(data=data, domain=domain) + print("POST No Tracking (Sync):", response.json()) -def post_scheduled() -> None: - # Scheduled message - data = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], - "cc": os.environ["MESSAGES_CC"], +def post_scheduled_sync( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, html_body: str +) -> None: + """ + Scheduled message + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, "subject": "Hello Vasyl Bodaj", - "html": html, + "html": html_body, "o:deliverytime": "Thu Jan 28 2021 14:00:03 EST", } + with Client(auth=("api", api_key)) as client: + response = client.messages.create(data=data, domain=domain) + print("POST Scheduled (Sync):", response.json()) + + +def resend_message_sync(api_key: str, domain: str, from_email: str, to_email: str) -> None: + """ + Resend message + :return: None + """ + data: dict[str, list[str]] = {"to": ["test1@example.com", "test2@example.com"]} + params: dict[str, Any] = { + "from": from_email, + "to": to_email, + "limit": 1, + } + + with Client(auth=("api", api_key)) as client: + events_response = client.events.get(domain=domain, filters=params) + print("GET Events (Sync):", events_response.json()) + + items: list[dict[str, Any]] = events_response.json().get("items", []) + if not items: + print("No events found to resend.") + return + + storage_url: str | None = items[0].get("storage", {}).get("url") + if not storage_url: + print("No storage URL found in event.") + return + + resend_response = client.resendmessage.create( + data=data, + domain=domain, + storage_url=storage_url, + ) + print("Resend Message (Sync):", resend_response.json()) + + +# ============================================================================== +# Message Management (Asynchronous) +# ============================================================================== + + +async def post_message_async( + api_key: str, + domain: str, + from_email: str, + to_email: str, + cc_email: str, + html_body: str, + file_path_1: str, + file_path_2: str, +) -> None: + """ + POST //messages (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, + "subject": "Hello Vasyl Bodaj", + "html": html_body, + "o:tag": "Python test", + } + + file_bytes_1: bytes = Path(file_path_1).read_bytes() + file_bytes_2: bytes = Path(file_path_2).read_bytes() - req = client.messages.create(data=data, domain=domain) - print(req.json()) + for file_content in (file_bytes_1, file_bytes_2): + if len(file_content) > MAX_FILE_SIZE: + raise UploadError("File too large") + files: list[tuple[str, tuple[str, bytes]]] = [ + ("attachment", (Path(file_path_1).name, file_bytes_1)), + ("attachment", (Path(file_path_2).name, file_bytes_2)), + ] -def post_message_tags() -> None: - # Message Tags - data = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], - "cc": os.environ["MESSAGES_CC"], + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.messages.create(data=data, files=files, domain=domain) + print("POST Message (Async):", response.json()) + + +async def post_message_tags_async( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, html_body: str +) -> None: + """ + Message Tags (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, "subject": "Hello Vasyl Bodaj", - "html": html, + "html": html_body, "o:tag": ["September newsletter", "newsletters"], } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.messages.create(data=data, domain=domain) + print("POST Message Tags (Async):", response.json()) + + +async def post_mime_async( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, mime_file_path: str +) -> None: + """ + Mime messages + POST //messages.mime (Asynchronous) + :return: None + """ + mime_data: dict[str, str] = { + "from": from_email, + "to": to_email, + "cc": cc_email, + "subject": "Hello HELLO", + } + files: dict[str, bytes] = {"message": Path(mime_file_path).read_bytes()} + + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.mimemessage.create(data=mime_data, files=files, domain=domain) + print("POST MIME (Async):", response.json()) + + +async def post_no_tracking_async( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, html_body: str +) -> None: + """ + Message no tracking (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, + "subject": "Hello Vasyl Bodaj", + "html": html_body, + "o:tracking": False, + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.messages.create(data=data, domain=domain) + print("POST No Tracking (Async):", response.json()) - req = client.messages.create(data=data, domain=domain) - print(req.json()) +async def post_scheduled_async( + api_key: str, domain: str, from_email: str, to_email: str, cc_email: str, html_body: str +) -> None: + """ + Scheduled message (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "from": from_email, + "to": to_email, + "cc": cc_email, + "subject": "Hello Vasyl Bodaj", + "html": html_body, + "o:deliverytime": "Thu Jan 28 2021 14:00:03 EST", + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.messages.create(data=data, domain=domain) + print("POST Scheduled (Async):", response.json()) -def resend_message() -> None: - data = {"to": ["test1@example.com", "test2@example.com"]} - params = { - "from": os.environ["MESSAGES_FROM"], - "to": os.environ["MESSAGES_TO"], +async def resend_message_async(api_key: str, domain: str, from_email: str, to_email: str) -> None: + """ + Resend message (Asynchronous) + :return: None + """ + data: dict[str, list[str]] = {"to": ["test1@example.com", "test2@example.com"]} + params: dict[str, Any] = { + "from": from_email, + "to": to_email, "limit": 1, } - req_ev = client.events.get(domain=domain, filters=params) - print(req_ev.json()) - req = client.resendmessage.create( - data=data, - domain=domain, - storage_url=req_ev.json()["items"][0]["storage"]["url"], - ) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + events_response = await client.events.get(domain=domain, filters=params) + print("GET Events (Async):", events_response.json()) + + items: list[dict[str, Any]] = events_response.json().get("items", []) + if not items: + print("No events found to resend.") + return + + storage_url: str | None = items[0].get("storage", {}).get("url") + if not storage_url: + print("No storage URL found in event.") + return + resend_response = await client.resendmessage.create( + data=data, + domain=domain, + storage_url=storage_url, + ) + print("Resend Message (Async):", resend_response.json()) + + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - post_message() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + MESSAGES_FROM: str = os.environ.get("MESSAGES_FROM", "sender@example.com") + MESSAGES_TO: str = os.environ.get("MESSAGES_TO", "recipient@example.com") + MESSAGES_CC: str = os.environ.get("MESSAGES_CC", "cc@example.com") + + HTML_BODY: str = """ + + + + +
+ Hello! +
+ """ + + # Dummy file paths mapped from the original logic for example execution + FILE_PATH_1: str = "mailgun/doc_tests/files/test1.txt" + FILE_PATH_2: str = "mailgun/doc_tests/files/test2.txt" + MIME_FILE_PATH: str = "mailgun/doc_tests/files/test_mime.mime" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + # Pre-seed paths so missing files don't crash execution if paths don't exist + Path("mailgun/doc_tests/files").mkdir(parents=True, exist_ok=True) + if not Path(FILE_PATH_1).exists(): + Path(FILE_PATH_1).write_bytes(b"Test File 1 Bytes") + if not Path(FILE_PATH_2).exists(): + Path(FILE_PATH_2).write_bytes(b"Test File 2 Bytes") + if not Path(MIME_FILE_PATH).exists(): + Path(MIME_FILE_PATH).write_bytes(b"Content-Type: text/plain\n\nTest MIME") + + print("--- Running Synchronous Examples ---") + post_message_sync( + api_key=API_KEY, + domain=DOMAIN, + from_email=MESSAGES_FROM, + to_email=MESSAGES_TO, + cc_email=MESSAGES_CC, + html_body=HTML_BODY, + file_path_1=FILE_PATH_1, + file_path_2=FILE_PATH_2, + ) + # post_message_tags_sync(api_key=API_KEY, domain=DOMAIN, from_email=MESSAGES_FROM, to_email=MESSAGES_TO, cc_email=MESSAGES_CC, html_body=HTML_BODY) + # post_mime_sync(api_key=API_KEY, domain=DOMAIN, from_email=MESSAGES_FROM, to_email=MESSAGES_TO, cc_email=MESSAGES_CC, mime_file_path=MIME_FILE_PATH) + # post_no_tracking_sync(api_key=API_KEY, domain=DOMAIN, from_email=MESSAGES_FROM, to_email=MESSAGES_TO, cc_email=MESSAGES_CC, html_body=HTML_BODY) + # post_scheduled_sync(api_key=API_KEY, domain=DOMAIN, from_email=MESSAGES_FROM, to_email=MESSAGES_TO, cc_email=MESSAGES_CC, html_body=HTML_BODY) + # resend_message_sync(api_key=API_KEY, domain=DOMAIN, from_email=MESSAGES_FROM, to_email=MESSAGES_TO) + + print("\n--- Running Asynchronous Examples ---") + # asyncio.run(post_message_async(api_key=API_KEY, domain=DOMAIN, from_email=MESSAGES_FROM, to_email=MESSAGES_TO, cc_email=MESSAGES_CC, html_body=HTML_BODY, file_path_1=FILE_PATH_1, file_path_2=FILE_PATH_2)) diff --git a/mailgun/examples/metrics_examples.py b/mailgun/examples/metrics_examples.py index 4135c92..0e51e8f 100644 --- a/mailgun/examples/metrics_examples.py +++ b/mailgun/examples/metrics_examples.py @@ -1,21 +1,26 @@ +"""Examples for fetching Mailgun Analytics Metrics.""" + +from __future__ import annotations + +import asyncio import os +from typing import Any -from mailgun.client import Client +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Analytics Metrics Management (Synchronous) +# ============================================================================== -def post_analytics_metrics() -> None: +def post_analytics_metrics_sync(api_key: str, domain: str) -> None: """ # Metrics # POST /v1/analytics/metrics - :return: + :return: None """ - - data = { + data: dict[str, Any] = { "start": "Sun, 08 Jun 2025 00:00:00 +0000", "end": "Tue, 08 Jul 2025 00:00:00 +0000", "resolution": "day", @@ -35,17 +40,97 @@ def post_analytics_metrics() -> None: "include_aggregates": True, } - req = client.analytics_metrics.create(data=data) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.analytics_metrics.create(data=data) + print("POST Analytics Metrics (Sync):", response.json()) -def post_analytics_usage_metrics() -> None: +def post_analytics_usage_metrics_sync(api_key: str) -> None: """ # Usage Metrics # POST /v1/analytics/usage/metrics - :return: + :return: None + """ + data: dict[str, Any] = { + "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, + } + + with Client(auth=("api", api_key)) as client: + response = client.analytics_usage_metrics.create(data=data) + print("POST Analytics Usage Metrics (Sync):", response.json()) + + +# ============================================================================== +# Analytics Metrics Management (Asynchronous) +# ============================================================================== + + +async def post_analytics_metrics_async(api_key: str, domain: str) -> None: + """ + # Metrics (Asynchronous) + # POST /v1/analytics/metrics + :return: None + """ + data: dict[str, Any] = { + "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, + } + + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_metrics.create(data=data) + print("POST Analytics Metrics (Async):", response.json()) + + +async def post_analytics_usage_metrics_async(api_key: str) -> None: + """ + # Usage Metrics (Asynchronous) + # POST /v1/analytics/usage/metrics + :return: None """ - data = { + data: dict[str, Any] = { "start": "Sun, 08 Jun 2025 00:00:00 +0000", "end": "Tue, 08 Jul 2025 00:00:00 +0000", "resolution": "day", @@ -77,10 +162,27 @@ def post_analytics_usage_metrics() -> None: "include_aggregates": True, } - req = client.analytics_usage_metrics.create(data=data) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_usage_metrics.create(data=data) + print("POST Analytics Usage Metrics (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - post_analytics_metrics() - post_analytics_usage_metrics() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + post_analytics_metrics_sync(api_key=API_KEY, domain=DOMAIN) + post_analytics_usage_metrics_sync(api_key=API_KEY) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(post_analytics_metrics_async(api_key=API_KEY, domain=DOMAIN)) + asyncio.run(post_analytics_usage_metrics_async(api_key=API_KEY)) diff --git a/mailgun/examples/routes_examples.py b/mailgun/examples/routes_examples.py index 93cff8b..42b9c82 100644 --- a/mailgun/examples/routes_examples.py +++ b/mailgun/examples/routes_examples.py @@ -1,82 +1,188 @@ -import os +"""Examples for managing Mailgun Routes.""" + +from __future__ import annotations -from mailgun.client import Client +import os +from typing import Any +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -sender: str = os.environ["MESSAGES_FROM"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Routes Management (Synchronous) +# ============================================================================== -def get_routes() -> None: +def delete_route_sync(api_key: str, domain: str, route_id: str) -> None: """ - GET /routes - :return: + DELETE /routes/ + :return: None """ - params = {"skip": 0, "limit": 1} - req = client.routes.get(domain=domain, filters=params) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.routes.delete(domain=domain, route_id=route_id) + print("DELETE Route (Sync):", response.json()) -def get_route_by_id() -> None: +def get_route_by_id_sync(api_key: str, domain: str, route_id: str) -> None: """ GET /routes/ - :return: + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.routes.get(domain=domain, route_id=route_id) + print("GET Route By ID (Sync):", response.json()) + + +def get_routes_match_sync(api_key: str, domain: str, sender: str) -> None: + """ + GET /routes/match + :return: None """ - req = client.routes.get(domain=domain, route_id="6012d994e8d489e24a127e79") - print(req.json()) + filters: dict[str, str] = {"address": sender} + with Client(auth=("api", api_key)) as client: + response = client.routes_match.get(domain=domain, filters=filters) + print("GET Routes Match (Sync):", response.json()) -def post_routes() -> None: +def get_routes_sync(api_key: str, domain: str) -> None: + """ + GET /routes + :return: None + """ + filters: dict[str, int] = {"skip": 0, "limit": 1} + with Client(auth=("api", api_key)) as client: + response = client.routes.get(domain=domain, filters=filters) + print("GET Routes (Sync):", response.json()) + + +def post_routes_sync(api_key: str, domain: str) -> None: """ POST /routes - :return: + :return: None """ - data = { + data: dict[str, Any] = { "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()) + with Client(auth=("api", api_key)) as client: + response = client.routes.create(domain=domain, data=data) + print("POST Routes (Sync):", response.json()) -def put_route() -> None: +def put_route_sync(api_key: str, domain: str, route_id: str) -> None: """ PUT /routes/ - :return: + :return: None """ - data = { + data: dict[str, Any] = { "priority": 2, "description": "Sample route", "expression": f"match_recipient('.*@{domain}')", "action": ["forward('http://myhost.com/messages/')", "stop()"], } - req = client.routes.put(domain=domain, data=data, route_id="60142b357c90c3c9f228e0a6") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.routes.put(domain=domain, data=data, route_id=route_id) + print("PUT Route (Sync):", response.json()) -def delete_route() -> None: +# ============================================================================== +# Routes Management (Asynchronous) +# ============================================================================== + + +async def delete_route_async(api_key: str, domain: str, route_id: str) -> None: """ - DELETE /routes/ - :return: + DELETE /routes/ (Asynchronous) + :return: None """ - req = client.routes.delete(domain=domain, route_id="60142b357c90c3c9f228e0a6") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.routes.delete(domain=domain, route_id=route_id) + print("DELETE Route (Async):", response.json()) -def get_routes_match() -> None: +async def get_route_by_id_async(api_key: str, domain: str, route_id: str) -> None: """ - GET /routes/match - :return: + GET /routes/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.routes.get(domain=domain, route_id=route_id) + print("GET Route By ID (Async):", response.json()) + + +async def get_routes_async(api_key: str, domain: str) -> None: + """ + GET /routes (Asynchronous) + :return: None """ - query = {"address": sender} - req = client.routes_match.get(domain=domain, filters=query) - print(req.json()) + filters: dict[str, int] = {"skip": 0, "limit": 1} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.routes.get(domain=domain, filters=filters) + print("GET Routes (Async):", response.json()) + + +async def get_routes_match_async(api_key: str, domain: str, sender: str) -> None: + """ + GET /routes/match (Asynchronous) + :return: None + """ + filters: dict[str, str] = {"address": sender} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.routes_match.get(domain=domain, filters=filters) + print("GET Routes Match (Async):", response.json()) + + +async def post_routes_async(api_key: str, domain: str) -> None: + """ + POST /routes (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "priority": 0, + "description": "Sample route", + "expression": f"match_recipient('.*@{domain}')", + "action": ["forward('http://myhost.com/messages/')", "stop()"], + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.routes.create(domain=domain, data=data) + print("POST Routes (Async):", response.json()) + + +async def put_route_async(api_key: str, domain: str, route_id: str) -> None: + """ + PUT /routes/ (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "priority": 2, + "description": "Sample route", + "expression": f"match_recipient('.*@{domain}')", + "action": ["forward('http://myhost.com/messages/')", "stop()"], + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.routes.put(domain=domain, data=data, route_id=route_id) + print("PUT Route (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - get_routes_match() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + SENDER: str = os.environ.get("MESSAGES_FROM", "sender@example.com") + + ROUTE_ID_1: str = "1234567890" + ROUTE_ID_2: str = "0987654321" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + get_routes_match_sync(api_key=API_KEY, domain=DOMAIN, sender=SENDER) + # get_routes_sync(api_key=API_KEY, domain=DOMAIN) + # get_route_by_id_sync(api_key diff --git a/mailgun/examples/smoke_test.py b/mailgun/examples/smoke_test.py index 5c87a9a..814137d 100644 --- a/mailgun/examples/smoke_test.py +++ b/mailgun/examples/smoke_test.py @@ -4,7 +4,7 @@ This script serves as both an integration verification tool and executable documentation for developers. It tests synchronous and asynchronous clients, standard Form-Data requests, JSON payloads, -and error handling. +fluent builders, typed dicts, and error handling. Usage: export APIKEY="your-api-key" # pragma: allowlist secret @@ -13,6 +13,8 @@ python mailgun/examples/smoke_test.py """ +from __future__ import annotations + import asyncio import logging import os @@ -20,6 +22,7 @@ from collections.abc import Awaitable, Callable from typing import Any +from mailgun.builders import MailgunMessageBuilder from mailgun.client import AsyncClient, Client from mailgun.handlers.error_handler import ApiError @@ -27,30 +30,26 @@ logging.getLogger("mailgun.client").setLevel(logging.DEBUG) logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") -# Environment setup -API_KEY = os.environ.get("APIKEY", "") -DOMAIN = os.environ.get("DOMAIN", "sandbox.mailgun.org") -MESSAGES_TO = os.environ.get("MESSAGES_TO", f"success@{DOMAIN}") -VALIDATION_ADDRESS_1 = os.environ.get("VALIDATION_ADDRESS_1", "") - -# Initialize clients -sync_client = Client(auth=("api", API_KEY)) +# ============================================================================== +# Test Runners +# ============================================================================== def run_sync_test( test_name: str, func: Callable[[], Any], expected_status: tuple[int, ...] = (200,) ) -> None: - """Wrapper to execute and validate synchronous API calls.""" + """Execute and validate synchronous API calls.""" print(f"\n{'=' * 60}\n🚀 SYNC RUN: {test_name}\n{'=' * 60}") try: result = func() - if getattr(result, "status_code", None) in expected_status: - print(f"✅ SUCCESS (Status Code: {result.status_code})") + if hasattr(result, "status_code"): + if result.status_code in expected_status: + print(f"✅ SUCCESS (Status Code: {result.status_code})\n") + else: + print(f"❌ FAILED (Expected {expected_status}, got {result.status_code})\n") else: - print( - f"❌ FAILED (Expected {expected_status}, got {getattr(result, 'status_code', 'None')})" - ) + print(f"✅ SUCCESS ({result})\n") except ApiError as e: print(f"⚠️ SDK CAUGHT API ERROR: {e}") except Exception as e: @@ -58,148 +57,223 @@ def run_sync_test( async def run_async_test( - test_name: str, func: Callable[[], Awaitable[Any]], expected_status: tuple[int, ...] = (200,) + test_name: str, + func: Callable[[], Awaitable[Any]], + expected_status: tuple[int, ...] = (200,), ) -> None: - """Wrapper to execute and validate asynchronous API calls.""" + """Execute and validate asynchronous API calls.""" print(f"\n{'=' * 60}\n⚡ ASYNC RUN: {test_name}\n{'=' * 60}") try: result = await func() - if getattr(result, "status_code", None) in expected_status: - print(f"✅ SUCCESS (Status Code: {result.status_code})") + if hasattr(result, "status_code"): + if result.status_code in expected_status: + print(f"✅ SUCCESS (Status Code: {result.status_code})\n") + else: + print(f"❌ FAILED (Expected {expected_status}, got {result.status_code})\n") else: - print( - f"❌ FAILED (Expected {expected_status}, got {getattr(result, 'status_code', 'None')})" - ) + # Safely handle our stream() tests that return strings or counts + print(f"✅ SUCCESS ({result})\n") except ApiError as e: print(f"⚠️ SDK CAUGHT API ERROR: {e}") except Exception as e: print(f"💥 FATAL UNEXPECTED ERROR: {e}") -# --- SYNC TESTS --- +# ============================================================================== +# Synchronous Smoke Tests +# ============================================================================== + + +def test_create_bounces_json_sync(api_key: str, domain: str) -> Any: + """Test: Bulk upload bounces using JSON (Validates `is_json` serialization).""" + data: list[dict[str, str]] = [ + {"address": f"bounce1@{domain}", "code": "550", "error": "Smoke Test Bounce 1"}, + {"address": f"bounce2@{domain}", "code": "550", "error": "Smoke Test Bounce 2"}, + ] + with Client(auth=("api", api_key)) as client: + return client.bounces.create( + domain=domain, data=data, headers={"Content-Type": "application/json"} + ) + + +def test_cross_version_routing_sync(api_key: str, validation_address: str) -> Any: + """Test: Call a v4 endpoint (Validates cross-API dynamic routing).""" + with Client(auth=("api", api_key)) as client: + return client.addressvalidate.get(filters={"address": validation_address}) -def test_get_domains() -> Any: - """Test 1: Fetch domains (Validates v3/v4 routing architecture).""" - return sync_client.domains.get(filters={"limit": 2}) +def test_deprecation_warnings_sync(api_key: str, domain: str) -> Any: + """Test: Verify SDK intercepts legacy APIs and emits DeprecationWarnings.""" + with warnings.catch_warnings(record=True) as caught_warnings: + warnings.simplefilter("always", DeprecationWarning) + with Client(auth=("api", api_key)) as client: + response = client.tag.get(domain=domain, filters={"tag": "my-tag"}) -def test_send_message_form_data() -> Any: - """Test 2: Send a message using standard Form-Data.""" - data = { - "from": f"Smoke Test ", - "to": [MESSAGES_TO], + warning_emitted = any( + issubclass(w.category, DeprecationWarning) and "legacy Tag API" in str(w.message) + for w in caught_warnings + ) + + if not warning_emitted: + raise AssertionError("SDK failed to emit a DeprecationWarning for legacy endpoint") + + return response + + +def test_expected_404_logging_sync(api_key: str) -> Any: + """Test: Fetch a fake domain to trigger CWE-532 secure logging.""" + with Client(auth=("api", api_key)) as client: + return client.domains.get(domain_name="this-domain-does-not-exist.com") + + +def test_get_domains_sync(api_key: str) -> Any: + """Test: Fetch domains (Validates v3/v4 routing architecture).""" + with Client(auth=("api", api_key)) as client: + return client.domains.get(filters={"limit": 2}) + + +def test_send_message_form_data_sync(api_key: str, domain: str, messages_to: str) -> Any: + """Test: Send a message using standard Form-Data.""" + # Using a raw dict prevents TypedDict 'from_' key mismatches + # over the network since Mailgun strictly demands 'from'. + data: dict[str, Any] = { + "from": f"Smoke Test ", + "to": [messages_to], "subject": "Mailgun SDK Smoke Test (Form-Data)", "text": "If you see this, the synchronous Form-Data test passed!", - "o:testmode": True, # Don't actually send the email + "o:testmode": "yes", # Must be a string boolean } - return sync_client.messages.create(domain=DOMAIN, data=data) + with Client(auth=("api", api_key)) as client: + return client.messages.create(domain=domain, data=data) -def test_create_bounces_json() -> Any: - """Test 3: Bulk upload bounces using JSON (Validates our new `is_json` serialization).""" - # Mailgun /lists doesn't support JSON. /bounces bulk upload DOES support JSON arrays! - data = [ - {"address": f"bounce1@{DOMAIN}", "code": "550", "error": "Smoke Test Bounce 1"}, - {"address": f"bounce2@{DOMAIN}", "code": "550", "error": "Smoke Test Bounce 2"}, - ] - return sync_client.bounces.create( - domain=DOMAIN, data=data, headers={"Content-Type": "application/json"} - ) +def test_send_message_with_builder_sync(api_key: str, domain: str, messages_to: str) -> Any: + """Test: Construct complex payloads safely using MailgunMessageBuilder.""" + builder = MailgunMessageBuilder(from_email=f"Smoke Builder ") + builder.add_recipient(messages_to) + builder.set_subject("Mailgun SDK Builder Test") + builder.set_text("If you see this, the MailgunMessageBuilder worked safely!") + # Safely abstract custom prefixes directly into the payload array + builder._payload["o:testmode"] = "yes" + builder._payload["v:smoke_run_id"] = "12345" + builder._payload["o:tag"] = "smoke-test" -def test_expected_404_logging() -> Any: - """Test 4: Fetch a fake domain to trigger CWE-532 secure logging.""" - return sync_client.domains.get(domain_name="this-domain-does-not-exist.com") + payload, files = builder.build() + with Client(auth=("api", api_key)) as client: + return client.messages.create(domain=domain, data=payload, files=files) -def test_cross_version_routing() -> Any: - """Test 5: Call a v4 endpoint (Validates cross-API dynamic routing).""" - # Using addressvalidate (/v4/address/validate). - # Returns 403 on Free plans, 200 on Paid plans. Both prove successful URL routing. - return sync_client.addressvalidate.get(filters={"address": VALIDATION_ADDRESS_1}) +def test_sync_context_manager(api_key: str) -> Any: + """Test: Demonstrate resource-safe client usage via Context Manager.""" + with Client(auth=("api", api_key)) as safe_client: + return safe_client.domainlist.get(filters={"limit": 1}) -def test_sync_context_manager() -> Any: - """Test 7: Demonstrate resource-safe client usage via Context Manager.""" - # Initialize a temporary client strictly for this block - with Client(auth=("api", API_KEY)) as safe_client: - # Make a lightweight call to prove the connection pool works - response = safe_client.domainlist.get(filters={"limit": 1}) - # When this block exits, safe_client._session.close() is automatically called, - # safely returning the TCP socket to the OS. - return response +def test_sync_stream_pagination(api_key: str, domain: str) -> str: + """Test: Lazy pagination generator (sync).""" + count = 0 + with Client(auth=("api", api_key)) as client: + for _ in client.events.stream(domain=domain, filters={"limit": 2}): + count += 1 + if count >= 5: + break + return f"Successfully streamed and paginated {count} events." -# --- DEPRECATION WARNING TESTS --- +# ============================================================================== +# Asynchronous Smoke Tests +# ============================================================================== -def test_deprecation_warnings() -> Any: - """Test 6: Verify SDK intercepts legacy APIs and emits DeprecationWarnings.""" - with warnings.catch_warnings(record=True) as caught_warnings: - # Force Python to capture all DeprecationWarnings - warnings.simplefilter("always", DeprecationWarning) - # Trigger the legacy Tag API (client.tag instead of client.tags) - # We don't care if it returns 200 or 404, we only care about the warning. - response = sync_client.tag.get(domain=DOMAIN, filters={"tag": "my-tag"}) +async def test_async_stream_pagination(api_key: str, domain: str) -> str: + """Test: Lazy pagination generator (async).""" + count = 0 + async with AsyncClient(auth=("api", api_key)) as async_client: + async for _ in async_client.events.stream(domain=domain, filters={"limit": 2}): + count += 1 + if count >= 5: + break - # Validate that our SDK Interceptor successfully fired the warning - warning_emitted = any( - issubclass(w.category, DeprecationWarning) and "legacy Tag API" in str(w.message) - for w in caught_warnings - ) + return f"Successfully streamed {count} events asynchronously." - if not warning_emitted: - raise AssertionError("SDK failed to emit a DeprecationWarning for a legacy endpoint!") - return response - - -# --- ASYNC TESTS --- +async def test_get_ips_async(api_key: str) -> Any: + """Test: Fetch dedicated IPs asynchronously.""" + async with AsyncClient(auth=("api", api_key)) as async_client: + return await async_client.ips.get() -async def async_smoke_suite() -> None: - """Execute asynchronous tests using the AsyncClient context manager.""" - async with AsyncClient(auth=("api", API_KEY)) as async_client: +async def test_get_tags_async(api_key: str, domain: str) -> Any: + """Test: Fetch analytics tags asynchronously.""" + async with AsyncClient(auth=("api", api_key)) as async_client: + return await async_client.tags.get(domain=domain, filters={"limit": 2}) - async def test_get_tags() -> Any: - """Test 5: Fetch analytics tags asynchronously.""" - return await async_client.tags.get(domain=DOMAIN, filters={"limit": 2}) - async def test_get_ips() -> Any: - """Test 6: Fetch dedicated IPs asynchronously.""" - return await async_client.ips.get() +async def async_smoke_suite(api_key: str, domain: str) -> None: + """Execute asynchronous tests.""" + await run_async_test("Async IPs Fetch", lambda: test_get_ips_async(api_key)) + await run_async_test( + "Async Stream Pagination", lambda: test_async_stream_pagination(api_key, domain) + ) + await run_async_test("Async Tags Fetch", lambda: test_get_tags_async(api_key, domain)) - await run_async_test("Async Tags Fetch", test_get_tags) - await run_async_test("Async IPs Fetch", test_get_ips) +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "sandbox.mailgun.org") + MESSAGES_TO: str = os.environ.get("MESSAGES_TO", f"success@{DOMAIN}") + VALIDATION_ADDRESS_1: str = os.environ.get("VALIDATION_ADDRESS_1", "test@example.com") + if not API_KEY: - print( - "⚠️ WARNING: 'MAILGUN_API_KEY' is not set. Network requests will return 401 Unauthorized." - ) + print("⚠️ WARNING: 'APIKEY' is not set. Network requests will return 401 Unauthorized.") print(f"🔧 Testing against domain: {DOMAIN}") print(f"📨 Authorized recipient: {MESSAGES_TO}\n") # Run Synchronous Suite - run_sync_test("Get Domains (v3/v4)", test_get_domains) - run_sync_test("Send Message (Form-Data)", test_send_message_form_data) - run_sync_test("Bulk Create Bounces (JSON Payload)", test_create_bounces_json) - run_sync_test("Test 404 Safe Logging", test_expected_404_logging, expected_status=(404,)) run_sync_test( - "Cross-Version Routing (v4)", test_cross_version_routing, expected_status=(200, 403) + "Bulk Create Bounces (JSON Payload)", lambda: test_create_bounces_json_sync(API_KEY, DOMAIN) + ) + run_sync_test( + "Cross-Version Routing (v4)", + lambda: test_cross_version_routing_sync(API_KEY, VALIDATION_ADDRESS_1), + expected_status=(200, 403), ) - run_sync_test("Sync Context Manager (Resource Safe)", test_sync_context_manager) run_sync_test( "Deprecation Warning Interceptor", - test_deprecation_warnings, + lambda: test_deprecation_warnings_sync(API_KEY, DOMAIN), expected_status=(200, 400, 404), ) + run_sync_test("Get Domains (v3/v4)", lambda: test_get_domains_sync(API_KEY)) + run_sync_test( + "Send Message (Form-Data)", + lambda: test_send_message_form_data_sync(API_KEY, DOMAIN, MESSAGES_TO), + ) + run_sync_test( + "Send Message (Fluent Builder)", + lambda: test_send_message_with_builder_sync(API_KEY, DOMAIN, MESSAGES_TO), + ) + run_sync_test( + "Stream Pagination (Lazy Loading)", lambda: test_sync_stream_pagination(API_KEY, DOMAIN) + ) + run_sync_test( + "Sync Context Manager (Resource Safe)", lambda: test_sync_context_manager(API_KEY) + ) + run_sync_test( + "Test 404 Safe Logging", + lambda: test_expected_404_logging_sync(API_KEY), + expected_status=(404,), + ) + # Run Asynchronous Suite - asyncio.run(async_smoke_suite()) + asyncio.run(async_smoke_suite(API_KEY, DOMAIN)) print(f"\n🎉 Smoke test suite completed.") diff --git a/mailgun/examples/suppressions_examples.py b/mailgun/examples/suppressions_examples.py index ff5a1d5..b0d4c1b 100644 --- a/mailgun/examples/suppressions_examples.py +++ b/mailgun/examples/suppressions_examples.py @@ -1,357 +1,802 @@ +"""Examples for managing Mailgun Suppressions (Bounces, Unsubscribes, Complaints, Whitelists).""" + +from __future__ import annotations + +import asyncio import json import os from pathlib import Path +from typing import Any -from mailgun.client import Client +from mailgun.client import AsyncClient, Client from mailgun.handlers.error_handler import UploadError -# The maximum message size Mailgun supports is 25MB, -# see https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-http#send-via-http -MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB +# The maximum message size Mailgun supports is 25MB +MAX_FILE_SIZE: int = 25 * 1024 * 1024 -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Suppressions: Bounces (Synchronous) +# ============================================================================== -# Bounces + +def delete_all_bounces_sync(api_key: str, domain: str) -> None: + """ + DELETE //bounces + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.bounces.delete(domain=domain) + print("DELETE All Bounces (Sync):", response.json()) -def get_bounces() -> None: +def delete_single_bounce_sync(api_key: str, domain: str, bounce_address: str) -> None: """ - GET //bounces - :return: + DELETE //bounces/
+ :return: None """ - req = client.bounces.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.bounces.delete(domain=domain, bounce_address=bounce_address) + print("DELETE Single Bounce (Sync):", response.json()) -def post_bounces() -> None: +def get_bounces_sync(api_key: str, domain: str) -> None: """ - POST //bounces - :return: + GET //bounces + :return: None """ - data = {"address": "test120@gmail.com", "code": 550, "error": "Test error"} - req = client.bounces.create(data=data, domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.bounces.get(domain=domain) + print("GET Bounces (Sync):", response.json()) -def get_single_bounce() -> None: +def get_single_bounce_sync(api_key: str, domain: str, bounce_address: str) -> None: """ GET //bounces/
- :return: + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.bounces.get(domain=domain, bounce_address=bounce_address) + print("GET Single Bounce (Sync):", response.json()) + + +def import_bounces_sync(api_key: str, domain: str, file_path: str) -> None: + """ + POST //bounces/import, Content-Type: multipart/form-data + :return: None + """ + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + if csv_filepath.stat().st_size > MAX_FILE_SIZE: + raise UploadError(f"File too large and exceeds the limit of {MAX_FILE_SIZE}") + + csv_data: bytes = csv_filepath.read_bytes() + if not csv_data: + raise ValueError("File is empty.") + + files: dict[str, bytes] = {"file": csv_data} + with Client(auth=("api", api_key)) as client: + response = client.bounces_import.create(domain=domain, files=files) + print("Import Bounces (Sync):", response.json()) + + +def post_bounce_sync(api_key: str, domain: str, bounce_address: str) -> None: + """ + POST //bounces + :return: None """ - req = client.bounces.get(domain=domain, bounce_address="test120@gmail.com") - print(req.json()) + data: dict[str, Any] = {"address": bounce_address, "code": 550, "error": "Test error"} + with Client(auth=("api", api_key)) as client: + response = client.bounces.create(data=data, domain=domain) + print("POST Bounce (Sync):", response.json()) -def add_multiple_bounces() -> None: +def post_multiple_bounces_sync(api_key: str, domain: str) -> None: """ POST //bounces, Content-Type: application/json - :return: + :return: None """ - data = """[{ + data: str = """[{ "address": "test121@i.ua", "code": "550", "error": "Test error2312" }, - { - "address": "test122@gmail.com", - "code": "550", - "error": "Test error" - }]""" - json_data = json.loads(data) - for address in json_data: - req = client.bounces.create( - data=address, domain=domain, headers={"Content-Type": "application/json"} - ) - print(req.json()) + { + "address": "test122@gmail.com", + "code": "550", + "error": "Test error" + }]""" + json_data: list[dict[str, Any]] = json.loads(data) + with Client(auth=("api", api_key)) as client: + for address_data in json_data: + response = client.bounces.create( + data=address_data, domain=domain, headers={"Content-Type": "application/json"} + ) + print("POST Multiple Bounces (Sync):", response.json()) + +# ============================================================================== +# Suppressions: Complaints (Synchronous) +# ============================================================================== -def import_bounce_list() -> None: + +def delete_all_complaints_sync(api_key: str, domain: str) -> None: """ - POST //bounces/import, Content-Type: multipart/form-data - :return: + DELETE //complaints/ + :return: None """ + with Client(auth=("api", api_key)) as client: + response = client.complaints.delete(domain=domain) + print("DELETE All Complaints (Sync):", response.json()) - csv_filepath = Path("mailgun/doc_tests/files/mailgun_bounces_test.csv") - if not csv_filepath: - raise FileNotFoundError(f"File {csv_filepath} not found.") +def delete_single_complaint_sync(api_key: str, domain: str, complaint_address: str) -> None: + """ + DELETE //complaints/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.complaints.delete(domain=domain, complaint_address=complaint_address) + print("DELETE Single Complaint (Sync):", response.json()) - if csv_filepath.stat().st_size > MAX_FILE_SIZE: - raise UploadError(f"File too large and exceeds the limit of {MAX_FILE_SIZE}") - # 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. - csv_data = csv_filepath.read_bytes() +def get_complaints_sync(api_key: str, domain: str) -> None: + """ + GET //complaints + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.complaints.get(domain=domain) + print("GET Complaints (Sync):", response.json()) - if not csv_data.startswith(b"") and not csv_data: - ValueError("File is empty.") - files = {"file": csv_data} - req = client.bounces_import.create(domain=domain, files=files) - print(req.json()) +def get_single_complaint_sync(api_key: str, domain: str, complaint_address: str) -> None: + """ + GET //complaints/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.complaints.get(domain=domain, complaint_address=complaint_address) + print("GET Single Complaint (Sync):", response.json()) -def delete_single_bounce() -> None: +def import_complaints_sync(api_key: str, domain: str, file_path: str) -> None: """ - DELETE //bounces/
- :return: + POST //complaints/import, Content-Type: multipart/form-data + :return: None """ - req = client.bounces.delete(domain=domain, bounce_address="test122@gmail.com") - print(req.json()) + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + files: dict[str, bytes] = {"complaints_csv": csv_filepath.read_bytes()} + with Client(auth=("api", api_key)) as client: + response = client.complaints_import.create(domain=domain, files=files) + print("Import Complaints (Sync):", response.json()) -def delete_bounce_list() -> None: +def post_complaint_sync(api_key: str, domain: str, complaint_address: str) -> None: """ - DELETE //bounces - :return: + POST //complaints + :return: None """ - req = client.bounces.delete(domain=domain) - print(req.json()) + data: dict[str, Any] = {"address": complaint_address, "tag": "compl_test_tag"} + with Client(auth=("api", api_key)) as client: + response = client.complaints.create(data=data, domain=domain) + print("POST Complaint (Sync):", response.json()) -# Unsubscribes +def post_multiple_complaints_sync(api_key: str, domain: str) -> None: + """ + POST //complaints, Content-Type: application/json + :return: None + """ + data: str = """[{ + "address": "alice1@example.com", + "tags": ["some tag"], + "created_at": "Thu, 13 Oct 2011 18:02:00 UTC" + }, + {"address": "carol1@example.com"}]""" + json_data: list[dict[str, Any]] = json.loads(data) + with Client(auth=("api", api_key)) as client: + for address_data in json_data: + response = client.complaints.create( + data=address_data, domain=domain, headers={"Content-Type": "application/json"} + ) + print("POST Multiple Complaints (Sync):", response.json()) + +# ============================================================================== +# Suppressions: Unsubscribes (Synchronous) +# ============================================================================== -def get_unsubs() -> None: + +def delete_all_unsubscribes_sync(api_key: str, domain: str) -> None: """ - GET //unsubscribes - :return: + DELETE //unsubscribes/ + :return: None """ - req = client.unsubscribes.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.unsubscribes.delete(domain=domain) + print("DELETE All Unsubscribes (Sync):", response.json()) + + +def delete_single_unsubscribe_sync(api_key: str, domain: str, unsubscribe_address: str) -> None: + """ + DELETE //unsubscribes/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.unsubscribes.delete( + domain=domain, unsubscribe_address=unsubscribe_address + ) + print("DELETE Single Unsubscribe (Sync):", response.json()) -def get_single_unsub() -> None: +def get_single_unsubscribe_sync(api_key: str, domain: str, unsubscribe_address: str) -> None: """ GET //unsubscribes/
- :return: + :return: None """ - req = client.unsubscribes.get(domain=domain, unsubscribe_address="test1@gmail.com") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.unsubscribes.get(domain=domain, unsubscribe_address=unsubscribe_address) + print("GET Single Unsubscribe (Sync):", response.json()) -def create_single_unsub() -> None: +def get_unsubscribes_sync(api_key: str, domain: str) -> None: """ - POST //unsubscribes - :return: + GET //unsubscribes + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.unsubscribes.get(domain=domain) + print("GET Unsubscribes (Sync):", response.json()) + + +def import_unsubscribes_sync(api_key: str, domain: str, file_path: str) -> None: + """ + POST //unsubscribes/import, Content-Type: multipart/form-data + :return: None """ - data = {"address": "bob@example.com", "tag": "*"} - req = client.unsubscribes.create(data=data, domain=domain) - print(req.json()) + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + files: dict[str, bytes] = {"unsubscribe_csv": csv_filepath.read_bytes()} + with Client(auth=("api", api_key)) as client: + response = client.unsubscribes_import.create(domain=domain, files=files) + print("Import Unsubscribes (Sync):", response.json()) -def create_multiple_unsub() -> None: +def post_multiple_unsubscribes_sync(api_key: str, domain: str) -> None: """ POST //unsubscribes, Content-Type: application/json - :return: - """ - data = """[{ - "address": "alice@example.com", - "tags": ["some tag"], - "created_at": "Thu, 13 Oct 2011 18:02:00 UTC" - }, - { - "address": "bob@example.com", - "tags": ["*"] - }, - {"address": "carol@example.com"}]""" - - json_data = json.loads(data) - for address in json_data: - req = client.unsubscribes.create( - data=address, domain=domain, headers={"Content-Type": "application/json"} - ) - print(req.json()) + :return: None + """ + data: str = """[{ + "address": "alice@example.com", + "tags": ["some tag"], + "created_at": "Thu, 13 Oct 2011 18:02:00 UTC" + }, + { + "address": "bob@example.com", + "tags": ["*"] + }, + {"address": "carol@example.com"}]""" + json_data: list[dict[str, Any]] = json.loads(data) + with Client(auth=("api", api_key)) as client: + for address_data in json_data: + response = client.unsubscribes.create( + data=address_data, domain=domain, headers={"Content-Type": "application/json"} + ) + print("POST Multiple Unsubscribes (Sync):", response.json()) -def import_list_unsubs() -> None: +def post_unsubscribe_sync(api_key: str, domain: str, unsubscribe_address: str) -> None: """ - POST //unsubscribes/import, Content-Type: multipart/form-data - :return: + POST //unsubscribes + :return: None """ - # 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 = { - "unsubscribe_csv": Path("mailgun/doc_tests/files/mailgun_unsubscribes.csv").read_bytes() - } - req = client.unsubscribes_import.create(domain=domain, files=files) - print(req.json()) + data: dict[str, Any] = {"address": unsubscribe_address, "tag": "*"} + with Client(auth=("api", api_key)) as client: + response = client.unsubscribes.create(data=data, domain=domain) + print("POST Unsubscribe (Sync):", response.json()) + +# ============================================================================== +# Suppressions: Whitelists (Synchronous) +# ============================================================================== -def delete_single_unsub() -> None: + +def delete_all_whitelists_sync(api_key: str, domain: str) -> None: """ - DELETE //unsubscribes/
- :return: + DELETE //whitelists + :return: None """ - req = client.unsubscribes.delete(domain=domain, unsubscribe_address="alice@example.com") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.whitelists.delete(domain=domain) + print("DELETE All Whitelists (Sync):", response.json()) -def delete_all_unsubs() -> None: +def delete_single_whitelist_sync(api_key: str, domain: str, whitelist_address: str) -> None: """ - DELETE //unsubscribes/ - :return: + DELETE //whitelists/
+ :return: None """ - req = client.unsubscribes.delete(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.whitelists.delete(domain=domain, whitelist_address=whitelist_address) + print("DELETE Single Whitelist (Sync):", response.json()) -# Complaints +def get_single_whitelist_sync(api_key: str, domain: str, whitelist_address: str) -> None: + """ + GET //whitelists/
+ :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.whitelists.get(domain=domain, whitelist_address=whitelist_address) + print("GET Single Whitelist (Sync):", response.json()) -def get_complaints() -> None: +def get_whitelists_sync(api_key: str, domain: str) -> None: """ - GET //complaints + GET //whitelists + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.whitelists.get(domain=domain) + print("GET Whitelists (Sync):", response.json()) + - :return: +def import_whitelists_sync(api_key: str, domain: str, file_path: str) -> None: """ - req = client.complaints.get(domain=domain) - print(req.json()) + POST //whitelists/import, Content-Type: multipart/form-data + :return: None + """ + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + files: dict[str, bytes] = {"whitelist_csv": csv_filepath.read_bytes()} + with Client(auth=("api", api_key)) as client: + response = client.whitelists_import.create(domain=domain, files=files) + print("Import Whitelists (Sync):", response.json()) -def add_complaints() -> None: +def post_whitelist_sync(api_key: str, domain: str, whitelist_address: str) -> None: """ - POST //complaints - :return: + POST //whitelists + :return: None """ - data = {"address": "bob@gmail.com", "tag": "compl_test_tag"} - req = client.complaints.create(data=data, domain=domain) - print(req.json()) + data: dict[str, Any] = {"address": whitelist_address, "tag": "whitel_test"} + with Client(auth=("api", api_key)) as client: + response = client.whitelists.create(data=data, domain=domain) + print("POST Whitelist (Sync):", response.json()) + +# ============================================================================== +# Suppressions: Bounces (Asynchronous) +# ============================================================================== -def get_single_complaint() -> None: + +async def delete_all_bounces_async(api_key: str, domain: str) -> None: """ - GET //complaints/
+ DELETE //bounces (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.bounces.delete(domain=domain) + print("DELETE All Bounces (Async):", response.json()) - :return: + +async def delete_single_bounce_async(api_key: str, domain: str, bounce_address: str) -> None: + """ + DELETE //bounces/
(Asynchronous) + :return: None """ - req = client.complaints.get(domain=domain, complaint_address="bob@gmail.com") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.bounces.delete(domain=domain, bounce_address=bounce_address) + print("DELETE Single Bounce (Async):", response.json()) -def add_multiple_complaints() -> None: +async def get_bounces_async(api_key: str, domain: str) -> None: """ - POST //complaints, Content-Type: application/json - :return: - """ - data = """[{ - "address": "alice1@example.com", - "tags": ["some tag"], - "created_at": "Thu, 13 Oct 2011 18:02:00 UTC" - }, - {"address": "carol1@example.com"}]""" - json_data = json.loads(data) - for address in json_data: - req = client.complaints.create( - data=address, domain=domain, headers={"Content-Type": "application/json"} + GET //bounces (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.bounces.get(domain=domain) + print("GET Bounces (Async):", response.json()) + + +async def get_single_bounce_async(api_key: str, domain: str, bounce_address: str) -> None: + """ + GET //bounces/
(Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.bounces.get(domain=domain, bounce_address=bounce_address) + print("GET Single Bounce (Async):", response.json()) + + +async def import_bounces_async(api_key: str, domain: str, file_path: str) -> None: + """ + POST //bounces/import (Asynchronous) + :return: None + """ + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + csv_data: bytes = csv_filepath.read_bytes() + if not csv_data: + raise ValueError("File is empty.") + + files: dict[str, bytes] = {"file": csv_data} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.bounces_import.create(domain=domain, files=files) + print("Import Bounces (Async):", response.json()) + + +async def post_bounce_async(api_key: str, domain: str, bounce_address: str) -> None: + """ + POST //bounces (Asynchronous) + :return: None + """ + data: dict[str, Any] = {"address": bounce_address, "code": 550, "error": "Test error"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.bounces.create(data=data, domain=domain) + print("POST Bounce (Async):", response.json()) + + +async def post_multiple_bounces_async(api_key: str, domain: str) -> None: + """ + POST //bounces, Content-Type: application/json (Asynchronous) + :return: None + """ + data: str = """[{ + "address": "test121@i.ua", + "code": "550", + "error": "Test error2312" + }, + { + "address": "test122@gmail.com", + "code": "550", + "error": "Test error" + }]""" + json_data: list[dict[str, Any]] = json.loads(data) + async with AsyncClient(auth=("api", api_key)) as client: + for address_data in json_data: + response = await client.bounces.create( + data=address_data, domain=domain, headers={"Content-Type": "application/json"} + ) + print("POST Multiple Bounces (Async):", response.json()) + + +# ============================================================================== +# Suppressions: Complaints (Asynchronous) +# ============================================================================== + + +async def delete_all_complaints_async(api_key: str, domain: str) -> None: + """ + DELETE //complaints/ (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.complaints.delete(domain=domain) + print("DELETE All Complaints (Async):", response.json()) + + +async def delete_single_complaint_async(api_key: str, domain: str, complaint_address: str) -> None: + """ + DELETE //complaints/
(Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.complaints.delete( + domain=domain, complaint_address=complaint_address ) - print(req.json()) + print("DELETE Single Complaint (Async):", response.json()) -def import_complaint_list() -> None: +async def get_complaints_async(api_key: str, domain: str) -> None: """ - POST //complaints/import, Content-Type: multipart/form-data - :return: + GET //complaints (Asynchronous) + :return: None """ - # 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 = {"complaints_csv": Path("mailgun/doc_tests/files/mailgun_complaints.csv").read_bytes()} - req = client.complaints_import.create(domain=domain, files=files) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.complaints.get(domain=domain) + print("GET Complaints (Async):", response.json()) -def delete_single_complaint() -> None: +async def get_single_complaint_async(api_key: str, domain: str, complaint_address: str) -> None: """ - DELETE //complaints/
- :return: + GET //complaints/
(Asynchronous) + :return: None """ - req = client.complaints.delete(domain=domain, complaint_address="carol1@example.com") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.complaints.get(domain=domain, complaint_address=complaint_address) + print("GET Single Complaint (Async):", response.json()) -def delete_all_complaints() -> None: +async def import_complaints_async(api_key: str, domain: str, file_path: str) -> None: """ - DELETE //complaints/ - :return: + POST //complaints/import (Asynchronous) + :return: None """ - req = client.complaints.delete(domain=domain) - print(req.json()) + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + files: dict[str, bytes] = {"complaints_csv": csv_filepath.read_bytes()} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.complaints_import.create(domain=domain, files=files) + print("Import Complaints (Async):", response.json()) -# Whitelists + +async def post_complaint_async(api_key: str, domain: str, complaint_address: str) -> None: + """ + POST //complaints (Asynchronous) + :return: None + """ + data: dict[str, Any] = {"address": complaint_address, "tag": "compl_test_tag"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.complaints.create(data=data, domain=domain) + print("POST Complaint (Async):", response.json()) -def get_whitelists() -> None: +async def post_multiple_complaints_async(api_key: str, domain: str) -> None: """ - GET //whitelists - :return: + POST //complaints, Content-Type: application/json (Asynchronous) + :return: None """ - req = client.whitelists.get(domain=domain) - print(req.json()) + data: str = """[{ + "address": "alice1@example.com", + "tags": ["some tag"], + "created_at": "Thu, 13 Oct 2011 18:02:00 UTC" + }, + {"address": "carol1@example.com"}]""" + json_data: list[dict[str, Any]] = json.loads(data) + async with AsyncClient(auth=("api", api_key)) as client: + for address_data in json_data: + response = await client.complaints.create( + data=address_data, domain=domain, headers={"Content-Type": "application/json"} + ) + print("POST Multiple Complaints (Async):", response.json()) + +# ============================================================================== +# Suppressions: Unsubscribes (Asynchronous) +# ============================================================================== -def create_whitelist() -> None: + +async def delete_all_unsubscribes_async(api_key: str, domain: str) -> None: """ - POST //whitelists - :return: + DELETE //unsubscribes/ (Asynchronous) + :return: None """ - data = {"address": "test122@gmail.com", "tag": "whitel_test"} - req = client.whitelists.create(data=data, domain=domain) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.unsubscribes.delete(domain=domain) + print("DELETE All Unsubscribes (Async):", response.json()) -def get_single_whitelist() -> None: +async def delete_single_unsubscribe_async( + api_key: str, domain: str, unsubscribe_address: str +) -> None: """ - GET //whitelists/
- :return: + DELETE //unsubscribes/
(Asynchronous) + :return: None """ - # You can set domain name or address for whitelist_address option - req = client.whitelists.get(domain=domain, whitelist_address="bob@gmail.com") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.unsubscribes.delete( + domain=domain, unsubscribe_address=unsubscribe_address + ) + print("DELETE Single Unsubscribe (Async):", response.json()) -def import_list_whitelists() -> None: +async def get_single_unsubscribe_async(api_key: str, domain: str, unsubscribe_address: str) -> None: """ - POST //whitelists/import, Content-Type: multipart/form-data - :return: + GET //unsubscribes/
(Asynchronous) + :return: None """ - # 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 = {"whitelist_csv": Path("mailgun/doc_tests/files/mailgun_whitelists.csv").read_bytes()} - req = client.whitelists_import.create(domain=domain, files=files) - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.unsubscribes.get( + domain=domain, unsubscribe_address=unsubscribe_address + ) + print("GET Single Unsubscribe (Async):", response.json()) -def delete_single_whitelist() -> None: +async def get_unsubscribes_async(api_key: str, domain: str) -> None: """ - DELETE //whitelists/
- :return: + GET //unsubscribes (Asynchronous) + :return: None """ - req = client.whitelists.delete(domain=domain, whitelist_address="bob@gmail.com") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.unsubscribes.get(domain=domain) + print("GET Unsubscribes (Async):", response.json()) -def delete_all_whitelists() -> None: +async def import_unsubscribes_async(api_key: str, domain: str, file_path: str) -> None: """ - DELETE //whitelists - :return: + POST //unsubscribes/import (Asynchronous) + :return: None + """ + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + files: dict[str, bytes] = {"unsubscribe_csv": csv_filepath.read_bytes()} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.unsubscribes_import.create(domain=domain, files=files) + print("Import Unsubscribes (Async):", response.json()) + + +async def post_multiple_unsubscribes_async(api_key: str, domain: str) -> None: + """ + POST //unsubscribes, Content-Type: application/json (Asynchronous) + :return: None + """ + data: str = """[{ + "address": "alice@example.com", + "tags": ["some tag"], + "created_at": "Thu, 13 Oct 2011 18:02:00 UTC" + }, + { + "address": "bob@example.com", + "tags": ["*"] + }, + {"address": "carol@example.com"}]""" + json_data: list[dict[str, Any]] = json.loads(data) + async with AsyncClient(auth=("api", api_key)) as client: + for address_data in json_data: + response = await client.unsubscribes.create( + data=address_data, domain=domain, headers={"Content-Type": "application/json"} + ) + print("POST Multiple Unsubscribes (Async):", response.json()) + + +async def post_unsubscribe_async(api_key: str, domain: str, unsubscribe_address: str) -> None: + """ + POST //unsubscribes (Asynchronous) + :return: None + """ + data: dict[str, Any] = {"address": unsubscribe_address, "tag": "*"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.unsubscribes.create(data=data, domain=domain) + print("POST Unsubscribe (Async):", response.json()) + + +# ============================================================================== +# Suppressions: Whitelists (Asynchronous) +# ============================================================================== + + +async def delete_all_whitelists_async(api_key: str, domain: str) -> None: + """ + DELETE //whitelists (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.whitelists.delete(domain=domain) + print("DELETE All Whitelists (Async):", response.json()) + + +async def delete_single_whitelist_async(api_key: str, domain: str, whitelist_address: str) -> None: + """ + DELETE //whitelists/
(Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.whitelists.delete( + domain=domain, whitelist_address=whitelist_address + ) + print("DELETE Single Whitelist (Async):", response.json()) + + +async def get_single_whitelist_async(api_key: str, domain: str, whitelist_address: str) -> None: + """ + GET //whitelists/
(Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.whitelists.get(domain=domain, whitelist_address=whitelist_address) + print("GET Single Whitelist (Async):", response.json()) + + +async def get_whitelists_async(api_key: str, domain: str) -> None: + """ + GET //whitelists (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.whitelists.get(domain=domain) + print("GET Whitelists (Async):", response.json()) + + +async def import_whitelists_async(api_key: str, domain: str, file_path: str) -> None: + """ + POST //whitelists/import (Asynchronous) + :return: None + """ + csv_filepath = Path(file_path) + if not csv_filepath.exists(): + print(f"File {csv_filepath} not found. Skipping import.") + return + + files: dict[str, bytes] = {"whitelist_csv": csv_filepath.read_bytes()} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.whitelists_import.create(domain=domain, files=files) + print("Import Whitelists (Async):", response.json()) + + +async def post_whitelist_async(api_key: str, domain: str, whitelist_address: str) -> None: + """ + POST //whitelists (Asynchronous) + :return: None """ - req = client.whitelists.delete(domain=domain) - print(req.json()) + data: dict[str, Any] = {"address": whitelist_address, "tag": "whitel_test"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.whitelists.create(data=data, domain=domain) + print("POST Whitelist (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - import_bounce_list() - delete_single_whitelist() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + # Identifiers mapping to your original business logic + BOUNCE_ADDR: str = "foo@example.com" + UNSUB_ADDR: str = "bar@example.com" + COMPLAINT_ADDR: str = "bob@example.com" + WHITELIST_ADDR: str = "test@example.com" + + # Dummy file paths extracted from original logic + BOUNCES_FILE: str = "mailgun/doc_tests/files/mailgun_bounces_test.csv" + UNSUBS_FILE: str = "mailgun/doc_tests/files/mailgun_unsubscribes.csv" + COMPLAINTS_FILE: str = "mailgun/doc_tests/files/mailgun_complaints.csv" + WHITELISTS_FILE: str = "mailgun/doc_tests/files/mailgun_whitelists.csv" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + # Pre-seed paths so missing files don't crash execution if paths don't exist locally + Path("mailgun/doc_tests/files").mkdir(parents=True, exist_ok=True) + for filepath in (BOUNCES_FILE, UNSUBS_FILE, COMPLAINTS_FILE, WHITELISTS_FILE): + if not Path(filepath).exists(): + Path(filepath).write_bytes(b"address,code,error\nplaceholder@example.com,550,test") + + print("--- Running Synchronous Examples ---") + import_bounces_sync(api_key=API_KEY, domain=DOMAIN, file_path=BOUNCES_FILE) + delete_single_whitelist_sync( + api_key=API_KEY, domain=DOMAIN, whitelist_address=WHITELIST_ADDR + ) + + # Other Sync operations you may uncomment to test: + # post_bounce_sync(api_key=API_KEY, domain=DOMAIN, bounce_address=BOUNCE_ADDR) + # get_bounces_sync(api_key=API_KEY, domain=DOMAIN) + # delete_all_bounces_sync(api_key=API_KEY, domain=DOMAIN) + # post_complaint_sync(api_key=API_KEY, domain=DOMAIN, complaint_address=COMPLAINT_ADDR) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(import_bounces_async(api_key=API_KEY, domain=DOMAIN, file_path=BOUNCES_FILE)) + asyncio.run( + delete_single_whitelist_async( + api_key=API_KEY, domain=DOMAIN, whitelist_address=WHITELIST_ADDR + ) + ) diff --git a/mailgun/examples/tags_examples.py b/mailgun/examples/tags_examples.py index b501b95..d2ffa55 100644 --- a/mailgun/examples/tags_examples.py +++ b/mailgun/examples/tags_examples.py @@ -1,88 +1,229 @@ -import os +"""Examples for managing Mailgun Tags and fetching Tag Statistics.""" + +from __future__ import annotations -from mailgun.client import Client +import asyncio +import os +from typing import Any +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Tag Management (Synchronous) +# ============================================================================== -def get_tags() -> None: +def delete_tag_sync(api_key: str, domain: str, tag_name: str) -> None: """ - GET //tags - :return: + DELETE //tags/ + :return: None """ - req = client.tags.get(domain=domain) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.tags.delete(domain=domain, tag_name=tag_name) + print("DELETE Tag (Sync):", response.json()) -def get_single_tag() -> None: +def get_single_tag_sync(api_key: str, domain: str, tag_name: str) -> None: """ GET //tags/ - :return: + :return: None """ - req = client.tags.get(domain=domain, tag_name="Python test") - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.tags.get(domain=domain, tag_name=tag_name) + print("GET Single Tag (Sync):", response.json()) -def put_single_tag() -> None: +def get_tags_sync(api_key: str, domain: str) -> None: + """ + GET //tags + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.tags.get(domain=domain) + print("GET Tags (Sync):", response.json()) + + +def put_single_tag_sync(api_key: str, domain: str, tag_name: str) -> None: """ PUT //tags/ - :return: + :return: None + """ + data: dict[str, Any] = {"description": "Python testtt"} + with Client(auth=("api", api_key)) as client: + response = client.tags.put(domain=domain, tag_name=tag_name, data=data) + print("PUT Single Tag (Sync):", response.json()) + + +# ============================================================================== +# Tag Statistics & Aggregates (Synchronous) +# ============================================================================== + + +def get_aggregate_countries_sync(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats/aggregates/countries + :return: None """ - data = {"description": "Python testtt"} + with Client(auth=("api", api_key)) as client: + response = client.tags_stats_aggregates_countries.get(domain=domain, tag_name=tag_name) + print("GET Aggregate Countries (Sync):", response.json()) - req = client.tags.put(domain=domain, tag_name="Python test", data=data) - print(req.json()) + +def get_aggregate_devices_sync(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats/aggregates/devices + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.tags_stats_aggregates_devices.get(domain=domain, tag_name=tag_name) + print("GET Aggregate Devices (Sync):", response.json()) + + +def get_aggregate_providers_sync(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats/aggregates/providers + :return: None + """ + with Client(auth=("api", api_key)) as client: + response = client.tags_stats_aggregates_providers.get(domain=domain, tag_name=tag_name) + print("GET Aggregate Providers (Sync):", response.json()) -def get_tag_stats() -> None: +def get_tag_stats_sync(api_key: str, domain: str, tag_name: str) -> None: """ GET //tags//stats - :return: + :return: None """ - params = {"event": "accepted"} - req = client.tags_stats.get(domain=domain, filters=params, tag_name="Python test") - print(req.json()) + filters: dict[str, str] = {"event": "accepted"} + with Client(auth=("api", api_key)) as client: + response = client.tags_stats.get(domain=domain, filters=filters, tag_name=tag_name) + print("GET Tag Stats (Sync):", response.json()) -def delete_tag() -> None: +# ============================================================================== +# Tag Management (Asynchronous) +# ============================================================================== + + +async def delete_tag_async(api_key: str, domain: str, tag_name: str) -> None: """ - DELETE //tags/ - :return: + DELETE //tags/ (Asynchronous) + :return: None """ - req = client.tags.delete(domain=domain, tag_name="Python test") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags.delete(domain=domain, tag_name=tag_name) + print("DELETE Tag (Async):", response.json()) -def get_aggregate_countries() -> None: +async def get_single_tag_async(api_key: str, domain: str, tag_name: str) -> None: """ - GET //tags//stats/aggregates/countries - :return: + GET //tags/ (Asynchronous) + :return: None """ - req = client.tags_stats_aggregates_countries.get(domain=domain, tag_name="September newsletter") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags.get(domain=domain, tag_name=tag_name) + print("GET Single Tag (Async):", response.json()) -def get_aggregate_providers() -> None: +async def get_tags_async(api_key: str, domain: str) -> None: """ - GET //tags//stats/aggregates/providers - :return: + GET //tags (Asynchronous) + :return: None """ - req = client.tags_stats_aggregates_providers.get(domain=domain, tag_name="September newsletter") - print(req.json()) + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags.get(domain=domain) + print("GET Tags (Async):", response.json()) -def get_aggregate_devices() -> None: +async def put_single_tag_async(api_key: str, domain: str, tag_name: str) -> None: """ - GET //tags//stats/aggregates/devices - :return: + PUT //tags/ (Asynchronous) + :return: None """ - req = client.tags_stats_aggregates_devices.get(domain=domain, tag_name="September newsletter") - print(req.json()) + data: dict[str, Any] = {"description": "Python testtt"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags.put(domain=domain, tag_name=tag_name, data=data) + print("PUT Single Tag (Async):", response.json()) + + +# ============================================================================== +# Tag Statistics & Aggregates (Asynchronous) +# ============================================================================== + + +async def get_aggregate_countries_async(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats/aggregates/countries (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags_stats_aggregates_countries.get( + domain=domain, tag_name=tag_name + ) + print("GET Aggregate Countries (Async):", response.json()) + + +async def get_aggregate_devices_async(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats/aggregates/devices (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags_stats_aggregates_devices.get(domain=domain, tag_name=tag_name) + print("GET Aggregate Devices (Async):", response.json()) + + +async def get_aggregate_providers_async(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats/aggregates/providers (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags_stats_aggregates_providers.get( + domain=domain, tag_name=tag_name + ) + print("GET Aggregate Providers (Async):", response.json()) + + +async def get_tag_stats_async(api_key: str, domain: str, tag_name: str) -> None: + """ + GET //tags//stats (Asynchronous) + :return: None + """ + filters: dict[str, str] = {"event": "accepted"} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.tags_stats.get(domain=domain, filters=filters, tag_name=tag_name) + print("GET Tag Stats (Async):", response.json()) + +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - get_aggregate_devices() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + DOMAIN: str = os.environ.get("DOMAIN", "") + + TEST_TAG: str = "Python test" + TAG: str = "September newsletter" + + if not API_KEY or not DOMAIN: + print("Please set the 'APIKEY' and 'DOMAIN' environment variables to run examples.") + else: + print("--- Running Synchronous Examples ---") + get_aggregate_devices_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TAG) + + # Additional Sync tests to execute: + # get_tags_sync(api_key=API_KEY, domain=DOMAIN) + # put_single_tag_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TEST_TAG) + # get_single_tag_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TEST_TAG) + # get_tag_stats_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TEST_TAG) + # get_aggregate_countries_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TAG) + # get_aggregate_providers_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TAG) + # delete_tag_sync(api_key=API_KEY, domain=DOMAIN, tag_name=TEST_TAG) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(get_aggregate_devices_async(api_key=API_KEY, domain=DOMAIN, tag_name=TAG)) + # asyncio.run(get_tags_async(api_key=API_KEY, domain=DOMAIN)) diff --git a/mailgun/examples/tags_new_examples.py b/mailgun/examples/tags_new_examples.py index ebbc2af..478a852 100644 --- a/mailgun/examples/tags_new_examples.py +++ b/mailgun/examples/tags_new_examples.py @@ -1,71 +1,159 @@ +"""Examples for managing Mailgun Analytics Tags (New).""" + +from __future__ import annotations + +import asyncio import os +from typing import Any -from mailgun.client import Client +from mailgun.client import AsyncClient, Client -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] -client: Client = Client(auth=("api", key)) +# ============================================================================== +# Analytics Tags Management (Synchronous) +# ============================================================================== -def post_analytics_tags() -> None: +def delete_analytics_tags_sync(api_key: str, tag_name: str) -> None: """ # Metrics - # POST /v1/analytics/tags - :return: + # DELETE /v1/analytics/tags + :return: None + """ + data: dict[str, str] = {"tag": tag_name} + with Client(auth=("api", api_key)) as client: + response = client.analytics_tags.delete(data=data) + print("DELETE Analytics Tags (Sync):", response.json()) + + +def get_account_analytics_tag_limit_information_sync(api_key: str) -> None: + """ + # Metrics + # GET /v1/analytics/tags/limits + :return: None """ + with Client(auth=("api", api_key)) as client: + response = client.analytics_tags_limits.get() + print("GET Analytics Tag Limits (Sync):", response.json()) - data = { + +def post_analytics_tags_sync(api_key: str) -> None: + """ + # Metrics + # POST /v1/analytics/tags + :return: None + """ + data: dict[str, Any] = { "pagination": {"sort": "lastseen:desc", "limit": 10}, "include_subaccounts": True, } - - req = client.analytics_tags.create(data=data) - print(req.json()) + with Client(auth=("api", api_key)) as client: + response = client.analytics_tags.create(data=data) + print("POST Analytics Tags (Sync):", response.json()) -def update_analytics_tags() -> None: +def update_analytics_tags_sync(api_key: str, tag_name: str, description: str) -> None: """ # Metrics # PUT /v1/analytics/tags - :return: + :return: None """ - - data = { - "tag": "name-of-tag-to-update", - "description": "updated tag description", + data: dict[str, str] = { + "tag": tag_name, + "description": description, } + with Client(auth=("api", api_key)) as client: + response = client.analytics_tags.update(data=data) + print("PUT Analytics Tags (Sync):", response.json()) + - req = client.analytics_tags.update(data=data) - print(req.json()) +# ============================================================================== +# Analytics Tags Management (Asynchronous) +# ============================================================================== -def delete_analytics_tags() -> None: +async def delete_analytics_tags_async(api_key: str, tag_name: str) -> None: """ # Metrics - # DELETE /v1/analytics/tags - :return: + # DELETE /v1/analytics/tags (Asynchronous) + :return: None """ + data: dict[str, str] = {"tag": tag_name} + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_tags.delete(data=data) + print("DELETE Analytics Tags (Async):", response.json()) - data = {"tag": "name-of-tag-to-delete"} - req = client.analytics_tags.delete(data=data) - print(req.json()) +async def get_account_analytics_tag_limit_information_async(api_key: str) -> None: + """ + # Metrics + # GET /v1/analytics/tags/limits (Asynchronous) + :return: None + """ + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_tags_limits.get() + print("GET Analytics Tag Limits (Async):", response.json()) -def get_account_analytics_tag_limit_information() -> None: +async def post_analytics_tags_async(api_key: str) -> None: """ # Metrics - # GET /v1/analytics/tags/limits - :return: + # POST /v1/analytics/tags (Asynchronous) + :return: None + """ + data: dict[str, Any] = { + "pagination": {"sort": "lastseen:desc", "limit": 10}, + "include_subaccounts": True, + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_tags.create(data=data) + print("POST Analytics Tags (Async):", response.json()) + + +async def update_analytics_tags_async(api_key: str, tag_name: str, description: str) -> None: + """ + # Metrics + # PUT /v1/analytics/tags (Asynchronous) + :return: None """ + data: dict[str, str] = { + "tag": tag_name, + "description": description, + } + async with AsyncClient(auth=("api", api_key)) as client: + response = await client.analytics_tags.update(data=data) + print("PUT Analytics Tags (Async):", response.json()) - req = client.analytics_tags_limits.get() - print(req.json()) +# ============================================================================== +# Execution +# ============================================================================== if __name__ == "__main__": - post_analytics_tags() - update_analytics_tags() - delete_analytics_tags() - get_account_analytics_tag_limit_information() + # Securely load environment variables at runtime + API_KEY: str = os.environ.get("APIKEY", "") + + TAG_NAME_TO_DELETE: str = "name-of-tag-to-delete" + TAG_NAME_TO_UPDATE: str = "name-of-tag-to-update" + UPDATED_DESCRIPTION: str = "updated tag description" + + if not API_KEY: + print("Please set the 'APIKEY' environment variable to run examples.") + else: + print("--- Running Synchronous Examples ---") + post_analytics_tags_sync(api_key=API_KEY) + update_analytics_tags_sync( + api_key=API_KEY, tag_name=TAG_NAME_TO_UPDATE, description=UPDATED_DESCRIPTION + ) + delete_analytics_tags_sync(api_key=API_KEY, tag_name=TAG_NAME_TO_DELETE) + get_account_analytics_tag_limit_information_sync(api_key=API_KEY) + + print("\n--- Running Asynchronous Examples ---") + asyncio.run(post_analytics_tags_async(api_key=API_KEY)) + asyncio.run( + update_analytics_tags_async( + api_key=API_KEY, tag_name=TAG_NAME_TO_UPDATE, description=UPDATED_DESCRIPTION + ) + ) + asyncio.run(delete_analytics_tags_async(api_key=API_KEY, tag_name=TAG_NAME_TO_DELETE)) + asyncio.run(get_account_analytics_tag_limit_information_async(api_key=API_KEY)) diff --git a/mailgun/examples/templates_examples.py b/mailgun/examples/templates_examples.py index f9da8c8..923144e 100644 --- a/mailgun/examples/templates_examples.py +++ b/mailgun/examples/templates_examples.py @@ -1,167 +1,318 @@ +""" +Examples for managing Mailgun Templates and Template Builders. + +This file demonstrates the strict API separation between: +1. Domain Templates (V3 API): Scoped to a specific domain (`client.templates`) +2. Account Templates (V4 API): Scoped globally to the account (`client.account_templates`) +""" + +from __future__ import annotations + import os +import sys +import uuid +from typing import Any +from mailgun.builders import MailgunTemplateBuilder from mailgun.client import Client +# ============================================================================== +# HELPER FUNCTIONS +# ============================================================================== + -key: str = os.environ["APIKEY"] -domain: str = os.environ["DOMAIN"] +def domain_template_exists(client: Client, domain: str, template_name: str) -> bool: + """Check if a V3 Domain template exists.""" + try: + client.templates.get(domain=domain, template_name=template_name) + return True + except Exception: + return False -client: Client = Client(auth=("api", key)) +def account_template_exists(client: Client, template_name: str) -> bool: + """Check if a V4 Account template exists.""" + try: + client.account_templates.get(template_name=template_name) + return True + except Exception: + return False -def post_template() -> None: - """ - POST //templates - :return: - """ - data = { - "name": "template.name1", + +# ============================================================================== +# PART 1: DOMAIN TEMPLATES (V3 API) - SYNCHRONOUS +# These operations REQUIRE the `domain` parameter. +# ============================================================================== + + +def create_domain_template_sync(api_key: str, domain: str, template_name: str) -> None: + """POST /v3//templates""" + data: dict[str, Any] = { + "name": template_name, "description": "template description", "template": "{{fname}} {{lname}}", "engine": "handlebars", "comment": "version comment", } - - req = client.templates.create(data=data, domain=domain) - print(req.json()) - - -def get_template() -> None: - """ - GET //templates/ - :return: - """ - params = {"active": "yes"} - req = client.templates.get(domain=domain, filters=params, template_name="template.name1") - print(req.json()) - - -def update_template() -> None: - """ - PUT //templates/ - :return: - """ - data = {"description": "new template description"} - - req = client.templates.put(data=data, domain=domain, template_name="template.name1") - print(req.json()) - - -def delete_template() -> None: - """ - DELETE //templates/ - :return: - """ - req = client.templates.delete(domain=domain, template_name="template.name1") - print(req.json()) - - -def get_domain_templates() -> None: - """ - GET //templates - :return: - """ - params = {"limit": 1} - req = client.templates.get(domain=domain, filters=params) - print(req.json()) - - -def delete_templates() -> None: - """ - DELETE //templates - :return: - """ - req = client.templates.delete(domain=domain) - print(req.json()) - - -def create_new_template_version() -> None: - """ - POST //templates/