vaartix 0.1.0__tar.gz
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-0.1.0/.gitignore +13 -0
- vaartix-0.1.0/CHANGELOG.md +10 -0
- vaartix-0.1.0/LICENSE +21 -0
- vaartix-0.1.0/PKG-INFO +150 -0
- vaartix-0.1.0/README.md +122 -0
- vaartix-0.1.0/pyproject.toml +61 -0
- vaartix-0.1.0/src/vaartix/__init__.py +14 -0
- vaartix-0.1.0/src/vaartix/_client.py +286 -0
- vaartix-0.1.0/src/vaartix/_errors.py +27 -0
- vaartix-0.1.0/src/vaartix/_types.py +83 -0
- vaartix-0.1.0/src/vaartix/_verify.py +85 -0
- vaartix-0.1.0/src/vaartix/_version.py +1 -0
- vaartix-0.1.0/src/vaartix/py.typed +0 -0
- vaartix-0.1.0/tests/test_client.py +164 -0
- vaartix-0.1.0/tests/test_verify.py +79 -0
vaartix-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- First release: `emails.send`, `get`, `list`, `cancel`, `batch.send`, webhook endpoints, and
|
|
8
|
+
`verify_webhook` for signed webhooks (Standard Webhooks).
|
|
9
|
+
- Idempotency keys, scheduled sending and test mode.
|
|
10
|
+
- No dependencies; Python 3.10 or newer; fully typed.
|
vaartix-0.1.0/LICENSE
ADDED
|
@@ -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.
|
vaartix-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
vaartix-0.1.0/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# vaartix
|
|
2
|
+
|
|
3
|
+
Official Python SDK for [Vaartix](https://vaartix.com), the email API for Indian developers.
|
|
4
|
+
|
|
5
|
+
> Vaartix is in private beta. [Request early access](https://vaartix.com/#early-access).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install vaartix
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.10 or newer. No dependencies.
|
|
12
|
+
|
|
13
|
+
## Send an email
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from vaartix import Vaartix
|
|
17
|
+
|
|
18
|
+
vaartix = Vaartix() # reads the VAARTIX_API_KEY environment variable
|
|
19
|
+
|
|
20
|
+
email = vaartix.emails.send(
|
|
21
|
+
{
|
|
22
|
+
"from": "Acme <onboarding@acme.in>",
|
|
23
|
+
"to": ["priya@example.com"],
|
|
24
|
+
"subject": "Hello from Vaartix",
|
|
25
|
+
"html": "<strong>It works!</strong>",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
print(email["id"]) # em_...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Emails are plain dicts because `from` is a Python keyword. Keys can be `snake_case`
|
|
32
|
+
(`reply_to`, `scheduled_at`) or the API's camelCase (`replyTo`, `scheduledAt`). Responses are the
|
|
33
|
+
API's JSON as dicts, exactly as in the [API reference](https://vaartix.com/docs/).
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# Retry safely: the same key within 24 hours returns the first email instead of sending twice.
|
|
37
|
+
vaartix.emails.send(email, idempotency_key=f"welcome-{user.id}")
|
|
38
|
+
|
|
39
|
+
# Send later (up to 30 days ahead), and cancel if plans change.
|
|
40
|
+
scheduled = vaartix.emails.send({**email, "scheduled_at": "2026-11-01T09:00:00+05:30"})
|
|
41
|
+
vaartix.emails.cancel(scheduled["id"])
|
|
42
|
+
|
|
43
|
+
# Up to 100 emails in one request.
|
|
44
|
+
vaartix.batch.send([email_1, email_2])
|
|
45
|
+
|
|
46
|
+
# Look up an email and its delivery events, or list recent bounces.
|
|
47
|
+
vaartix.emails.get("em_...")
|
|
48
|
+
vaartix.emails.list(status="bounced", days=7)
|
|
49
|
+
|
|
50
|
+
# Try it without a domain: test addresses simulate delivery, bounces and complaints.
|
|
51
|
+
vaartix.emails.send(
|
|
52
|
+
{
|
|
53
|
+
"from": "onboarding@test.vaartix.com",
|
|
54
|
+
"to": "delivered@test.vaartix.com",
|
|
55
|
+
"subject": "Hello",
|
|
56
|
+
"text": "It works!",
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Errors
|
|
62
|
+
|
|
63
|
+
API and network errors raise `VaartixError`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from vaartix import VaartixError
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
vaartix.emails.send(email)
|
|
70
|
+
except VaartixError as err:
|
|
71
|
+
print(err.type, err.status_code, err.message) # e.g. rate_limited 429 Too many requests...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`err.type` is a stable code; the full list is in [Errors](https://vaartix.com/docs/concepts/errors/).
|
|
75
|
+
Network failures have `type == "network_error"` and `status_code is None`.
|
|
76
|
+
|
|
77
|
+
## Webhooks
|
|
78
|
+
|
|
79
|
+
Check that a webhook request really came from Vaartix before trusting it. Pass the **raw** body.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import os
|
|
83
|
+
|
|
84
|
+
from flask import Flask, request
|
|
85
|
+
from vaartix import verify_webhook, WebhookVerificationError
|
|
86
|
+
|
|
87
|
+
app = Flask(__name__)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.post("/webhooks/vaartix")
|
|
91
|
+
def vaartix_webhook():
|
|
92
|
+
try:
|
|
93
|
+
event = verify_webhook(
|
|
94
|
+
request.get_data(), # raw bytes, not request.json
|
|
95
|
+
request.headers,
|
|
96
|
+
os.environ["VAARTIX_WEBHOOK_SECRET"],
|
|
97
|
+
)
|
|
98
|
+
except WebhookVerificationError:
|
|
99
|
+
return "", 400
|
|
100
|
+
if event["type"] == "email.bounced":
|
|
101
|
+
... # stop emailing event["data"]["recipient"]
|
|
102
|
+
return "", 200
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
In Django use `request.body` and `request.headers`. Signatures follow the
|
|
106
|
+
[Standard Webhooks](https://www.standardwebhooks.com) spec.
|
|
107
|
+
|
|
108
|
+
Manage endpoints with `vaartix.webhooks.create(url=..., events=[...])`, `list()`, `get()`,
|
|
109
|
+
`update()`, `remove()`, `rotate_secret()`, `deliveries()` and `retry_delivery()`.
|
|
110
|
+
|
|
111
|
+
## Docs
|
|
112
|
+
|
|
113
|
+
Full API reference and guides: [vaartix.com/docs](https://vaartix.com/docs/).
|
|
114
|
+
|
|
115
|
+
## Contributing and security
|
|
116
|
+
|
|
117
|
+
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report
|
|
118
|
+
security problems privately as described in [SECURITY.md](SECURITY.md).
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaartix"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the Vaartix email API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "NAP Designs", email = "support@vaartix.com" }]
|
|
14
|
+
keywords = ["email", "transactional-email", "api", "india", "vaartix", "ses", "sdk"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Communications :: Email",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://vaartix.com"
|
|
33
|
+
Documentation = "https://vaartix.com/docs/"
|
|
34
|
+
Source = "https://github.com/vaartix-mail/vaartix-python"
|
|
35
|
+
Issues = "https://github.com/vaartix-mail/vaartix-python/issues"
|
|
36
|
+
Changelog = "https://github.com/vaartix-mail/vaartix-python/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
path = "src/vaartix/_version.py"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
target-version = "py310"
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
53
|
+
ignore = ["UP006", "UP007", "UP035", "UP045"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint.per-file-ignores]
|
|
56
|
+
"tests/*" = ["E501"]
|
|
57
|
+
|
|
58
|
+
[tool.mypy]
|
|
59
|
+
strict = true
|
|
60
|
+
python_version = "3.10"
|
|
61
|
+
files = ["src"]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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."""
|
|
@@ -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."""
|
|
@@ -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.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.error
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from vaartix import Vaartix, VaartixError, __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeTransport:
|
|
10
|
+
def __init__(self, status=200, body=None):
|
|
11
|
+
self.status = status
|
|
12
|
+
self.body = body if body is not None else {"id": "em_1"}
|
|
13
|
+
self.calls = []
|
|
14
|
+
|
|
15
|
+
def __call__(self, method, url, headers, data, timeout):
|
|
16
|
+
self.calls.append(
|
|
17
|
+
{
|
|
18
|
+
"method": method,
|
|
19
|
+
"url": url,
|
|
20
|
+
"headers": headers,
|
|
21
|
+
"json": json.loads(data) if data else None,
|
|
22
|
+
"timeout": timeout,
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
raw = self.body if isinstance(self.body, bytes) else json.dumps(self.body).encode()
|
|
26
|
+
return self.status, raw
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def client(transport, **kwargs):
|
|
30
|
+
return Vaartix("vx_test", transport=transport, **kwargs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_sends_with_auth_json_and_idempotency_key():
|
|
34
|
+
t = FakeTransport()
|
|
35
|
+
result = client(t, base_url="https://api.example.test/").emails.send(
|
|
36
|
+
{"from": "a@acme.in", "to": "b@example.com", "subject": "Hi", "html": "<p>Hi</p>"},
|
|
37
|
+
idempotency_key="order-42",
|
|
38
|
+
)
|
|
39
|
+
assert result == {"id": "em_1"}
|
|
40
|
+
call = t.calls[0]
|
|
41
|
+
assert call["method"] == "POST"
|
|
42
|
+
assert call["url"] == "https://api.example.test/emails"
|
|
43
|
+
assert call["headers"]["Authorization"] == "Bearer vx_test"
|
|
44
|
+
assert call["headers"]["Idempotency-Key"] == "order-42"
|
|
45
|
+
assert call["headers"]["User-Agent"] == f"vaartix-python/{__version__}"
|
|
46
|
+
assert call["json"]["subject"] == "Hi"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_snake_case_keys_become_api_names():
|
|
50
|
+
t = FakeTransport()
|
|
51
|
+
client(t).emails.send(
|
|
52
|
+
{
|
|
53
|
+
"from": "a@acme.in",
|
|
54
|
+
"to": ["b@example.com"],
|
|
55
|
+
"subject": "Invoice",
|
|
56
|
+
"text": "Attached",
|
|
57
|
+
"reply_to": "billing@acme.in",
|
|
58
|
+
"scheduled_at": "2026-11-01T09:00:00+05:30",
|
|
59
|
+
"attachments": [
|
|
60
|
+
{"filename": "a.pdf", "content": "QQ==", "content_type": "application/pdf"}
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
sent = t.calls[0]["json"]
|
|
65
|
+
assert sent["replyTo"] == "billing@acme.in"
|
|
66
|
+
assert sent["scheduledAt"] == "2026-11-01T09:00:00+05:30"
|
|
67
|
+
assert sent["attachments"][0]["contentType"] == "application/pdf"
|
|
68
|
+
assert "reply_to" not in sent
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_api_errors_raise_vaartix_error():
|
|
72
|
+
t = FakeTransport(422, {"error": {"type": "validation_error", "message": "from is required"}})
|
|
73
|
+
with pytest.raises(VaartixError) as info:
|
|
74
|
+
client(t).emails.send({"to": "b@example.com", "subject": "Hi"})
|
|
75
|
+
assert info.value.type == "validation_error"
|
|
76
|
+
assert info.value.message == "from is required"
|
|
77
|
+
assert info.value.status_code == 422
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_non_json_errors_still_raise():
|
|
81
|
+
t = FakeTransport(502, b"<html>Bad gateway</html>")
|
|
82
|
+
with pytest.raises(VaartixError) as info:
|
|
83
|
+
client(t).emails.get("em_1")
|
|
84
|
+
assert info.value.type == "api_error"
|
|
85
|
+
assert info.value.status_code == 502
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_network_errors_raise_with_no_status():
|
|
89
|
+
def broken(*args):
|
|
90
|
+
raise urllib.error.URLError("connection refused")
|
|
91
|
+
|
|
92
|
+
with pytest.raises(VaartixError) as info:
|
|
93
|
+
client(broken).emails.get("em_1")
|
|
94
|
+
assert info.value.type == "network_error"
|
|
95
|
+
assert info.value.status_code is None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_list_builds_query_and_skips_empty_filters():
|
|
99
|
+
t = FakeTransport(body={"data": [], "hasMore": False})
|
|
100
|
+
client(t).emails.list(limit=5, status="bounced", api_key_id="key_1")
|
|
101
|
+
assert (
|
|
102
|
+
t.calls[0]["url"] == "https://api.vaartix.com/emails?limit=5&status=bounced&apiKeyId=key_1"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_ids_are_escaped_in_paths():
|
|
107
|
+
t = FakeTransport(body={"id": "em_1", "status": "canceled"})
|
|
108
|
+
client(t).emails.cancel("em_1/../x")
|
|
109
|
+
assert t.calls[0]["url"] == "https://api.vaartix.com/emails/em_1%2F..%2Fx/cancel"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_batch_sends_a_list():
|
|
113
|
+
t = FakeTransport(body={"data": [{"id": "em_1"}, {"id": "em_2"}]})
|
|
114
|
+
result = client(t).batch.send(
|
|
115
|
+
[
|
|
116
|
+
{"from": "a@acme.in", "to": "b@example.com", "subject": "1", "text": "x"},
|
|
117
|
+
{
|
|
118
|
+
"from": "a@acme.in",
|
|
119
|
+
"to": "c@example.com",
|
|
120
|
+
"subject": "2",
|
|
121
|
+
"text": "y",
|
|
122
|
+
"reply_to": "r@acme.in",
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
assert [e["id"] for e in result["data"]] == ["em_1", "em_2"]
|
|
127
|
+
assert t.calls[0]["url"].endswith("/emails/batch")
|
|
128
|
+
assert t.calls[0]["json"][1]["replyTo"] == "r@acme.in"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_webhook_management():
|
|
132
|
+
t = FakeTransport(body={"id": "wh_1", "status": "disabled"})
|
|
133
|
+
hooks = client(t).webhooks
|
|
134
|
+
hooks.create(url="https://acme.in/hooks", events=["email.bounced"])
|
|
135
|
+
hooks.update("wh_1", status="disabled")
|
|
136
|
+
hooks.deliveries("wh_1", limit=10)
|
|
137
|
+
hooks.retry_delivery("wh_1", "whd_1")
|
|
138
|
+
assert [(c["method"], c["url"].replace("https://api.vaartix.com", "")) for c in t.calls] == [
|
|
139
|
+
("POST", "/webhooks"),
|
|
140
|
+
("PATCH", "/webhooks/wh_1"),
|
|
141
|
+
("GET", "/webhooks/wh_1/deliveries?limit=10"),
|
|
142
|
+
("POST", "/webhooks/wh_1/deliveries/whd_1/retry"),
|
|
143
|
+
]
|
|
144
|
+
assert t.calls[0]["json"] == {"url": "https://acme.in/hooks", "events": ["email.bounced"]}
|
|
145
|
+
assert t.calls[1]["json"] == {"status": "disabled"}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_api_key_from_environment(monkeypatch):
|
|
149
|
+
monkeypatch.setenv("VAARTIX_API_KEY", "vx_env")
|
|
150
|
+
t = FakeTransport()
|
|
151
|
+
Vaartix(transport=t).emails.get("em_1")
|
|
152
|
+
assert t.calls[0]["headers"]["Authorization"] == "Bearer vx_env"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_missing_api_key(monkeypatch):
|
|
156
|
+
monkeypatch.delenv("VAARTIX_API_KEY", raising=False)
|
|
157
|
+
with pytest.raises(ValueError, match="VAARTIX_API_KEY"):
|
|
158
|
+
Vaartix()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_many_trailing_slashes_in_base_url():
|
|
162
|
+
t = FakeTransport()
|
|
163
|
+
client(t, base_url="https://api.example.test" + "/" * 50_000).emails.get("em_1")
|
|
164
|
+
assert t.calls[0]["url"] == "https://api.example.test/emails/em_1"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from vaartix import Vaartix, WebhookVerificationError, verify_webhook
|
|
9
|
+
|
|
10
|
+
# Test-only secrets, built from a separate prefix so secret scanners don't flag them.
|
|
11
|
+
PREFIX = "whsec_"
|
|
12
|
+
|
|
13
|
+
# The worked example from the Standard Webhooks specification.
|
|
14
|
+
SPEC_SECRET = f"{PREFIX}MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
|
|
15
|
+
SPEC_HEADERS = {
|
|
16
|
+
"webhook-id": "msg_p5jXN8AQM9LWM0D4loKWxJek",
|
|
17
|
+
"webhook-timestamp": "1614265330",
|
|
18
|
+
"webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
|
|
19
|
+
}
|
|
20
|
+
SPEC_BODY = '{"test": 2432232314}'
|
|
21
|
+
SPEC_NOW = 1614265330
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sign(secret, msg_id, timestamp, body):
|
|
25
|
+
key = base64.b64decode(secret[len(PREFIX) :])
|
|
26
|
+
digest = hmac.new(key, f"{msg_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()
|
|
27
|
+
return "v1," + base64.b64encode(digest).decode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_matches_the_standard_webhooks_example():
|
|
31
|
+
assert verify_webhook(SPEC_BODY, SPEC_HEADERS, SPEC_SECRET, now=SPEC_NOW) == {
|
|
32
|
+
"test": 2432232314
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_accepts_bytes_and_any_header_case():
|
|
37
|
+
headers = {k.title(): v for k, v in SPEC_HEADERS.items()}
|
|
38
|
+
assert (
|
|
39
|
+
verify_webhook(SPEC_BODY.encode(), headers, SPEC_SECRET, now=SPEC_NOW)["test"] == 2432232314
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_accepts_one_valid_signature_among_several():
|
|
44
|
+
headers = dict(SPEC_HEADERS)
|
|
45
|
+
headers["webhook-signature"] = "v1,AAAA " + SPEC_HEADERS["webhook-signature"]
|
|
46
|
+
verify_webhook(SPEC_BODY, headers, SPEC_SECRET, now=SPEC_NOW)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_rejects_a_changed_body():
|
|
50
|
+
with pytest.raises(WebhookVerificationError, match="does not match"):
|
|
51
|
+
verify_webhook('{"test": 1}', SPEC_HEADERS, SPEC_SECRET, now=SPEC_NOW)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_rejects_old_timestamps():
|
|
55
|
+
with pytest.raises(WebhookVerificationError, match="too old"):
|
|
56
|
+
verify_webhook(SPEC_BODY, SPEC_HEADERS, SPEC_SECRET, now=SPEC_NOW + 301)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_rejects_missing_headers_and_bad_secrets():
|
|
60
|
+
with pytest.raises(WebhookVerificationError, match="Missing"):
|
|
61
|
+
verify_webhook(SPEC_BODY, {}, SPEC_SECRET, now=SPEC_NOW)
|
|
62
|
+
with pytest.raises(WebhookVerificationError, match="start with"):
|
|
63
|
+
verify_webhook(SPEC_BODY, SPEC_HEADERS, "not-a-secret", now=SPEC_NOW)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_round_trip_with_a_vaartix_style_payload():
|
|
67
|
+
secret = f"{PREFIX}" + base64.b64encode(b"x" * 24).decode()
|
|
68
|
+
body = json.dumps({"type": "email.bounced", "data": {"recipient": "priya@example.com"}})
|
|
69
|
+
headers = {
|
|
70
|
+
"webhook-id": "whd_1",
|
|
71
|
+
"webhook-timestamp": "1793181600",
|
|
72
|
+
"webhook-signature": sign(secret, "whd_1", "1793181600", body),
|
|
73
|
+
}
|
|
74
|
+
result = verify_webhook(body, headers, secret, now=1793181600)
|
|
75
|
+
assert result["data"]["recipient"] == "priya@example.com"
|
|
76
|
+
# The client exposes the same check.
|
|
77
|
+
client = Vaartix("vx_test", transport=lambda *a: (200, b"{}"))
|
|
78
|
+
with pytest.raises(WebhookVerificationError):
|
|
79
|
+
client.webhooks.verify(body, headers, secret) # real clock: timestamp is far off
|