vaartix 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.
- vaartix/__init__.py +14 -0
- vaartix/_client.py +286 -0
- vaartix/_errors.py +27 -0
- vaartix/_types.py +83 -0
- vaartix/_verify.py +85 -0
- vaartix/_version.py +1 -0
- vaartix/py.typed +0 -0
- vaartix-0.1.0.dist-info/METADATA +150 -0
- vaartix-0.1.0.dist-info/RECORD +11 -0
- vaartix-0.1.0.dist-info/WHEEL +4 -0
- vaartix-0.1.0.dist-info/licenses/LICENSE +21 -0
vaartix/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Official Python SDK for Vaartix, the email API for Indian developers."""
|
|
2
|
+
|
|
3
|
+
from ._client import Vaartix
|
|
4
|
+
from ._errors import VaartixError, WebhookVerificationError
|
|
5
|
+
from ._verify import verify_webhook
|
|
6
|
+
from ._version import __version__
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Vaartix",
|
|
10
|
+
"VaartixError",
|
|
11
|
+
"WebhookVerificationError",
|
|
12
|
+
"__version__",
|
|
13
|
+
"verify_webhook",
|
|
14
|
+
]
|
vaartix/_client.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
9
|
+
|
|
10
|
+
from ._errors import VaartixError
|
|
11
|
+
from ._types import (
|
|
12
|
+
SendEmailParams,
|
|
13
|
+
SendEmailResponse,
|
|
14
|
+
Webhook,
|
|
15
|
+
WebhookEventType,
|
|
16
|
+
WebhookWithSecret,
|
|
17
|
+
)
|
|
18
|
+
from ._verify import verify_webhook
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.vaartix.com"
|
|
22
|
+
|
|
23
|
+
# (method, url, headers, body, timeout) -> (status code, response body)
|
|
24
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, bytes]]
|
|
25
|
+
|
|
26
|
+
# snake_case keys Python developers expect, mapped to the API's names.
|
|
27
|
+
_KEY_NAMES = {
|
|
28
|
+
"reply_to": "replyTo",
|
|
29
|
+
"scheduled_at": "scheduledAt",
|
|
30
|
+
"content_type": "contentType",
|
|
31
|
+
"content_id": "contentId",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _api_keys(value: Any) -> Any:
|
|
36
|
+
if isinstance(value, Mapping):
|
|
37
|
+
return {_KEY_NAMES.get(k, k): _api_keys(v) for k, v in value.items()}
|
|
38
|
+
if isinstance(value, (list, tuple)):
|
|
39
|
+
return [_api_keys(v) for v in value]
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _urllib_transport(
|
|
44
|
+
method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float
|
|
45
|
+
) -> Tuple[int, bytes]:
|
|
46
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
49
|
+
return response.status, response.read()
|
|
50
|
+
except urllib.error.HTTPError as err:
|
|
51
|
+
return err.code, err.read()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _trim_trailing_slashes(url: str) -> str:
|
|
55
|
+
end = len(url)
|
|
56
|
+
while end > 0 and url[end - 1] == "/":
|
|
57
|
+
end -= 1
|
|
58
|
+
return url[:end]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Vaartix:
|
|
62
|
+
"""Client for the Vaartix email API.
|
|
63
|
+
|
|
64
|
+
>>> vaartix = Vaartix() # reads VAARTIX_API_KEY
|
|
65
|
+
>>> vaartix.emails.send({"from": "Acme <hi@acme.in>", "to": "priya@example.com",
|
|
66
|
+
... "subject": "Hello", "html": "<p>Hi</p>"})
|
|
67
|
+
{'id': 'em_...'}
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
api_key: Optional[str] = None,
|
|
73
|
+
*,
|
|
74
|
+
base_url: Optional[str] = None,
|
|
75
|
+
timeout: float = 30.0,
|
|
76
|
+
transport: Optional[Transport] = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
key = api_key or os.environ.get("VAARTIX_API_KEY")
|
|
79
|
+
if not key:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
'Missing API key. Pass it as Vaartix("vx_...") or set the VAARTIX_API_KEY '
|
|
82
|
+
"environment variable."
|
|
83
|
+
)
|
|
84
|
+
self._key = key
|
|
85
|
+
self._base_url = _trim_trailing_slashes(base_url or DEFAULT_BASE_URL)
|
|
86
|
+
self._timeout = timeout
|
|
87
|
+
self._transport = transport or _urllib_transport
|
|
88
|
+
self.emails = Emails(self)
|
|
89
|
+
self.batch = Batch(self)
|
|
90
|
+
self.webhooks = Webhooks(self)
|
|
91
|
+
|
|
92
|
+
def _request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
body: Any = None,
|
|
97
|
+
*,
|
|
98
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
99
|
+
idempotency_key: Optional[str] = None,
|
|
100
|
+
) -> Any:
|
|
101
|
+
url = self._base_url + path
|
|
102
|
+
if query:
|
|
103
|
+
params = {k: v for k, v in query.items() if v is not None}
|
|
104
|
+
if params:
|
|
105
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
106
|
+
headers = {
|
|
107
|
+
"Authorization": f"Bearer {self._key}",
|
|
108
|
+
"User-Agent": f"vaartix-python/{__version__}",
|
|
109
|
+
"Accept": "application/json",
|
|
110
|
+
}
|
|
111
|
+
data = None
|
|
112
|
+
if body is not None:
|
|
113
|
+
headers["Content-Type"] = "application/json"
|
|
114
|
+
data = json.dumps(body).encode()
|
|
115
|
+
if idempotency_key:
|
|
116
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
status, raw = self._transport(method, url, headers, data, self._timeout)
|
|
120
|
+
except (TimeoutError, urllib.error.URLError, OSError) as err:
|
|
121
|
+
reason = getattr(err, "reason", err)
|
|
122
|
+
raise VaartixError("network_error", str(reason)) from err
|
|
123
|
+
|
|
124
|
+
payload: Any = None
|
|
125
|
+
if raw:
|
|
126
|
+
try:
|
|
127
|
+
payload = json.loads(raw)
|
|
128
|
+
except ValueError:
|
|
129
|
+
payload = None
|
|
130
|
+
if status >= 400:
|
|
131
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
132
|
+
error = error if isinstance(error, dict) else {}
|
|
133
|
+
raise VaartixError(
|
|
134
|
+
str(error.get("type") or "api_error"),
|
|
135
|
+
str(error.get("message") or f"Request failed with status {status}"),
|
|
136
|
+
status,
|
|
137
|
+
)
|
|
138
|
+
return payload
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class Emails:
|
|
142
|
+
def __init__(self, client: Vaartix) -> None:
|
|
143
|
+
self._client = client
|
|
144
|
+
|
|
145
|
+
def send(
|
|
146
|
+
self, params: SendEmailParams, *, idempotency_key: Optional[str] = None
|
|
147
|
+
) -> SendEmailResponse:
|
|
148
|
+
"""Send an email. Returns ``{"id": "em_..."}`` once it is queued.
|
|
149
|
+
|
|
150
|
+
With ``idempotency_key``, retrying within 24 hours returns the first email instead of
|
|
151
|
+
sending again.
|
|
152
|
+
"""
|
|
153
|
+
result: SendEmailResponse = self._client._request(
|
|
154
|
+
"POST", "/emails", _api_keys(params), idempotency_key=idempotency_key
|
|
155
|
+
)
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def get(self, email_id: str) -> Dict[str, Any]:
|
|
159
|
+
"""An email with its status and event timeline. Needs a full-access key."""
|
|
160
|
+
result: Dict[str, Any] = self._client._request(
|
|
161
|
+
"GET", f"/emails/{urllib.parse.quote(email_id, safe='')}"
|
|
162
|
+
)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def list(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
limit: Optional[int] = None,
|
|
169
|
+
before: Optional[str] = None,
|
|
170
|
+
status: Optional[str] = None,
|
|
171
|
+
search: Optional[str] = None,
|
|
172
|
+
days: Optional[int] = None,
|
|
173
|
+
api_key_id: Optional[str] = None,
|
|
174
|
+
) -> Dict[str, Any]:
|
|
175
|
+
"""Recent emails, newest first: ``{"data": [...], "hasMore": bool}``.
|
|
176
|
+
|
|
177
|
+
Page with ``before=<last email ID>``. Needs a full-access key.
|
|
178
|
+
"""
|
|
179
|
+
result: Dict[str, Any] = self._client._request(
|
|
180
|
+
"GET",
|
|
181
|
+
"/emails",
|
|
182
|
+
query={
|
|
183
|
+
"limit": limit,
|
|
184
|
+
"before": before,
|
|
185
|
+
"status": status,
|
|
186
|
+
"search": search,
|
|
187
|
+
"days": days,
|
|
188
|
+
"apiKeyId": api_key_id,
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def cancel(self, email_id: str) -> Dict[str, Any]:
|
|
194
|
+
"""Cancel a scheduled email before it is sent. Needs a full-access key."""
|
|
195
|
+
result: Dict[str, Any] = self._client._request(
|
|
196
|
+
"POST", f"/emails/{urllib.parse.quote(email_id, safe='')}/cancel"
|
|
197
|
+
)
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Batch:
|
|
202
|
+
def __init__(self, client: Vaartix) -> None:
|
|
203
|
+
self._client = client
|
|
204
|
+
|
|
205
|
+
def send(self, emails: Sequence[SendEmailParams]) -> Dict[str, List[SendEmailResponse]]:
|
|
206
|
+
"""Send up to 100 emails in one request (no attachments or scheduling). All or nothing."""
|
|
207
|
+
result: Dict[str, List[SendEmailResponse]] = self._client._request(
|
|
208
|
+
"POST", "/emails/batch", [_api_keys(e) for e in emails]
|
|
209
|
+
)
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Webhooks:
|
|
214
|
+
"""Webhook endpoints. Managing them needs a full-access key."""
|
|
215
|
+
|
|
216
|
+
def __init__(self, client: Vaartix) -> None:
|
|
217
|
+
self._client = client
|
|
218
|
+
|
|
219
|
+
def _path(self, webhook_id: str, suffix: str = "") -> str:
|
|
220
|
+
return f"/webhooks/{urllib.parse.quote(webhook_id, safe='')}{suffix}"
|
|
221
|
+
|
|
222
|
+
def create(self, *, url: str, events: Sequence[WebhookEventType]) -> WebhookWithSecret:
|
|
223
|
+
"""Create an endpoint. The response includes the signing secret, shown only this once."""
|
|
224
|
+
result: WebhookWithSecret = self._client._request(
|
|
225
|
+
"POST", "/webhooks", {"url": url, "events": list(events)}
|
|
226
|
+
)
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
def list(self) -> Dict[str, List[Webhook]]:
|
|
230
|
+
result: Dict[str, List[Webhook]] = self._client._request("GET", "/webhooks")
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
def get(self, webhook_id: str) -> Webhook:
|
|
234
|
+
result: Webhook = self._client._request("GET", self._path(webhook_id))
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
def update(
|
|
238
|
+
self,
|
|
239
|
+
webhook_id: str,
|
|
240
|
+
*,
|
|
241
|
+
url: Optional[str] = None,
|
|
242
|
+
events: Optional[Sequence[WebhookEventType]] = None,
|
|
243
|
+
status: Optional[str] = None,
|
|
244
|
+
) -> Webhook:
|
|
245
|
+
"""Change the URL or events, or turn the endpoint off (``status="disabled"``) and on."""
|
|
246
|
+
body: Dict[str, Any] = {}
|
|
247
|
+
if url is not None:
|
|
248
|
+
body["url"] = url
|
|
249
|
+
if events is not None:
|
|
250
|
+
body["events"] = list(events)
|
|
251
|
+
if status is not None:
|
|
252
|
+
body["status"] = status
|
|
253
|
+
result: Webhook = self._client._request("PATCH", self._path(webhook_id), body)
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
def remove(self, webhook_id: str) -> None:
|
|
257
|
+
self._client._request("DELETE", self._path(webhook_id))
|
|
258
|
+
|
|
259
|
+
def rotate_secret(self, webhook_id: str) -> WebhookWithSecret:
|
|
260
|
+
"""Replace the signing secret. The old one stops working immediately."""
|
|
261
|
+
result: WebhookWithSecret = self._client._request(
|
|
262
|
+
"POST", self._path(webhook_id, "/rotate-secret")
|
|
263
|
+
)
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
def deliveries(self, webhook_id: str, *, limit: Optional[int] = None) -> Dict[str, Any]:
|
|
267
|
+
"""Recent deliveries to an endpoint, newest first, with your server's responses."""
|
|
268
|
+
result: Dict[str, Any] = self._client._request(
|
|
269
|
+
"GET", self._path(webhook_id, "/deliveries"), query={"limit": limit}
|
|
270
|
+
)
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
def retry_delivery(self, webhook_id: str, delivery_id: str) -> Dict[str, Any]:
|
|
274
|
+
"""Send a delivery again, with the same ``webhook-id``."""
|
|
275
|
+
path = self._path(
|
|
276
|
+
webhook_id, f"/deliveries/{urllib.parse.quote(delivery_id, safe='')}/retry"
|
|
277
|
+
)
|
|
278
|
+
result: Dict[str, Any] = self._client._request("POST", path)
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def verify(
|
|
283
|
+
payload: Any, headers: Mapping[str, Any], secret: str, tolerance_seconds: int = 300
|
|
284
|
+
) -> Dict[str, Any]:
|
|
285
|
+
"""Same as the standalone ``verify_webhook``."""
|
|
286
|
+
return verify_webhook(payload, headers, secret, tolerance_seconds)
|
vaartix/_errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VaartixError(Exception):
|
|
7
|
+
"""Raised when the API returns an error or cannot be reached.
|
|
8
|
+
|
|
9
|
+
``type`` is a stable code such as ``validation_error`` or ``rate_limited`` (see
|
|
10
|
+
https://vaartix.com/docs/concepts/errors/). ``status_code`` is ``None`` for network errors.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, type: str, message: str, status_code: Optional[int] = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.type = type
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
|
|
19
|
+
def __repr__(self) -> str:
|
|
20
|
+
return (
|
|
21
|
+
f"VaartixError(type={self.type!r}, message={self.message!r}, "
|
|
22
|
+
f"status_code={self.status_code!r})"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WebhookVerificationError(Exception):
|
|
27
|
+
"""Raised by ``verify_webhook`` when a request's signature or timestamp is not valid."""
|
vaartix/_types.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Shapes of the data the API accepts and returns. Responses are the API's JSON, unchanged."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
6
|
+
|
|
7
|
+
Addresses = Union[str, List[str]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Tag(TypedDict):
|
|
11
|
+
name: str
|
|
12
|
+
value: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _AttachmentRequired(TypedDict):
|
|
16
|
+
filename: str
|
|
17
|
+
content: str
|
|
18
|
+
"""Base64-encoded file content."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Attachment(_AttachmentRequired, total=False):
|
|
22
|
+
content_type: str
|
|
23
|
+
content_id: str
|
|
24
|
+
"""For inline images: reference it in your HTML as ``cid:<content_id>``."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# "from" is a Python keyword, so emails are passed as plain dicts:
|
|
28
|
+
# {"from": "...", "to": "...", "subject": "...", "html": "..."}
|
|
29
|
+
# Keys can be snake_case (reply_to, scheduled_at) or the API's camelCase (replyTo, scheduledAt).
|
|
30
|
+
SendEmailParams = Dict[str, Any]
|
|
31
|
+
|
|
32
|
+
EmailStatus = Literal[
|
|
33
|
+
"queued",
|
|
34
|
+
"scheduled",
|
|
35
|
+
"sending",
|
|
36
|
+
"sent",
|
|
37
|
+
"delivered",
|
|
38
|
+
"delivery_delayed",
|
|
39
|
+
"bounced",
|
|
40
|
+
"complained",
|
|
41
|
+
"suppressed",
|
|
42
|
+
"failed",
|
|
43
|
+
"canceled",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
WebhookEventType = Literal[
|
|
47
|
+
"email.sent",
|
|
48
|
+
"email.delivered",
|
|
49
|
+
"email.delivery_delayed",
|
|
50
|
+
"email.bounced",
|
|
51
|
+
"email.complained",
|
|
52
|
+
"email.rejected",
|
|
53
|
+
"email.rendering_failure",
|
|
54
|
+
"email.suppressed",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SendEmailResponse(TypedDict):
|
|
59
|
+
id: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EmailSummary(TypedDict):
|
|
63
|
+
id: str
|
|
64
|
+
status: EmailStatus
|
|
65
|
+
subject: str
|
|
66
|
+
to: List[str]
|
|
67
|
+
createdAt: str
|
|
68
|
+
sentAt: Optional[str]
|
|
69
|
+
scheduledAt: Optional[str]
|
|
70
|
+
error: Optional[str]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Webhook(TypedDict):
|
|
74
|
+
id: str
|
|
75
|
+
url: str
|
|
76
|
+
events: List[WebhookEventType]
|
|
77
|
+
status: Literal["enabled", "disabled"]
|
|
78
|
+
createdAt: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WebhookWithSecret(Webhook):
|
|
82
|
+
secret: str
|
|
83
|
+
"""The signing secret (``whsec_...``). Only returned when created or rotated."""
|
vaartix/_verify.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
10
|
+
|
|
11
|
+
from ._errors import WebhookVerificationError
|
|
12
|
+
|
|
13
|
+
_PREFIX = "whsec_"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _header(headers: Mapping[str, Any], name: str) -> Optional[str]:
|
|
17
|
+
# Works with dicts, Django's request.headers, Flask/Werkzeug and Starlette headers.
|
|
18
|
+
getter = getattr(headers, "get", None)
|
|
19
|
+
value = getter(name) if getter else None
|
|
20
|
+
if value is None:
|
|
21
|
+
for key in headers:
|
|
22
|
+
if str(key).lower() == name:
|
|
23
|
+
value = headers[key]
|
|
24
|
+
break
|
|
25
|
+
if isinstance(value, (list, tuple)):
|
|
26
|
+
value = value[0] if value else None
|
|
27
|
+
return None if value is None else str(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_webhook(
|
|
31
|
+
payload: Union[str, bytes],
|
|
32
|
+
headers: Mapping[str, Any],
|
|
33
|
+
secret: str,
|
|
34
|
+
tolerance_seconds: int = 300,
|
|
35
|
+
*,
|
|
36
|
+
now: Optional[float] = None,
|
|
37
|
+
) -> Dict[str, Any]:
|
|
38
|
+
"""Check that a webhook request came from Vaartix and return its parsed JSON body.
|
|
39
|
+
|
|
40
|
+
``payload`` must be the raw request body, exactly as received (for example
|
|
41
|
+
``request.get_data()`` in Flask or ``request.body`` in Django), not parsed JSON.
|
|
42
|
+
Raises ``WebhookVerificationError`` if the signature or timestamp is wrong.
|
|
43
|
+
|
|
44
|
+
Follows the Standard Webhooks spec, so any Standard Webhooks library works too.
|
|
45
|
+
"""
|
|
46
|
+
msg_id = _header(headers, "webhook-id")
|
|
47
|
+
timestamp = _header(headers, "webhook-timestamp")
|
|
48
|
+
signatures = _header(headers, "webhook-signature")
|
|
49
|
+
if not msg_id or not timestamp or not signatures:
|
|
50
|
+
raise WebhookVerificationError(
|
|
51
|
+
"Missing webhook-id, webhook-timestamp or webhook-signature header."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
sent_at = int(timestamp)
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise WebhookVerificationError("The webhook timestamp is not a number.") from None
|
|
58
|
+
current = time.time() if now is None else now
|
|
59
|
+
if abs(current - sent_at) > tolerance_seconds:
|
|
60
|
+
raise WebhookVerificationError("The webhook timestamp is too old or in the future.")
|
|
61
|
+
|
|
62
|
+
if not secret.startswith(_PREFIX):
|
|
63
|
+
raise WebhookVerificationError(f"The secret should start with {_PREFIX}.")
|
|
64
|
+
try:
|
|
65
|
+
key = base64.b64decode(secret[len(_PREFIX) :], validate=True)
|
|
66
|
+
except (binascii.Error, ValueError):
|
|
67
|
+
raise WebhookVerificationError("The secret is not valid base64.") from None
|
|
68
|
+
|
|
69
|
+
body = payload.decode("utf-8") if isinstance(payload, bytes) else payload
|
|
70
|
+
signed = f"{msg_id}.{timestamp}.{body}".encode()
|
|
71
|
+
expected = hmac.new(key, signed, hashlib.sha256).digest()
|
|
72
|
+
|
|
73
|
+
# The header can hold several space-separated signatures, e.g. while a secret is rotated.
|
|
74
|
+
for entry in signatures.split(" "):
|
|
75
|
+
version, _, signature = entry.partition(",")
|
|
76
|
+
if version != "v1" or not signature:
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
given = base64.b64decode(signature)
|
|
80
|
+
except (binascii.Error, ValueError):
|
|
81
|
+
continue
|
|
82
|
+
if hmac.compare_digest(given, expected):
|
|
83
|
+
parsed: Dict[str, Any] = json.loads(body)
|
|
84
|
+
return parsed
|
|
85
|
+
raise WebhookVerificationError("The webhook signature does not match.")
|
vaartix/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
vaartix/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vaartix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Vaartix email API
|
|
5
|
+
Project-URL: Homepage, https://vaartix.com
|
|
6
|
+
Project-URL: Documentation, https://vaartix.com/docs/
|
|
7
|
+
Project-URL: Source, https://github.com/vaartix-mail/vaartix-python
|
|
8
|
+
Project-URL: Issues, https://github.com/vaartix-mail/vaartix-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/vaartix-mail/vaartix-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: NAP Designs <support@vaartix.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,email,india,sdk,ses,transactional-email,vaartix
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Communications :: Email
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# vaartix
|
|
30
|
+
|
|
31
|
+
Official Python SDK for [Vaartix](https://vaartix.com), the email API for Indian developers.
|
|
32
|
+
|
|
33
|
+
> Vaartix is in private beta. [Request early access](https://vaartix.com/#early-access).
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install vaartix
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Python 3.10 or newer. No dependencies.
|
|
40
|
+
|
|
41
|
+
## Send an email
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from vaartix import Vaartix
|
|
45
|
+
|
|
46
|
+
vaartix = Vaartix() # reads the VAARTIX_API_KEY environment variable
|
|
47
|
+
|
|
48
|
+
email = vaartix.emails.send(
|
|
49
|
+
{
|
|
50
|
+
"from": "Acme <onboarding@acme.in>",
|
|
51
|
+
"to": ["priya@example.com"],
|
|
52
|
+
"subject": "Hello from Vaartix",
|
|
53
|
+
"html": "<strong>It works!</strong>",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
print(email["id"]) # em_...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Emails are plain dicts because `from` is a Python keyword. Keys can be `snake_case`
|
|
60
|
+
(`reply_to`, `scheduled_at`) or the API's camelCase (`replyTo`, `scheduledAt`). Responses are the
|
|
61
|
+
API's JSON as dicts, exactly as in the [API reference](https://vaartix.com/docs/).
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# Retry safely: the same key within 24 hours returns the first email instead of sending twice.
|
|
65
|
+
vaartix.emails.send(email, idempotency_key=f"welcome-{user.id}")
|
|
66
|
+
|
|
67
|
+
# Send later (up to 30 days ahead), and cancel if plans change.
|
|
68
|
+
scheduled = vaartix.emails.send({**email, "scheduled_at": "2026-11-01T09:00:00+05:30"})
|
|
69
|
+
vaartix.emails.cancel(scheduled["id"])
|
|
70
|
+
|
|
71
|
+
# Up to 100 emails in one request.
|
|
72
|
+
vaartix.batch.send([email_1, email_2])
|
|
73
|
+
|
|
74
|
+
# Look up an email and its delivery events, or list recent bounces.
|
|
75
|
+
vaartix.emails.get("em_...")
|
|
76
|
+
vaartix.emails.list(status="bounced", days=7)
|
|
77
|
+
|
|
78
|
+
# Try it without a domain: test addresses simulate delivery, bounces and complaints.
|
|
79
|
+
vaartix.emails.send(
|
|
80
|
+
{
|
|
81
|
+
"from": "onboarding@test.vaartix.com",
|
|
82
|
+
"to": "delivered@test.vaartix.com",
|
|
83
|
+
"subject": "Hello",
|
|
84
|
+
"text": "It works!",
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Errors
|
|
90
|
+
|
|
91
|
+
API and network errors raise `VaartixError`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from vaartix import VaartixError
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
vaartix.emails.send(email)
|
|
98
|
+
except VaartixError as err:
|
|
99
|
+
print(err.type, err.status_code, err.message) # e.g. rate_limited 429 Too many requests...
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`err.type` is a stable code; the full list is in [Errors](https://vaartix.com/docs/concepts/errors/).
|
|
103
|
+
Network failures have `type == "network_error"` and `status_code is None`.
|
|
104
|
+
|
|
105
|
+
## Webhooks
|
|
106
|
+
|
|
107
|
+
Check that a webhook request really came from Vaartix before trusting it. Pass the **raw** body.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import os
|
|
111
|
+
|
|
112
|
+
from flask import Flask, request
|
|
113
|
+
from vaartix import verify_webhook, WebhookVerificationError
|
|
114
|
+
|
|
115
|
+
app = Flask(__name__)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.post("/webhooks/vaartix")
|
|
119
|
+
def vaartix_webhook():
|
|
120
|
+
try:
|
|
121
|
+
event = verify_webhook(
|
|
122
|
+
request.get_data(), # raw bytes, not request.json
|
|
123
|
+
request.headers,
|
|
124
|
+
os.environ["VAARTIX_WEBHOOK_SECRET"],
|
|
125
|
+
)
|
|
126
|
+
except WebhookVerificationError:
|
|
127
|
+
return "", 400
|
|
128
|
+
if event["type"] == "email.bounced":
|
|
129
|
+
... # stop emailing event["data"]["recipient"]
|
|
130
|
+
return "", 200
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
In Django use `request.body` and `request.headers`. Signatures follow the
|
|
134
|
+
[Standard Webhooks](https://www.standardwebhooks.com) spec.
|
|
135
|
+
|
|
136
|
+
Manage endpoints with `vaartix.webhooks.create(url=..., events=[...])`, `list()`, `get()`,
|
|
137
|
+
`update()`, `remove()`, `rotate_secret()`, `deliveries()` and `retry_delivery()`.
|
|
138
|
+
|
|
139
|
+
## Docs
|
|
140
|
+
|
|
141
|
+
Full API reference and guides: [vaartix.com/docs](https://vaartix.com/docs/).
|
|
142
|
+
|
|
143
|
+
## Contributing and security
|
|
144
|
+
|
|
145
|
+
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report
|
|
146
|
+
security problems privately as described in [SECURITY.md](SECURITY.md).
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
vaartix/__init__.py,sha256=1DwEAisnrc6qqfKeHshL2BgqbRjEMuiZv3MIb9Qn1Vw,359
|
|
2
|
+
vaartix/_client.py,sha256=umeO96ZQSZR3CTduWYvtwvF8SGvTdDKaPlgv38rV5lE,9990
|
|
3
|
+
vaartix/_errors.py,sha256=ATwkGf1ISrsNd8aPad9lWEmRXoW3gsfW8Hqy94VNiq4,895
|
|
4
|
+
vaartix/_types.py,sha256=3867yIhF0Nb4p1rQp9d0INs23Fl0URdv1_3a05l49RY,1819
|
|
5
|
+
vaartix/_verify.py,sha256=g9UNcV5IPMZyjlG8479wJay5VvejDIUIKWCE_45EVJU,3189
|
|
6
|
+
vaartix/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
7
|
+
vaartix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
vaartix-0.1.0.dist-info/METADATA,sha256=ZJ9GSw7f16hmTO85Y2XEMmxYqBDfvQ6iMRT2cFdEul8,4719
|
|
9
|
+
vaartix-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
vaartix-0.1.0.dist-info/licenses/LICENSE,sha256=CIibD82FyaGxNd8Z7B7wPpcKtrTTm3NMhMJByf3V1D0,1068
|
|
11
|
+
vaartix-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NAP Designs
|
|
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.
|