7171 from requests .models import Response
7272
7373
74+ # Public API
75+ __all__ = [
76+ "APIVersion" ,
77+ "Config" ,
78+ "BaseEndpoint" ,
79+ "Endpoint" ,
80+ "AsyncEndpoint" ,
81+ "Client" ,
82+ "AsyncClient" ,
83+ "ApiError" ,
84+ ]
85+
7486logger = logging .getLogger ("mailgun.client" )
7587# Ensure logger doesn't stay silent if the user hasn't configured basicConfig
7688if not logger .hasHandlers ():
7789 logger .addHandler (logging .NullHandler ())
7890
79- HANDLERS : dict [str , Callable ] = { # type: ignore[type-arg]
91+ HANDLERS : dict [str , Callable [..., str ] ] = { # type: ignore[type-arg]
8092 "resendmessage" : handle_resend_message ,
8193 "domains" : handle_domains ,
8294 "domainlist" : handle_domainlist ,
@@ -187,6 +199,7 @@ def __init__(self, api_url: str | None = None) -> None: # noqa: D107
187199 self .ex_handler : bool = True
188200 base_url_input : str = api_url or self .DEFAULT_API_URL
189201 self .api_url : str = self ._sanitize_url (base_url_input )
202+ self ._validate_api_url ()
190203
191204 @staticmethod
192205 def _sanitize_url (raw_url : str ) -> str :
@@ -197,6 +210,15 @@ def _sanitize_url(raw_url: str) -> str:
197210 raw_url = f"https://{ raw_url } "
198211 return raw_url .rstrip ("/" )
199212
213+ def _validate_api_url (self ) -> None :
214+ """DX Guardrail & CWE-319: Warn on cleartext HTTP transmission."""
215+ parsed = urlparse (self .api_url )
216+ if parsed .scheme == "http" and parsed .hostname not in ("localhost" , "127.0.0.1" ):
217+ logger .warning (
218+ "SECURITY WARNING: Cleartext HTTP transmission detected in API URL. "
219+ "Use 'https://' to prevent CWE-319 vulnerabilities."
220+ )
221+
200222 @classmethod
201223 def _sanitize_key (cls , key : str ) -> str :
202224 """Normalize and validate the endpoint key."""
@@ -322,7 +344,7 @@ def build_url(
322344 domain : str | None = None ,
323345 method : str | None = None ,
324346 ** kwargs : Any ,
325- ) -> Any :
347+ ) -> str :
326348 """Build final request url using predefined handlers.
327349
328350 Note: Some urls are being built in Config class, as they can't be generated dynamically.
@@ -350,8 +372,8 @@ def api_call(
350372 headers : dict [str , str ],
351373 data : Any | None = None ,
352374 filters : Mapping [str , str | Any ] | None = None ,
353- timeout : int = 60 ,
354- files : dict [ str , bytes ] | None = None ,
375+ timeout : int | float | tuple [ float , float ] = 60 ,
376+ files : Any | None = None ,
355377 domain : str | None = None ,
356378 ** kwargs : Any ,
357379 ) -> Response | Any :
@@ -399,18 +421,20 @@ def api_call(
399421 stream = False ,
400422 )
401423
402- try :
403- is_error = response .status_code >= 400
404- except TypeError :
405- is_error = False
424+ status_code = getattr (response , "status_code" , 200 )
425+ is_error = isinstance (status_code , int ) and status_code >= 400
406426
407427 if is_error :
428+ # Prevent showing huge HTML-pages in logging
429+ raw_text = getattr (response , "text" , "" )
430+ error_body = raw_text [:500 ] + "..." if len (raw_text ) > 500 else raw_text
431+
408432 logger .error (
409433 "API Error %s | %s %s | Response: %s" ,
410- response . status_code ,
434+ status_code ,
411435 method .upper (),
412436 target_url ,
413- getattr ( response , "text" , "" ) ,
437+ error_body ,
414438 )
415439 else :
416440 logger .debug (
@@ -422,9 +446,9 @@ def api_call(
422446
423447 return response
424448
425- except requests .exceptions .Timeout :
449+ except requests .exceptions .Timeout as e :
426450 logger .error ("Timeout Error: %s %s" , method .upper (), target_url )
427- raise TimeoutError
451+ raise TimeoutError from e
428452 except requests .RequestException as e :
429453 logger .critical ("Request Exception: %s | URL: %s" , e , target_url )
430454 raise ApiError (e ) from e
@@ -462,7 +486,7 @@ def create(
462486 filters : Mapping [str , str | Any ] | None = None ,
463487 domain : str | None = None ,
464488 headers : Any = None ,
465- files : dict [ str , bytes ] | None = None ,
489+ files : Any | None = None ,
466490 ** kwargs : Any ,
467491 ) -> Response :
468492 """POST method for API calls.
@@ -476,18 +500,18 @@ def create(
476500 :param headers: incoming headers
477501 :type headers: dict[str, str]
478502 :param files: incoming files
479- :type files: dict[str, Any] | None
503+ :type files: Any | None = None,
480504 :param kwargs: kwargs
481505 :type kwargs: Any
482506 :return: api_call POST request
483507 :rtype: requests.models.Response
484508 """
485509 req_headers = self .headers .copy ()
486510
487- is_json = "application/json" in (req_headers .get ("Content-Type" ), headers )
511+ if headers and isinstance (headers , dict ):
512+ req_headers .update (headers )
488513
489- if is_json :
490- req_headers ["Content-Type" ] = "application/json"
514+ if req_headers .get ("Content-Type" ) == "application/json" :
491515 if data is not None and not isinstance (data , (str , bytes )):
492516 data = json .dumps (data )
493517
@@ -574,13 +598,20 @@ def update(
574598 :return: api_call PUT request
575599 :rtype: requests.models.Response
576600 """
577- if self .headers .get ("Content-Type" ) == "application/json" :
578- data = json .dumps (data ) if data is not None else None
601+ custom_headers = kwargs .pop ("headers" , {})
602+ req_headers = self .headers .copy ()
603+ if custom_headers and isinstance (custom_headers , dict ):
604+ req_headers .update (custom_headers )
605+
606+ if req_headers .get ("Content-Type" ) == "application/json" :
607+ if data is not None and not isinstance (data , (str , bytes )):
608+ data = json .dumps (data )
609+
579610 return self .api_call (
580611 self ._auth ,
581612 "put" ,
582613 self ._url ,
583- headers = self . headers ,
614+ headers = req_headers ,
584615 data = data ,
585616 filters = filters ,
586617 ** kwargs ,
@@ -635,6 +666,14 @@ def __getattr__(self, name: str) -> Any:
635666 url , headers = self .config [name ]
636667 return Endpoint (url = url , headers = headers , auth = self .auth )
637668
669+ def __repr__ (self ) -> str :
670+ """OWASP Secrets Management: Redact sensitive information from object representation.
671+
672+ Returns:
673+ str: A redacted string representation of the Client instance.
674+ """
675+ return f"<{ self .__class__ .__name__ } api_url={ self .config .api_url !r} >"
676+
638677
639678class AsyncEndpoint (BaseEndpoint ):
640679 """Generate async request and return response using httpx."""
@@ -671,8 +710,8 @@ async def api_call(
671710 headers : dict [str , str ],
672711 data : Any | None = None ,
673712 filters : Mapping [str , str | Any ] | None = None ,
674- timeout : int = 60 ,
675- files : dict [ str , bytes ] | None = None ,
713+ timeout : int | float | tuple [ float , float ] = 60 ,
714+ files : Any | None = None ,
676715 domain : str | None = None ,
677716 ** kwargs : Any ,
678717 ) -> HttpxResponse :
@@ -715,7 +754,7 @@ async def api_call(
715754 "timeout" : timeout ,
716755 }
717756
718- # For httpx
757+ # Deprecation Warning for httpx
719758 if isinstance (data , (str , bytes )):
720759 request_kwargs ["content" ] = data
721760 else :
@@ -726,18 +765,20 @@ async def api_call(
726765 try :
727766 response = await self ._client .request (** request_kwargs )
728767
729- try :
730- is_error = response .status_code >= 400
731- except TypeError :
732- is_error = False
768+ status_code = getattr (response , "status_code" , 200 )
769+ is_error = isinstance (status_code , int ) and status_code >= 400
733770
734771 if is_error :
772+ # Prevent showing huge HTML-pages in logging
773+ raw_text = getattr (response , "text" , "" )
774+ error_body = raw_text [:500 ] + "..." if len (raw_text ) > 500 else raw_text
775+
735776 logger .error (
736777 "API Error %s | %s %s | Response: %s" ,
737- response . status_code ,
778+ status_code ,
738779 method .upper (),
739780 target_url ,
740- getattr ( response , "text" , "" ) ,
781+ error_body ,
741782 )
742783 else :
743784 logger .debug (
@@ -749,14 +790,12 @@ async def api_call(
749790
750791 return response
751792
752- except httpx .TimeoutException :
793+ except httpx .TimeoutException as e :
753794 logger .error ("Timeout Error: %s %s" , method .upper (), target_url )
754- raise TimeoutError
795+ raise TimeoutError from e
755796 except httpx .RequestError as e :
756797 logger .critical ("Request Exception: %s | URL: %s" , e , target_url )
757- raise ApiError (e )
758- except Exception as e :
759- raise e
798+ raise ApiError (e ) from e
760799
761800 async def get (
762801 self ,
@@ -791,7 +830,7 @@ async def create(
791830 filters : Mapping [str , str | Any ] | None = None ,
792831 domain : str | None = None ,
793832 headers : Any = None ,
794- files : dict [ str , bytes ] | None = None ,
833+ files : Any | None = None ,
795834 ** kwargs : Any ,
796835 ) -> httpx .Response :
797836 """POST method for async API calls.
@@ -805,18 +844,18 @@ async def create(
805844 :param headers: incoming headers
806845 :type headers: dict[str, str]
807846 :param files: incoming files
808- :type files: dict[str, Any] | None
847+ :type files: Any | None = None,
809848 :param kwargs: kwargs
810849 :type kwargs: Any
811850 :return: api_call POST request
812851 :rtype: httpx.Response
813852 """
814853 req_headers = self .headers .copy ()
815854
816- is_json = "application/json" in (req_headers .get ("Content-Type" ), headers )
855+ if headers and isinstance (headers , dict ):
856+ req_headers .update (headers )
817857
818- if is_json :
819- req_headers ["Content-Type" ] = "application/json"
858+ if req_headers .get ("Content-Type" ) == "application/json" :
820859 if data is not None and not isinstance (data , (str , bytes )):
821860 data = json .dumps (data )
822861
@@ -903,13 +942,20 @@ async def update(
903942 :return: api_call PUT request
904943 :rtype: httpx.Response
905944 """
906- if self .headers .get ("Content-Type" ) == "application/json" :
907- data = json .dumps (data )
945+ custom_headers = kwargs .pop ("headers" , {})
946+ req_headers = self .headers .copy ()
947+ if custom_headers and isinstance (custom_headers , dict ):
948+ req_headers .update (custom_headers )
949+
950+ if req_headers .get ("Content-Type" ) == "application/json" :
951+ if data is not None and not isinstance (data , (str , bytes )):
952+ data = json .dumps (data )
953+
908954 return await self .api_call (
909955 self ._auth ,
910956 "put" ,
911957 self ._url ,
912- headers = self . headers ,
958+ headers = req_headers ,
913959 data = data ,
914960 filters = filters ,
915961 ** kwargs ,
0 commit comments