diff --git a/.github/workflows/cflite_build.yml b/.github/workflows/cflite_build.yml
deleted file mode 100644
index 9c49958..0000000
--- a/.github/workflows/cflite_build.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: ClusterFuzzLite continuous builds
-on:
- push:
- branches:
- - main
-permissions: read-all
-jobs:
- Build:
- runs-on: ubuntu-latest
- concurrency:
- group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }}
- cancel-in-progress: true
- strategy:
- fail-fast: false
- matrix:
- sanitizer:
- - address
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- with:
- fetch-depth: 0
- persist-credentials: false
- - name: Build Fuzzers (${{ matrix.sanitizer }})
- id: build
- uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1
- with:
- language: python
- sanitizer: ${{ matrix.sanitizer }}
- upload-build: true
diff --git a/.github/workflows/cflite_cron.yml b/.github/workflows/cflite_cron.yml
deleted file mode 100644
index c5e47fa..0000000
--- a/.github/workflows/cflite_cron.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: ClusterFuzzLite cron tasks
-
-on:
- schedule:
- - cron: '0 0 * * *' # Runs daily at midnight
-permissions:
- contents: read
-
-jobs:
- Pruning:
- runs-on: ubuntu-latest
- steps:
- - name: Build Fuzzers
- id: build
- uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1
- with:
- language: python
- - name: Run Fuzzers
- id: run
- uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- fuzz-seconds: 600
- mode: 'prune'
- output-sarif: true
-
- Coverage:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- with:
- fetch-depth: 0
- persist-credentials: false
-
- - name: Build Fuzzers
- id: build
- uses: google/clusterfuzzlite/actions/build_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1
- with:
- language: python
- sanitizer: coverage
- - name: Run Fuzzers
- id: run
- uses: google/clusterfuzzlite/actions/run_fuzzers@52ecc61cb587ee99c26825a112a21abf19c7448c # v1
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- fuzz-seconds: 600
- mode: 'coverage'
- sanitizer: 'coverage'
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8787ef2..a15ac36 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -105,7 +105,7 @@ repos:
# Git commit quality
- repo: https://github.com/commitizen-tools/commitizen
- rev: v4.17.0
+ rev: v4.18.0
hooks:
- id: commitizen
name: "🌳 git · Validate commit message"
@@ -134,7 +134,7 @@ repos:
additional_dependencies: [".[toml]"]
- repo: https://github.com/semgrep/pre-commit
- rev: 'v1.173.0'
+ rev: 'v1.177.0'
hooks:
- id: semgrep
name: "🔒 security · Static analysis (semgrep)"
@@ -145,16 +145,17 @@ repos:
hooks:
- id: pip-audit
name: "🔒 security · Audit Python dependencies"
+ additional_dependencies: ["pip>=26.2"]
- repo: https://github.com/woodruffw/zizmor-pre-commit
- rev: v1.29.0
+ rev: v1.30.1
hooks:
- id: zizmor
name: "🔒 security · Scan GitHub Actions (zizmor)"
# Spelling and typos
- repo: https://github.com/crate-ci/typos
- rev: v1.49.0
+ rev: v1.50.1
hooks:
- id: typos
name: "📝 spelling · Check typos"
@@ -170,7 +171,7 @@ repos:
files: ^\.github/workflows/.*\.ya?ml$
- repo: https://github.com/ariebovenberg/slotscheck
- rev: v0.20.1
+ rev: v0.21.0
hooks:
- id: slotscheck
name: "🔍 check · slotscheck"
@@ -183,7 +184,7 @@ repos:
- hypothesis
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.16.3
+ rev: v0.16.7
hooks:
- id: ruff-check
name: "🐍 lint · Check with Ruff"
@@ -213,14 +214,14 @@ repos:
exclude: ^samples/
- repo: https://github.com/RobertCraigie/pyright-python
- rev: v1.1.411
+ rev: v1.1.414
hooks:
- id: pyright
name: "🐍 types · Check with pyright"
# Python project configuration
- repo: https://github.com/abravalheri/validate-pyproject
- rev: v0.25
+ rev: '0.26'
hooks:
- id: validate-pyproject
name: "🐍 config · Validate pyproject.toml"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c289002..a3469ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,48 @@ ______________________________________________________________________
## [Unreleased]
-______________________________________________________________________
+### Added
+
+- **Static O(1) Route Additions:** Registered missing static route definitions in `ROUTE_MAP` for `contactslist_csvdata` and `batchjob_csverror`.
+- **Sub-Action Hyphenation:** Re-implemented native CamelCase-to-kebab-case transformation for sub-actions in `Endpoint.__init__` (e.g., `statistics_linkClick` $\\rightarrow$ `statistics/link-click`).
+- **Stream Query Parameter Casting:** Added `Endpoint._cast_query_param` and `_normalize_stream_filters` to automatically parse and cast multidict query filter values (such as those from `urllib.parse.parse_qs`) to `int`, `float`, `bool`, `list`, `tuple`, or `set`.
+- **Custom Headers Support:** Added optional `headers` parameter to `Endpoint.__call__`, `create()`, and `update()`.
+- **Fuzz Dictionary Expansion:** Added route tokens, streaming keywords, HTML/XSS triggers, IDN tags, and secret patterns to `tests/fuzz/fuzzer.dict`.
+
+### Changed
+
+- **Stream Generator Termination:** Enhanced `Endpoint.stream()` to check `Total` from response bodies, automatically halting iteration when `current_offset + len(data) >= total`.
+- **Redaction of Complex Types:** Expanded `RedactingFilter` to recursively sanitize `typing.NamedTuple` instances (preserving structure), objects with `model_dump()` (e.g., Pydantic models), `__dict__`-based instances, and `set`/`frozenset` collections.
+- **Path Control Character Neutralization:** `SecurityGuard.sanitize_log_trace` now cleans unprintable ASCII control characters (`[\x00-\x1f\x7f]`) before whitespace normalization.
+- **Email IDN Parsing:** Switched IDN extraction in `SecurityGuard.normalize_domain` to `rpartition("@")` and scoped exception interception directly to `UnicodeError`.
+- **Sample Updates:** Refactored `samples/segments_sample.py` to use `campaigndraft` and updated `samples/smoke_readme_runner.py` health checks to use canonical endpoints `eventcallbackurl` and `template_contents`.
+
+### Deprecated
+
+- **Deprecation Advisory Mapping:** Introduced `DEPRECATION_ADVISORY` in `routes.py` to emit non-breaking `DeprecationWarning` notices pointing to recommended replacements:
+ - `newsletter` and sub-resources (`newsletter_*`) $\\rightarrow$ `campaigndraft` / `campaigndraft_*`
+ - Legacy statistics (`apikeytotals`, `campaignstatistics`, `liststatistics`, `domainstatistics`, etc.) $\\rightarrow$ `statcounters` and `statistics_recipientEsp`
+ - Removed SDK alias `webhook` $\\rightarrow$ official REST resource `eventcallbackurl`
+ - Legacy template endpoints (`template_update`, `templates_contents`) $\\rightarrow$ `template.update(id=...)` or `template_detailcontent` (v3) / `template_contents` (v1)
+
+### Security
+
+- **Strict Timeout Type Guard:** Explicitly blocked boolean flags (`True`/`False`) in `SecurityGuard.validate_timeout` to prevent coercion to numeric `1.0`/`0.0` seconds.
+- **Regular File Validation (CWE-400):** Added explicit `Path.is_file()` verification in `SecurityGuard.check_file_size` before evaluating file stats.
+- **Header Injection Screen (CWE-113):** Broadened header sanitization in `Client.api_call` to accept `Mapping[str, str | None]` and screen non-`None` values against CRLF injection patterns.
+
+### Removed
+
+- **ClusterFuzzLite Workflows:** Removed redundant `.github/workflows/cflite_build.yml` and `.github/workflows/cflite_cron.yml` CI tasks.
+
+### Pull Requests Merged
+
+- PR #149: Deprecate endpoints.
+- PR #148: build(deps): bump github/codeql-action/upload-sarif from 4.37.7 to 4.37.9.
+- PR #147: build(deps): bump github/codeql-action/analyze from 4.37.7 to 4.37.9.
+- PR #146: build(deps): bump github/codeql-action/analyze from 4.37.6 to 4.37.7.
+- PR #145: build(deps): bump github/codeql-action/upload-sarif from 4.37.6 to 4.37.7.
+- PR #144: build(deps): bump google/osv-scanner-action/osv-scanner-action from 2.5.0 to 2.5.1.
## [1.8.0] - 2026-08-17
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
index a27f385..f6eca29 100644
--- a/PERFORMANCE.md
+++ b/PERFORMANCE.md
@@ -68,6 +68,23 @@ The modern v1.8.0 release incorporates security hardening, input sanitization, a
*Note: Benchmarks measure network-isolated internal overhead using mocked `responses`. Testing hardware: Darwin-CPython-3.12-64bit.*
+### v1.8.0 vs. Current Refined (v1.9.0)
+
+Version 1.9.0 introduces route deprecation advisories, dynamic stream parameter casting, and custom header forwarding without adding measurable latency to the request execution hot path.
+
+| Metric | Baseline (v1.8.0) | Current Refined (v1.9.0) | Delta / Notes |
+| :---------------------------------- | :---------------- | :----------------------- | :------------------------------------ |
+| **Routing Speed (Mean)** | ~87.15 ns | **~87.25 ns** | **Identical (>11.4 Million Ops/sec)** |
+| **Routing Speed (Min)** | ~76.25 ns | **~76.25 ns** | **Zero Latency Regression** |
+| **Routing Ops/Sec** | ~11,475 Kops/s | **~11,461 Kops/s** | **High Throughput Maintained** |
+| **Request Cycle (Mean)** | ~177.46 µs | **~180.98 µs** | **Stable (\<2% Mock Delta)** |
+| **Cold-Boot Init Time** | ~0.142 s | **~0.102 s** | **~28.2% Profiler Speedup** |
+| **Wall-Clock Startup Time** | ~0.194 s | **~0.152 s** | **~21.3% Faster Startup** |
+| **Message Builder Validation** | ~1.17 µs | **~1.17 µs** | **Stable** |
+| **Idempotency Fingerprint Hashing** | ~2.98 µs | **~2.99 µs** | **Stable** |
+
+*Note: Benchmarks measure network-isolated internal overhead using mocked `responses`. Testing hardware: Apple M4 Pro, Darwin-CPython-3.12-64bit.*
+
______________________________________________________________________
## Profiling the Codebase
diff --git a/README.md b/README.md
index ecb7a90..0cfa649 100644
--- a/README.md
+++ b/README.md
@@ -518,7 +518,8 @@ message = (
.add_cc("copilot@mailjet.com")
.set_subject("Your Boarding Pass")
.set_content(html="
Welcome aboard!
")
- .attach_file("tickets/pass.pdf") # Safely encodes using memory-efficient ChunkedStreamer
+ # Safely encodes using memory-efficient ChunkedStreamer
+ .attach_file("tickets/pass.pdf")
.attach_inline("assets/logo.png") # Adds inline attachments seamlessly
.build()
)
@@ -551,6 +552,16 @@ result = mailjet.contact.create(data=data)
print(result.json())
```
+The support for explicit per-request custom HTTP headers (e.g. tracking or custom metadata) across `create()`, `update()`, and direct endpoint calls.
+
+```python
+# Pass custom per-request headers (screened for CRLF safety)
+result = mailjet.contact.create(
+ data={"Email": "pilot@mailjet.com"},
+ headers={"X-Custom-Source": "Onboarding-Service"},
+)
+```
+
##### Using actions
```python
@@ -639,6 +650,17 @@ for contact in mailjet.contact.stream(chunk_size=500):
print(contact["Email"])
```
+Resuming pagination from an existing offset or raw query parameters
+
+```python
+from urllib.parse import parse_qs
+
+# Seamlessly handles multidicts from parse_qs (offset, limit)
+query = parse_qs("offset=500&limit=100")
+for contact in mailjet.contact.stream(filters=query, chunk_size=100):
+ 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.
@@ -786,6 +808,15 @@ The SDK includes an active native Python deprecation system to protect your appl
If you attempt to use legacy arguments (like `ensure_ascii` or `data_encoding`), obsolete utility functions (`parse_response`), or ambiguous routing (`v1` with `/template`), the SDK will **not** break your code.
It will successfully execute the request but will emit a non-breaking `DeprecationWarning` to help you gracefully migrate to modern standards.
+### Deprecated API Endpoints & Routes
+
+When calling retired Mailjet endpoints, the SDK executes the request but emits an actionable `DeprecationWarning` directing you to canonical replacements:
+
+- **Newsletters (`newsletter*`):** Migrate to `campaigndraft` and `campaigndraft_*`.
+- **Legacy Statistics (`campaignstatistics`, `liststatistics`, `domainstatistics`, `apikeytotals`):** Migrate to `statcounters` or `statistics_recipientEsp`.
+- **Webhook Alias (`client.webhook`):** Use the official REST resource `client.eventcallbackurl`.
+- **Ambiguous Templates (`templates_contents`):** Use `template_detailcontent` (v3) or `template_contents` (v1).
+
## Type Hinting
This SDK is fully type-hinted and compatible with static type checkers like `mypy` and `pyright`.
diff --git a/SECURITY.md b/SECURITY.md
index ec5d56a..2ed1121 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,8 +6,8 @@ We currently provide security updates for the active major version of the Mailje
| Version | Supported |
| ------- | ------------------ |
-| 1.8.x | :white_check_mark: |
-| < 1.8.0 | :x: |
+| 1.9.x | :white_check_mark: |
+| < 1.9.0 | :x: |
# Vulnerability Disclosure
diff --git a/mailjet_rest/client.py b/mailjet_rest/client.py
index 4dd92ba..04dfa55 100644
--- a/mailjet_rest/client.py
+++ b/mailjet_rest/client.py
@@ -43,6 +43,7 @@
if TYPE_CHECKING:
+ from collections.abc import Mapping
from types import TracebackType
from mailjet_rest.types import HttpMethod, PayloadType, TimeoutType
@@ -242,7 +243,7 @@ def _execute_request(
url: str,
headers: dict[str, Any],
data: Any,
- params: dict[str, Any] | None,
+ params: Mapping[str, str | None] | None,
timeout: Any,
**kwargs: Any,
) -> requests.Response:
@@ -314,7 +315,7 @@ def api_call( # ruff: ignore[complex-structure]
url: str,
filters: dict[str, Any] | None = None,
data: PayloadType = None,
- headers: dict[str, str] | None = None,
+ headers: Mapping[str, str | None] | None = None,
timeout: TimeoutType = None,
**kwargs: Any,
) -> requests.Response:
@@ -325,20 +326,15 @@ def api_call( # ruff: ignore[complex-structure]
url (str): The fully constructed API URL.
filters (dict[str, Any] | None, optional): Query parameters.
data (PayloadType, optional): Request payload.
- headers (dict[str, str] | None, optional): Custom HTTP headers.
+ headers (Mapping[str, str | None] | None, optional): Custom HTTP headers.
timeout (TimeoutType, optional): Request timeout.
**kwargs (Any): Additional arguments passed to 'requests.Session.request'.
Returns:
requests.Response: The authenticated HTTP response from Mailjet.
"""
- # Ensure headers is a dictionary to prevent crashes if a legacy call explicitly passes None,
- # or relies on the default fallback, before we attempt to mutate it for Idempotency keys.
- if headers is None:
- headers = {}
-
- # CWE-113: Prevent Request Smuggling / CRLF Injection in headers
- headers = SecurityGuard.sanitize_headers(headers)
+ # Ensure headers is a dictionary and screened for CRLF injections (CWE-113)
+ req_headers: dict[str, str | None] = {} if headers is None else SecurityGuard.sanitize_headers(headers)
if not kwargs.get("verify", True):
sys.audit("mailjet.security.tls_disabled", url)
@@ -364,19 +360,19 @@ def api_call( # ruff: ignore[complex-structure]
return mock
# Allow idempotency hashing for valid batch lists
- if isinstance(data, (dict, list)) and "Idempotency-Key" not in headers:
- headers["Idempotency-Key"] = SecurityGuard.generate_payload_fingerprint(data)
+ if isinstance(data, (dict, list)) and "Idempotency-Key" not in req_headers:
+ req_headers["Idempotency-Key"] = SecurityGuard.generate_payload_fingerprint(data)
# Strip None filters
clean_filters = {k: v for k, v in filters.items() if v is not None} if filters else None
- trace_suffix, _ = self._extract_telemetry(data, headers)
+ trace_suffix, _ = self._extract_telemetry(data, req_headers)
try:
response = self._execute_request(
method=method,
url=url,
- headers=headers,
+ headers=req_headers,
data=data,
params=clean_filters,
timeout=req_timeout,
@@ -417,11 +413,12 @@ def _log_request(method: str, url: str, response: requests.Response, trace_str:
logger.debug("API Success %s | %s %s%s", getattr(response, "status_code", 200), method, url, trace_str)
@staticmethod
- def _extract_telemetry(data: Any, _headers: dict[str, str] | None) -> tuple[str, dict[str, str]]:
+ def _extract_telemetry(data: Any, _headers: Mapping[str, str | None] | None) -> tuple[str, dict[str, str]]:
"""Extract tracing identifiers for safe logging and structured telemetry.
Args:
data (Any): The request payload.
+ _headers (Mapping[str, str | None] | None): Request headers.
Returns:
tuple[str, dict[str, str]]: A tuple containing the formatted telemetry trace suffix
diff --git a/mailjet_rest/endpoint.py b/mailjet_rest/endpoint.py
index 64ee69b..a35f517 100644
--- a/mailjet_rest/endpoint.py
+++ b/mailjet_rest/endpoint.py
@@ -6,13 +6,13 @@
import warnings
from typing import TYPE_CHECKING, Any
-from mailjet_rest.routes import ROUTE_MAP
+from mailjet_rest.routes import DEPRECATION_ADVISORY, ROUTE_MAP
from mailjet_rest.types import _JSON_HEADERS, _TEXT_HEADERS, HttpMethod, PayloadType, TimeoutType
from mailjet_rest.utils.guardrails import SecurityGuard
if TYPE_CHECKING:
- from collections.abc import Generator
+ from collections.abc import Generator, Mapping
import requests
@@ -33,8 +33,28 @@ def __init__(self, client: Client, name: str) -> None:
self.client = client
self.name = name
self._name_lower = name.lower()
- self._action_parts = self._name_lower.split("_")
- self._resource_lower = self._action_parts[0]
+ parts = name.split("_")
+
+ # Base resource ignores CamelCase-to-dash conversion
+ 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:]:
+ dashed = "".join("-" + c.lower() if c.isupper() else c for c in part)
+ self._action_parts.append(dashed.lstrip("-"))
+
+ def _check_deprecation(self) -> None:
+ """Emit a non-breaking warning when a deprecated route is invoked."""
+ target = self._name_lower if self._name_lower in DEPRECATION_ADVISORY else self._resource_lower
+ if target in DEPRECATION_ADVISORY:
+ replacement = DEPRECATION_ADVISORY[target]
+ warnings.warn(
+ f"Endpoint '{target}' is deprecated in the Mailjet API. Migrate to '{replacement}'.",
+ DeprecationWarning,
+ stacklevel=3,
+ )
def _resolve_registry_route(
self, base_url: str, version: str, id_val: int | str | None, action_id: int | str | None
@@ -146,6 +166,7 @@ def _build_url(self, id_val: int | str | None = None, action_id: int | str | Non
Returns:
str: The fully qualified, sanitized secure URL.
"""
+ self._check_deprecation()
version = self.client.config.version
# Test Parity DX warning:
@@ -176,24 +197,93 @@ def _build_url(self, id_val: int | str | None = None, action_id: int | str | Non
return url
- def _build_headers(self, custom_headers: dict[str, str] | None = None) -> dict[str, str]:
+ def _build_headers(self, custom_headers: Mapping[str, str | None] | None = None) -> dict[str, str | None]:
"""Build headers based on the endpoint requirements.
Args:
- custom_headers (dict[str, str] | None): Custom headers to merge.
+ custom_headers: Custom headers to merge.
Returns:
- dict[str, str]: The composed dictionary of HTTP headers.
+ dict[str, str | None]: The composed dictionary of HTTP headers.
"""
base_headers = _TEXT_HEADERS if self._name_lower.endswith("_csvdata") else _JSON_HEADERS
if custom_headers:
- clean_custom = SecurityGuard.sanitize_headers(custom_headers)
- merged = dict(base_headers)
+ clean_custom = SecurityGuard.sanitize_headers(dict(custom_headers))
+ merged: dict[str, str | None] = dict(base_headers)
merged.update(clean_custom)
return merged
return dict(base_headers)
+ @staticmethod
+ def _cast_query_param(orig_ref: Any, raw_values: Any) -> Any:
+ """Cast query string values to match the target filter parameter type.
+
+ Handles single values or lists from urllib.parse.parse_qs and casts them
+ to bool, int, float, list, tuple, set, or str based on orig_ref.
+
+ Args:
+ orig_ref: Reference value or type indicating the intended target type.
+ raw_values: List of string values or a single scalar value.
+
+ Returns:
+ The parsed value cast to the target type.
+ """
+ vals = list(raw_values) if isinstance(raw_values, (list, tuple, set)) else [raw_values]
+
+ if not vals:
+ empty_defaults: dict[type, Any] = {
+ bool: False,
+ int: 0,
+ float: 0.0,
+ list: [],
+ tuple: (),
+ set: set(),
+ }
+ return empty_defaults.get(type(orig_ref), "")
+
+ first_val = vals[0]
+ if isinstance(orig_ref, bool):
+ return str(first_val).strip().lower() in {"true", "1", "yes", "t"}
+
+ converters: dict[type, Any] = {
+ int: lambda: int(first_val),
+ float: lambda: float(first_val),
+ list: lambda: list(vals),
+ tuple: lambda: tuple(vals),
+ set: lambda: set(vals),
+ }
+ converter = converters.get(type(orig_ref))
+ if converter:
+ return converter()
+
+ return first_val if len(vals) == 1 else vals
+
+ @classmethod
+ def _normalize_stream_filters(
+ cls, filters: Mapping[str, Any] | None, chunk_size: int
+ ) -> tuple[dict[str, Any], int]:
+ """Normalize filter keys and extract initial offset for pagination.
+
+ Returns:
+ tuple[dict[str, Any], int]: A tuple containing the sanitized filters
+ dictionary (with Limit set) and the validated starting offset.
+ """
+ current_filters = dict(filters) if filters else {}
+ raw_offset = current_filters.pop("offset", None)
+ if raw_offset is None:
+ raw_offset = current_filters.get("Offset")
+
+ try:
+ current_offset: int = cls._cast_query_param(0, raw_offset) if raw_offset is not None else 0
+ except (ValueError, TypeError) as e:
+ msg = f"stream() Offset filter must be an integer, got: {raw_offset!r}"
+ raise ValueError(msg) from e
+
+ current_filters.pop("limit", None)
+ current_filters["Limit"] = chunk_size
+ return current_filters, current_offset
+
def __call__(
self,
method: HttpMethod = "GET",
@@ -202,6 +292,7 @@ def __call__(
filters: dict[str, Any] | None = None,
action_id: int | str | None = None,
timeout: TimeoutType = None,
+ headers: Mapping[str, str | None] | None = None,
**kwargs: Any,
) -> requests.Response:
"""Execute the specific HTTP method on the constructed endpoint.
@@ -213,13 +304,18 @@ def __call__(
filters (dict[str, Any] | None): Query string URL parameters.
action_id (int | str | None): Sub-action ID.
timeout (TimeoutType): Request timeout.
+ headers (Mapping[str, str | None] | None): Custom HTTP request headers.
**kwargs (Any): Additional arguments.
Returns:
requests.Response: The resulting HTTP response from the request execution.
"""
- # Pop deprecated/HTTP kwargs safely
- headers = kwargs.pop("headers", None)
+ # Pop deprecated/HTTP kwargs safely without overwriting the explicit 'headers' argument
+ if headers is None:
+ headers = kwargs.pop("headers", None)
+ else:
+ kwargs.pop("headers", None)
+
ensure_ascii = kwargs.pop("ensure_ascii", None)
data_encoding = kwargs.pop("data_encoding", None)
@@ -233,10 +329,13 @@ def __call__(
data_str = json.dumps(data, ensure_ascii=ensure_ascii if ensure_ascii is not None else True)
data = data_str.encode(data_encoding) if data_encoding else data_str
+ # Screen and merge headers through _build_headers
+ merged_headers = self._build_headers(headers)
+
return self.client.api_call(
method=method,
url=self._build_url(id_val=id, action_id=action_id),
- headers=self._build_headers(headers),
+ headers=merged_headers,
data=data,
filters=filters,
timeout=timeout,
@@ -248,73 +347,92 @@ def get(
id: int | str | None = None,
filters: dict[str, Any] | None = None,
action_id: int | str | None = None,
+ headers: Mapping[str, str | None] | None = None,
**kwargs: Any,
) -> requests.Response:
- """Perform a GET request.
+ """Perform a GET request on the constructed endpoint.
Args:
- id (int | str | None): The primary resource ID.
- filters (dict[str, Any] | None): Query string URL parameters.
- action_id (int | str | None): Sub-action ID.
- **kwargs (Any): Additional args passed to requests.
+ id: The primary resource ID.
+ filters: Query string URL parameters.
+ action_id: Sub-action ID.
+ headers: Custom HTTP request headers.
+ **kwargs: Additional arguments passed to the request layer.
Returns:
- requests.Response: The resulting HTTP response for the GET request.
+ requests.Response: The resulting HTTP response from the GET request.
"""
- return self(method="GET", id=id, filters=filters, action_id=action_id, **kwargs)
+ return self(method="GET", id=id, filters=filters, action_id=action_id, headers=headers, **kwargs)
def stream(
self,
id: int | str | None = None,
- filters: dict[str, Any] | None = None,
+ filters: Mapping[str, Any] | None = None,
action_id: int | str | None = None,
chunk_size: int = 1000,
+ method: HttpMethod = "GET",
**kwargs: Any,
) -> Generator[dict[str, Any], None, None]:
"""Automatically paginates over GET requests yielding resource dictionaries.
Args:
- id (int | str | None): The primary resource ID.
- filters (dict[str, Any] | None): Query string URL parameters.
- action_id (int | str | None): Sub-action ID.
- chunk_size (int): Objects returned per loop (Limit). Defaults to 1000.
- **kwargs (Any): Additional args passed to requests.
+ id: The primary resource ID.
+ filters: Query string URL parameters. Accepts dicts, MappingProxy, or parse_qs multi-dicts.
+ action_id: Sub-action ID.
+ chunk_size: Objects returned per loop (Limit). Defaults to 1000.
+ method: The HTTP method to use (must be GET).
+ **kwargs: Additional arguments passed to requests.
Yields:
dict[str, Any]: Individual resource objects from the paginated API response.
+
+ Raises:
+ ValueError: If method is not GET, chunk_size <= 0, or Offset is invalid.
"""
- # Prevent infinite CPU/Network loops if 0 or negative numbers are passed
+ if method.upper() != "GET":
+ msg = f"stream() is designed for GET requests only, got {method}"
+ raise ValueError(msg)
+
if chunk_size <= 0:
msg = "stream() chunk_size must be a strictly positive integer."
raise ValueError(msg)
- current_filters = dict(filters) if filters else {}
- current_filters["Limit"] = chunk_size
-
- # Respect user-provided offsets to allow stream resumption.
- # Cast to int to prevent TypeError when adding chunk_size later.
- # Protect against 'None' values throwing a TypeError when cast to int
- offset_val = current_filters.get("Offset")
- current_filters["Offset"] = int(offset_val) if offset_val is not None else 0
+ current_filters, current_offset = self._normalize_stream_filters(filters, chunk_size)
while True:
- response = self.get(id=id, filters=current_filters, action_id=action_id, **kwargs)
- body = response.json()
- data = body.get("Data", [])
+ current_filters["Offset"] = current_offset
+ response = self.get(id=id, filters=current_filters.copy(), action_id=action_id, **kwargs)
+
+ if hasattr(response, "raise_for_status"):
+ response.raise_for_status()
+
+ try:
+ body = response.json()
+ except (ValueError, AttributeError):
+ break
+
+ if not isinstance(body, dict):
+ break
+
+ data = body.get("Data") or []
+ if not isinstance(data, list):
+ break
yield from data
- # Break early if we've reached the absolute end
- if not data or len(data) < chunk_size:
+ total = body.get("Total")
+ reached_total = isinstance(total, int) and (current_offset + len(data) >= total)
+ if not data or len(data) < chunk_size or reached_total:
break
- current_filters["Offset"] += chunk_size
+ current_offset += chunk_size
def create(
self,
data: PayloadType = None,
id: int | str | None = None,
action_id: int | str | None = None,
+ headers: Mapping[str, str | None] | None = None,
ensure_ascii: bool | None = None,
data_encoding: str | None = None,
**kwargs: Any,
@@ -325,8 +443,9 @@ def create(
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).
+ headers (dict[str, str] | None): Custom headers.
+ ensure_ascii (bool | None): Ensure ASCII encoding (deprecated).
+ data_encoding (str | None): Data encoding (deprecated).
**kwargs (Any): Additional arguments.
Returns:
@@ -337,6 +456,7 @@ def create(
id=id,
data=data,
action_id=action_id,
+ headers=headers,
ensure_ascii=ensure_ascii,
data_encoding=data_encoding,
**kwargs,
@@ -347,6 +467,7 @@ def update(
id: int | str,
data: PayloadType = None,
action_id: int | str | None = None,
+ headers: Mapping[str, str | None] | None = None,
ensure_ascii: bool | None = None,
data_encoding: str | None = None,
**kwargs: Any,
@@ -355,10 +476,11 @@ def update(
Args:
id (int | str): The primary resource ID.
- data (PayloadType): Updated payload.
+ data (PayloadType): Request 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).
+ headers (dict[str, str] | None): Custom headers.
+ ensure_ascii (bool | None): Ensure ASCII encoding (deprecated).
+ data_encoding (str | None): Data encoding (deprecated).
**kwargs (Any): Additional arguments.
Returns:
@@ -369,6 +491,7 @@ def update(
id=id,
data=data,
action_id=action_id,
+ headers=headers,
ensure_ascii=ensure_ascii,
data_encoding=data_encoding,
**kwargs,
diff --git a/mailjet_rest/routes.py b/mailjet_rest/routes.py
index 53a82e3..851dc4d 100644
--- a/mailjet_rest/routes.py
+++ b/mailjet_rest/routes.py
@@ -20,6 +20,33 @@ class Route(NamedTuple):
RouteMapType = dict[str, Route]
+# Advisory mapping for legacy and deprecated endpoints
+DEPRECATION_ADVISORY: Final[MappingProxyType[str, str]] = MappingProxyType(
+ {
+ # Newsletters -> Campaign Drafts (Official Mailjet Deprecation)
+ "newsletter": "campaigndraft",
+ "newsletter_detailcontent": "campaigndraft_detailcontent",
+ "newsletter_schedule": "campaigndraft_schedule",
+ "newsletter_send": "campaigndraft_send",
+ "newsletter_status": "campaigndraft_status",
+ "newsletter_test": "campaigndraft_test",
+ # Legacy Statistics -> Statcounters & Recipient ESP (Official Mailjet Deprecation)
+ "apikeytotals": "statcounters", # pragma: allowlist secret
+ "campaigngraphstatistics": "statcounters",
+ "campaignstatistics": "statcounters",
+ "domainstatistics": "statistics_recipientEsp",
+ "graphstatistics": "statcounters",
+ "liststatistics": "statcounters",
+ "messagestatistics": "statcounters",
+ "openstatistics": "statcounters",
+ "senderstatistics": "statcounters",
+ # Removed SDK route alias -> Official REST resource
+ "webhook": "eventcallbackurl",
+ # Redundant / Ambiguous Template routes
+ "template_update": "template.update(id=...)",
+ "templates_contents": "template_detailcontent (v3) or template_contents (v1)",
+ }
+)
_ROUTE_MAP: RouteMapType = {
# ==========================================
@@ -28,6 +55,7 @@ class Route(NamedTuple):
"send": Route(None, "send"),
"batch": Route(None, "batch"),
"batchjob": Route(None, "REST/batchjob"),
+ "batchjob_csverror": Route(None, "DATA/batchjob/{id}/CSVError/text:csv"),
# ==========================================
# Messages
# ==========================================
@@ -37,49 +65,57 @@ class Route(NamedTuple):
"messagesentstatistics": Route(None, "REST/messagesentstatistics"),
"messagestate": Route(None, "REST/messagestate"),
# ==========================================
+ # Message Events
+ # ==========================================
+ "bouncestatistics": Route(None, "REST/bouncestatistics"),
+ "clickstatistics": Route(None, "REST/clickstatistics"),
+ "openinformation": Route(None, "REST/openinformation"),
+ # ==========================================
+ # Webhooks & Parse API
+ # ==========================================
+ "eventcallbackurl": Route(None, "REST/eventcallbackurl"),
+ "webhook": Route(None, "REST/webhook"),
+ "parseroute": Route(None, "REST/parseroute"),
+ # ==========================================
# Contacts
# ==========================================
"contact": Route(None, "REST/contact"),
- "contactslist": Route(None, "REST/contactslist"),
- # Bulk Contact Management
- "contact_managemanycontacts": Route(None, "REST/contact/managemanycontacts"),
- "contactslist_importlist": Route(None, "REST/contactslist/{id}/importlist"),
- "contactslist_managemanycontacts": Route(None, "REST/contactslist/{id}/managemanycontacts"),
- "csvimport": Route(None, "REST/csvimport"),
- # Contact Properties
"contactdata": Route(None, "REST/contactdata"),
"contactmetadata": Route(None, "REST/contactmetadata"),
- # Subscriptions
- "contact_getcontactslists": Route(None, "REST/contact/{id}/getcontactslists"),
- "contact_managecontactslists": Route(None, "REST/contact/{id}/managecontactslists"),
- "contactslist_managecontact": Route(None, "REST/contactslist/{id}/managecontact"),
+ "contactslist": Route(None, "REST/contactslist"),
+ "contactslist_csvdata": Route(None, "DATA/contactslist/{id}/CSVData/text:plain"),
"contactslistsignup": Route(None, "REST/contactslistsignup"),
"listrecipient": Route(None, "REST/listrecipient"),
- # Verifications
+ "csvimport": Route(None, "REST/csvimport"),
+ # Contact Sub-resources & Actions
+ "contact_managemanycontacts": Route(None, "REST/contact/managemanycontacts"),
+ "contact_managecontactslists": Route(None, "REST/contact/{id}/managecontactslists"),
+ "contact_getcontactslists": Route(None, "REST/contact/{id}/getcontactslists"),
+ "contactslist_managemanycontacts": Route(None, "REST/contactslist/{id}/managemanycontacts"),
+ "contactslist_importlist": Route(None, "REST/contactslist/{id}/importlist"),
+ "contactslist_managecontact": Route(None, "REST/contactslist/{id}/managecontact"),
"contactslist_verify": Route(None, "REST/contactslist/{id}/verify"),
# ==========================================
- # Campaigns
+ # Segmentation
# ==========================================
- # Drafts
+ "contactfilter": Route(None, "REST/contactfilter"),
+ # ==========================================
+ # Campaigns & Newsletters
+ # ==========================================
+ "campaign": Route(None, "REST/campaign"),
"campaigndraft": Route(None, "REST/campaigndraft"),
"campaigndraft_detailcontent": Route(None, "REST/campaigndraft/{id}/detailcontent"),
"campaigndraft_schedule": Route(None, "REST/campaigndraft/{id}/schedule"),
"campaigndraft_send": Route(None, "REST/campaigndraft/{id}/send"),
"campaigndraft_status": Route(None, "REST/campaigndraft/{id}/status"),
"campaigndraft_test": Route(None, "REST/campaigndraft/{id}/test"),
- # Newsletters
+ # Deprecated Newsletters (Maintained for Backward Compatibility)
"newsletter": Route(None, "REST/newsletter"),
"newsletter_detailcontent": Route(None, "REST/newsletter/{id}/detailcontent"),
"newsletter_schedule": Route(None, "REST/newsletter/{id}/schedule"),
"newsletter_send": Route(None, "REST/newsletter/{id}/send"),
"newsletter_status": Route(None, "REST/newsletter/{id}/status"),
"newsletter_test": Route(None, "REST/newsletter/{id}/test"),
- # Sent Campaigns
- "campaign": Route(None, "REST/campaign"),
- # ==========================================
- # Segmentation
- # ==========================================
- "contactfilter": Route(None, "REST/contactfilter"),
# ==========================================
# Templates
# ==========================================
@@ -91,17 +127,18 @@ class Route(NamedTuple):
"template_contents": Route("v1", "REST/templates/{id}/contents"),
"template_content_by_type": Route("v1", "REST/templates/{id}/contents/types/{action_id}"),
# ==========================================
- # Statistics
+ # Active Statistics
# ==========================================
+ "statcounters": Route(None, "REST/statcounters"),
"campaignoverview": Route(None, "REST/campaignoverview"),
"contactstatistics": Route(None, "REST/contactstatistics"),
"geostatistics": Route(None, "REST/geostatistics"),
"listrecipientstatistics": Route(None, "REST/listrecipientstatistics"),
- "statcounters": Route(None, "REST/statcounters"),
"statistics_linkClick": Route(None, "REST/statistics/link-click"),
"statistics_recipientEsp": Route(None, "REST/statistics/recipient-esp"),
"toplinkclicked": Route(None, "REST/toplinkclicked"),
"useragentstatistics": Route(None, "REST/useragentstatistics"),
+ # Deprecated Statistics (Maintained for Backward Compatibility)
"apikeytotals": Route(None, "REST/apikeytotals"),
"campaigngraphstatistics": Route(None, "REST/campaigngraphstatistics"),
"campaignstatistics": Route(None, "REST/campaignstatistics"),
@@ -112,18 +149,6 @@ class Route(NamedTuple):
"openstatistics": Route(None, "REST/openstatistics"),
"senderstatistics": Route(None, "REST/senderstatistics"),
# ==========================================
- # Message Events
- # ==========================================
- "bouncestatistics": Route(None, "REST/bouncestatistics"),
- "clickstatistics": Route(None, "REST/clickstatistics"),
- "openinformation": Route(None, "REST/openinformation"),
- # ==========================================
- # Webhook & Parse
- # ==========================================
- "eventcallbackurl": Route(None, "REST/eventcallbackurl"),
- "webhook": Route(None, "REST/webhook"),
- "parseroute": Route(None, "REST/parseroute"),
- # ==========================================
# Sender Addresses and Domains
# ==========================================
"sender": Route(None, "REST/sender"),
@@ -132,7 +157,7 @@ class Route(NamedTuple):
"dns": Route(None, "REST/dns"),
"dns_check": Route(None, "REST/dns/{id}/check"),
# ==========================================
- # Settings (API Key Configuration & Account)
+ # Settings (API Keys & Account)
# ==========================================
"apikey": Route(None, "REST/apikey"),
"apikeyaccess": Route(None, "REST/apikeyaccess"),
diff --git a/mailjet_rest/utils/guardrails.py b/mailjet_rest/utils/guardrails.py
index 5d796eb..cb4e246 100644
--- a/mailjet_rest/utils/guardrails.py
+++ b/mailjet_rest/utils/guardrails.py
@@ -27,6 +27,8 @@
if TYPE_CHECKING:
+ from collections.abc import Mapping
+
import requests
from mailjet_rest.types import TimeoutType
@@ -198,15 +200,49 @@ def _redact_str(data: str) -> str:
except Exception: # ruff: ignore[blind-except]
return "[REDACTION_FAILED_UNSAFE_STRING]"
+ def _redact_tuple(self, data: tuple[Any, ...], depth: int) -> tuple[Any, ...]:
+ """Recursively sanitize tuple items preserving namedtuple structure.
+
+ Returns:
+ The sanitized tuple or NamedTuple instance.
+ """
+ if hasattr(data, "_fields"): # Preserves typing.NamedTuple
+ with contextlib.suppress(Exception):
+ return type(data)(*(self._deep_redact(item, depth + 1) for item in data))
+ return tuple(self._deep_redact(item, depth + 1) for item in data)
+
+ def _redact_object(self, data: Any, depth: int) -> Any:
+ """Recursively sanitize custom objects, dataclasses, and Pydantic models.
+
+ Returns:
+ The sanitized dictionary, string, or primitive representation.
+ """
+ if hasattr(data, "model_dump") and callable(data.model_dump):
+ with contextlib.suppress(Exception):
+ return self._deep_redact(data.model_dump(), depth + 1)
+
+ if hasattr(data, "__dict__"):
+ with contextlib.suppress(Exception):
+ return self._deep_redact(vars(data), depth + 1)
+
+ try:
+ str_val = str(data)
+ except Exception: # ruff: ignore[blind-except]
+ return ""
+
+ return self._redact_str(str_val)
+
def _deep_redact(self, data: Any, depth: int = 0) -> Any:
"""Recursively search and scrub secrets from complex nested data structures.
Returns:
- Any: The fully scrubbed and redacted data structure representation.
+ The fully sanitized data structure representation.
"""
if depth > self.MAX_REDACTION_DEPTH:
return "[MAX_DEPTH_REACHED]"
+ if isinstance(data, (int, float, bool, type(None))):
+ return data
if isinstance(data, str):
return self._redact_str(data)
if isinstance(data, dict):
@@ -214,11 +250,11 @@ def _deep_redact(self, data: Any, depth: int = 0) -> Any:
if isinstance(data, list):
return [self._deep_redact(item, depth + 1) for item in data]
if isinstance(data, tuple):
- return tuple(self._deep_redact(item, depth + 1) for item in data)
+ return self._redact_tuple(data, depth)
if isinstance(data, set):
return {self._deep_redact(item, depth + 1) for item in data}
- return data
+ return self._redact_object(data, depth)
@override
def filter(self, record: logging.LogRecord) -> bool:
@@ -341,15 +377,15 @@ def check_request_security(kwargs: dict[str, Any]) -> None:
warnings.warn("Security Warning: Unencrypted HTTP proxy detected.", UserWarning, stacklevel=3)
@staticmethod
- def sanitize_headers(headers: dict[str, str]) -> dict[str, str]:
+ def sanitize_headers(headers: Mapping[str, str | None]) -> dict[str, str | None]:
"""Prevent HTTP Header Injection (CWE-113).
Returns:
dict[str, str]: The sanitized headers safely screened for CRLF injections.
"""
- clean_headers = {}
+ clean_headers: dict[str, str | None] = {}
for k, v in headers.items():
- if _CRLF_RE.search(k) or _CRLF_RE.search(str(v)):
+ if _CRLF_RE.search(k) or (v is not None and _CRLF_RE.search(str(v))):
sys.audit("mailjet.security.header_injection", k)
msg = f"Security Violation: CRLF injection detected in header '{k}'"
raise ValueError(msg)
@@ -492,9 +528,14 @@ def validate_attachment_path(file_path: Path | str, safe_base_dir: Path | str |
@staticmethod
def check_file_size(path: Path, max_size_bytes: int = 15 * 1024 * 1024) -> None:
"""Prevent Resource Exhaustion (CWE-400). Limit defaults to 15MB."""
- size = path.stat().st_size
+ target = Path(path)
+ if not target.is_file():
+ msg = f"Security Alert (CWE-400): Path is not a regular file: {target}"
+ raise ValueError(msg)
+
+ size = target.stat().st_size
if size > max_size_bytes:
- msg = f"Security Violation: File '{path.name}' exceeds safe threshold."
+ msg = f"Security Violation: File '{target.name}' exceeds safe threshold."
raise ValueError(msg)
@staticmethod
@@ -507,7 +548,8 @@ def _validate_scalar_timeout(timeout: Any) -> float:
Returns:
float: The validated scalar timeout in seconds.
"""
- if not isinstance(timeout, (int, float)):
+ # Explicitly check for bool to prevent True/False coercing to 1.0/0.0
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
msg = f"Timeout must be a numeric float or int, got {type(timeout).__name__}."
raise TypeError(msg)
@@ -580,23 +622,21 @@ def normalize_domain(email_or_domain: str) -> str:
"""
if not email_or_domain:
return email_or_domain
- parts = email_or_domain.rsplit("@", 1)
- if len(parts) == 2:
- local, domain = parts
+ local_part, sep, domain_part = email_or_domain.rpartition("@")
+ if sep:
try:
- puny_domain = domain.encode("idna").decode("ascii")
- except Exception as e:
+ puny_domain = domain_part.encode("idna").decode("ascii")
+ except UnicodeError as e:
msg = f"Invalid IDN in email: {email_or_domain}"
raise ValueError(msg) from e
- else:
- return f"{local}@{puny_domain}"
- else:
- try:
- return email_or_domain.encode("idna").decode("ascii")
- except Exception as e:
- msg = f"Invalid IDN: {email_or_domain}"
- raise ValueError(msg) from e
+ return f"{local_part}@{puny_domain}"
+
+ try:
+ return email_or_domain.encode("idna").decode("ascii")
+ except UnicodeError as e:
+ msg = f"Invalid IDN: {email_or_domain}"
+ raise ValueError(msg) from e
@staticmethod
def sanitize_segment(segment: Any) -> str:
@@ -669,7 +709,10 @@ def sanitize_log_trace(trace_val: Any) -> str:
"""
if not trace_val:
return ""
- return re.sub(r"\s+", "_", str(trace_val))
+
+ # Neutralize control characters [\x00-\x1f\x7f] and normalize whitespace
+ clean_str = _PATH_CONTROL_CHAR_RE.sub("_", str(trace_val))
+ return re.sub(r"\s+", "_", clean_str)
@staticmethod
def _validate_token(auth: str) -> str:
diff --git a/samples/segments_sample.py b/samples/segments_sample.py
index a8a39d1..7570008 100644
--- a/samples/segments_sample.py
+++ b/samples/segments_sample.py
@@ -27,7 +27,7 @@ def create_a_campaign_with_a_segmentation_filter(segmentation_id=0):
"ContactsListID": 0,
"SegmentationID": segmentation_id,
}
- return mailjet30.newsletter.create(data=data)
+ return mailjet30.campaigndraft.create(data=data)
if __name__ == "__main__":
diff --git a/samples/smoke_readme_runner.py b/samples/smoke_readme_runner.py
index daf9a2e..12d67b3 100644
--- a/samples/smoke_readme_runner.py
+++ b/samples/smoke_readme_runner.py
@@ -27,7 +27,6 @@ def section(title: str) -> None:
def safe_cleanup(action, name, **kwargs):
"""Executes a cleanup action without failing on permission (401) or consistency (404) errors."""
try:
- # Temporarily silence SDK error logs for cleanup to keep output clean
client_logger = logging.getLogger("mailjet_rest.client")
old_level = client_logger.level
client_logger.setLevel(logging.CRITICAL)
@@ -42,7 +41,6 @@ def safe_cleanup(action, name, **kwargs):
except MailjetAuthError:
print(f"⚠️ CLEANUP: {name} skipped (Permission denied: Operation not allowed).")
except DoesNotExistError:
- # The SDK now correctly raises DoesNotExistError for 404 responses
print(f"⚠️ CLEANUP: {name} skipped (Not found: likely eventual consistency delay).")
except Exception as e:
print(f"❌ CLEANUP: {name} raised unexpected exception: {e}")
@@ -57,17 +55,13 @@ def run_readme_tests():
print("⚠️ Missing Mailjet API credentials in environment variables.")
return
- # Using the Context Manager (Best Practice for resource management)
with (
Client(auth=(api_key, api_secret), version="v3.1") as mailjet_v31,
Client(auth=(api_key, api_secret), version="v3") as mailjet_v3,
Client(auth=content_token or (api_key, api_secret), version="v1") as mailjet_v1,
):
- # ---------------------------------------------------------------------
- # 1. SEND API (v3.1) - Sanitized Telemetry
- # ---------------------------------------------------------------------
+ # 1. SEND API (v3.1)
section("Send API (v3.1) - Basic Email & Telemetry")
-
message = (
MessageBuilder()
.set_sender("pilot@mailjet.com", "Mailjet Pilot")
@@ -75,58 +69,42 @@ def run_readme_tests():
.set_subject("README Test: Your email flight plan!")
.set_content(text="Welcome to Mailjet!")
).build()
-
- # Verification: Check logs to see this sanitized to '_' (CWE-117)
message["CustomID"] = "Readme_Test\n[CRITICAL]_INJECTION_ATTEMPT"
-
payload = SendPayloadBuilder().add_message(message).set_sandbox_mode(True).build()
-
res = mailjet_v31.send.create(data=payload)
assert res.status_code == 200, f"Failed Send API: {res.text}"
print("✅ Send API passed (Check logs for sanitized CustomID).")
- # ---------------------------------------------------------------------
- # 2. SECURITY GUARDRAILS (Poka-Yoke Verification)
- # ---------------------------------------------------------------------
+ # 2. SECURITY GUARDRAILS
section("Security Guardrails (Active Protection)")
-
- # 1. Test CRLF Injection
try:
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}'")
- # 2. Test TLS Bypass (MITM Prevention)
try:
- # We explicitly test that the SDK refuses insecure connections
mailjet_v3.contact.get(verify=False)
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)
- # ---------------------------------------------------------------------
+ # 3. STANDARD REST ACTIONS
section("Standard REST Actions (Contact Lifecycle)")
-
test_email = f"readme_test_{uuid.uuid4().hex[:8]}@mailjet.com"
res = mailjet_v3.contact.create(data={"Email": test_email})
assert res.status_code == 201
contact_id = res.json()["Data"][0]["ID"]
print(f"✅ POST (Create Contact) passed. Created ID: {contact_id}")
- # GET (Read all & Filtering & Pagination)
res = mailjet_v3.contact.get(filters={"limit": 2, "sort": "Email desc"})
assert res.status_code == 200
print("✅ GET (Read all/Pagination) passed.")
- # GET (Read one)
res = mailjet_v3.contact.get(id=contact_id)
assert res.status_code == 200
print("✅ GET (Read one) passed.")
- # PUT (Update Contact Metadata)
prop_name = f"test_prop_{uuid.uuid4().hex[:6]}"
res_meta = mailjet_v3.contactmetadata.create(data={"Datatype": "str", "Name": prop_name, "NameSpace": "static"})
if res_meta.status_code == 201:
@@ -135,21 +113,13 @@ def run_readme_tests():
res = mailjet_v3.contactdata.update(id=contact_id, data=update_data)
assert res.status_code == 200
print("✅ PUT (Update Contact Data) passed.")
- # Resilient Teardown: Metadata
safe_cleanup(mailjet_v3.contactmetadata.delete, f"Metadata {prop_id}", id=prop_id)
- # Resilient Teardown: Contact
safe_cleanup(mailjet_v3.contact.delete, f"Contact {contact_id}", id=contact_id)
- # ---------------------------------------------------------------------
- # 4. EMAIL API ECOSYSTEM (Webhooks, Parse, Segmentation, Stats)
- # ---------------------------------------------------------------------
+ # 4. EMAIL API ECOSYSTEM
section("Email API Ecosystem")
-
- # Webhooks
webhook_url = f"https://www.example.com/webhook_{uuid.uuid4().hex[:6]}"
-
- # Prevent MJ18 Conflict by checking for an existing webhook first
get_webhook = mailjet_v3.eventcallbackurl.get()
if get_webhook.status_code == 200 and get_webhook.json().get("Data"):
w_id = get_webhook.json()["Data"][0]["ID"]
@@ -164,10 +134,7 @@ def run_readme_tests():
print("✅ Webhooks (eventcallbackurl) created/updated.")
safe_cleanup(mailjet_v3.eventcallbackurl.delete, f"Webhook {w_id}", id=w_id)
- # Parse API
parse_url = f"https://www.example.com/parse_{uuid.uuid4().hex[:6]}"
-
- # Prevent MJ18 Conflict by checking for an existing route first
get_parse = mailjet_v3.parseroute.get()
if get_parse.status_code == 200 and get_parse.json().get("Data"):
p_id = get_parse.json()["Data"][0]["ID"]
@@ -180,7 +147,6 @@ def run_readme_tests():
print("✅ Parse API (parseroute) created/updated.")
safe_cleanup(mailjet_v3.parseroute.delete, f"ParseRoute {p_id}", id=p_id)
- # Segmentation
res = mailjet_v3.contactfilter.create(
data={
"Description": "README Test Filter",
@@ -193,19 +159,14 @@ def run_readme_tests():
print("✅ Segmentation (contactfilter) created.")
safe_cleanup(mailjet_v3.contactfilter.delete, f"ContactFilter {f_id}", id=f_id)
- # Statcounters
res = mailjet_v3.statcounters.get(
filters={"CounterSource": "APIKey", "CounterTiming": "Message", "CounterResolution": "Lifetime"}
)
assert res.status_code == 200
print("✅ Statcounters passed.")
- # ---------------------------------------------------------------------
- # 5. CONTENT API (v1) - Full Image Lifecycle
- # ---------------------------------------------------------------------
+ # 5. CONTENT API (v1)
section("Content API (v1)")
-
- # Negative Upload (Verifying error handling)
client_logger = logging.getLogger("mailjet_rest.client")
prev_level = client_logger.level
client_logger.setLevel(logging.CRITICAL)
@@ -219,7 +180,6 @@ def run_readme_tests():
finally:
client_logger.setLevel(prev_level)
- # Real Multipart Upload & Resilient Cleanup
b64_string = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
files_payload = {
"metadata": (None, '{"name": "readme_logo.png", "Status": "open"}', "application/json"),
@@ -230,10 +190,7 @@ def run_readme_tests():
if res.status_code == 201:
image_id = res.json()["Data"][0]["ID"]
print(f"✅ Content API Upload passed. Image ID: {image_id}")
-
- # CRITICAL: Wait 1 second for the server to process the upload before trying to delete it.
time.sleep(1)
-
try:
safe_cleanup(mailjet_v1.data_images.delete, f"Image {image_id}", id=image_id)
except Exception:
@@ -241,20 +198,16 @@ def run_readme_tests():
else:
print(f"⚠️ Content API Upload skipped/failed: {res.status_code}")
- # ---------------------------------------------------------------------
- # 6. ADDITIONAL HEALTH CHECKS (Read-Only & RPC-Actions)
- # ---------------------------------------------------------------------
+ # 6. ADDITIONAL HEALTH CHECKS
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", None),
("Contacts", mailjet_v3.contact, "stream", None),
- ("Webhooks", mailjet_v3.webhook, "ping", None),
+ ("Webhooks", mailjet_v3.eventcallbackurl, "ping", None),
("Sender Validate", mailjet_v3.sender_validate, "ping", 999999),
("Tokens (v1)", mailjet_v1.tokens, "stream", None),
("Labels (v1)", mailjet_v1.labels, "stream", None),
- ("Template Contents", mailjet_v1.templates_contents, "stream", 999999),
+ ("Template Contents (v1)", mailjet_v1.template_contents, "stream", 999999),
("Senders", mailjet_v3.sender, "stream", None),
("Campaigns", mailjet_v3.campaign, "stream", None),
("Messages", mailjet_v3.message, "stream", None),
@@ -266,7 +219,6 @@ def run_readme_tests():
if strategy == "stream":
iterator = endpoint.stream(id=resource_id, chunk_size=1)
item = next(iterator, None)
-
if item is not None:
print(f"✅ {name} (stream) passed (Found items).")
else:
@@ -279,7 +231,6 @@ def run_readme_tests():
assert False, f"Unexpected status {res.status_code}"
except Exception as e:
- # Check if the status code is on the domain exception, or nested inside the HTTP error cause
status = getattr(e, "status_code", None)
if status is None and getattr(e, "__cause__", None) is not None:
response = getattr(e.__cause__, "response", None)
diff --git a/tests/fuzz/fuzz_core.py b/tests/fuzz/fuzz_core.py
index 77ebe14..1dc44b8 100644
--- a/tests/fuzz/fuzz_core.py
+++ b/tests/fuzz/fuzz_core.py
@@ -1,9 +1,12 @@
import sys
+import warnings
import atheris
import gc
from mailjet_rest.errors import MailjetAuthError, ValidationError, MailjetApiError
+# Silence deprecation warnings during fuzzing to reduce overhead and log noise
+warnings.simplefilter("ignore")
# Instrument all internal modules
with atheris.instrument_imports(enable_loader_override=False):
@@ -25,7 +28,6 @@ def raise_for_status(self): pass
_mock_client.session.request = lambda *args, **kwargs: DummyResponse() # type: ignore[method-assign, assignment]
-
def fuzz_config(fdp: atheris.FuzzedDataProvider) -> int:
"""Target 1: Config Validation."""
try:
@@ -37,6 +39,7 @@ def fuzz_config(fdp: atheris.FuzzedDataProvider) -> int:
)
return 0
except (ValueError, TypeError):
+ # We expect ValueError, TypeError
return -1
@@ -83,7 +86,8 @@ def fuzz_telemetry_and_difflib(fdp: atheris.FuzzedDataProvider) -> int:
try:
getattr(_mock_client, fdp.ConsumeUnicodeNoSurrogates(150))
except AttributeError:
- pass # We expect typos to raise AttributeError
+ # We expect typos to raise AttributeError
+ pass
# Fuzz telemetry extractor with lists and dicts
num_keys = fdp.ConsumeIntInRange(1, 10)
@@ -114,8 +118,9 @@ def TestOneInput(data: bytes) -> int:
global execution_counter
execution_counter += 1
- # Force garbage collection more frequently to prevent 2GB OOM peaks
- if execution_counter % 2500 == 0:
+ # Periodically flush the client endpoint cache and run garbage collection
+ if execution_counter % 1000 == 0:
+ _mock_client._endpoint_cache.clear()
gc.collect()
if len(data) < 5:
diff --git a/tests/fuzz/fuzz_endpoint.py b/tests/fuzz/fuzz_endpoint.py
index 7fc8a79..4f4a172 100644
--- a/tests/fuzz/fuzz_endpoint.py
+++ b/tests/fuzz/fuzz_endpoint.py
@@ -67,6 +67,22 @@ def TestOneInput(data: bytes) -> None:
for _ in range(fdp.ConsumeIntInRange(1, 5)):
dynamic_headers[fdp.ConsumeUnicodeNoSurrogates(15)] = fdp.ConsumeUnicodeNoSurrogates(30)
+ # Target pagination and generator parsing
+ if fdp.ConsumeBool():
+ chunk_size = fdp.ConsumeIntInRange(-10, 2000)
+ try:
+ for idx, _ in enumerate(endpoint.stream(
+ id=id_val,
+ action_id=action_id,
+ filters=filters,
+ chunk_size=chunk_size,
+ )):
+ if idx > 5:
+ break
+ except (ValueError, TypeError, ValidationError):
+ # Expected for malformed fuzzed inputs traversing validation logic
+ pass
+
payload = {fdp.ConsumeUnicodeNoSurrogates(5): fdp.ConsumeUnicodeNoSurrogates(10)}
if method_idx == 0:
diff --git a/tests/fuzz/fuzz_guardrails.py b/tests/fuzz/fuzz_guardrails.py
index 1bf2b2b..270447e 100644
--- a/tests/fuzz/fuzz_guardrails.py
+++ b/tests/fuzz/fuzz_guardrails.py
@@ -49,6 +49,25 @@ def TestOneInput(data: bytes) -> None:
payload = fdp.ConsumeUnicodeNoSurrogates(256)
SecurityGuard.check_control_characters("fuzzed_field", payload)
+ elif target == 5:
+ # Target 6: Auth coercion (Tuple vs String Token vs Malformed)
+ auth_val = fdp.ConsumeUnicodeNoSurrogates(128) if fdp.ConsumeBool() else (
+ fdp.ConsumeUnicodeNoSurrogates(32),
+ fdp.ConsumeUnicodeNoSurrogates(32),
+ )
+ SecurityGuard.validate_and_coerce_auth(auth_val)
+
+ elif target == 6:
+ # Target 7: HTML SpamGuard static parser
+ html = fdp.ConsumeUnicodeNoSurrogates(512)
+ SecurityGuard.analyze_html_safety(html)
+
+ elif target == 7:
+ # Target 8: Safe Kwargs Filtering (Mass Assignment / CWE-915)
+ k = fdp.ConsumeUnicodeNoSurrogates(16)
+ v = fdp.ConsumeUnicodeNoSurrogates(16)
+ SecurityGuard.filter_safe_kwargs({k: v})
+
except (ValueError, TypeError, FileNotFoundError, AttributeError):
# SECURITY SUCCESS: The fail-closed architecture intercepted the malformed data.
pass
diff --git a/tests/fuzz/fuzz_redacting_filter.py b/tests/fuzz/fuzz_redacting_filter.py
index 3cab537..8b56c8f 100644
--- a/tests/fuzz/fuzz_redacting_filter.py
+++ b/tests/fuzz/fuzz_redacting_filter.py
@@ -34,6 +34,10 @@ def generate_deep_dict(fdp: atheris.FuzzedDataProvider, depth: int = 0) -> Any:
fdp.ConsumeUnicodeNoSurrogates(10): generate_deep_dict(fdp, depth + 1)
for _ in range(fdp.ConsumeIntInRange(1, 3))
}
+ choice = fdp.ConsumeIntInRange(0, 5)
+ if choice == 4:
+ # Fuzz Set and Object redaction pathways
+ return {fdp.ConsumeUnicodeNoSurrogates(16) for _ in range(fdp.ConsumeIntInRange(1, 3))}
return None
diff --git a/tests/fuzz/fuzzer.dict b/tests/fuzz/fuzzer.dict
index 6f2f314..a02f4c4 100644
--- a/tests/fuzz/fuzzer.dict
+++ b/tests/fuzz/fuzzer.dict
@@ -58,6 +58,10 @@
"APIKey"
"SecretKey"
"Token"
+"MAX_DEPTH_REACHED"
+"REDACTION_FAILED_UNSAFE_STRING"
+"Authorization: Basic "
+"Authorization: Bearer "
# Common state flags & analytics
@@ -107,6 +111,12 @@
"messagehistory"
"messageinformation"
"messagestate"
+"contactslist_csvdata"
+"batchjob_csverror"
+"contact_data"
+"widget"
+"widgetcustomdesign"
+"template_content_by_type"
# Expected Exception & State Tracking Overrides
@@ -1820,3 +1830,196 @@
"\x00Q"
"\xe9\x93\xa6\xee\x8a\xaf\xeb\xa2\xbd\xe8\x93\xa2\xef\xbe\x80\xe3\x81\xa5%8e"
"\xcc\x04\x00\x00\x00\x00\x00\x00"
+"\xef\xbc\x80\xe0\xb8\x80\x01\x00\xe2\x94\x80\xe3\x81\xa5%8e\xef\xbf\xbf"
+"\x00\xe8\x80\x80\x00\xe3\xbc\x80\xe3\x81\xa5\xef\xbf\xbd\xe5\xbc\x80\xe6\x94\xa5"
+"]]]]]\x00%e0"
+"\xe3\xac\xae\xe2\x84\xbc[\xe2\xb8\xaf\xe3\xac\xae\xe3\x94\xaf\xef\xbf\xbd\xe5\xb4\x80"
+"(\x00\x00\x00\x00\x00\x00\x00"
+"\xff\xff\xff\xff\xff\xff\xff\x1c"
+"!!!"
+"\xe1\x80\x80\xc4\x80\xe2\x94\x80\xe3\x81\xa5%8e\xc2\x80"
+"\x00\xdb\x00"
+"5\x00"
+"%Pu%\xef\xbf\xbdE22"
+"\x00\x01\x00"
+"\xe7\xb1\xa1\xe4\x8d\xbc\xe7\x91\xa5\xe6\x96\xb3 \xcc\x85"
+"\xe3\x81\xa1\xe7\x91\xaf\xe6\x96\xb3\xca\x81"
+"\x00\x00\x00\x00\x00\x0e\x00\x06"
+"contactslist_csvdata"
+"batchjob_csverror"
+"contact_data"
+"widget"
+"widgetcustomdesign"
+"template_content_by_type"
+"template_detailcontent"
+"template_contents"
+"templates_contents"
+"DATA/contactslist"
+"DATA/batchjob"
+"CSVData/text:plain"
+"CSVError/text:csv"
+"contents/lock"
+"contents/unlock"
+"contents/publish"
+"data_images"
+"tokens"
+"labels"
+"images"
+"Offset"
+"offset"
+"Limit"
+"limit"
+"chunk_size"
+"raise_for_status"
+"Total"
+"Count"
+"Data"
+"sort"
+"