diff --git a/.gitignore b/.gitignore
index ad08cff..cfbcbcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -227,6 +227,7 @@ tmp/
tmp.txt
wget-log
Downloads/
+logs/
*.gz
*.rar
*.zip
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f7a2c97..fea0dab 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -105,7 +105,7 @@ repos:
name: "馃敀 security 路 Detect private keys"
- repo: https://github.com/commitizen-tools/commitizen
- rev: v4.17.0
+ rev: v4.18.1
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.171.0'
+ rev: 'v1.177.0'
hooks:
- id: semgrep
name: "馃敀 security 路 Static analysis (semgrep)"
@@ -143,14 +143,14 @@ repos:
# Spelling and typos
- repo: https://github.com/crate-ci/typos
- rev: v1.48.0
+ rev: v1.50.2
hooks:
- id: typos
name: "馃摑 spelling 路 Check typos"
# CI/CD validation
- repo: https://github.com/python-jsonschema/check-jsonschema
- rev: 0.37.4
+ rev: 0.38.0
hooks:
- id: check-dependabot
name: "馃敡 ci/cd 路 Validate Dependabot config"
@@ -159,7 +159,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"
@@ -172,7 +172,7 @@ repos:
- responses
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.16.1
+ rev: v0.16.8
hooks:
- id: ruff-check
name: "馃悕 lint 路 Check with Ruff"
@@ -191,7 +191,7 @@ repos:
# Python type checking
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v2.3.0
+ rev: v2.3.1
hooks:
- id: mypy
name: "馃悕 types 路 Check with mypy"
@@ -202,14 +202,14 @@ repos:
exclude: ^mailgun/examples/
- 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 9113bf1..12e6804 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,9 @@
We [keep a changelog.](http://keepachangelog.com/)
-## [Unreleased] (1.9.1)
+## [Unreleased]
+
+## [1.9.1] - 2026-09-21
### Security
@@ -35,9 +37,14 @@ We [keep a changelog.](http://keepachangelog.com/)
### Pull Requests Merged
+- PR #61: build(deps): Bump the minor-and-patch group across 1 directory with 3 updates.
+- PR #63: build(deps): Bump the minor-and-patch group across 1 directory with 2 updates.
+- PR #64: build(deps): Bump github/codeql-action/upload-sarif from 4.37.8 to 4.37.9 in the minor-and-patch group.
- PR #65: Hardening security and stability.
+- PR #66: Release 1.9.1.
+- PR #67: build(deps): Bump github/codeql-action/upload-sarif from 4.37.9 to 4.38.0 in the minor-and-patch group.
-## v1.9.0 - 2026-08-04
+## [1.9.0] - 2026-08-04
### Added
@@ -76,7 +83,7 @@ We [keep a changelog.](http://keepachangelog.com/)
- [PR_58](https://github.com/mailgun/mailgun-python/pull/58) - build(deps): Bump the minor-and-patch group with 2 updates.
- [PR_59](https://github.com/mailgun/mailgun-python/pull/59) - build(deps): Bump actions/setup-python from 6.3.0 to 7.0.0.
-## v1.8.0 - 2026-07-20
+## [1.8.0] - 2026-07-20
### 馃専 Top Highlights (The "Big Wins")
@@ -505,4 +512,7 @@ We [keep a changelog.](http://keepachangelog.com/)
[1.6.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.6.0
[1.7.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.7.0
[1.7.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.7.1
-[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.8.0...HEAD
+[1.8.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.8.0
+[1.9.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.9.0
+[1.9.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.9.1
+[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.9.1...HEAD
diff --git a/mailgun/_version.py b/mailgun/_version.py
index 0a0a43a..38cf6db 100644
--- a/mailgun/_version.py
+++ b/mailgun/_version.py
@@ -1 +1 @@
-__version__ = "1.9.0"
+__version__ = "1.9.1"
diff --git a/tests/fuzz/fuzz.dict b/tests/fuzz/fuzz.dict
index 807fcbf..f458c58 100644
--- a/tests/fuzz/fuzz.dict
+++ b/tests/fuzz/fuzz.dict
@@ -2209,3 +2209,36 @@
"}{\xef\xbf\xbd"
"}}}}\xef\xbf\xbd"
"~\x13"
+"\xff\xff\xff\xff\xff\xff\xff\xc8"
+"qq;qCF %2}\x7f\xef\xbf\xbd{\x7f\x7f"
+"_\x00\x00\x00\x00\x00\x00\x00"
+"E"
+"im1"
+"ccccc"
+"---"
+"\xff\xff\xff\xff\xff\xff\xff\xf0"
+""
+"<"
+" None:
action = getattr(client.domains, fuzzed_method)
action(domain=fuzzed_domain)
+
+ # Probe header injection & control character audit hooks
+ if fdp.ConsumeBool():
+ hostile_headers = {
+ fdp.ConsumeUnicodeNoSurrogates(16): fdp.PickValueInList(
+ ["valid", "bad\r\nHeader: 1", "control\x01char", "null\x00byte"]
+ )
+ }
+ client.messages.create(
+ domain=fuzzed_domain,
+ data={"from": "test@example.com", "to": "user@example.com"},
+ headers=hostile_headers,
+ )
+
+ # Probe SSRF URL audit hooks
+ if fdp.ConsumeBool():
+ hostile_url = fdp.PickValueInList(
+ ["ftp://api.mailgun.net", "gopher://127.0.0.1", "http://attacker.com/v3"]
+ )
+ client.messages.api_call(method="get", url=hostile_url)
+
# Verify invariant: if audit hook fired, arguments must be safe
for event, args in _AUDIT_LOG:
for arg in args:
diff --git a/tests/fuzz/fuzz_builders_advanced.py b/tests/fuzz/fuzz_builders_advanced.py
index d36cd2b..f77bbbb 100644
--- a/tests/fuzz/fuzz_builders_advanced.py
+++ b/tests/fuzz/fuzz_builders_advanced.py
@@ -11,12 +11,28 @@
import atheris
+from mailgun.security import IdempotencyGuard
+
+
with atheris.instrument_imports():
from mailgun.builders import ChunkedStreamer, MailgunMessageBuilder
logging.disable(logging.CRITICAL)
+def _test_cyclic_and_stream_pointer_invariants(
+ fdp: atheris.FuzzedDataProvider, builder: MailgunMessageBuilder
+) -> None:
+ # Explicitly annotate cyclic_dict as dict[str, Any] to allow self-referential structures
+ cyclic_dict: dict[str, Any] = {"domain": "test.com", "to": ["a@b.com"]}
+ cyclic_dict["self"] = cyclic_dict
+ cyclic_dict["nested"] = [cyclic_dict]
+
+ key = IdempotencyGuard.generate_key("test.com", cyclic_dict)
+ assert isinstance(key, str)
+ assert len(key) == 64
+
+
def _generate_nested_ast(fdp: atheris.FuzzedDataProvider, depth: int = 0) -> Any:
"""Generate arbitrary nested JSON structures to stress idempotency hashing."""
if depth > 4 or fdp.ConsumeBool():
@@ -57,7 +73,7 @@ def TestOneInput(data: bytes) -> None:
try:
num_operations = fdp.ConsumeIntInRange(1, 10)
for _ in range(num_operations):
- op_code = fdp.ConsumeIntInRange(0, 5)
+ op_code = fdp.ConsumeIntInRange(0, 6)
if op_code == 0:
builder.set_idempotency_safe(safe=fdp.ConsumeBool())
@@ -101,6 +117,16 @@ def TestOneInput(data: bytes) -> None:
# Expected during fuzzing: optional API may be missing or reject malformed input.
pass
+ elif op_code == 6:
+ # Direct cyclic graph insertion into builder payload
+ cyclic: dict[str, Any] = {"nested": []}
+ cyclic["self"] = cyclic
+ cyclic["nested"].append(cyclic)
+ builder.add_custom_variable("cyclic_prop", cyclic)
+
+ # Exercise the standalone idempotency cyclic invariant
+ _test_cyclic_and_stream_pointer_invariants(fdp, builder)
+
# Build and trigger hash serialization
final_payload, files = builder.build()
diff --git a/tests/fuzz/fuzz_headers.py b/tests/fuzz/fuzz_headers.py
index edd3fed..57fc0aa 100755
--- a/tests/fuzz/fuzz_headers.py
+++ b/tests/fuzz/fuzz_headers.py
@@ -46,6 +46,9 @@
"value\x00_null_byte",
"value\x09tab_separated",
"https://api.mailgun.net/v3\r\n\r\n",
+ "锌褉芯褋褌芯-蟹邪谐芯谢芯胁芯泻",
+ "header-with-emoji-馃殌",
+ "\ud800\udc00",
]
@@ -118,7 +121,7 @@ def TestOneInput(data: bytes) -> None:
if "\r" in v or "\n" in v or "\x00" in v:
raise RuntimeError(f"INJECTION LEAK in header value: {repr(v)}")
- except (TypeError, ValueError):
+ except (TypeError, ValueError, UnicodeEncodeError):
# Expected security rejection for malformed headers or control characters
pass
except Exception as e:
diff --git a/tests/fuzz/fuzz_pagination_stream.py b/tests/fuzz/fuzz_pagination_stream.py
index 86e4cd9..5782b43 100644
--- a/tests/fuzz/fuzz_pagination_stream.py
+++ b/tests/fuzz/fuzz_pagination_stream.py
@@ -26,6 +26,8 @@
"https://api.mailgun.net/v3/events?limit=10&ascending=true&ascending=false",
"https://api.mailgun.net/v3/events?tags=tag1&tags=tag2&tags=tag3",
"https://api.mailgun.net/v3/events?\x00=corrupted",
+ "https://api.mailgun.net/v3/events?ascending=yes&limit=NaN&score=Infinity",
+ "https://api.mailgun.net/v3/events?tags=promo&tags=newsletter&threshold=1e300",
]
@@ -48,6 +50,11 @@ def TestOneInput(data: bytes) -> None:
["delivered", "failed", "opened", "clicked", None]
)
+ if fdp.ConsumeBool():
+ initial_filters["tags"] = [
+ fdp.ConsumeUnicodeNoSurrogates(10) for _ in range(fdp.ConsumeIntInRange(1, 3))
+ ]
+
# Next URL selection: either from hostile seeds or dynamically fuzzed
if fdp.ConsumeBool():
next_url = fdp.PickValueInList(_HOSTILE_PAGING_URLS)
diff --git a/tests/fuzz/fuzz_spamguard.py b/tests/fuzz/fuzz_spamguard.py
index b0c9d0e..797b685 100644
--- a/tests/fuzz/fuzz_spamguard.py
+++ b/tests/fuzz/fuzz_spamguard.py
@@ -18,12 +18,10 @@
logging.disable(logging.CRITICAL)
-_MALFORMED_HTML_SNIPPETS = [
+_BENIGN_MALFORMED_SNIPPETS = [
'Click here',
'
',
'