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
4 changes: 0 additions & 4 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ end_of_line = lf
[*.md]
trim_trailing_whitespace = false

[*.bat]
indent_style = tab
end_of_line = crlf

[LICENSE]
insert_final_newline = false

Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ We [keep a changelog.](http://keepachangelog.com/)

## [Unreleased]

## [1.6.0] - 2026-04-27

### Security

- **CWE-22 (Prevented Path Traversal):** Prevented vulnerabilities by enforcing strict URL encoding (`urllib.parse.quote`) on all dynamically injected path parameters (`id` and `action_id`).
Expand Down Expand Up @@ -68,6 +70,9 @@ We [keep a changelog.](http://keepachangelog.com/)
### Pull Requests Merged

- [PR_125](https://github.com/mailjet/mailjet-apiv3-python/pull/125) - Refactor client.
- [PR_126](https://github.com/mailjet/mailjet-apiv3-python/pull/126) - build(deps): bump conda-incubator/setup-miniconda from 3.3.0 to 4.0.1
- [PR_128](https://github.com/mailjet/mailjet-apiv3-python/pull/128) - Release 1.6.0.
- [PR_129](https://github.com/mailjet/mailjet-apiv3-python/pull/129) - Use hyphen in the package name in readme.

## [1.5.1] - 2025-07-14

Expand Down Expand Up @@ -255,4 +260,5 @@ We [keep a changelog.](http://keepachangelog.com/)
[1.4.0]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.4.0
[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
[unreleased]: https://github.com/mailjet/mailjet-apiv3-python/releases/tag/v1.5.1...HEAD
[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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ with Client(auth=(api_key, api_secret), version="v3.1") as mailjet:
(Note:

> **Note**
> If you choose not to use the context manager, you should manually call mailjet.close() when your application shuts down).
> If you choose not to use the context manager, you should manually call mailjet.close() when your application shuts down.

### Advanced Configuration

Expand Down Expand Up @@ -464,7 +464,7 @@ Some requests (for example [GET /contact](https://dev.mailjet.com/email/referenc
`limit` `int` Limit the response to a select number of returned objects. Default value: `10`. Maximum value: `1000`
`offset` `int` Retrieve a list of objects starting from a certain offset. Combine this query parameter with `limit` to retrieve a specific section of the list of objects. Default value: `0`
`sort` `str` Sort the results by a property and select ascending (ASC) or descending (DESC) order. The default order is ascending. Keep in mind that this is not available for all properties. Default value: `ID asc`
Next example returns 40 contacts starting from 51th record sorted by `Email` field descendally:
Next example returns 40 contacts starting from 51st record sorted by `Email` field descendally:

```python
filters = {
Expand Down Expand Up @@ -645,7 +645,7 @@ Feel free to ask anything, and contribute:
- Create a new branch.
- Implement your feature or bug fix.
- Add documentation to it.
- Commit, push, open a pull request and voila.
- Commit, push, open a pull request and voilà.

If you have suggestions on how to improve the guides, please submit an issue in our [Official API Documentation repo](https://github.com/mailjet/api-documentation).

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Please include the following details:

If English is not your first language, please try to describe the
problem and its impact to the best of your ability. For greater detail,
please use your native language and we will try our best to translate it
please use your native language, and we will try our best to translate it
using online services.

Please also include the code you used to find the problem and the
Expand Down
5 changes: 2 additions & 3 deletions conda.recipe/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ requirements:
{% endfor %}
run:
- python
{% for dep in pyproject['project']['dependencies'] %}
- {{ dep.lower() }}
{% endfor %}
- requests >=2.33.0
- typing-extensions >=4.7.1 # [py<311]

test:
imports:
Expand Down
2 changes: 1 addition & 1 deletion mailjet_rest/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.5.1.post1.dev40"
__version__ = "1.6.0"
46 changes: 24 additions & 22 deletions mailjet_rest/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,6 @@ class ApiRateLimitError(ApiError):
# Utilities
# ==========================================


def prepare_url(match: Any) -> str:
"""Replace capital letters in the input string with a dash prefix and convert to lowercase.

Args:
match (Any): A regex match object containing a capital letter.

Returns:
str: A formatted URL string fragment (e.g., '_m').
"""
return f"_{match.group(0).lower()}"


# --- Deprecated Utilities ---


Expand All @@ -141,7 +128,7 @@ def logging_handler(to_file: bool = False, **_kwargs: Any) -> logging.Logger: #

Args:
to_file (bool): Deprecated flag. Output is no longer written to files natively.
**kwargs (Any): Absorbs any other legacy keyword arguments.
**_kwargs (Any): Absorbs any other legacy keyword arguments.

Returns:
logging.Logger: A legacy logger instance to prevent AttributeError in old integrations.
Expand All @@ -152,15 +139,15 @@ def logging_handler(to_file: bool = False, **_kwargs: Any) -> logging.Logger: #
)
warnings.warn(msg, DeprecationWarning, stacklevel=2)

logger = logging.getLogger("mailjet_legacy")
logger.setLevel(logging.DEBUG)
legacy_logger = logging.getLogger("mailjet_legacy")
legacy_logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(levelname)s | %(message)s")
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(formatter)
logger.addHandler(stdout_handler)
legacy_logger.addHandler(stdout_handler)

# Return a safe, isolated logger so downstream code like `logger.debug()` doesn't crash
return logger
return legacy_logger


def parse_response(
Expand All @@ -175,7 +162,7 @@ def parse_response(
response (requests.Response): The HTTP response.
log (Any, optional): Deprecated logging callable.
debug (bool): Deprecated debug flag.
**kwargs (Any): Absorbs any other legacy keyword arguments.
**_kwargs (Any): Absorbs any other legacy keyword arguments.

Returns:
Any: The parsed JSON dictionary or raw text string.
Expand All @@ -194,7 +181,8 @@ def parse_response(
# Soft legacy support: run the logger if explicitly passed without crashing
if debug and callable(log):
with suppress(Exception):
lgr = log()
lgr = cast("logging.Logger", cast("object", log()))

lgr.debug("REQUEST: %s", response.request.url)
lgr.debug("RESPONSE_CODE: %s", response.status_code)
logging.getLogger().handlers.clear()
Expand Down Expand Up @@ -303,8 +291,18 @@ class Endpoint:
def __post_init__(self) -> None:
"""Pre-compute routing strings ONCE instead of on every network call."""
self._name_lower = self.name.lower()
self._action_parts = self.name.split("_")
self._resource_lower = self._action_parts[0].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:
Expand Down Expand Up @@ -621,6 +619,10 @@ class Client:
"myprofile",
)

config: Config
session: requests.Session
_endpoint_cache: dict[str, Endpoint]

# --- Initialization & Magic Methods ---

def __init__(
Expand Down
2 changes: 1 addition & 1 deletion mailjet_rest/utils/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def clean_version(version_str: str) -> tuple[int, ...]:
except (IndexError, ValueError):
return 0, 0, 0
else:
return (major, minor, patch)
return major, minor, patch


# VERSION is a tuple of integers (1, 3, 2).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ namespace_packages = true
pretty = true
# 3rd party import
ignore_missing_imports = true
# flag to suppress Name <var> already defined on line
# flag to suppress Name <var> already defined on a line
allow_redefinition = false
# Disallow dynamic typing
disallow_any_unimported = false
Expand Down
40 changes: 8 additions & 32 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,15 @@
from requests.exceptions import RequestException
from requests.exceptions import Timeout as RequestsTimeout

from mailjet_rest._version import __version__
from mailjet_rest.client import (
ApiError,
Client,
Config,
CriticalApiError,
TimeoutError,
prepare_url,
)
from mailjet_rest.utils.guardrails import SecurityGuard
from mailjet_rest.client import _JSON_HEADERS, _TEXT_HEADERS
from mailjet_rest.client import _JSON_HEADERS, _TEXT_HEADERS # type: ignore[attr-defined]

if TYPE_CHECKING:
# Explicitly import fixture type for MyPy in a type-checking block
Expand Down Expand Up @@ -253,6 +251,12 @@ def test_statcounters_endpoint_routing(client_offline: Client) -> None:
assert url == "https://api.mailjet.com/v3/REST/statcounters"


def test_camel_case_to_dash_routing(client_offline: Client) -> None:
"""Verify that CamelCase endpoints correctly translate to dashed paths (e.g., linkClick -> link-click)."""
url = client_offline.statistics_linkClick._build_url()
assert "link-click" in url, f"Expected 'link-click' in URL, got {url}"


# ==========================================
# 4. HTTP Execution & Network Handling Tests
# ==========================================
Expand Down Expand Up @@ -413,34 +417,6 @@ def mock_request(method: str, url: str, **kwargs: Any) -> requests.Response:
# Calling with action_id but no id
client_offline.contact.get(action_id=123)


def test_prepare_url_headers_and_url() -> None:
assert prepare_url(re.search(r"[A-Z]", "MyURL")) == "_m"


def test_prepare_url_mixed_case_input() -> None:
match = re.search(r"[A-Z]", "mixedCaseInput")
assert match is not None
assert prepare_url(match) == "_c"


def test_prepare_url_empty_input() -> None:
match = re.search(r"[A-Z]", "")
assert match is None


def test_prepare_url_with_numbers_input_bad() -> None:
match = re.search(r"[A-Z]", "url1With2Numbers")
assert match is not None
assert prepare_url(match) == "_w"


def test_prepare_url_leading_trailing_underscores_input_bad() -> None:
match = re.search(r"[A-Z]", "_urlWithUnderscores_")
assert match is not None
assert prepare_url(match) == "_w"


# ==========================================
# 5. Resource Management (Context Managers)
# ==========================================
Expand Down Expand Up @@ -534,7 +510,7 @@ def test_endpoint_precomputes_routing_strings(client_offline: Client) -> None:
endpoint = getattr(client_offline, "Contact_Data")

assert getattr(endpoint, "_name_lower") == "contact_data"
assert getattr(endpoint, "_action_parts") == ["Contact", "Data"]
assert getattr(endpoint, "_action_parts") == ["contact", "data"]
assert getattr(endpoint, "_resource_lower") == "contact"


Expand Down
Loading