Skip to content

Commit 3890005

Browse files
committed
refactor(client): implement O(1) registry routing and secure URI interpolation
This modernization transitions the SDK from a dynamic __getattr__ resolution mechanism to an immutable, O(1) Registry-First architecture, improving cold-boot performance and establishing strict API boundaries. Security (CWE-22): - Mitigated Path Traversal vulnerabilities in Endpoint._build_url. Dynamic URI template variables (e.g., {id}, {action_id}) are now strictly sanitized via urllib.parse.quote(safe=) prior to regex interpolation. Architecture & DX: - Added mailjet_rest/routes.py containing an immutable MappingProxyType registry defining exact API versions and paths for all resources. - Introduced TemplateContentBuilder with fail-fast Boundary Parsing to enforce schema correctness before network execution. - Removed legacy _DYNAMIC_ENDPOINTS tuple, completely decoupling the Client from hardcoded resource lists. Testing: - Deployed a data-driven, parameterized test suite covering 50+ registry combinations, proving 100% parity with legacy routing logic and explicit separation between Content API (v1) and Email API (v3).
1 parent 20b36db commit 3890005

7 files changed

Lines changed: 471 additions & 53 deletions

File tree

mailjet_rest/builders.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ def set_content(self, text: str | None = None, html: str | None = None) -> Self:
118118
self._msg["HTMLPart"] = html
119119
return self
120120

121+
def set_headers(self, headers: dict[str, str]) -> Self:
122+
"""Set custom headers (e.g., Reply-To, X-Custom).
123+
124+
Args:
125+
headers (dict[str, str]): Custom key-value string pairs.
126+
127+
Returns:
128+
Self: The builder instance for method chaining.
129+
"""
130+
self._msg["Headers"] = headers
131+
return self
132+
121133
def set_template(self, template_id: int, enable_language: bool = True) -> Self:
122134
"""Use a pre-defined Mailjet Template.
123135
@@ -188,3 +200,77 @@ def build(self) -> SendV31Message:
188200
raise ValueError(msg)
189201

190202
return self._msg # type: ignore[return-value]
203+
204+
205+
class TemplateContentBuilder:
206+
"""Builder for /template/{id}/contents API payloads."""
207+
208+
__slots__ = ("_data",)
209+
210+
def __init__(self) -> None:
211+
"""Initialize an empty template contents data payload descriptor."""
212+
self._data: dict[str, Any] = {}
213+
214+
def set_meta(self, author: str | None = None, name: str | None = None, locale: str = "en_US") -> Self:
215+
"""Set core template identity and structural configuration attributes.
216+
217+
Args:
218+
author (str | None): Optional author identifier name.
219+
name (str | None): Optional unique template layout identifier name.
220+
locale (str): Language and country locale definition (default: "en_US").
221+
222+
Returns:
223+
Self: The builder instance for method chaining.
224+
"""
225+
if author:
226+
self._data["Author"] = author
227+
if name:
228+
self._data["Name"] = name
229+
self._data["Locale"] = locale
230+
return self
231+
232+
def set_content(self, text: str | None = None, html: str | None = None, mjml: str | None = None) -> Self:
233+
"""Set content keys as per API documentation.
234+
235+
Args:
236+
text (str | None): Plain text part component.
237+
html (str | None): Rendered raw HTML layout sequence.
238+
mjml (str | None): Semantic responsive MJML markup representation.
239+
240+
Returns:
241+
Self: The builder instance for method chaining.
242+
"""
243+
if text:
244+
self._data["TextPart"] = text
245+
if html:
246+
self._data["HTMLPart"] = html
247+
if mjml:
248+
self._data["MJMLPart"] = mjml
249+
return self
250+
251+
def set_headers(self, headers: dict[str, str]) -> Self:
252+
"""Sets the Headers JSON object structure crossing the ingress gate.
253+
254+
Args:
255+
headers (dict[str, str]): Custom key-value structural protocol attributes.
256+
257+
Returns:
258+
Self: The builder instance for method chaining.
259+
"""
260+
self._data["Headers"] = headers
261+
return self
262+
263+
def build(self) -> dict[str, Any]:
264+
"""Validate and return the completed templates engine schema dictionary.
265+
266+
Returns:
267+
dict[str, Any]: Fully validated parsed dynamic payload payload mapping.
268+
269+
Raises:
270+
ValueError: If no valid text, html or mjml boundary tokens are passed.
271+
"""
272+
if not any(k in self._data for k in ("TextPart", "HTMLPart", "MJMLPart")):
273+
msg = "Template validation failed: At least one of text, html, or mjml content is required."
274+
raise ValueError(msg)
275+
276+
return self._data

mailjet_rest/client.py

Lines changed: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from mailjet_rest.errors import MailjetAuthError
3535
from mailjet_rest.errors import TimeoutError # noqa: A004
3636
from mailjet_rest.errors import ValidationError
37+
from mailjet_rest.routes import ROUTE_MAP
3738
from mailjet_rest.types import _ALLOWED_TRACE_FIELDS
3839
from mailjet_rest.utils.guardrails import RedactingFilter
3940
from mailjet_rest.utils.guardrails import SecureHTTPAdapter
@@ -171,50 +172,6 @@ class Client:
171172
respect_retry_after_header=True, # To prevent aggressive polling
172173
)
173174

174-
_DYNAMIC_ENDPOINTS: ClassVar[tuple[str, ...]] = (
175-
"send",
176-
"contact",
177-
"contactdata",
178-
"contactmetadata",
179-
"contactslist",
180-
"contact_managemanycontacts",
181-
"contactfilter",
182-
"csvimport",
183-
"listrecipient",
184-
"campaign",
185-
"campaigndraft",
186-
"campaigndraft_schedule",
187-
"campaigndraft_send",
188-
"campaigndraft_test",
189-
"campaigndraft_detailcontent",
190-
"newsletter",
191-
"message",
192-
"messagehistory",
193-
"messageinformation",
194-
"template",
195-
"templates",
196-
"template_detailcontent",
197-
"templates_contents",
198-
"token",
199-
"data_images",
200-
"statcounters",
201-
"contactstatistics",
202-
"liststatistics",
203-
"statistics_linkClick",
204-
"statistics_recipientEsp",
205-
"geostatistics",
206-
"toplinkclicked",
207-
"eventcallbackurl",
208-
"parseroute",
209-
"dns",
210-
"dns_check",
211-
"sender",
212-
"sender_validate",
213-
"apikey",
214-
"user",
215-
"myprofile",
216-
)
217-
218175
config: Config
219176
session: requests.Session
220177
_endpoint_cache: dict[str, Endpoint]
@@ -311,10 +268,20 @@ def __getattr__(self, name: str) -> Endpoint:
311268
Returns:
312269
Endpoint: An Endpoint instance for the requested resource.
313270
"""
314-
SecurityGuard.validate_attribute_access(self.__class__.__qualname__, name)
271+
# 1. Check Cache
272+
if name in self._endpoint_cache:
273+
return self._endpoint_cache[name]
274+
275+
# 2. Registry Check & Security Validation
276+
# If it's not in the registry, we validate it via SecurityGuard.
277+
# This keeps the "fail-fast" security behavior for unknown attributes.
278+
if name not in ROUTE_MAP:
279+
SecurityGuard.validate_attribute_access(self.__class__.__qualname__, name)
315280

316-
if name not in self._endpoint_cache:
317-
self._endpoint_cache[name] = Endpoint(self, name)
281+
# 3. Instantiate and Cache
282+
# Endpoint._build_url handles the URL resolution internally
283+
# using the ROUTE_MAP or dynamic fallback strategies.
284+
self._endpoint_cache[name] = Endpoint(self, name)
318285

319286
return self._endpoint_cache[name]
320287

@@ -341,7 +308,7 @@ def __dir__(self) -> list[str]:
341308
list[str]: A sorted list of all standard attributes and dynamic API endpoints.
342309
"""
343310
standard_attrs = list(super().__dir__())
344-
return sorted(set(standard_attrs + list(self._DYNAMIC_ENDPOINTS)))
311+
return sorted(set(standard_attrs + list(ROUTE_MAP.keys())))
345312

346313
# --- Public API ---
347314

mailjet_rest/endpoint.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
from __future__ import annotations
44

5+
import re
56
import warnings
67
from dataclasses import dataclass
78
from dataclasses import field
89
from typing import TYPE_CHECKING
910
from typing import Any
1011
from urllib.parse import quote
1112

13+
from mailjet_rest.routes import ROUTE_MAP
1214
from mailjet_rest.types import _JSON_HEADERS
1315
from mailjet_rest.types import _TEXT_HEADERS
1416
from mailjet_rest.types import HttpMethod
@@ -118,15 +120,53 @@ def _build_csv_url(base_url: str, version: str, resource: str, name_lower: str,
118120
return url
119121

120122
def _build_url(self, id_val: int | str | None = None, action_id: int | str | None = None) -> str:
121-
"""Construct the URL for the specific API request.
123+
"""Constructs the fully qualified API URL.
124+
125+
Leverages immutable static registry routing mappings with URI template
126+
safe injection gates to fail-closed against cross-boundary vulnerabilities.
122127
123128
Args:
124-
id_val (int | str | None): The primary resource ID.
125-
action_id (int | str | None): The sub-action ID.
129+
id_val (int | str | None): The resource ID.
130+
action_id (int | str | None): Additional specific resource action id.
126131
127132
Returns:
128-
str: The fully qualified URL.
133+
str: The fully qualified, sanitized secure URL.
129134
"""
135+
# 1. Registry-First Routing (Express Lane Orchestrator)
136+
if self.name in ROUTE_MAP:
137+
route = ROUTE_MAP[self.name]
138+
base_url = self.client.config.api_url.rstrip("/")
139+
version = route.version if route.version is not None else self.client.config.version
140+
141+
# Validate structural DX constraints prior to assembly
142+
SecurityGuard.validate_dx_routing(version, self._name_lower, self._resource_lower)
143+
144+
path = route.path
145+
146+
# Enforce centralized sanitization layer directly on URI template parameters
147+
if id_val is not None and "{" in path:
148+
safe_id = SecurityGuard.sanitize_segment(id_val)
149+
path = re.sub(r"\{[^}]+\}", safe_id, path, count=1)
150+
id_val = None # Parameter fully consumed by the template boundary
151+
152+
if action_id is not None and "{" in path:
153+
safe_action = SecurityGuard.sanitize_segment(action_id)
154+
path = re.sub(r"\{[^}]+\}", safe_action, path, count=1)
155+
action_id = None # Parameter fully consumed by the template boundary
156+
157+
# Assemble clean path matrix
158+
url = f"{base_url}/{version}/{path}"
159+
160+
# Append any unconsumed trailing parameters safely
161+
if id_val is not None:
162+
url += f"/{SecurityGuard.sanitize_segment(id_val)}"
163+
if action_id is not None:
164+
url += f"/{SecurityGuard.sanitize_segment(action_id)}"
165+
166+
return url
167+
168+
# 2. Existing Fallback (Legacy Support)
169+
# This keeps original routing logic running for everything else
130170
base_url = self.client.config.api_url.rstrip("/")
131171
version = self.client.config.version
132172

mailjet_rest/routes.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Static routing mappings table and compilation rules registry."""
2+
3+
from __future__ import annotations
4+
5+
from types import MappingProxyType
6+
from typing import Final
7+
from typing import NamedTuple
8+
9+
10+
class Route(NamedTuple):
11+
"""Named tuple descriptor mapping localized API boundaries.
12+
13+
Attributes:
14+
version (str | None): Hardcoded version overwrite or None for dynamic fallback.
15+
path (str): Fully qualified URL template path inside the API target.
16+
"""
17+
18+
version: str | None
19+
path: str
20+
21+
22+
RouteMapType = dict[str, Route]
23+
24+
25+
_ROUTE_MAP: RouteMapType = {
26+
# ==========================================
27+
# Send API & Batching
28+
# ==========================================
29+
"send": Route(None, "send"),
30+
"batch": Route(None, "batch"),
31+
"batchjob": Route(None, "REST/batchjob"),
32+
# ==========================================
33+
# Contacts & Contact Lists
34+
# ==========================================
35+
"contact": Route(None, "REST/contact"),
36+
"contactdata": Route(None, "REST/contactdata"),
37+
"contactmetadata": Route(None, "REST/contactmetadata"),
38+
"contactslist": Route(None, "REST/contactslist"),
39+
"contactfilter": Route(None, "REST/contactfilter"),
40+
"contactslistsignup": Route(None, "REST/contactslistsignup"),
41+
"csvimport": Route(None, "REST/csvimport"),
42+
"listrecipient": Route(None, "REST/listrecipient"),
43+
# Contact Actions (Sub-resources)
44+
"contact_managemanycontacts": Route(None, "REST/contact/managemanycontacts"),
45+
"contactslist_managemanycontacts": Route(None, "REST/contactslist/{id}/managemanycontacts"),
46+
"contact_getcontactslists": Route(None, "REST/contact/{id}/getcontactslists"),
47+
# ==========================================
48+
# Campaigns & Newsletters
49+
# ==========================================
50+
"campaign": Route(None, "REST/campaign"),
51+
"newsletter": Route(None, "REST/newsletter"),
52+
"campaigndraft": Route(None, "REST/campaigndraft"),
53+
# Campaign Actions
54+
"campaigndraft_schedule": Route(None, "REST/campaigndraft/{id}/schedule"),
55+
"campaigndraft_send": Route(None, "REST/campaigndraft/{id}/send"),
56+
"campaigndraft_test": Route(None, "REST/campaigndraft/{id}/test"),
57+
"campaigndraft_detailcontent": Route(None, "REST/campaigndraft/{id}/detailcontent"),
58+
# ==========================================
59+
# Messages & History
60+
# ==========================================
61+
"message": Route(None, "REST/message"),
62+
"messagehistory": Route(None, "REST/messagehistory"),
63+
"messageinformation": Route(None, "REST/messageinformation"),
64+
"messagestate": Route(None, "REST/messagestate"),
65+
# ==========================================
66+
# Templates
67+
# (Email API v3 uses Singular, Content API v1 uses Plural)
68+
# ==========================================
69+
"template": Route(None, "REST/template"),
70+
"templates": Route(None, "REST/templates"),
71+
"template_update": Route(None, "REST/template/{id}"),
72+
"template_detailcontent": Route(None, "REST/template/{id}/detailcontent"),
73+
"templates_contents": Route(None, "REST/templates/{id}/contents"),
74+
# Content API Template Actions
75+
"template_contents": Route("v1", "REST/templates/{id}/contents"),
76+
"template_content_by_type": Route("v1", "REST/templates/{id}/contents/types/{action_id}"),
77+
# ==========================================
78+
# Statistics & Analytics
79+
# ==========================================
80+
"statcounters": Route(None, "REST/statcounters"),
81+
"contactstatistics": Route(None, "REST/contactstatistics"),
82+
"liststatistics": Route(None, "REST/liststatistics"),
83+
"bouncestatistics": Route(None, "REST/bouncestatistics"),
84+
"clickstatistics": Route(None, "REST/clickstatistics"),
85+
"openinformation": Route(None, "REST/openinformation"),
86+
"senderstatistics": Route(None, "REST/senderstatistics"),
87+
"domainstatistics": Route(None, "REST/domainstatistics"),
88+
"campaignstatistics": Route(None, "REST/campaignstatistics"),
89+
"statistics_linkClick": Route(None, "REST/statistics/link-click"),
90+
"statistics_recipientEsp": Route(None, "REST/statistics/recipient-esp"),
91+
"geostatistics": Route(None, "REST/geostatistics"),
92+
"toplinkclicked": Route(None, "REST/toplinkclicked"),
93+
# ==========================================
94+
# Account, Senders & Domains
95+
# ==========================================
96+
"myprofile": Route(None, "REST/myprofile"),
97+
"user": Route(None, "REST/user"),
98+
"apikey": Route(None, "REST/apikey"),
99+
"apikeyaccess": Route(None, "REST/apikeyaccess"),
100+
"apikeytotals": Route(None, "REST/apikeytotals"),
101+
"sender": Route(None, "REST/sender"),
102+
"metasender": Route(None, "REST/metasender"),
103+
"sender_validate": Route(None, "REST/sender/{id}/validate"),
104+
"dns": Route(None, "REST/dns"),
105+
"dns_check": Route(None, "REST/dns/{id}/check"),
106+
# ==========================================
107+
# Webhooks & Parse API
108+
# ==========================================
109+
"eventcallbackurl": Route(None, "REST/eventcallbackurl"),
110+
"webhook": Route(None, "REST/webhook"),
111+
"parseroute": Route(None, "REST/parseroute"),
112+
# ==========================================
113+
# Content API (v1) - Assets, Labels & Tokens
114+
# ==========================================
115+
"tokens": Route("v1", "REST/tokens"),
116+
"labels": Route("v1", "REST/labels"),
117+
"images": Route("v1", "REST/images"),
118+
"data_images": Route("v1", "DATA/images"),
119+
}
120+
121+
ROUTE_MAP: Final = MappingProxyType(_ROUTE_MAP)

0 commit comments

Comments
 (0)