Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions .github/workflows/cflite_build.yml

This file was deleted.

49 changes: 0 additions & 49 deletions .github/workflows/cflite_cron.yml

This file was deleted.

17 changes: 9 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
43 changes: 42 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,8 @@ message = (
.add_cc("copilot@mailjet.com")
.set_subject("Your Boarding Pass")
.set_content(html="<h3>Welcome aboard!</h3>")
.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()
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 12 additions & 15 deletions mailjet_rest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@


if TYPE_CHECKING:
from collections.abc import Mapping
from types import TracebackType

from mailjet_rest.types import HttpMethod, PayloadType, TimeoutType
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading