norialabs-send 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.
- norialabs_send-0.1.0/.gitignore +22 -0
- norialabs_send-0.1.0/LICENSE +21 -0
- norialabs_send-0.1.0/PKG-INFO +144 -0
- norialabs_send-0.1.0/README.md +122 -0
- norialabs_send-0.1.0/pyproject.toml +58 -0
- norialabs_send-0.1.0/src/noria_send/__init__.py +4 -0
- norialabs_send-0.1.0/src/noria_send/client.py +412 -0
- norialabs_send-0.1.0/src/noria_send/py.typed +0 -0
- norialabs_send-0.1.0/src/noria_send/webhooks.py +51 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
.env.*
|
|
5
|
+
!.env.example
|
|
6
|
+
*.log
|
|
7
|
+
.DS_Store
|
|
8
|
+
coverage/
|
|
9
|
+
|
|
10
|
+
sdks/php/vendor/
|
|
11
|
+
sdks/php/composer.lock
|
|
12
|
+
sdks/php/.phpstan-cache/
|
|
13
|
+
sdks/php/.phpunit.cache/
|
|
14
|
+
|
|
15
|
+
sdks/python/.venv/
|
|
16
|
+
sdks/python/.pytest_cache/
|
|
17
|
+
sdks/python/.ruff_cache/
|
|
18
|
+
sdks/python/dist/
|
|
19
|
+
**/__pycache__/
|
|
20
|
+
|
|
21
|
+
apps/console/dist/
|
|
22
|
+
apps/console/tsconfig.tsbuildinfo
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noria Labs
|
|
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.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: norialabs-send
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Noria Send: send transactional email and SMS through one internal service instead of wiring a provider into every product.
|
|
5
|
+
Project-URL: Homepage, https://github.com/norialabs/send
|
|
6
|
+
Project-URL: Source, https://github.com/norialabs/send
|
|
7
|
+
Project-URL: Issues, https://github.com/norialabs/send/issues
|
|
8
|
+
Author-email: Joseph Gitonga <thekiharani@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: email,noria,onfon,send,ses,sms,transactional
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Communications :: Email
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Requires-Dist: httpx>=0.28.1
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# norialabs-send
|
|
24
|
+
|
|
25
|
+
Python client for [Noria Send](https://github.com/norialabs/send): transactional email and
|
|
26
|
+
SMS through one internal service. Sync and async, typed, no dependency beyond `httpx`.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install norialabs-send # or: uv add norialabs-send
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from noria_send import Send
|
|
34
|
+
|
|
35
|
+
send = Send(api_key=os.environ["NORIA_SEND_KEY"])
|
|
36
|
+
|
|
37
|
+
send.emails.send({
|
|
38
|
+
"from_": "Noria <hello@norialabs.com>",
|
|
39
|
+
"to": "founder@example.com",
|
|
40
|
+
"subject": "Welcome",
|
|
41
|
+
"html": "<p>Hi there</p>",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
send.sms.send({"from_": "NORIA", "to": "0712345678", "text": "Your code is 482913"})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`base_url` defaults to `https://send.noria.co.ke`; pass it to reach a local service or another
|
|
48
|
+
instance.
|
|
49
|
+
|
|
50
|
+
`from_` carries the trailing underscore because `from` is a keyword; it is sent as `from`.
|
|
51
|
+
Every other field is exactly what goes on the wire.
|
|
52
|
+
|
|
53
|
+
## Async
|
|
54
|
+
|
|
55
|
+
The same surface, for FastAPI and anything else on asyncio.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from noria_send import AsyncSend
|
|
59
|
+
|
|
60
|
+
async with AsyncSend(api_key=key, base_url=url) as send:
|
|
61
|
+
await send.emails.send({"to": "a@example.com", "subject": "Hi", "text": "there"})
|
|
62
|
+
await send.sms.send({"to": "0712345678", "text": "Your code is 482913"})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Sending
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
send.emails.send(email, idempotency_key=f"signin-{token.id}")
|
|
69
|
+
send.emails.send_batch([email, email])
|
|
70
|
+
|
|
71
|
+
send.sms.send(message, idempotency_key=f"otp-{token.id}")
|
|
72
|
+
send.sms.send_batch([message, message])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Both also take `template` and `variables` instead of a body, `scheduled_at` for a future send
|
|
76
|
+
and `tags`. Email adds `attachments` as base64, plus `cc`, `bcc`, `reply_to` and `headers`.
|
|
77
|
+
|
|
78
|
+
An SMS takes one recipient, because one row carries one provider message id and that is what a
|
|
79
|
+
delivery receipt is matched against. Use `send_batch` for many.
|
|
80
|
+
|
|
81
|
+
## Reading what was sent
|
|
82
|
+
|
|
83
|
+
One ledger covers every channel:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
send.messages.list(channel="sms", status="failed", limit=50)
|
|
87
|
+
send.messages.get(message_id)
|
|
88
|
+
send.messages.events(message_id)
|
|
89
|
+
send.messages.cancel(message_id)
|
|
90
|
+
send.messages.requeue(message_id)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Domains, senders, templates, suppressions, webhooks
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
send.domains.create("norialabs.com") # returns the DNS records to publish
|
|
97
|
+
send.senders.create("NORIA") # registered pending approval
|
|
98
|
+
send.templates.upsert("welcome", subject="Hi {{name}}", html=html)
|
|
99
|
+
send.templates.upsert("otp", channel="sms", text="Code {{code}}")
|
|
100
|
+
send.suppressions.add("0712345678", channel="sms", reason="unsubscribe")
|
|
101
|
+
send.is_suppressed("0712345678", "sms")
|
|
102
|
+
send.webhooks.create("https://app.example.com/hooks/send", ["delivered", "bounced"])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A slug is unique per channel, so an `otp` email template and an `otp` SMS template can coexist.
|
|
106
|
+
|
|
107
|
+
## Errors
|
|
108
|
+
|
|
109
|
+
Failures raise `SendError` with `code`, `status`, `details` and `request_id`, plus
|
|
110
|
+
`suppressed`, `over_quota` and `retryable` for the common branches. The client retries 408,
|
|
111
|
+
429 and 5xx with backoff and never retries `quota_exceeded`, `suppressed_recipient`,
|
|
112
|
+
`domain_not_verified`, `sender_not_approved` or `message_too_long`.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
try:
|
|
116
|
+
send.sms.send(message)
|
|
117
|
+
except SendError as error:
|
|
118
|
+
if error.suppressed:
|
|
119
|
+
return
|
|
120
|
+
raise
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Webhooks
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from noria_send import verify_webhook
|
|
127
|
+
|
|
128
|
+
event = verify_webhook(
|
|
129
|
+
payload=await request.body(),
|
|
130
|
+
signature=request.headers["noria-signature"],
|
|
131
|
+
secret=os.environ["NORIA_SEND_WEBHOOK_SECRET"],
|
|
132
|
+
)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
HMAC-SHA256 over `<timestamp>.<raw body>`, compared in constant time, with a five minute
|
|
136
|
+
tolerance against replay. `event["data"]["channel"]` says which channel the event came from.
|
|
137
|
+
|
|
138
|
+
## Tests
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
uv sync
|
|
142
|
+
uv run pytest
|
|
143
|
+
uv run ruff check src tests
|
|
144
|
+
```
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# norialabs-send
|
|
2
|
+
|
|
3
|
+
Python client for [Noria Send](https://github.com/norialabs/send): transactional email and
|
|
4
|
+
SMS through one internal service. Sync and async, typed, no dependency beyond `httpx`.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install norialabs-send # or: uv add norialabs-send
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from noria_send import Send
|
|
12
|
+
|
|
13
|
+
send = Send(api_key=os.environ["NORIA_SEND_KEY"])
|
|
14
|
+
|
|
15
|
+
send.emails.send({
|
|
16
|
+
"from_": "Noria <hello@norialabs.com>",
|
|
17
|
+
"to": "founder@example.com",
|
|
18
|
+
"subject": "Welcome",
|
|
19
|
+
"html": "<p>Hi there</p>",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
send.sms.send({"from_": "NORIA", "to": "0712345678", "text": "Your code is 482913"})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`base_url` defaults to `https://send.noria.co.ke`; pass it to reach a local service or another
|
|
26
|
+
instance.
|
|
27
|
+
|
|
28
|
+
`from_` carries the trailing underscore because `from` is a keyword; it is sent as `from`.
|
|
29
|
+
Every other field is exactly what goes on the wire.
|
|
30
|
+
|
|
31
|
+
## Async
|
|
32
|
+
|
|
33
|
+
The same surface, for FastAPI and anything else on asyncio.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from noria_send import AsyncSend
|
|
37
|
+
|
|
38
|
+
async with AsyncSend(api_key=key, base_url=url) as send:
|
|
39
|
+
await send.emails.send({"to": "a@example.com", "subject": "Hi", "text": "there"})
|
|
40
|
+
await send.sms.send({"to": "0712345678", "text": "Your code is 482913"})
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Sending
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
send.emails.send(email, idempotency_key=f"signin-{token.id}")
|
|
47
|
+
send.emails.send_batch([email, email])
|
|
48
|
+
|
|
49
|
+
send.sms.send(message, idempotency_key=f"otp-{token.id}")
|
|
50
|
+
send.sms.send_batch([message, message])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Both also take `template` and `variables` instead of a body, `scheduled_at` for a future send
|
|
54
|
+
and `tags`. Email adds `attachments` as base64, plus `cc`, `bcc`, `reply_to` and `headers`.
|
|
55
|
+
|
|
56
|
+
An SMS takes one recipient, because one row carries one provider message id and that is what a
|
|
57
|
+
delivery receipt is matched against. Use `send_batch` for many.
|
|
58
|
+
|
|
59
|
+
## Reading what was sent
|
|
60
|
+
|
|
61
|
+
One ledger covers every channel:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
send.messages.list(channel="sms", status="failed", limit=50)
|
|
65
|
+
send.messages.get(message_id)
|
|
66
|
+
send.messages.events(message_id)
|
|
67
|
+
send.messages.cancel(message_id)
|
|
68
|
+
send.messages.requeue(message_id)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Domains, senders, templates, suppressions, webhooks
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
send.domains.create("norialabs.com") # returns the DNS records to publish
|
|
75
|
+
send.senders.create("NORIA") # registered pending approval
|
|
76
|
+
send.templates.upsert("welcome", subject="Hi {{name}}", html=html)
|
|
77
|
+
send.templates.upsert("otp", channel="sms", text="Code {{code}}")
|
|
78
|
+
send.suppressions.add("0712345678", channel="sms", reason="unsubscribe")
|
|
79
|
+
send.is_suppressed("0712345678", "sms")
|
|
80
|
+
send.webhooks.create("https://app.example.com/hooks/send", ["delivered", "bounced"])
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A slug is unique per channel, so an `otp` email template and an `otp` SMS template can coexist.
|
|
84
|
+
|
|
85
|
+
## Errors
|
|
86
|
+
|
|
87
|
+
Failures raise `SendError` with `code`, `status`, `details` and `request_id`, plus
|
|
88
|
+
`suppressed`, `over_quota` and `retryable` for the common branches. The client retries 408,
|
|
89
|
+
429 and 5xx with backoff and never retries `quota_exceeded`, `suppressed_recipient`,
|
|
90
|
+
`domain_not_verified`, `sender_not_approved` or `message_too_long`.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
try:
|
|
94
|
+
send.sms.send(message)
|
|
95
|
+
except SendError as error:
|
|
96
|
+
if error.suppressed:
|
|
97
|
+
return
|
|
98
|
+
raise
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Webhooks
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from noria_send import verify_webhook
|
|
105
|
+
|
|
106
|
+
event = verify_webhook(
|
|
107
|
+
payload=await request.body(),
|
|
108
|
+
signature=request.headers["noria-signature"],
|
|
109
|
+
secret=os.environ["NORIA_SEND_WEBHOOK_SECRET"],
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
HMAC-SHA256 over `<timestamp>.<raw body>`, compared in constant time, with a five minute
|
|
114
|
+
tolerance against replay. `event["data"]["channel"]` says which channel the event came from.
|
|
115
|
+
|
|
116
|
+
## Tests
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
uv sync
|
|
120
|
+
uv run pytest
|
|
121
|
+
uv run ruff check src tests
|
|
122
|
+
```
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "norialabs-send"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for Noria Send: send transactional email and SMS through one internal service instead of wiring a provider into every product."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Joseph Gitonga", email = "thekiharani@gmail.com" }]
|
|
10
|
+
keywords = ["email", "sms", "transactional", "ses", "onfon", "noria", "send"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Topic :: Communications :: Email",
|
|
17
|
+
"Topic :: Communications :: Telephony",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["httpx>=0.28.1"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/norialabs/send"
|
|
24
|
+
Source = "https://github.com/norialabs/send"
|
|
25
|
+
Issues = "https://github.com/norialabs/send/issues"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=9.0.1",
|
|
30
|
+
"pytest-asyncio>=1.3.0",
|
|
31
|
+
"pytest-cov>=7.0.0",
|
|
32
|
+
"ruff>=0.14.5",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["hatchling"]
|
|
37
|
+
build-backend = "hatchling.build"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/noria_send", "README.md", "LICENSE", "pyproject.toml"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/noria_send"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
46
|
+
"src/noria_send/py.typed" = "noria_send/py.typed"
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
asyncio_mode = "auto"
|
|
51
|
+
addopts = "-q --tb=short"
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 110
|
|
55
|
+
target-version = "py313"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://send.noria.co.ke"
|
|
11
|
+
RETRYABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
|
|
12
|
+
NON_RETRYABLE_CODES = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"quota_exceeded",
|
|
15
|
+
"suppressed_recipient",
|
|
16
|
+
"domain_not_verified",
|
|
17
|
+
"sender_not_approved",
|
|
18
|
+
"message_too_long",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Json = dict[str, Any]
|
|
23
|
+
Channel = Literal["email", "sms"]
|
|
24
|
+
Reason = Literal["bounce", "complaint", "manual", "unsubscribe"]
|
|
25
|
+
Status = Literal["queued", "sending", "sent", "failed", "canceled"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Attachment(TypedDict):
|
|
29
|
+
filename: str
|
|
30
|
+
content: str
|
|
31
|
+
content_type: NotRequired[str]
|
|
32
|
+
content_id: NotRequired[str]
|
|
33
|
+
disposition: NotRequired[Literal["attachment", "inline"]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SendEmail(TypedDict):
|
|
37
|
+
from_: NotRequired[str]
|
|
38
|
+
to: str | list[str]
|
|
39
|
+
cc: NotRequired[str | list[str]]
|
|
40
|
+
bcc: NotRequired[str | list[str]]
|
|
41
|
+
reply_to: NotRequired[str | list[str]]
|
|
42
|
+
subject: NotRequired[str]
|
|
43
|
+
html: NotRequired[str]
|
|
44
|
+
text: NotRequired[str]
|
|
45
|
+
template: NotRequired[str]
|
|
46
|
+
variables: NotRequired[Json]
|
|
47
|
+
headers: NotRequired[dict[str, str]]
|
|
48
|
+
tags: NotRequired[dict[str, str]]
|
|
49
|
+
attachments: NotRequired[list[Attachment]]
|
|
50
|
+
scheduled_at: NotRequired[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SendSms(TypedDict):
|
|
54
|
+
from_: NotRequired[str]
|
|
55
|
+
to: str
|
|
56
|
+
text: NotRequired[str]
|
|
57
|
+
template: NotRequired[str]
|
|
58
|
+
variables: NotRequired[Json]
|
|
59
|
+
tags: NotRequired[dict[str, str]]
|
|
60
|
+
scheduled_at: NotRequired[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SendError(Exception):
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
code: str,
|
|
67
|
+
status: int,
|
|
68
|
+
message: str,
|
|
69
|
+
details: Any = None,
|
|
70
|
+
request_id: str | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(message)
|
|
73
|
+
self.code = code
|
|
74
|
+
self.status = status
|
|
75
|
+
self.message = message
|
|
76
|
+
self.details = details
|
|
77
|
+
self.request_id = request_id
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
return f"SendError(code={self.code!r}, status={self.status}, message={self.message!r})"
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_response(cls, status: int, body: Any) -> SendError:
|
|
84
|
+
error = body.get("error") if isinstance(body, dict) else None
|
|
85
|
+
error = error if isinstance(error, dict) else {}
|
|
86
|
+
return cls(
|
|
87
|
+
code=error.get("code") or "internal_error",
|
|
88
|
+
status=status,
|
|
89
|
+
message=error.get("message") or f"Request failed with status {status}",
|
|
90
|
+
details=error.get("details"),
|
|
91
|
+
request_id=error.get("request_id"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def suppressed(self) -> bool:
|
|
96
|
+
return self.code == "suppressed_recipient"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def over_quota(self) -> bool:
|
|
100
|
+
return self.code in {"quota_exceeded", "rate_limited"}
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def retryable(self) -> bool:
|
|
104
|
+
if self.code in NON_RETRYABLE_CODES:
|
|
105
|
+
return False
|
|
106
|
+
return self.status == 0 or self.status in RETRYABLE_STATUSES
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _payload(message: SendEmail | SendSms) -> Json:
|
|
110
|
+
body: Json = {key: value for key, value in message.items() if key != "from_"}
|
|
111
|
+
if "from_" in message:
|
|
112
|
+
body["from"] = message["from_"]
|
|
113
|
+
return body
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _query(**parameters: Any) -> Json:
|
|
117
|
+
return {key: value for key, value in parameters.items() if value not in (None, "")}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _backoff(attempt: int) -> float:
|
|
121
|
+
return min(2.0, 0.2 * 2 ** (attempt - 1)) * (0.5 + random.random())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _decode(response: httpx.Response) -> Json:
|
|
125
|
+
if response.status_code == 204 or not response.content:
|
|
126
|
+
return {}
|
|
127
|
+
return response.json()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _Namespace:
|
|
131
|
+
def __init__(self, client: Send | AsyncSend) -> None:
|
|
132
|
+
self._client = client
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Emails(_Namespace):
|
|
136
|
+
def send(self, email: SendEmail, idempotency_key: str | None = None) -> Any:
|
|
137
|
+
headers = {"idempotency-key": idempotency_key} if idempotency_key else None
|
|
138
|
+
return self._client.request("POST", "/v1/emails", _payload(email), headers=headers)
|
|
139
|
+
|
|
140
|
+
def send_batch(self, emails: list[SendEmail]) -> Any:
|
|
141
|
+
return self._client.request("POST", "/v1/emails/batch", {"emails": [_payload(e) for e in emails]})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Sms(_Namespace):
|
|
145
|
+
def send(self, message: SendSms, idempotency_key: str | None = None) -> Any:
|
|
146
|
+
headers = {"idempotency-key": idempotency_key} if idempotency_key else None
|
|
147
|
+
return self._client.request("POST", "/v1/sms", _payload(message), headers=headers)
|
|
148
|
+
|
|
149
|
+
def send_batch(self, messages: list[SendSms]) -> Any:
|
|
150
|
+
return self._client.request("POST", "/v1/sms/batch", {"messages": [_payload(m) for m in messages]})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Messages(_Namespace):
|
|
154
|
+
def get(self, message_id: str) -> Any:
|
|
155
|
+
return self._client.request("GET", f"/v1/messages/{quote(message_id)}")
|
|
156
|
+
|
|
157
|
+
def list(
|
|
158
|
+
self,
|
|
159
|
+
status: Status | None = None,
|
|
160
|
+
channel: Channel | None = None,
|
|
161
|
+
to: str | None = None,
|
|
162
|
+
limit: int | None = None,
|
|
163
|
+
cursor: str | None = None,
|
|
164
|
+
) -> Any:
|
|
165
|
+
return self._client.request(
|
|
166
|
+
"GET",
|
|
167
|
+
"/v1/messages",
|
|
168
|
+
params=_query(status=status, channel=channel, to=to, limit=limit, cursor=cursor),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def events(self, message_id: str) -> Any:
|
|
172
|
+
return self._client.request("GET", f"/v1/messages/{quote(message_id)}/events")
|
|
173
|
+
|
|
174
|
+
def cancel(self, message_id: str) -> Any:
|
|
175
|
+
return self._client.request("POST", f"/v1/messages/{quote(message_id)}/cancel")
|
|
176
|
+
|
|
177
|
+
def requeue(self, message_id: str) -> Any:
|
|
178
|
+
return self._client.request("POST", f"/v1/messages/{quote(message_id)}/requeue")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Domains(_Namespace):
|
|
182
|
+
def create(self, name: str, custom_return_path: bool = True) -> Any:
|
|
183
|
+
return self._client.request(
|
|
184
|
+
"POST", "/v1/domains", {"name": name, "custom_return_path": custom_return_path}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def list(self) -> Any:
|
|
188
|
+
return self._client.request("GET", "/v1/domains")
|
|
189
|
+
|
|
190
|
+
def get(self, domain_id: str) -> Any:
|
|
191
|
+
return self._client.request("GET", f"/v1/domains/{quote(domain_id)}")
|
|
192
|
+
|
|
193
|
+
def verify(self, domain_id: str) -> Any:
|
|
194
|
+
return self._client.request("POST", f"/v1/domains/{quote(domain_id)}/verify")
|
|
195
|
+
|
|
196
|
+
def remove(self, domain_id: str) -> Any:
|
|
197
|
+
return self._client.request("DELETE", f"/v1/domains/{quote(domain_id)}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Senders(_Namespace):
|
|
201
|
+
def create(self, sender_id: str, channel: Channel = "sms") -> Any:
|
|
202
|
+
return self._client.request("POST", "/v1/senders", {"sender_id": sender_id, "channel": channel})
|
|
203
|
+
|
|
204
|
+
def list(self) -> Any:
|
|
205
|
+
return self._client.request("GET", "/v1/senders")
|
|
206
|
+
|
|
207
|
+
def get(self, sender_row_id: str) -> Any:
|
|
208
|
+
return self._client.request("GET", f"/v1/senders/{quote(sender_row_id)}")
|
|
209
|
+
|
|
210
|
+
def remove(self, sender_row_id: str) -> Any:
|
|
211
|
+
return self._client.request("DELETE", f"/v1/senders/{quote(sender_row_id)}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Templates(_Namespace):
|
|
215
|
+
def upsert(
|
|
216
|
+
self,
|
|
217
|
+
slug: str,
|
|
218
|
+
subject: str | None = None,
|
|
219
|
+
html: str | None = None,
|
|
220
|
+
text: str | None = None,
|
|
221
|
+
channel: Channel = "email",
|
|
222
|
+
) -> Any:
|
|
223
|
+
return self._client.request(
|
|
224
|
+
"POST", "/v1/templates", _query(slug=slug, channel=channel, subject=subject, html=html, text=text)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def list(self) -> Any:
|
|
228
|
+
return self._client.request("GET", "/v1/templates")
|
|
229
|
+
|
|
230
|
+
def get(self, slug: str, channel: Channel = "email") -> Any:
|
|
231
|
+
return self._client.request("GET", f"/v1/templates/{quote(slug)}", params={"channel": channel})
|
|
232
|
+
|
|
233
|
+
def remove(self, slug: str, channel: Channel = "email") -> Any:
|
|
234
|
+
return self._client.request("DELETE", f"/v1/templates/{quote(slug)}", params={"channel": channel})
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class Suppressions(_Namespace):
|
|
238
|
+
def add(
|
|
239
|
+
self,
|
|
240
|
+
destination: str,
|
|
241
|
+
channel: Channel = "email",
|
|
242
|
+
reason: Reason = "manual",
|
|
243
|
+
detail: str | None = None,
|
|
244
|
+
) -> Any:
|
|
245
|
+
return self._client.request(
|
|
246
|
+
"POST",
|
|
247
|
+
"/v1/suppressions",
|
|
248
|
+
_query(destination=destination, channel=channel, reason=reason, detail=detail),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def list(self, destination: str | None = None, channel: Channel | None = None) -> Any:
|
|
252
|
+
return self._client.request(
|
|
253
|
+
"GET", "/v1/suppressions", params=_query(destination=destination, channel=channel)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def remove(self, destination: str, channel: Channel = "email") -> Any:
|
|
257
|
+
return self._client.request(
|
|
258
|
+
"DELETE", f"/v1/suppressions/{quote(destination)}", params={"channel": channel}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class Webhooks(_Namespace):
|
|
263
|
+
def create(self, url: str, event_types: list[str] | None = None, description: str | None = None) -> Any:
|
|
264
|
+
return self._client.request(
|
|
265
|
+
"POST",
|
|
266
|
+
"/v1/webhook-endpoints",
|
|
267
|
+
_query(url=url, event_types=event_types or [], description=description),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def list(self) -> Any:
|
|
271
|
+
return self._client.request("GET", "/v1/webhook-endpoints")
|
|
272
|
+
|
|
273
|
+
def remove(self, endpoint_id: str) -> Any:
|
|
274
|
+
return self._client.request("DELETE", f"/v1/webhook-endpoints/{quote(endpoint_id)}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class _BaseClient:
|
|
278
|
+
def __init__(
|
|
279
|
+
self,
|
|
280
|
+
api_key: str,
|
|
281
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
282
|
+
timeout: float = 15.0,
|
|
283
|
+
retries: int = 2,
|
|
284
|
+
) -> None:
|
|
285
|
+
if not api_key:
|
|
286
|
+
raise SendError("validation_error", 0, "A Noria Send API key is required")
|
|
287
|
+
|
|
288
|
+
self._api_key = api_key
|
|
289
|
+
self._base_url = base_url.rstrip("/")
|
|
290
|
+
self._timeout = timeout
|
|
291
|
+
self._retries = retries
|
|
292
|
+
|
|
293
|
+
self.emails = Emails(self) # type: ignore[arg-type]
|
|
294
|
+
self.sms = Sms(self) # type: ignore[arg-type]
|
|
295
|
+
self.messages = Messages(self) # type: ignore[arg-type]
|
|
296
|
+
self.domains = Domains(self) # type: ignore[arg-type]
|
|
297
|
+
self.senders = Senders(self) # type: ignore[arg-type]
|
|
298
|
+
self.templates = Templates(self) # type: ignore[arg-type]
|
|
299
|
+
self.suppressions = Suppressions(self) # type: ignore[arg-type]
|
|
300
|
+
self.webhooks = Webhooks(self) # type: ignore[arg-type]
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def _headers(self) -> dict[str, str]:
|
|
304
|
+
return {"authorization": f"Bearer {self._api_key}", "accept": "application/json"}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Send(_BaseClient):
|
|
308
|
+
def __init__(self, *args: Any, transport: httpx.BaseTransport | None = None, **kwargs: Any) -> None:
|
|
309
|
+
super().__init__(*args, **kwargs)
|
|
310
|
+
self._http = httpx.Client(timeout=self._timeout, transport=transport)
|
|
311
|
+
|
|
312
|
+
def close(self) -> None:
|
|
313
|
+
self._http.close()
|
|
314
|
+
|
|
315
|
+
def __enter__(self) -> Send:
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
def __exit__(self, *_: object) -> None:
|
|
319
|
+
self.close()
|
|
320
|
+
|
|
321
|
+
def request(
|
|
322
|
+
self,
|
|
323
|
+
method: str,
|
|
324
|
+
path: str,
|
|
325
|
+
body: Json | None = None,
|
|
326
|
+
params: Json | None = None,
|
|
327
|
+
headers: dict[str, str] | None = None,
|
|
328
|
+
) -> Json:
|
|
329
|
+
last: SendError | None = None
|
|
330
|
+
|
|
331
|
+
for attempt in range(1, self._retries + 2):
|
|
332
|
+
if attempt > 1:
|
|
333
|
+
time.sleep(_backoff(attempt - 1))
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
response = self._http.request(
|
|
337
|
+
method,
|
|
338
|
+
f"{self._base_url}{path}",
|
|
339
|
+
json=body,
|
|
340
|
+
params=params,
|
|
341
|
+
headers={**self._headers, **(headers or {})},
|
|
342
|
+
)
|
|
343
|
+
except httpx.HTTPError as exception:
|
|
344
|
+
last = SendError("network_error", 0, str(exception))
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
if response.is_success:
|
|
348
|
+
return _decode(response)
|
|
349
|
+
|
|
350
|
+
last = SendError.from_response(response.status_code, _decode(response))
|
|
351
|
+
if not last.retryable:
|
|
352
|
+
raise last
|
|
353
|
+
|
|
354
|
+
raise last or SendError("network_error", 0, "Request failed")
|
|
355
|
+
|
|
356
|
+
def is_suppressed(self, destination: str, channel: Channel = "email") -> bool:
|
|
357
|
+
return bool(self.suppressions.list(destination, channel).get("data"))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class AsyncSend(_BaseClient):
|
|
361
|
+
def __init__(self, *args: Any, transport: httpx.AsyncBaseTransport | None = None, **kwargs: Any) -> None:
|
|
362
|
+
super().__init__(*args, **kwargs)
|
|
363
|
+
self._http = httpx.AsyncClient(timeout=self._timeout, transport=transport)
|
|
364
|
+
|
|
365
|
+
async def aclose(self) -> None:
|
|
366
|
+
await self._http.aclose()
|
|
367
|
+
|
|
368
|
+
async def __aenter__(self) -> AsyncSend:
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
async def __aexit__(self, *_: object) -> None:
|
|
372
|
+
await self.aclose()
|
|
373
|
+
|
|
374
|
+
async def request(
|
|
375
|
+
self,
|
|
376
|
+
method: str,
|
|
377
|
+
path: str,
|
|
378
|
+
body: Json | None = None,
|
|
379
|
+
params: Json | None = None,
|
|
380
|
+
headers: dict[str, str] | None = None,
|
|
381
|
+
) -> Json:
|
|
382
|
+
import asyncio
|
|
383
|
+
|
|
384
|
+
last: SendError | None = None
|
|
385
|
+
|
|
386
|
+
for attempt in range(1, self._retries + 2):
|
|
387
|
+
if attempt > 1:
|
|
388
|
+
await asyncio.sleep(_backoff(attempt - 1))
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
response = await self._http.request(
|
|
392
|
+
method,
|
|
393
|
+
f"{self._base_url}{path}",
|
|
394
|
+
json=body,
|
|
395
|
+
params=params,
|
|
396
|
+
headers={**self._headers, **(headers or {})},
|
|
397
|
+
)
|
|
398
|
+
except httpx.HTTPError as exception:
|
|
399
|
+
last = SendError("network_error", 0, str(exception))
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
if response.is_success:
|
|
403
|
+
return _decode(response)
|
|
404
|
+
|
|
405
|
+
last = SendError.from_response(response.status_code, _decode(response))
|
|
406
|
+
if not last.retryable:
|
|
407
|
+
raise last
|
|
408
|
+
|
|
409
|
+
raise last or SendError("network_error", 0, "Request failed")
|
|
410
|
+
|
|
411
|
+
async def is_suppressed(self, destination: str, channel: Channel = "email") -> bool:
|
|
412
|
+
return bool((await self.suppressions.list(destination, channel)).get("data"))
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from noria_send.client import SendError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def verify_webhook(
|
|
13
|
+
payload: str | bytes,
|
|
14
|
+
signature: str,
|
|
15
|
+
secret: str,
|
|
16
|
+
tolerance_seconds: int = 300,
|
|
17
|
+
now: int | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
timestamp, provided = _parse(signature)
|
|
20
|
+
|
|
21
|
+
if abs((now if now is not None else int(time.time())) - timestamp) > tolerance_seconds:
|
|
22
|
+
raise SendError("validation_error", 400, "Signature timestamp is outside the tolerance window")
|
|
23
|
+
|
|
24
|
+
body = payload.decode() if isinstance(payload, bytes) else payload
|
|
25
|
+
expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
26
|
+
|
|
27
|
+
if not hmac.compare_digest(expected, provided):
|
|
28
|
+
raise SendError("unauthorized", 401, "Invalid webhook signature")
|
|
29
|
+
|
|
30
|
+
event = json.loads(body)
|
|
31
|
+
|
|
32
|
+
if not isinstance(event, dict):
|
|
33
|
+
raise SendError("validation_error", 400, "Webhook payload is not a JSON object")
|
|
34
|
+
|
|
35
|
+
return event
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse(signature: str) -> tuple[int, str]:
|
|
39
|
+
parts: dict[str, str] = {}
|
|
40
|
+
|
|
41
|
+
for pair in signature.split(","):
|
|
42
|
+
key, _, value = pair.strip().partition("=")
|
|
43
|
+
if value:
|
|
44
|
+
parts[key] = value
|
|
45
|
+
|
|
46
|
+
timestamp = parts.get("t", "")
|
|
47
|
+
|
|
48
|
+
if not timestamp.isdigit() or "v1" not in parts:
|
|
49
|
+
raise SendError("validation_error", 400, "Malformed Noria-Signature header")
|
|
50
|
+
|
|
51
|
+
return int(timestamp), parts["v1"]
|