sendstack 0.1.1__tar.gz → 0.1.3__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.
- {sendstack-0.1.1/src/sendstack.egg-info → sendstack-0.1.3}/PKG-INFO +52 -6
- {sendstack-0.1.1 → sendstack-0.1.3}/README.md +51 -5
- {sendstack-0.1.1 → sendstack-0.1.3}/pyproject.toml +1 -1
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack/__init__.py +11 -0
- sendstack-0.1.3/src/sendstack/files.py +83 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack/types.py +4 -2
- {sendstack-0.1.1 → sendstack-0.1.3/src/sendstack.egg-info}/PKG-INFO +52 -6
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack.egg-info/SOURCES.txt +2 -0
- sendstack-0.1.3/tests/test_files.py +86 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/tests/test_sendstack.py +14 -14
- {sendstack-0.1.1 → sendstack-0.1.3}/LICENSE +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/setup.cfg +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack/client.py +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack/errors.py +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack/py.typed +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack/utils.py +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack.egg-info/dependency_links.txt +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack.egg-info/requires.txt +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/src/sendstack.egg-info/top_level.txt +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/tests/test_conformance.py +0 -0
- {sendstack-0.1.1 → sendstack-0.1.3}/tests/test_distribution_identity.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendstack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Sync and async Python SDK for the SendStack email SaaS API.
|
|
5
5
|
Author: Noria Labs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,11 +123,13 @@ decoded payload; on `AsyncSendstack` the same call returns a coroutine you
|
|
|
123
123
|
|
|
124
124
|
### Base URL
|
|
125
125
|
|
|
126
|
-
The SDK defaults to `https://
|
|
127
|
-
|
|
126
|
+
The SDK defaults to `https://sendstack.norialabs.com/api/v1` (the versioned API
|
|
127
|
+
base). Override `base_url` to point at another environment — include the
|
|
128
|
+
`/api/v1` version segment, since resource paths (e.g. `/emails`) are sent
|
|
129
|
+
relative to whatever base you provide:
|
|
128
130
|
|
|
129
131
|
```python
|
|
130
|
-
client = Sendstack(token, base_url="https://
|
|
132
|
+
client = Sendstack(token, base_url="https://staging.norialabs.com/api/v1")
|
|
131
133
|
```
|
|
132
134
|
|
|
133
135
|
## Docs Split
|
|
@@ -139,7 +141,7 @@ The SaaS docs remain the canonical source for product/API behavior: account
|
|
|
139
141
|
setup, API tokens, domain verification, DNS records, webhook event catalogs,
|
|
140
142
|
deliverability concepts, provider behavior, dashboard workflows, and the raw
|
|
141
143
|
HTTP API reference. Current live SaaS docs are at
|
|
142
|
-
`https://
|
|
144
|
+
`https://sendstack.norialabs.com/api/docs`.
|
|
143
145
|
|
|
144
146
|
## Auth
|
|
145
147
|
|
|
@@ -162,7 +164,7 @@ request (sync or async callables both work):
|
|
|
162
164
|
from sendstack import BearerAuthStrategy, Sendstack
|
|
163
165
|
|
|
164
166
|
client = Sendstack(
|
|
165
|
-
base_url="https://
|
|
167
|
+
base_url="https://sendstack.norialabs.com/api/v1",
|
|
166
168
|
auth=BearerAuthStrategy(token=lambda context: get_fresh_token()),
|
|
167
169
|
)
|
|
168
170
|
```
|
|
@@ -293,6 +295,45 @@ client.emails.send(
|
|
|
293
295
|
)
|
|
294
296
|
```
|
|
295
297
|
|
|
298
|
+
### Reading from files
|
|
299
|
+
|
|
300
|
+
The API accepts strings (`html`/`text`) and base64 (`attachments`). These
|
|
301
|
+
stdlib-only helpers do the read-and-encode step so you don't repeat it:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from sendstack import (
|
|
305
|
+
Sendstack,
|
|
306
|
+
html_from_file,
|
|
307
|
+
text_from_file,
|
|
308
|
+
attachment_from_file,
|
|
309
|
+
attachment_from_bytes,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
client.emails.send(
|
|
313
|
+
{
|
|
314
|
+
"from": "billing@example.com",
|
|
315
|
+
"to": "customer@example.com",
|
|
316
|
+
"subject": "Your invoice",
|
|
317
|
+
"html": html_from_file("templates/invoice.html"),
|
|
318
|
+
"text": text_from_file("templates/invoice.txt"),
|
|
319
|
+
"attachments": [
|
|
320
|
+
# From a path: filename defaults to the basename, content is base64-encoded.
|
|
321
|
+
attachment_from_file("invoices/2026-06.pdf", content_type="application/pdf"),
|
|
322
|
+
# From in-memory bytes (e.g. a generated PDF): filename is required.
|
|
323
|
+
attachment_from_bytes(generated_pdf, filename="summary.pdf", content_type="application/pdf"),
|
|
324
|
+
],
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
- `html_from_file(path, *, encoding="utf-8")` / `text_from_file(...)` — read a text
|
|
330
|
+
file into a string.
|
|
331
|
+
- `attachment_from_file(path, *, filename=None, content_type=None, inline=None, content_id=None)`
|
|
332
|
+
— read a file into a base64 attachment `dict`. `filename` defaults to the basename.
|
|
333
|
+
`path` accepts a `str` or any `os.PathLike`.
|
|
334
|
+
- `attachment_from_bytes(data, *, filename, content_type=None, inline=None, content_id=None)`
|
|
335
|
+
— encode in-memory `bytes`; `filename` is required.
|
|
336
|
+
|
|
296
337
|
## Domains
|
|
297
338
|
|
|
298
339
|
```python
|
|
@@ -510,6 +551,11 @@ Clients and errors:
|
|
|
510
551
|
- `SendstackError`
|
|
511
552
|
- `DEFAULT_BASE_URL`
|
|
512
553
|
|
|
554
|
+
Filesystem helpers:
|
|
555
|
+
|
|
556
|
+
- `html_from_file`, `text_from_file`
|
|
557
|
+
- `attachment_from_file`, `attachment_from_bytes`
|
|
558
|
+
|
|
513
559
|
Auth, options, and machinery:
|
|
514
560
|
|
|
515
561
|
- `BearerAuthStrategy`, `HeadersAuthStrategy`, `SendstackAuthStrategy`
|
|
@@ -96,11 +96,13 @@ decoded payload; on `AsyncSendstack` the same call returns a coroutine you
|
|
|
96
96
|
|
|
97
97
|
### Base URL
|
|
98
98
|
|
|
99
|
-
The SDK defaults to `https://
|
|
100
|
-
|
|
99
|
+
The SDK defaults to `https://sendstack.norialabs.com/api/v1` (the versioned API
|
|
100
|
+
base). Override `base_url` to point at another environment — include the
|
|
101
|
+
`/api/v1` version segment, since resource paths (e.g. `/emails`) are sent
|
|
102
|
+
relative to whatever base you provide:
|
|
101
103
|
|
|
102
104
|
```python
|
|
103
|
-
client = Sendstack(token, base_url="https://
|
|
105
|
+
client = Sendstack(token, base_url="https://staging.norialabs.com/api/v1")
|
|
104
106
|
```
|
|
105
107
|
|
|
106
108
|
## Docs Split
|
|
@@ -112,7 +114,7 @@ The SaaS docs remain the canonical source for product/API behavior: account
|
|
|
112
114
|
setup, API tokens, domain verification, DNS records, webhook event catalogs,
|
|
113
115
|
deliverability concepts, provider behavior, dashboard workflows, and the raw
|
|
114
116
|
HTTP API reference. Current live SaaS docs are at
|
|
115
|
-
`https://
|
|
117
|
+
`https://sendstack.norialabs.com/api/docs`.
|
|
116
118
|
|
|
117
119
|
## Auth
|
|
118
120
|
|
|
@@ -135,7 +137,7 @@ request (sync or async callables both work):
|
|
|
135
137
|
from sendstack import BearerAuthStrategy, Sendstack
|
|
136
138
|
|
|
137
139
|
client = Sendstack(
|
|
138
|
-
base_url="https://
|
|
140
|
+
base_url="https://sendstack.norialabs.com/api/v1",
|
|
139
141
|
auth=BearerAuthStrategy(token=lambda context: get_fresh_token()),
|
|
140
142
|
)
|
|
141
143
|
```
|
|
@@ -266,6 +268,45 @@ client.emails.send(
|
|
|
266
268
|
)
|
|
267
269
|
```
|
|
268
270
|
|
|
271
|
+
### Reading from files
|
|
272
|
+
|
|
273
|
+
The API accepts strings (`html`/`text`) and base64 (`attachments`). These
|
|
274
|
+
stdlib-only helpers do the read-and-encode step so you don't repeat it:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from sendstack import (
|
|
278
|
+
Sendstack,
|
|
279
|
+
html_from_file,
|
|
280
|
+
text_from_file,
|
|
281
|
+
attachment_from_file,
|
|
282
|
+
attachment_from_bytes,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
client.emails.send(
|
|
286
|
+
{
|
|
287
|
+
"from": "billing@example.com",
|
|
288
|
+
"to": "customer@example.com",
|
|
289
|
+
"subject": "Your invoice",
|
|
290
|
+
"html": html_from_file("templates/invoice.html"),
|
|
291
|
+
"text": text_from_file("templates/invoice.txt"),
|
|
292
|
+
"attachments": [
|
|
293
|
+
# From a path: filename defaults to the basename, content is base64-encoded.
|
|
294
|
+
attachment_from_file("invoices/2026-06.pdf", content_type="application/pdf"),
|
|
295
|
+
# From in-memory bytes (e.g. a generated PDF): filename is required.
|
|
296
|
+
attachment_from_bytes(generated_pdf, filename="summary.pdf", content_type="application/pdf"),
|
|
297
|
+
],
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- `html_from_file(path, *, encoding="utf-8")` / `text_from_file(...)` — read a text
|
|
303
|
+
file into a string.
|
|
304
|
+
- `attachment_from_file(path, *, filename=None, content_type=None, inline=None, content_id=None)`
|
|
305
|
+
— read a file into a base64 attachment `dict`. `filename` defaults to the basename.
|
|
306
|
+
`path` accepts a `str` or any `os.PathLike`.
|
|
307
|
+
- `attachment_from_bytes(data, *, filename, content_type=None, inline=None, content_id=None)`
|
|
308
|
+
— encode in-memory `bytes`; `filename` is required.
|
|
309
|
+
|
|
269
310
|
## Domains
|
|
270
311
|
|
|
271
312
|
```python
|
|
@@ -483,6 +524,11 @@ Clients and errors:
|
|
|
483
524
|
- `SendstackError`
|
|
484
525
|
- `DEFAULT_BASE_URL`
|
|
485
526
|
|
|
527
|
+
Filesystem helpers:
|
|
528
|
+
|
|
529
|
+
- `html_from_file`, `text_from_file`
|
|
530
|
+
- `attachment_from_file`, `attachment_from_bytes`
|
|
531
|
+
|
|
486
532
|
Auth, options, and machinery:
|
|
487
533
|
|
|
488
534
|
- `BearerAuthStrategy`, `HeadersAuthStrategy`, `SendstackAuthStrategy`
|
|
@@ -20,6 +20,12 @@ from .errors import (
|
|
|
20
20
|
is_success_envelope,
|
|
21
21
|
to_sendstack_error,
|
|
22
22
|
)
|
|
23
|
+
from .files import (
|
|
24
|
+
attachment_from_bytes,
|
|
25
|
+
attachment_from_file,
|
|
26
|
+
html_from_file,
|
|
27
|
+
text_from_file,
|
|
28
|
+
)
|
|
23
29
|
from .types import (
|
|
24
30
|
DEFAULT_BASE_URL,
|
|
25
31
|
BearerAuthStrategy,
|
|
@@ -77,6 +83,11 @@ __all__ = [
|
|
|
77
83
|
"is_error_envelope",
|
|
78
84
|
"is_success_envelope",
|
|
79
85
|
"to_sendstack_error",
|
|
86
|
+
# Filesystem helpers
|
|
87
|
+
"html_from_file",
|
|
88
|
+
"text_from_file",
|
|
89
|
+
"attachment_from_file",
|
|
90
|
+
"attachment_from_bytes",
|
|
80
91
|
# Auth
|
|
81
92
|
"BearerAuthStrategy",
|
|
82
93
|
"HeadersAuthStrategy",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Filesystem conveniences for building email payloads from local files.
|
|
2
|
+
|
|
3
|
+
The SendStack API accepts strings (``html``/``text``) and base64 (``attachments``).
|
|
4
|
+
These helpers do the read-and-encode step so callers don't repeat it. They use
|
|
5
|
+
only the standard library, so they add no dependencies.
|
|
6
|
+
|
|
7
|
+
The returned attachment ``dict`` uses the snake_case wire field names, so it can
|
|
8
|
+
be dropped straight into ``emails.send({"attachments": [...]})``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
PathLike = str | os.PathLike[str]
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"html_from_file",
|
|
22
|
+
"text_from_file",
|
|
23
|
+
"attachment_from_file",
|
|
24
|
+
"attachment_from_bytes",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def html_from_file(path: PathLike, *, encoding: str = "utf-8") -> str:
|
|
29
|
+
"""Read a text file (e.g. an HTML template) into a string for ``html``."""
|
|
30
|
+
return Path(path).read_text(encoding=encoding)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def text_from_file(path: PathLike, *, encoding: str = "utf-8") -> str:
|
|
34
|
+
"""Read a text file (e.g. a ``.txt`` body) into a string for ``text``."""
|
|
35
|
+
return Path(path).read_text(encoding=encoding)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def attachment_from_file(
|
|
39
|
+
path: PathLike,
|
|
40
|
+
*,
|
|
41
|
+
filename: str | None = None,
|
|
42
|
+
content_type: str | None = None,
|
|
43
|
+
inline: bool | None = None,
|
|
44
|
+
content_id: str | None = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Read a file from disk into a base64 attachment dict.
|
|
47
|
+
|
|
48
|
+
``filename`` defaults to the file's basename. The result is ready to drop
|
|
49
|
+
into ``emails.send({"attachments": [...]})``.
|
|
50
|
+
"""
|
|
51
|
+
data = Path(path).read_bytes()
|
|
52
|
+
return attachment_from_bytes(
|
|
53
|
+
data,
|
|
54
|
+
filename=filename if filename is not None else Path(os.fspath(path)).name,
|
|
55
|
+
content_type=content_type,
|
|
56
|
+
inline=inline,
|
|
57
|
+
content_id=content_id,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def attachment_from_bytes(
|
|
62
|
+
data: bytes,
|
|
63
|
+
*,
|
|
64
|
+
filename: str,
|
|
65
|
+
content_type: str | None = None,
|
|
66
|
+
inline: bool | None = None,
|
|
67
|
+
content_id: str | None = None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Encode in-memory bytes (e.g. a generated PDF) into a base64 attachment dict.
|
|
70
|
+
|
|
71
|
+
``filename`` is required since there is no path to derive one from.
|
|
72
|
+
"""
|
|
73
|
+
attachment: dict[str, Any] = {
|
|
74
|
+
"filename": filename,
|
|
75
|
+
"content_base64": base64.b64encode(data).decode("ascii"),
|
|
76
|
+
}
|
|
77
|
+
if content_type is not None:
|
|
78
|
+
attachment["content_type"] = content_type
|
|
79
|
+
if inline is not None:
|
|
80
|
+
attachment["inline"] = inline
|
|
81
|
+
if content_id is not None:
|
|
82
|
+
attachment["content_id"] = content_id
|
|
83
|
+
return attachment
|
|
@@ -20,8 +20,10 @@ from typing import Any, Literal, NotRequired, TypeAlias, TypedDict
|
|
|
20
20
|
|
|
21
21
|
import httpx
|
|
22
22
|
|
|
23
|
-
# The
|
|
24
|
-
|
|
23
|
+
# The versioned API base. Override via ``base_url`` for other environments;
|
|
24
|
+
# include the /api/v1 segment, since resource paths (e.g. /emails) are sent
|
|
25
|
+
# relative to whatever base is configured.
|
|
26
|
+
DEFAULT_BASE_URL = "https://sendstack.norialabs.com/api/v1"
|
|
25
27
|
|
|
26
28
|
# Sentinel distinguishing "no body" from an explicit ``None`` body.
|
|
27
29
|
UNSET: Any = object()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendstack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Sync and async Python SDK for the SendStack email SaaS API.
|
|
5
5
|
Author: Noria Labs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,11 +123,13 @@ decoded payload; on `AsyncSendstack` the same call returns a coroutine you
|
|
|
123
123
|
|
|
124
124
|
### Base URL
|
|
125
125
|
|
|
126
|
-
The SDK defaults to `https://
|
|
127
|
-
|
|
126
|
+
The SDK defaults to `https://sendstack.norialabs.com/api/v1` (the versioned API
|
|
127
|
+
base). Override `base_url` to point at another environment — include the
|
|
128
|
+
`/api/v1` version segment, since resource paths (e.g. `/emails`) are sent
|
|
129
|
+
relative to whatever base you provide:
|
|
128
130
|
|
|
129
131
|
```python
|
|
130
|
-
client = Sendstack(token, base_url="https://
|
|
132
|
+
client = Sendstack(token, base_url="https://staging.norialabs.com/api/v1")
|
|
131
133
|
```
|
|
132
134
|
|
|
133
135
|
## Docs Split
|
|
@@ -139,7 +141,7 @@ The SaaS docs remain the canonical source for product/API behavior: account
|
|
|
139
141
|
setup, API tokens, domain verification, DNS records, webhook event catalogs,
|
|
140
142
|
deliverability concepts, provider behavior, dashboard workflows, and the raw
|
|
141
143
|
HTTP API reference. Current live SaaS docs are at
|
|
142
|
-
`https://
|
|
144
|
+
`https://sendstack.norialabs.com/api/docs`.
|
|
143
145
|
|
|
144
146
|
## Auth
|
|
145
147
|
|
|
@@ -162,7 +164,7 @@ request (sync or async callables both work):
|
|
|
162
164
|
from sendstack import BearerAuthStrategy, Sendstack
|
|
163
165
|
|
|
164
166
|
client = Sendstack(
|
|
165
|
-
base_url="https://
|
|
167
|
+
base_url="https://sendstack.norialabs.com/api/v1",
|
|
166
168
|
auth=BearerAuthStrategy(token=lambda context: get_fresh_token()),
|
|
167
169
|
)
|
|
168
170
|
```
|
|
@@ -293,6 +295,45 @@ client.emails.send(
|
|
|
293
295
|
)
|
|
294
296
|
```
|
|
295
297
|
|
|
298
|
+
### Reading from files
|
|
299
|
+
|
|
300
|
+
The API accepts strings (`html`/`text`) and base64 (`attachments`). These
|
|
301
|
+
stdlib-only helpers do the read-and-encode step so you don't repeat it:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from sendstack import (
|
|
305
|
+
Sendstack,
|
|
306
|
+
html_from_file,
|
|
307
|
+
text_from_file,
|
|
308
|
+
attachment_from_file,
|
|
309
|
+
attachment_from_bytes,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
client.emails.send(
|
|
313
|
+
{
|
|
314
|
+
"from": "billing@example.com",
|
|
315
|
+
"to": "customer@example.com",
|
|
316
|
+
"subject": "Your invoice",
|
|
317
|
+
"html": html_from_file("templates/invoice.html"),
|
|
318
|
+
"text": text_from_file("templates/invoice.txt"),
|
|
319
|
+
"attachments": [
|
|
320
|
+
# From a path: filename defaults to the basename, content is base64-encoded.
|
|
321
|
+
attachment_from_file("invoices/2026-06.pdf", content_type="application/pdf"),
|
|
322
|
+
# From in-memory bytes (e.g. a generated PDF): filename is required.
|
|
323
|
+
attachment_from_bytes(generated_pdf, filename="summary.pdf", content_type="application/pdf"),
|
|
324
|
+
],
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
- `html_from_file(path, *, encoding="utf-8")` / `text_from_file(...)` — read a text
|
|
330
|
+
file into a string.
|
|
331
|
+
- `attachment_from_file(path, *, filename=None, content_type=None, inline=None, content_id=None)`
|
|
332
|
+
— read a file into a base64 attachment `dict`. `filename` defaults to the basename.
|
|
333
|
+
`path` accepts a `str` or any `os.PathLike`.
|
|
334
|
+
- `attachment_from_bytes(data, *, filename, content_type=None, inline=None, content_id=None)`
|
|
335
|
+
— encode in-memory `bytes`; `filename` is required.
|
|
336
|
+
|
|
296
337
|
## Domains
|
|
297
338
|
|
|
298
339
|
```python
|
|
@@ -510,6 +551,11 @@ Clients and errors:
|
|
|
510
551
|
- `SendstackError`
|
|
511
552
|
- `DEFAULT_BASE_URL`
|
|
512
553
|
|
|
554
|
+
Filesystem helpers:
|
|
555
|
+
|
|
556
|
+
- `html_from_file`, `text_from_file`
|
|
557
|
+
- `attachment_from_file`, `attachment_from_bytes`
|
|
558
|
+
|
|
513
559
|
Auth, options, and machinery:
|
|
514
560
|
|
|
515
561
|
- `BearerAuthStrategy`, `HeadersAuthStrategy`, `SendstackAuthStrategy`
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/sendstack/__init__.py
|
|
5
5
|
src/sendstack/client.py
|
|
6
6
|
src/sendstack/errors.py
|
|
7
|
+
src/sendstack/files.py
|
|
7
8
|
src/sendstack/py.typed
|
|
8
9
|
src/sendstack/types.py
|
|
9
10
|
src/sendstack/utils.py
|
|
@@ -14,4 +15,5 @@ src/sendstack.egg-info/requires.txt
|
|
|
14
15
|
src/sendstack.egg-info/top_level.txt
|
|
15
16
|
tests/test_conformance.py
|
|
16
17
|
tests/test_distribution_identity.py
|
|
18
|
+
tests/test_files.py
|
|
17
19
|
tests/test_sendstack.py
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tests for the filesystem convenience helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sendstack import (
|
|
9
|
+
attachment_from_bytes,
|
|
10
|
+
attachment_from_file,
|
|
11
|
+
html_from_file,
|
|
12
|
+
text_from_file,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_html_from_file_reads_utf8(tmp_path: Path) -> None:
|
|
17
|
+
path = tmp_path / "welcome.html"
|
|
18
|
+
path.write_text("<p>Hëllo</p>", encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
assert html_from_file(path) == "<p>Hëllo</p>"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_text_from_file_reads_utf8_and_honors_encoding(tmp_path: Path) -> None:
|
|
24
|
+
path = tmp_path / "welcome.txt"
|
|
25
|
+
path.write_text("Héllo", encoding="latin-1")
|
|
26
|
+
|
|
27
|
+
assert text_from_file(path, encoding="latin-1") == "Héllo"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_attachment_from_file_defaults_filename_to_basename(tmp_path: Path) -> None:
|
|
31
|
+
path = tmp_path / "invoice.pdf"
|
|
32
|
+
payload = b"%PDF-1.4 fake"
|
|
33
|
+
path.write_bytes(payload)
|
|
34
|
+
|
|
35
|
+
assert attachment_from_file(path) == {
|
|
36
|
+
"filename": "invoice.pdf",
|
|
37
|
+
"content_base64": base64.b64encode(payload).decode("ascii"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_attachment_from_file_accepts_str_path_and_overrides(tmp_path: Path) -> None:
|
|
42
|
+
path = tmp_path / "logo.png"
|
|
43
|
+
payload = bytes([0x89, 0x50, 0x4E, 0x47])
|
|
44
|
+
path.write_bytes(payload)
|
|
45
|
+
|
|
46
|
+
# Pass the path as a str to exercise the os.fspath branch.
|
|
47
|
+
assert attachment_from_file(
|
|
48
|
+
str(path),
|
|
49
|
+
filename="brand-logo.png",
|
|
50
|
+
content_type="image/png",
|
|
51
|
+
inline=True,
|
|
52
|
+
content_id="logo",
|
|
53
|
+
) == {
|
|
54
|
+
"filename": "brand-logo.png",
|
|
55
|
+
"content_base64": base64.b64encode(payload).decode("ascii"),
|
|
56
|
+
"content_type": "image/png",
|
|
57
|
+
"inline": True,
|
|
58
|
+
"content_id": "logo",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_attachment_from_bytes_minimal() -> None:
|
|
63
|
+
data = b"generated bytes"
|
|
64
|
+
|
|
65
|
+
assert attachment_from_bytes(data, filename="report.bin") == {
|
|
66
|
+
"filename": "report.bin",
|
|
67
|
+
"content_base64": base64.b64encode(data).decode("ascii"),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_attachment_from_bytes_with_all_metadata() -> None:
|
|
72
|
+
data = bytes([1, 2, 3, 4])
|
|
73
|
+
|
|
74
|
+
assert attachment_from_bytes(
|
|
75
|
+
data,
|
|
76
|
+
filename="raw.dat",
|
|
77
|
+
content_type="application/octet-stream",
|
|
78
|
+
inline=False,
|
|
79
|
+
content_id="raw",
|
|
80
|
+
) == {
|
|
81
|
+
"filename": "raw.dat",
|
|
82
|
+
"content_base64": base64.b64encode(data).decode("ascii"),
|
|
83
|
+
"content_type": "application/octet-stream",
|
|
84
|
+
"inline": False,
|
|
85
|
+
"content_id": "raw",
|
|
86
|
+
}
|
|
@@ -131,7 +131,7 @@ def test_send_email_aliases_and_unwraps():
|
|
|
131
131
|
|
|
132
132
|
assert result == {"id": "m_1", "status": "queued"}
|
|
133
133
|
request = calls[0]
|
|
134
|
-
assert str(request.url) == "https://
|
|
134
|
+
assert str(request.url) == "https://sendstack.norialabs.com/api/v1/emails"
|
|
135
135
|
assert request.method == "POST"
|
|
136
136
|
assert request.headers["authorization"] == "Bearer tok"
|
|
137
137
|
assert request.headers["accept"] == "application/json"
|
|
@@ -192,10 +192,10 @@ def test_emails_get_events_cancel_requeue():
|
|
|
192
192
|
assert client.emails.requeue("m1")["status"] == "queued"
|
|
193
193
|
finally:
|
|
194
194
|
http.close()
|
|
195
|
-
assert calls[0].url.path == "/emails/m 1" # decoded back by httpx (sent percent-encoded)
|
|
196
|
-
assert calls[1].url.path == "/emails/m1/events"
|
|
197
|
-
assert calls[2].method == "POST" and calls[2].url.path == "/emails/m1/cancel"
|
|
198
|
-
assert calls[3].url.path == "/emails/m1/requeue"
|
|
195
|
+
assert calls[0].url.path == "/api/v1/emails/m 1" # decoded back by httpx (sent percent-encoded)
|
|
196
|
+
assert calls[1].url.path == "/api/v1/emails/m1/events"
|
|
197
|
+
assert calls[2].method == "POST" and calls[2].url.path == "/api/v1/emails/m1/cancel"
|
|
198
|
+
assert calls[3].url.path == "/api/v1/emails/m1/requeue"
|
|
199
199
|
|
|
200
200
|
|
|
201
201
|
# --------------------------------------------------------------------------- #
|
|
@@ -224,8 +224,8 @@ def test_domains_resource():
|
|
|
224
224
|
"provider_id": "p1",
|
|
225
225
|
"custom_return_path": "bounce",
|
|
226
226
|
}
|
|
227
|
-
assert calls[1].method == "GET" and calls[1].url.path == "/domains"
|
|
228
|
-
assert calls[3].url.path == "/domains/d1/verify"
|
|
227
|
+
assert calls[1].method == "GET" and calls[1].url.path == "/api/v1/domains"
|
|
228
|
+
assert calls[3].url.path == "/api/v1/domains/d1/verify"
|
|
229
229
|
|
|
230
230
|
|
|
231
231
|
def test_templates_resource_including_delete_returns_none():
|
|
@@ -247,7 +247,7 @@ def test_templates_resource_including_delete_returns_none():
|
|
|
247
247
|
finally:
|
|
248
248
|
http.close()
|
|
249
249
|
assert removed is None
|
|
250
|
-
assert calls[3].method == "PATCH" and calls[3].url.path == "/templates/t1"
|
|
250
|
+
assert calls[3].method == "PATCH" and calls[3].url.path == "/api/v1/templates/t1"
|
|
251
251
|
assert calls[4].method == "DELETE"
|
|
252
252
|
|
|
253
253
|
|
|
@@ -268,8 +268,8 @@ def test_webhooks_resource_event_types_alias():
|
|
|
268
268
|
finally:
|
|
269
269
|
http.close()
|
|
270
270
|
assert json.loads(calls[0].content) == {"url": "https://e.com", "event_types": ["email.sent"]}
|
|
271
|
-
assert calls[0].url.path == "/webhook-endpoints"
|
|
272
|
-
assert calls[3].method == "DELETE" and calls[3].url.path == "/webhook-endpoints/wh"
|
|
271
|
+
assert calls[0].url.path == "/api/v1/webhook-endpoints"
|
|
272
|
+
assert calls[3].method == "DELETE" and calls[3].url.path == "/api/v1/webhook-endpoints/wh"
|
|
273
273
|
|
|
274
274
|
|
|
275
275
|
def test_webhook_events_retry_alias_attribute():
|
|
@@ -280,7 +280,7 @@ def test_webhook_events_retry_alias_attribute():
|
|
|
280
280
|
finally:
|
|
281
281
|
http.close()
|
|
282
282
|
assert result["webhook_status"] == "queued"
|
|
283
|
-
assert calls[0].method == "POST" and calls[0].url.path == "/events/ev_1/retry"
|
|
283
|
+
assert calls[0].method == "POST" and calls[0].url.path == "/api/v1/events/ev_1/retry"
|
|
284
284
|
|
|
285
285
|
|
|
286
286
|
def test_suppressions_resource():
|
|
@@ -297,8 +297,8 @@ def test_suppressions_resource():
|
|
|
297
297
|
client.suppressions.remove("bad@x.com")
|
|
298
298
|
finally:
|
|
299
299
|
http.close()
|
|
300
|
-
assert calls[0].url.path == "/suppressions"
|
|
301
|
-
assert calls[2].method == "DELETE" and calls[2].url.path == "/suppressions/bad@x.com"
|
|
300
|
+
assert calls[0].url.path == "/api/v1/suppressions"
|
|
301
|
+
assert calls[2].method == "DELETE" and calls[2].url.path == "/api/v1/suppressions/bad@x.com"
|
|
302
302
|
|
|
303
303
|
|
|
304
304
|
def test_attachments_upload_alias():
|
|
@@ -646,7 +646,7 @@ def test_close_owns_vs_injected_and_context_manager():
|
|
|
646
646
|
def test_default_base_url_when_unspecified():
|
|
647
647
|
client = Sendstack("tok")
|
|
648
648
|
try:
|
|
649
|
-
assert client.base_url == "https://
|
|
649
|
+
assert client.base_url == "https://sendstack.norialabs.com/api/v1"
|
|
650
650
|
finally:
|
|
651
651
|
client.close()
|
|
652
652
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|