mailhive 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mailhive/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """The official Python SDK for Mailhive Send.
2
+
3
+ from mailhive import Mailhive
4
+
5
+ client = Mailhive() # reads MAILHIVE_API_KEY
6
+ client.emails.send({"from": "hello@acme.com", "to": "ada@example.com", "subject": "Hi", "text": "Hello"})
7
+ """
8
+
9
+ from ._client import DEFAULT_BASE_URL, AsyncMailhive, Mailhive
10
+ from ._errors import (
11
+ APIConnectionError,
12
+ APIError,
13
+ AuthenticationError,
14
+ BillingError,
15
+ ConflictError,
16
+ MailhiveError,
17
+ NotFoundError,
18
+ PermissionDeniedError,
19
+ RateLimitError,
20
+ ValidationError,
21
+ WebhookVerificationError,
22
+ )
23
+ from ._types import AcceptedEmail, Attachment, Email, SendEmailParams
24
+ from ._version import __version__
25
+ from ._webhooks import verify as verify_webhook
26
+
27
+ __all__ = [
28
+ "DEFAULT_BASE_URL",
29
+ "APIConnectionError",
30
+ "APIError",
31
+ "AcceptedEmail",
32
+ "AsyncMailhive",
33
+ "Attachment",
34
+ "AuthenticationError",
35
+ "BillingError",
36
+ "ConflictError",
37
+ "Email",
38
+ "Mailhive",
39
+ "MailhiveError",
40
+ "NotFoundError",
41
+ "PermissionDeniedError",
42
+ "RateLimitError",
43
+ "SendEmailParams",
44
+ "ValidationError",
45
+ "WebhookVerificationError",
46
+ "__version__",
47
+ "verify_webhook",
48
+ ]
mailhive/_client.py ADDED
@@ -0,0 +1,287 @@
1
+ """The Mailhive Send clients: Mailhive (sync) and AsyncMailhive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import os
9
+ import platform
10
+ import random
11
+ import time
12
+ import uuid
13
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
14
+
15
+ import httpx
16
+
17
+ from ._errors import APIConnectionError, APIError, MailhiveError, error_for
18
+ from ._types import AcceptedEmail, Email, SendEmailParams
19
+ from ._version import __version__
20
+ from ._webhooks import verify as _verify_webhook
21
+
22
+ DEFAULT_BASE_URL = "https://api.mailhive.africa/v1"
23
+ _MAX_RETRY_AFTER = 60.0
24
+
25
+
26
+ def _backoff(attempt: int) -> float:
27
+ """About 0.5s, 1s, 2s … up to 8s, with jitter."""
28
+ ceiling = min(8.0, 0.5 * 2**attempt)
29
+ return ceiling / 2 + random.random() * ceiling / 2
30
+
31
+
32
+ def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
33
+ value = headers.get("retry-after")
34
+ try:
35
+ seconds = float(value) if value not in (None, "") else None
36
+ except ValueError:
37
+ return None
38
+ return seconds if seconds is not None and seconds >= 0 else None
39
+
40
+
41
+ def _encode_email(params: Mapping[str, Any]) -> Dict[str, Any]:
42
+ email = dict(params)
43
+ if "from_" in email:
44
+ email["from"] = email.pop("from_")
45
+ attachments = email.get("attachments")
46
+ if attachments:
47
+ email["attachments"] = [
48
+ {**a, "content": base64.b64encode(a["content"]).decode()} if isinstance(a.get("content"), (bytes, bytearray)) else dict(a)
49
+ for a in attachments
50
+ ]
51
+ return email
52
+
53
+
54
+ class _Base:
55
+ def __init__(
56
+ self,
57
+ api_key: Optional[str],
58
+ base_url: Optional[str],
59
+ timeout: float,
60
+ max_retries: int,
61
+ ) -> None:
62
+ key = api_key or os.environ.get("MAILHIVE_API_KEY")
63
+ if not key:
64
+ raise MailhiveError(
65
+ "No API key. Pass one to Mailhive(...) or set MAILHIVE_API_KEY. "
66
+ "Create keys under Mailhive Send → API keys."
67
+ )
68
+ if key.startswith("mhp_"):
69
+ raise MailhiveError(
70
+ "That's a form's publishable key (mhp_…). The server SDK needs a secret API key (mhs_…) "
71
+ "from Mailhive Send → API keys."
72
+ )
73
+ self._api_key = key
74
+ self.base_url = (base_url or os.environ.get("MAILHIVE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
75
+ self.timeout = timeout
76
+ self.max_retries = max(0, max_retries)
77
+
78
+ def __repr__(self) -> str: # never shows the key
79
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
80
+
81
+ def _prepare(self, method: str, body: Any, idempotency_key: Optional[str]) -> Tuple[Dict[str, str], Optional[bytes]]:
82
+ headers = {
83
+ "Authorization": f"Bearer {self._api_key}",
84
+ "Accept": "application/json",
85
+ "User-Agent": f"mailhive-python/{__version__} python/{platform.python_version()}",
86
+ }
87
+ content = None
88
+ if body is not None:
89
+ headers["Content-Type"] = "application/json"
90
+ content = json.dumps(body).encode()
91
+ if method == "POST":
92
+ # One key per call, reused on every retry: a retry never sends twice.
93
+ headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
94
+ return headers, content
95
+
96
+ @staticmethod
97
+ def _error(response: httpx.Response) -> APIError:
98
+ try:
99
+ error = response.json().get("error") or {}
100
+ except (ValueError, AttributeError):
101
+ error = {}
102
+ return error_for(
103
+ error.get("message") or f"HTTP {response.status_code} {response.reason_phrase}".strip(),
104
+ status=response.status_code,
105
+ code=error.get("code") or "http_error",
106
+ details=error.get("details"),
107
+ request_id=error.get("request_id") or response.headers.get("x-request-id"),
108
+ headers=response.headers,
109
+ )
110
+
111
+ def _wait_before_retry(self, error: APIError, attempt: int) -> Optional[float]:
112
+ """Seconds to wait before retrying, or None to give up. Only a rate
113
+ limit and server errors are worth retrying."""
114
+ if attempt >= self.max_retries:
115
+ return None
116
+ if not (error.status >= 500 or (error.status == 429 and error.code == "rate_limited")):
117
+ return None
118
+ wait = _retry_after({k.lower(): v for k, v in error.headers.items()})
119
+ if wait is not None and wait > _MAX_RETRY_AFTER:
120
+ return None
121
+ return _backoff(attempt) if wait is None else wait
122
+
123
+
124
+ class Emails:
125
+ def __init__(self, client: "Mailhive") -> None:
126
+ self._client = client
127
+
128
+ def send(
129
+ self, params: Optional[SendEmailParams] = None, *, idempotency_key: Optional[str] = None, **fields: Any
130
+ ) -> AcceptedEmail:
131
+ """Sends one email. Pass the fields as a dict, or as keyword
132
+ arguments with ``from_`` for ``from``."""
133
+ return self._client._request("POST", "/send/emails", _encode_email({**(params or {}), **fields}), idempotency_key)
134
+
135
+ def send_batch(self, emails: List[SendEmailParams], *, idempotency_key: Optional[str] = None) -> List[AcceptedEmail]:
136
+ """Up to 100 independent emails; all are accepted, or none."""
137
+ response = self._client._request(
138
+ "POST", "/send/emails/batch", {"emails": [_encode_email(e) for e in emails]}, idempotency_key
139
+ )
140
+ return response["data"]
141
+
142
+ def get(self, email_id: str) -> Email:
143
+ return self._client._request("GET", f"/send/emails/{_quote(email_id)}", None, None)
144
+
145
+
146
+ class AsyncEmails:
147
+ def __init__(self, client: "AsyncMailhive") -> None:
148
+ self._client = client
149
+
150
+ async def send(
151
+ self, params: Optional[SendEmailParams] = None, *, idempotency_key: Optional[str] = None, **fields: Any
152
+ ) -> AcceptedEmail:
153
+ return await self._client._request("POST", "/send/emails", _encode_email({**(params or {}), **fields}), idempotency_key)
154
+
155
+ async def send_batch(self, emails: List[SendEmailParams], *, idempotency_key: Optional[str] = None) -> List[AcceptedEmail]:
156
+ response = await self._client._request(
157
+ "POST", "/send/emails/batch", {"emails": [_encode_email(e) for e in emails]}, idempotency_key
158
+ )
159
+ return response["data"]
160
+
161
+ async def get(self, email_id: str) -> Email:
162
+ return await self._client._request("GET", f"/send/emails/{_quote(email_id)}", None, None)
163
+
164
+
165
+ def _quote(value: str) -> str:
166
+ from urllib.parse import quote
167
+
168
+ return quote(value, safe="")
169
+
170
+
171
+ class _Webhooks:
172
+ @staticmethod
173
+ def verify(payload, signature, secret, *, tolerance: int = 300, now: Optional[float] = None) -> Dict[str, Any]:
174
+ return _verify_webhook(payload, signature, secret, tolerance=tolerance, now=now)
175
+
176
+
177
+ class Mailhive(_Base):
178
+ """The Mailhive Send API client.
179
+
180
+ ``Mailhive()`` reads MAILHIVE_API_KEY. Use it as a context manager, or
181
+ call ``close()``, to release its connections."""
182
+
183
+ webhooks = _Webhooks()
184
+
185
+ def __init__(
186
+ self,
187
+ api_key: Optional[str] = None,
188
+ *,
189
+ base_url: Optional[str] = None,
190
+ timeout: float = 30.0,
191
+ max_retries: int = 2,
192
+ http_client: Optional[httpx.Client] = None,
193
+ ) -> None:
194
+ super().__init__(api_key, base_url, timeout, max_retries)
195
+ self._http = http_client or httpx.Client(timeout=timeout)
196
+ self._owns_http = http_client is None
197
+ self.emails = Emails(self)
198
+
199
+ def _request(self, method: str, path: str, body: Any, idempotency_key: Optional[str]) -> Any:
200
+ headers, content = self._prepare(method, body, idempotency_key)
201
+ url = f"{self.base_url}{path}"
202
+ attempt = 0
203
+ while True:
204
+ try:
205
+ response = self._http.request(method, url, headers=headers, content=content, timeout=self.timeout)
206
+ except httpx.TransportError as exc:
207
+ if attempt < self.max_retries:
208
+ time.sleep(_backoff(attempt))
209
+ attempt += 1
210
+ continue
211
+ raise APIConnectionError(_connection_message(exc, self)) from exc
212
+ if response.is_success:
213
+ return response.json()
214
+ error = self._error(response)
215
+ wait = self._wait_before_retry(error, attempt)
216
+ if wait is None:
217
+ raise error
218
+ time.sleep(wait)
219
+ attempt += 1
220
+
221
+ def close(self) -> None:
222
+ if self._owns_http:
223
+ self._http.close()
224
+
225
+ def __enter__(self) -> "Mailhive":
226
+ return self
227
+
228
+ def __exit__(self, *exc: Any) -> None:
229
+ self.close()
230
+
231
+
232
+ class AsyncMailhive(_Base):
233
+ """The async client: ``await client.emails.send(...)``."""
234
+
235
+ webhooks = _Webhooks()
236
+
237
+ def __init__(
238
+ self,
239
+ api_key: Optional[str] = None,
240
+ *,
241
+ base_url: Optional[str] = None,
242
+ timeout: float = 30.0,
243
+ max_retries: int = 2,
244
+ http_client: Optional[httpx.AsyncClient] = None,
245
+ ) -> None:
246
+ super().__init__(api_key, base_url, timeout, max_retries)
247
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
248
+ self._owns_http = http_client is None
249
+ self.emails = AsyncEmails(self)
250
+
251
+ async def _request(self, method: str, path: str, body: Any, idempotency_key: Optional[str]) -> Any:
252
+ headers, content = self._prepare(method, body, idempotency_key)
253
+ url = f"{self.base_url}{path}"
254
+ attempt = 0
255
+ while True:
256
+ try:
257
+ response = await self._http.request(method, url, headers=headers, content=content, timeout=self.timeout)
258
+ except httpx.TransportError as exc:
259
+ if attempt < self.max_retries:
260
+ await asyncio.sleep(_backoff(attempt))
261
+ attempt += 1
262
+ continue
263
+ raise APIConnectionError(_connection_message(exc, self)) from exc
264
+ if response.is_success:
265
+ return response.json()
266
+ error = self._error(response)
267
+ wait = self._wait_before_retry(error, attempt)
268
+ if wait is None:
269
+ raise error
270
+ await asyncio.sleep(wait)
271
+ attempt += 1
272
+
273
+ async def aclose(self) -> None:
274
+ if self._owns_http:
275
+ await self._http.aclose()
276
+
277
+ async def __aenter__(self) -> "AsyncMailhive":
278
+ return self
279
+
280
+ async def __aexit__(self, *exc: Any) -> None:
281
+ await self.aclose()
282
+
283
+
284
+ def _connection_message(exc: Exception, client: _Base) -> str:
285
+ if isinstance(exc, httpx.TimeoutException):
286
+ return f"The Mailhive API didn't answer within {client.timeout:g} seconds."
287
+ return f"Couldn't reach the Mailhive API at {client.base_url}."
mailhive/_errors.py ADDED
@@ -0,0 +1,102 @@
1
+ """Every error the SDK raises is a MailhiveError. Errors from the API carry
2
+ the HTTP status, Mailhive's stable error ``code`` and the ``request_id`` to
3
+ quote to support."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Mapping, Optional
8
+
9
+
10
+ class MailhiveError(Exception):
11
+ """Base class for everything this SDK raises."""
12
+
13
+
14
+ class APIError(MailhiveError):
15
+ """An error response from the API. Check ``code``, not ``message``."""
16
+
17
+ def __init__(
18
+ self,
19
+ message: str,
20
+ *,
21
+ status: int,
22
+ code: str,
23
+ details: Any = None,
24
+ request_id: Optional[str] = None,
25
+ headers: Optional[Mapping[str, str]] = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.message = message
29
+ self.status = status
30
+ self.code = code
31
+ self.details = details
32
+ self.request_id = request_id
33
+ self.headers = dict(headers or {})
34
+
35
+ def __repr__(self) -> str:
36
+ return f"{type(self).__name__}(status={self.status}, code={self.code!r}, request_id={self.request_id!r})"
37
+
38
+
39
+ class AuthenticationError(APIError):
40
+ """401: missing, unknown or revoked API key."""
41
+
42
+
43
+ class BillingError(APIError):
44
+ """402: Mailhive Send is paused over an unpaid invoice."""
45
+
46
+
47
+ class PermissionDeniedError(APIError):
48
+ """403: Send isn't activated, or the stream is paused."""
49
+
50
+
51
+ class NotFoundError(APIError):
52
+ """404"""
53
+
54
+
55
+ class ConflictError(APIError):
56
+ """409: e.g. an Idempotency-Key reused for a different request."""
57
+
58
+
59
+ class ValidationError(APIError):
60
+ """422: the request is invalid; ``details`` lists every problem."""
61
+
62
+
63
+ class RateLimitError(APIError):
64
+ """429: ``rate_limited`` (retried for you), or ``monthly_quota_reached``
65
+ / ``daily_cap_reached`` (not retried: waiting won't help)."""
66
+
67
+ @property
68
+ def retry_after(self) -> Optional[float]:
69
+ value = {k.lower(): v for k, v in self.headers.items()}.get("retry-after")
70
+ try:
71
+ return float(value) if value not in (None, "") else None
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ class APIConnectionError(MailhiveError):
77
+ """The API couldn't be reached, or didn't answer in time."""
78
+
79
+
80
+ class WebhookVerificationError(MailhiveError):
81
+ """A webhook's signature didn't check out. ``reason`` is ``header``,
82
+ ``timestamp`` or ``signature``."""
83
+
84
+ def __init__(self, message: str, reason: str) -> None:
85
+ super().__init__(message)
86
+ self.reason = reason
87
+
88
+
89
+ _BY_STATUS = {
90
+ 400: ValidationError,
91
+ 401: AuthenticationError,
92
+ 402: BillingError,
93
+ 403: PermissionDeniedError,
94
+ 404: NotFoundError,
95
+ 409: ConflictError,
96
+ 422: ValidationError,
97
+ 429: RateLimitError,
98
+ }
99
+
100
+
101
+ def error_for(message: str, **kwargs: Any) -> APIError:
102
+ return _BY_STATUS.get(kwargs["status"], APIError)(message, **kwargs)
mailhive/_types.py ADDED
@@ -0,0 +1,73 @@
1
+ """Request and response shapes. Keys match the REST API exactly, so the API
2
+ reference applies as written (``from`` is a Python keyword, hence the
3
+ functional TypedDict syntax)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from typing import Dict, List, Optional, Union
9
+
10
+ if sys.version_info >= (3, 11):
11
+ from typing import NotRequired, TypedDict
12
+ else: # pragma: no cover
13
+ from typing_extensions import NotRequired, TypedDict
14
+
15
+ Recipients = Union[str, List[str]]
16
+
17
+
18
+ class Attachment(TypedDict):
19
+ filename: str
20
+ #: The file: base64 text, or bytes (encoded for you).
21
+ content: Union[str, bytes]
22
+ content_type: NotRequired[str]
23
+
24
+
25
+ SendEmailParams = TypedDict(
26
+ "SendEmailParams",
27
+ {
28
+ "from": str,
29
+ "to": Recipients,
30
+ "cc": NotRequired[Recipients],
31
+ "bcc": NotRequired[Recipients],
32
+ "subject": NotRequired[str],
33
+ "html": NotRequired[str],
34
+ "text": NotRequired[str],
35
+ "template_id": NotRequired[str],
36
+ "variables": NotRequired[Dict[str, Union[str, int, float, bool, None]]],
37
+ "reply_to": NotRequired[Recipients],
38
+ "headers": NotRequired[Dict[str, str]],
39
+ "tags": NotRequired[Dict[str, str]],
40
+ "attachments": NotRequired[List[Attachment]],
41
+ },
42
+ )
43
+
44
+
45
+ class AcceptedEmail(TypedDict):
46
+ id: str
47
+ status: str
48
+ suppressed: List[str]
49
+ #: True when sent with a test key (mhs_test_…): delivery is simulated.
50
+ test: bool
51
+
52
+
53
+ Email = TypedDict(
54
+ "Email",
55
+ {
56
+ "id": str,
57
+ "status": str,
58
+ "stream": str,
59
+ "test": bool,
60
+ "from": str,
61
+ "to": List[str],
62
+ "cc": List[str],
63
+ "bcc": List[str],
64
+ "subject": str,
65
+ "suppressed": List[str],
66
+ "tags": Dict[str, str],
67
+ "template_id": Optional[str],
68
+ "template_version": Optional[int],
69
+ "created_at": Optional[str],
70
+ "sent_at": Optional[str],
71
+ "last_event_at": Optional[str],
72
+ },
73
+ )
mailhive/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
mailhive/_webhooks.py ADDED
@@ -0,0 +1,67 @@
1
+ """Checking Mailhive-Signature on webhooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ import time
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+ from ._errors import WebhookVerificationError
13
+
14
+ _V1 = re.compile(r"^[0-9a-fA-F]{64}$")
15
+
16
+
17
+ def _parse(header: str) -> Optional[tuple]:
18
+ timestamp: Optional[int] = None
19
+ signatures: List[str] = []
20
+ for part in header.split(","):
21
+ key, sep, value = part.partition("=")
22
+ if not sep:
23
+ continue
24
+ key, value = key.strip(), value.strip()
25
+ if key == "t" and value.isdigit():
26
+ timestamp = int(value)
27
+ elif key == "v1" and _V1.match(value):
28
+ signatures.append(value.lower())
29
+ if timestamp is None or not signatures:
30
+ return None
31
+ return timestamp, signatures
32
+
33
+
34
+ def verify(
35
+ payload: Union[str, bytes],
36
+ signature: Optional[str],
37
+ secret: str,
38
+ *,
39
+ tolerance: int = 300,
40
+ now: Optional[float] = None,
41
+ ) -> Dict[str, Any]:
42
+ """Checks a webhook's ``Mailhive-Signature`` header (HMAC-SHA256 of
43
+ ``"<t>.<raw body>"`` with the endpoint's secret) and returns the parsed
44
+ event. ``payload`` must be the raw body exactly as received: parsing and
45
+ re-serializing it changes the bytes. Several ``v1=`` values are accepted,
46
+ so secrets can be rotated. Raises WebhookVerificationError."""
47
+ if isinstance(payload, (dict, list)):
48
+ raise TypeError(
49
+ "verify() needs the raw request body (str or bytes), not parsed JSON: "
50
+ "re-serializing changes the bytes, so the signature can't match."
51
+ )
52
+ body = payload.encode() if isinstance(payload, str) else bytes(payload)
53
+ parsed = _parse(signature) if signature else None
54
+ if parsed is None:
55
+ raise WebhookVerificationError("Missing or malformed Mailhive-Signature header.", "header")
56
+ timestamp, signatures = parsed
57
+ current = time.time() if now is None else now
58
+ if abs(current - timestamp) > tolerance:
59
+ raise WebhookVerificationError(
60
+ f"The webhook's timestamp is more than {tolerance} seconds from now; it may be a replay.", "timestamp"
61
+ )
62
+ expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
63
+ if not any(hmac.compare_digest(expected, candidate) for candidate in signatures):
64
+ raise WebhookVerificationError(
65
+ "The webhook's signature doesn't match. Check the endpoint's signing secret.", "signature"
66
+ )
67
+ return json.loads(body)
mailhive/django.py ADDED
@@ -0,0 +1,127 @@
1
+ """A Django email backend that sends through Mailhive Send.
2
+
3
+ # settings.py, Django 6.1+
4
+ MAILERS = {"default": {"BACKEND": "mailhive.django.EmailBackend", "OPTIONS": {"api_key": env("MAILHIVE_API_KEY")}}}
5
+
6
+ # settings.py, earlier versions
7
+ EMAIL_BACKEND = "mailhive.django.EmailBackend"
8
+ MAILHIVE_API_KEY = env("MAILHIVE_API_KEY") # or leave it in the environment
9
+
10
+ Everything Django sends then goes through the API: send_mail, EmailMessage,
11
+ EmailMultiAlternatives (the text/html alternative becomes ``html``),
12
+ attachments, cc, bcc, reply_to and extra headers. Mailhive features: set
13
+ ``message.mailhive_tags = {...}`` or ``message.mailhive_idempotency_key``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from email.mime.base import MIMEBase
19
+ from typing import Any, Dict, List, Optional, Sequence
20
+
21
+ from django.conf import settings
22
+ from django.core.mail.backends.base import BaseEmailBackend
23
+ from django.core.mail.message import EmailMessage
24
+
25
+ from ._client import Mailhive
26
+ from ._errors import MailhiveError
27
+
28
+
29
+ def _attachment(item: Any) -> Dict[str, Any]:
30
+ if isinstance(item, MIMEBase):
31
+ return {
32
+ "filename": item.get_filename() or "attachment",
33
+ "content": item.get_payload(decode=True) or b"",
34
+ "content_type": item.get_content_type(),
35
+ }
36
+ filename, content, mimetype = item[0], item[1], item[2] if len(item) > 2 else None
37
+ data = content.encode() if isinstance(content, str) else bytes(content)
38
+ return {"filename": filename or "attachment", "content": data, **({"content_type": mimetype} if mimetype else {})}
39
+
40
+
41
+ def message_params(message: EmailMessage) -> Dict[str, Any]:
42
+ """The API request for one Django message."""
43
+ params: Dict[str, Any] = {
44
+ "from": message.from_email or settings.DEFAULT_FROM_EMAIL,
45
+ "to": list(message.to),
46
+ "subject": message.subject,
47
+ }
48
+ if message.cc:
49
+ params["cc"] = list(message.cc)
50
+ if message.bcc:
51
+ params["bcc"] = list(message.bcc)
52
+ if message.reply_to:
53
+ params["reply_to"] = list(message.reply_to)
54
+ if message.content_subtype == "html":
55
+ params["html"] = message.body
56
+ elif message.body:
57
+ params["text"] = message.body
58
+ for content, mimetype in getattr(message, "alternatives", None) or []:
59
+ if mimetype == "text/html":
60
+ params["html"] = content
61
+ if message.extra_headers:
62
+ params["headers"] = {k: str(v) for k, v in message.extra_headers.items()}
63
+ if message.attachments:
64
+ params["attachments"] = [_attachment(a) for a in message.attachments]
65
+ tags = getattr(message, "mailhive_tags", None)
66
+ if tags:
67
+ params["tags"] = dict(tags)
68
+ return params
69
+
70
+
71
+ class EmailBackend(BaseEmailBackend):
72
+ def __init__(
73
+ self,
74
+ fail_silently: bool = False,
75
+ *,
76
+ api_key: Optional[str] = None,
77
+ base_url: Optional[str] = None,
78
+ **kwargs: Any,
79
+ ) -> None:
80
+ # fail_silently is kept here rather than passed up: Django 6.1+
81
+ # deprecates it on the base class. alias and anything unknown go up,
82
+ # so Django can report a misconfigured MAILERS entry.
83
+ super().__init__(**kwargs)
84
+ self.fail_silently = fail_silently
85
+ self.api_key = api_key or getattr(settings, "MAILHIVE_API_KEY", None)
86
+ self.base_url = base_url or getattr(settings, "MAILHIVE_BASE_URL", None)
87
+ self._client: Optional[Mailhive] = None
88
+
89
+ def open(self) -> bool:
90
+ if self._client is not None:
91
+ return False
92
+ try:
93
+ self._client = Mailhive(self.api_key, base_url=self.base_url)
94
+ except MailhiveError:
95
+ if not self.fail_silently:
96
+ raise
97
+ return False
98
+ return True
99
+
100
+ def close(self) -> None:
101
+ if self._client is not None:
102
+ self._client.close()
103
+ self._client = None
104
+
105
+ def send_messages(self, email_messages: Sequence[EmailMessage]) -> int:
106
+ if not email_messages:
107
+ return 0
108
+ opened = self.open()
109
+ if self._client is None:
110
+ return 0
111
+ sent = 0
112
+ try:
113
+ for message in email_messages:
114
+ if not message.recipients():
115
+ continue
116
+ try:
117
+ self._client.emails.send(
118
+ message_params(message), idempotency_key=getattr(message, "mailhive_idempotency_key", None)
119
+ )
120
+ sent += 1
121
+ except MailhiveError:
122
+ if not self.fail_silently:
123
+ raise
124
+ finally:
125
+ if opened:
126
+ self.close()
127
+ return sent
mailhive/py.typed ADDED
File without changes
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.5
2
+ Name: mailhive
3
+ Version: 0.1.0
4
+ Summary: The official Python SDK for Mailhive Send: transactional email from Africa.
5
+ Project-URL: Homepage, https://mailhive.africa/docs/sdks/python
6
+ Project-URL: Source, https://github.com/Naszat/mailhive-sdks/tree/main/python
7
+ Project-URL: Issues, https://github.com/Naszat/mailhive-sdks/issues
8
+ Author: Naszat
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: africa,django,email,mailhive,transactional-email
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: Django
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Communications :: Email
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: httpx<1,>=0.25
20
+ Requires-Dist: typing-extensions>=4.7; python_version < '3.11'
21
+ Provides-Extra: django
22
+ Requires-Dist: django>=4.2; extra == 'django'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # mailhive
26
+
27
+ The official Python SDK for [Mailhive Send](https://mailhive.africa/docs/send/overview). It supports Python 3.9+, with sync and async clients and a Django email backend.
28
+
29
+ ```sh
30
+ pip install mailhive
31
+ ```
32
+
33
+ ## Send an email
34
+
35
+ ```python
36
+ from mailhive import Mailhive
37
+
38
+ client = Mailhive() # reads MAILHIVE_API_KEY
39
+
40
+ email = client.emails.send({
41
+ "from": "Acme <hello@acme.com>",
42
+ "to": "ada@example.com",
43
+ "subject": "Your receipt",
44
+ "html": "<p>Thanks for your order.</p>",
45
+ })
46
+ print(email["id"])
47
+ ```
48
+
49
+ The keys match the [API reference](https://mailhive.africa/docs/api/send-email) exactly: `cc`, `bcc`, `reply_to`, `headers`, `tags`, `template_id`, `variables` and `attachments`. You can also pass keyword arguments, with `from_` standing in for `from`:
50
+
51
+ ```python
52
+ client.emails.send(from_="hello@acme.com", to="ada@example.com", subject="Hi", text="Hello")
53
+ ```
54
+
55
+ Attachment `content` can be `bytes`, which are encoded for you, or base64 text.
56
+
57
+ ```python
58
+ client.emails.send_batch([email1, email2]) # up to 100; all accepted or none
59
+ client.emails.get(email_id) # status: "delivered", "bounced", …
60
+ ```
61
+
62
+ ### Async
63
+
64
+ ```python
65
+ from mailhive import AsyncMailhive
66
+
67
+ async with AsyncMailhive() as client:
68
+ await client.emails.send({...})
69
+ ```
70
+
71
+ ## Retries and idempotency
72
+
73
+ Every send carries an `Idempotency-Key`. Retries reuse it, so **a retry never sends twice**.
74
+
75
+ - **Retried** (up to 2 times, then configurable): network errors, timeouts, 5xx responses, and `429 rate_limited` after the `Retry-After` delay.
76
+ - **Not retried:** a used-up allowance (`monthly_quota_reached`, `daily_cap_reached`) and invalid requests.
77
+
78
+ To stay safe across restarts, pass your own key:
79
+
80
+ ```python
81
+ client.emails.send(email, idempotency_key=f"order-{order.id}-receipt")
82
+ ```
83
+
84
+ ## Errors
85
+
86
+ ```python
87
+ from mailhive import RateLimitError, ValidationError
88
+
89
+ try:
90
+ client.emails.send(email)
91
+ except ValidationError as error:
92
+ print(error.code, error.message, error.details, error.request_id)
93
+ except RateLimitError as error:
94
+ if error.code == "monthly_quota_reached":
95
+ ...
96
+ ```
97
+
98
+ | Exception | Status |
99
+ |---|---|
100
+ | `AuthenticationError` | 401 |
101
+ | `BillingError` | 402 |
102
+ | `PermissionDeniedError` | 403 |
103
+ | `NotFoundError` | 404 |
104
+ | `ConflictError` | 409 (e.g. `idempotency_conflict`) |
105
+ | `ValidationError` | 422 |
106
+ | `RateLimitError` | 429 (`retry_after` in seconds) |
107
+ | `APIError` | other statuses, and the base class of all of the above |
108
+ | `APIConnectionError` | no response |
109
+
110
+ All of them subclass `MailhiveError`.
111
+
112
+ ## Webhooks
113
+
114
+ Pass the **raw** body, exactly as received:
115
+
116
+ ```python
117
+ from mailhive import WebhookVerificationError, verify_webhook
118
+
119
+ # Django
120
+ def mailhive_webhook(request):
121
+ try:
122
+ event = verify_webhook(request.body, request.headers.get("Mailhive-Signature"), settings.MAILHIVE_WEBHOOK_SECRET)
123
+ except WebhookVerificationError:
124
+ return HttpResponse(status=400)
125
+ ...
126
+
127
+ # Flask: verify_webhook(request.get_data(), request.headers.get("Mailhive-Signature"), secret)
128
+ # FastAPI: verify_webhook(await request.body(), request.headers.get("mailhive-signature"), secret)
129
+ ```
130
+
131
+ ## Django
132
+
133
+ ```python
134
+ # settings.py, Django 6.1 and later
135
+ MAILERS = {
136
+ "default": {
137
+ "BACKEND": "mailhive.django.EmailBackend",
138
+ "OPTIONS": {"api_key": os.environ["MAILHIVE_API_KEY"]},
139
+ },
140
+ }
141
+
142
+ # settings.py, earlier versions
143
+ EMAIL_BACKEND = "mailhive.django.EmailBackend"
144
+ MAILHIVE_API_KEY = os.environ["MAILHIVE_API_KEY"]
145
+ ```
146
+
147
+ `send_mail`, `EmailMessage` and `EmailMultiAlternatives` all send through Mailhive, including HTML alternatives, attachments, cc, bcc, Reply-To and extra headers. You can also set `message.mailhive_tags` or `message.mailhive_idempotency_key` on a message.
148
+
149
+ ## Options
150
+
151
+ ```python
152
+ Mailhive(
153
+ api_key="mhs_…", # default: MAILHIVE_API_KEY
154
+ base_url="https://api-beta.mailhive.africa/v1", # default: MAILHIVE_BASE_URL, then production
155
+ timeout=30.0,
156
+ max_retries=2,
157
+ )
158
+ ```
159
+
160
+ **Test keys** (`mhs_test_…`) work unchanged: delivery is simulated and nothing is billed. [More about test keys](https://mailhive.africa/docs/send/api-keys#test-keys).
@@ -0,0 +1,12 @@
1
+ mailhive/__init__.py,sha256=Jz4m4yQJ3b61wWDqfyM88Y_ySuHTPdbYgdlg57Lyg44,1173
2
+ mailhive/_client.py,sha256=Ab6DVD7AdhRJ1xPCozvYttFITeRCA8iN9Y70BMuykSk,10677
3
+ mailhive/_errors.py,sha256=82x1rJW3NZW2jzwk8makQHHOgoHU9Di6TNCxenfFcMQ,2821
4
+ mailhive/_types.py,sha256=SxJBbKhTjpvcWC3G63xbYz5yDSDVfLHY1xqWuA04e0A,1953
5
+ mailhive/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
6
+ mailhive/_webhooks.py,sha256=L5P7670JoT_VqlnyG0TbgenrNvl64ioEl9126XJgcrI,2540
7
+ mailhive/django.py,sha256=5u7zG1TqVQPI3Ioww1Vr1QiNiMYTdWyf0GNlSZ29dkY,4671
8
+ mailhive/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ mailhive-0.1.0.dist-info/METADATA,sha256=6FuBf4sxGSoO0V2rfsBU8Orw_O764gxpjaraYUcJaYY,5084
10
+ mailhive-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ mailhive-0.1.0.dist-info/licenses/LICENSE,sha256=HBvBAv8I3cl7npvnos9QQGJvogd1MuCtOo9G7bZhvt8,1063
12
+ mailhive-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Naszat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.