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 +48 -0
- mailhive/_client.py +287 -0
- mailhive/_errors.py +102 -0
- mailhive/_types.py +73 -0
- mailhive/_version.py +1 -0
- mailhive/_webhooks.py +67 -0
- mailhive/django.py +127 -0
- mailhive/py.typed +0 -0
- mailhive-0.1.0.dist-info/METADATA +160 -0
- mailhive-0.1.0.dist-info/RECORD +12 -0
- mailhive-0.1.0.dist-info/WHEEL +4 -0
- mailhive-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|