sendstack 0.1.1__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sendstack
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Sync and async Python SDK for the SendStack email SaaS API.
5
5
  Author: Noria Labs
6
6
  License-Expression: MIT
@@ -293,6 +293,45 @@ client.emails.send(
293
293
  )
294
294
  ```
295
295
 
296
+ ### Reading from files
297
+
298
+ The API accepts strings (`html`/`text`) and base64 (`attachments`). These
299
+ stdlib-only helpers do the read-and-encode step so you don't repeat it:
300
+
301
+ ```python
302
+ from sendstack import (
303
+ Sendstack,
304
+ html_from_file,
305
+ text_from_file,
306
+ attachment_from_file,
307
+ attachment_from_bytes,
308
+ )
309
+
310
+ client.emails.send(
311
+ {
312
+ "from": "billing@example.com",
313
+ "to": "customer@example.com",
314
+ "subject": "Your invoice",
315
+ "html": html_from_file("templates/invoice.html"),
316
+ "text": text_from_file("templates/invoice.txt"),
317
+ "attachments": [
318
+ # From a path: filename defaults to the basename, content is base64-encoded.
319
+ attachment_from_file("invoices/2026-06.pdf", content_type="application/pdf"),
320
+ # From in-memory bytes (e.g. a generated PDF): filename is required.
321
+ attachment_from_bytes(generated_pdf, filename="summary.pdf", content_type="application/pdf"),
322
+ ],
323
+ }
324
+ )
325
+ ```
326
+
327
+ - `html_from_file(path, *, encoding="utf-8")` / `text_from_file(...)` — read a text
328
+ file into a string.
329
+ - `attachment_from_file(path, *, filename=None, content_type=None, inline=None, content_id=None)`
330
+ — read a file into a base64 attachment `dict`. `filename` defaults to the basename.
331
+ `path` accepts a `str` or any `os.PathLike`.
332
+ - `attachment_from_bytes(data, *, filename, content_type=None, inline=None, content_id=None)`
333
+ — encode in-memory `bytes`; `filename` is required.
334
+
296
335
  ## Domains
297
336
 
298
337
  ```python
@@ -510,6 +549,11 @@ Clients and errors:
510
549
  - `SendstackError`
511
550
  - `DEFAULT_BASE_URL`
512
551
 
552
+ Filesystem helpers:
553
+
554
+ - `html_from_file`, `text_from_file`
555
+ - `attachment_from_file`, `attachment_from_bytes`
556
+
513
557
  Auth, options, and machinery:
514
558
 
515
559
  - `BearerAuthStrategy`, `HeadersAuthStrategy`, `SendstackAuthStrategy`
@@ -266,6 +266,45 @@ client.emails.send(
266
266
  )
267
267
  ```
268
268
 
269
+ ### Reading from files
270
+
271
+ The API accepts strings (`html`/`text`) and base64 (`attachments`). These
272
+ stdlib-only helpers do the read-and-encode step so you don't repeat it:
273
+
274
+ ```python
275
+ from sendstack import (
276
+ Sendstack,
277
+ html_from_file,
278
+ text_from_file,
279
+ attachment_from_file,
280
+ attachment_from_bytes,
281
+ )
282
+
283
+ client.emails.send(
284
+ {
285
+ "from": "billing@example.com",
286
+ "to": "customer@example.com",
287
+ "subject": "Your invoice",
288
+ "html": html_from_file("templates/invoice.html"),
289
+ "text": text_from_file("templates/invoice.txt"),
290
+ "attachments": [
291
+ # From a path: filename defaults to the basename, content is base64-encoded.
292
+ attachment_from_file("invoices/2026-06.pdf", content_type="application/pdf"),
293
+ # From in-memory bytes (e.g. a generated PDF): filename is required.
294
+ attachment_from_bytes(generated_pdf, filename="summary.pdf", content_type="application/pdf"),
295
+ ],
296
+ }
297
+ )
298
+ ```
299
+
300
+ - `html_from_file(path, *, encoding="utf-8")` / `text_from_file(...)` — read a text
301
+ file into a string.
302
+ - `attachment_from_file(path, *, filename=None, content_type=None, inline=None, content_id=None)`
303
+ — read a file into a base64 attachment `dict`. `filename` defaults to the basename.
304
+ `path` accepts a `str` or any `os.PathLike`.
305
+ - `attachment_from_bytes(data, *, filename, content_type=None, inline=None, content_id=None)`
306
+ — encode in-memory `bytes`; `filename` is required.
307
+
269
308
  ## Domains
270
309
 
271
310
  ```python
@@ -483,6 +522,11 @@ Clients and errors:
483
522
  - `SendstackError`
484
523
  - `DEFAULT_BASE_URL`
485
524
 
525
+ Filesystem helpers:
526
+
527
+ - `html_from_file`, `text_from_file`
528
+ - `attachment_from_file`, `attachment_from_bytes`
529
+
486
530
  Auth, options, and machinery:
487
531
 
488
532
  - `BearerAuthStrategy`, `HeadersAuthStrategy`, `SendstackAuthStrategy`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sendstack"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Sync and async Python SDK for the SendStack email SaaS API."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sendstack
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Sync and async Python SDK for the SendStack email SaaS API.
5
5
  Author: Noria Labs
6
6
  License-Expression: MIT
@@ -293,6 +293,45 @@ client.emails.send(
293
293
  )
294
294
  ```
295
295
 
296
+ ### Reading from files
297
+
298
+ The API accepts strings (`html`/`text`) and base64 (`attachments`). These
299
+ stdlib-only helpers do the read-and-encode step so you don't repeat it:
300
+
301
+ ```python
302
+ from sendstack import (
303
+ Sendstack,
304
+ html_from_file,
305
+ text_from_file,
306
+ attachment_from_file,
307
+ attachment_from_bytes,
308
+ )
309
+
310
+ client.emails.send(
311
+ {
312
+ "from": "billing@example.com",
313
+ "to": "customer@example.com",
314
+ "subject": "Your invoice",
315
+ "html": html_from_file("templates/invoice.html"),
316
+ "text": text_from_file("templates/invoice.txt"),
317
+ "attachments": [
318
+ # From a path: filename defaults to the basename, content is base64-encoded.
319
+ attachment_from_file("invoices/2026-06.pdf", content_type="application/pdf"),
320
+ # From in-memory bytes (e.g. a generated PDF): filename is required.
321
+ attachment_from_bytes(generated_pdf, filename="summary.pdf", content_type="application/pdf"),
322
+ ],
323
+ }
324
+ )
325
+ ```
326
+
327
+ - `html_from_file(path, *, encoding="utf-8")` / `text_from_file(...)` — read a text
328
+ file into a string.
329
+ - `attachment_from_file(path, *, filename=None, content_type=None, inline=None, content_id=None)`
330
+ — read a file into a base64 attachment `dict`. `filename` defaults to the basename.
331
+ `path` accepts a `str` or any `os.PathLike`.
332
+ - `attachment_from_bytes(data, *, filename, content_type=None, inline=None, content_id=None)`
333
+ — encode in-memory `bytes`; `filename` is required.
334
+
296
335
  ## Domains
297
336
 
298
337
  ```python
@@ -510,6 +549,11 @@ Clients and errors:
510
549
  - `SendstackError`
511
550
  - `DEFAULT_BASE_URL`
512
551
 
552
+ Filesystem helpers:
553
+
554
+ - `html_from_file`, `text_from_file`
555
+ - `attachment_from_file`, `attachment_from_bytes`
556
+
513
557
  Auth, options, and machinery:
514
558
 
515
559
  - `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
+ }
File without changes
File without changes