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.
@@ -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,4 @@
1
+ from noria_send.client import AsyncSend, Send, SendError
2
+ from noria_send.webhooks import verify_webhook
3
+
4
+ __all__ = ["AsyncSend", "Send", "SendError", "verify_webhook"]
@@ -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"]