sendclipper 0.1.0__tar.gz → 0.2.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.
Files changed (30) hide show
  1. sendclipper-0.2.0/CHANGELOG.md +23 -0
  2. {sendclipper-0.1.0 → sendclipper-0.2.0}/PKG-INFO +23 -1
  3. {sendclipper-0.1.0 → sendclipper-0.2.0}/README.md +22 -0
  4. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_core.py +25 -0
  5. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_version.py +1 -1
  6. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/emails.py +9 -3
  7. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/sandboxes.py +4 -3
  8. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/types.py +20 -0
  9. {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/test_client.py +72 -0
  10. sendclipper-0.1.0/CHANGELOG.md +0 -12
  11. {sendclipper-0.1.0 → sendclipper-0.2.0}/.gitignore +0 -0
  12. {sendclipper-0.1.0 → sendclipper-0.2.0}/LICENSE +0 -0
  13. {sendclipper-0.1.0 → sendclipper-0.2.0}/pyproject.toml +0 -0
  14. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/__init__.py +0 -0
  15. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_client.py +0 -0
  16. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_errors.py +0 -0
  17. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/_webhooks.py +0 -0
  18. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/py.typed +0 -0
  19. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/__init__.py +0 -0
  20. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/_base.py +0 -0
  21. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/audience.py +0 -0
  22. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/domains.py +0 -0
  23. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/mailboxes.py +0 -0
  24. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/templates.py +0 -0
  25. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/webhooks.py +0 -0
  26. {sendclipper-0.1.0 → sendclipper-0.2.0}/src/sendclipper/resources/workspace.py +0 -0
  27. {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/__init__.py +0 -0
  28. {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/conftest.py +0 -0
  29. {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/test_webhooks.py +0 -0
  30. {sendclipper-0.1.0 → sendclipper-0.2.0}/tests/wheel/smoke.py +0 -0
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-10-18
4
+
5
+ - `emails.send` and `sandboxes.send` take `replyTo` (one address or a list of up to 10) and `attachments` (a list of `{"filename", "content", "contentType"}`). Give `content` as `bytes`, `bytearray` or `memoryview` and the SDK base64-encodes it for you; a `str` is taken to be base64 already and sent as is. `contentType` is optional.
6
+ - `batch.send` items take `replyTo` too. An item carrying `attachments` is failed by the server with `attachments_not_batchable`; send those messages with `emails.send`.
7
+ - New type `sendclipper.types.Attachment`.
8
+ - Needs the SendClipper API from 2026-10-18 on; an older API ignores both fields.
9
+
10
+ ## 0.1.1 — 2026-10-17
11
+
12
+ - README: how to install in a uv workspace whose root has no `[project]` (`uv add --package`). No code change.
13
+
14
+ ## 0.1.0 — 2026-10-16
15
+
16
+ First release.
17
+
18
+ - `emails.send`, `batch.send`, `emails.get`, `emails.list`, `emails.iterate`, `emails.stats`.
19
+ - Templates (with tags and image assets), domains, sandboxes (including the JSON door and `wait_for`), webhooks and their dispatches, contacts, audiences, suppressions, mailboxes, automations, workspace and API keys.
20
+ - Every method returns a `Result` with `data`, `error` and `headers`; it unpacks to `(data, error)`.
21
+ - Automatic `Idempotency-Key` on every JSON write; retries on network errors, 429 and 5xx.
22
+ - `verify_webhook()` for `X-SendClipper-Signature-V2`, rotation-aware.
23
+ - No runtime dependencies; typed (`py.typed`), Python 3.9 or newer.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sendclipper
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for the SendClipper email API: send mail, manage domains, templates, sandboxes, webhooks, contacts and suppressions.
5
5
  Project-URL: Homepage, https://sendclipper.com
6
6
  Project-URL: Documentation, https://sendclipper.com/docs#sdk-python
@@ -39,6 +39,8 @@ uv add sendclipper
39
39
  poetry add sendclipper
40
40
  ```
41
41
 
42
+ In a uv workspace whose root has no `[project]` table, `uv add` refuses at the root: name the member that sends mail with `uv add --package <member> sendclipper`.
43
+
42
44
  Python 3.9 or newer. No runtime dependencies. Fully typed.
43
45
 
44
46
  ## Setup
@@ -73,6 +75,26 @@ Parameters and responses are plain dicts whose keys are the API's own field name
73
75
 
74
76
  `from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
75
77
 
78
+ ## Reply-To and attachments
79
+
80
+ ```python
81
+ with open("booking-42.pdf", "rb") as f:
82
+ pdf = f.read()
83
+
84
+ data, error = sc.emails.send({
85
+ "from": "bookings@acme.io",
86
+ "to": "ada@lumen.io",
87
+ "subject": "Your booking",
88
+ "html": "<p>Your confirmation is attached.</p>",
89
+ "replyTo": "support@acme.io",
90
+ "attachments": [
91
+ {"filename": "booking-42.pdf", "content": pdf},
92
+ ],
93
+ })
94
+ ```
95
+
96
+ `replyTo` is one address or a list of up to 10. `content` is the file's bytes (`bytes`, `bytearray` or `memoryview`, which the SDK base64-encodes for you) or a `str` that is already base64; `contentType` is guessed from the filename when you leave it out. Limits: 10 MiB per file, 25 MiB of files per message, and your sending provider's own. Attachments are not accepted on a batch item; send those emails on their own. `sandboxes.send` takes the same two fields.
97
+
76
98
  ## Send with a template
77
99
 
78
100
  Design and publish a template in the dashboard, then send it by id or by its send name:
@@ -10,6 +10,8 @@ uv add sendclipper
10
10
  poetry add sendclipper
11
11
  ```
12
12
 
13
+ In a uv workspace whose root has no `[project]` table, `uv add` refuses at the root: name the member that sends mail with `uv add --package <member> sendclipper`.
14
+
13
15
  Python 3.9 or newer. No runtime dependencies. Fully typed.
14
16
 
15
17
  ## Setup
@@ -44,6 +46,26 @@ Parameters and responses are plain dicts whose keys are the API's own field name
44
46
 
45
47
  `from` must be a bare address on a domain you verified in SendClipper. Each message has one recipient in `to`; for several, use `batch.send`.
46
48
 
49
+ ## Reply-To and attachments
50
+
51
+ ```python
52
+ with open("booking-42.pdf", "rb") as f:
53
+ pdf = f.read()
54
+
55
+ data, error = sc.emails.send({
56
+ "from": "bookings@acme.io",
57
+ "to": "ada@lumen.io",
58
+ "subject": "Your booking",
59
+ "html": "<p>Your confirmation is attached.</p>",
60
+ "replyTo": "support@acme.io",
61
+ "attachments": [
62
+ {"filename": "booking-42.pdf", "content": pdf},
63
+ ],
64
+ })
65
+ ```
66
+
67
+ `replyTo` is one address or a list of up to 10. `content` is the file's bytes (`bytes`, `bytearray` or `memoryview`, which the SDK base64-encodes for you) or a `str` that is already base64; `contentType` is guessed from the filename when you leave it out. Limits: 10 MiB per file, 25 MiB of files per message, and your sending provider's own. Attachments are not accepted on a batch item; send those emails on their own. `sandboxes.send` takes the same two fields.
68
+
47
69
  ## Send with a template
48
70
 
49
71
  Design and publish a template in the dashboard, then send it by id or by its send name:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
5
  import os
5
6
  import random
@@ -427,6 +428,30 @@ def strip_none(params: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
427
428
  return {k: v for k, v in (params or {}).items() if v is not None}
428
429
 
429
430
 
431
+ def send_body(params: Mapping[str, Any]) -> Dict[str, Any]:
432
+ """
433
+ The JSON body of a send (``emails.send``, ``sandboxes.send``, each batch
434
+ item): a copy of ``params`` whose attachment ``content`` given as bytes is
435
+ base64-encoded. A ``str`` content is taken to be base64 already and sent as
436
+ is. The caller's dicts are never changed.
437
+ """
438
+ body = dict(params)
439
+ attachments = body.get("attachments")
440
+ if isinstance(attachments, (list, tuple)):
441
+ body["attachments"] = [_encode_attachment(a) for a in attachments]
442
+ return body
443
+
444
+
445
+ def _encode_attachment(attachment: Any) -> Any:
446
+ if not isinstance(attachment, Mapping):
447
+ return attachment
448
+ out = dict(attachment)
449
+ content = out.get("content")
450
+ if isinstance(content, (bytes, bytearray, memoryview)):
451
+ out["content"] = base64.b64encode(bytes(content)).decode("ascii")
452
+ return out
453
+
454
+
430
455
  def multipart(fields: List[Tuple[str, str]], files: List[Tuple[str, str, str, bytes]]) -> Tuple[bytes, str]:
431
456
  """``multipart/form-data``: ``fields`` are (name, value), ``files`` are (name, filename, type, bytes)."""
432
457
  boundary = f"----sendclipper{uuid.uuid4().hex}"
@@ -1,3 +1,3 @@
1
1
  """Kept equal to the release tag by the publish workflow."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Iterator, List, Optional, cast
4
4
 
5
- from .._core import Result, seg, strip_none
5
+ from .._core import Result, send_body, seg, strip_none
6
6
  from ._base import Resource
7
7
 
8
8
  if TYPE_CHECKING:
@@ -20,8 +20,11 @@ class Emails(Resource):
20
20
 
21
21
  Every call carries an ``Idempotency-Key``, so the SDK's own retries never
22
22
  send twice. Pass ``idempotency_key=`` yourself to make YOUR retries safe too.
23
+
24
+ ``replyTo`` takes one address or a list of up to 10. An attachment's
25
+ ``content`` may be ``bytes`` (encoded for you) or a base64 ``str``.
23
26
  """
24
- return self._http.call("POST", "/emails", body=dict(params), **options)
27
+ return self._http.call("POST", "/emails", body=send_body(params), **options)
25
28
 
26
29
  def send_batch(self, emails: List[SendEmailParams], **options: Unpack[RequestOptions]) -> Result[SendBatchResponse]:
27
30
  """Same as ``batch.send()``."""
@@ -60,9 +63,12 @@ class Batch(Resource):
60
63
  """
61
64
  Up to 500 messages in one request (one recipient each). Every item is
62
65
  accepted or refused on its own; a refused item costs its siblings nothing.
66
+
67
+ An item may carry ``replyTo``; one carrying ``attachments`` fails with
68
+ ``attachments_not_batchable``, so send those messages with ``emails.send()``.
63
69
  """
64
70
  return _send_batch(self, emails, **options)
65
71
 
66
72
 
67
73
  def _send_batch(resource: Resource, emails: List[SendEmailParams], **options: Unpack[RequestOptions]) -> Result[SendBatchResponse]:
68
- return resource._http.call("POST", "/emails/batch", body={"emails": [dict(e) for e in emails]}, **options)
74
+ return resource._http.call("POST", "/emails/batch", body={"emails": [send_body(e) for e in emails]}, **options)
@@ -4,7 +4,7 @@ import time
4
4
  from datetime import datetime
5
5
  from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
6
6
 
7
- from .._core import ErrorResponse, Result, seg, strip_none
7
+ from .._core import ErrorResponse, Result, send_body, seg, strip_none
8
8
  from ._base import Resource
9
9
 
10
10
  if TYPE_CHECKING:
@@ -125,9 +125,10 @@ class Sandboxes(Resource):
125
125
  def send(self, sandbox_token: str, params: SendEmailParams, **options: Unpack[RequestOptions]) -> Result[SandboxSendResult]:
126
126
  """
127
127
  Send to a sandbox through its JSON door, with the sandbox's own
128
- ``sc_sbx_...`` token. Same fields as ``emails.send()``; nothing is delivered.
128
+ ``sc_sbx_...`` token. Same fields as ``emails.send()`` (``replyTo`` and
129
+ ``attachments`` included, bytes encoded for you); nothing is delivered.
129
130
  """
130
- return self._http.call("POST", "/sandbox/emails", body=dict(params), bearer=sandbox_token, **options)
131
+ return self._http.call("POST", "/sandbox/emails", body=send_body(params), bearer=sandbox_token, **options)
131
132
 
132
133
 
133
134
  def _epoch(iso: str) -> float:
@@ -41,6 +41,21 @@ _SendEmailRequired = TypedDict(
41
41
  )
42
42
 
43
43
 
44
+ class _AttachmentRequired(TypedDict):
45
+ #: The name the recipient sees, e.g. ``"booking-42.pdf"``.
46
+ filename: str
47
+ #: The file's bytes (``bytes``, ``bytearray`` or ``memoryview``, which the SDK
48
+ #: base64-encodes for you) or a ``str`` that is already standard base64 (sent as is).
49
+ content: Union[str, bytes, bytearray, memoryview]
50
+
51
+
52
+ class Attachment(_AttachmentRequired, total=False):
53
+ """One file on a message. Limits: 10 MiB per file, 25 MiB of files per message, 20 files."""
54
+
55
+ #: A MIME type such as ``"application/pdf"``. Guessed from ``filename`` when left out.
56
+ contentType: str
57
+
58
+
44
59
  class SendEmailParams(_SendEmailRequired, total=False):
45
60
  """
46
61
  One message. Give it a body (``subject`` + ``html``) or a published template
@@ -63,6 +78,11 @@ class SendEmailParams(_SendEmailRequired, total=False):
63
78
  trackOpens: bool
64
79
  #: ``False`` leaves links unwrapped. Default ``True``.
65
80
  trackClicks: bool
81
+ #: The Reply-To header: one bare address, or a list of 1-10.
82
+ replyTo: Union[str, List[str]]
83
+ #: Files to attach. Not accepted on a batch item: the server fails that item
84
+ #: with ``attachments_not_batchable`` (a batch item does accept ``replyTo``).
85
+ attachments: List[Attachment]
66
86
 
67
87
 
68
88
  EmailStatus = Literal["queued", "sent", "delivered", "opened", "clicked", "bounced", "complained", "failed"]
@@ -233,6 +233,78 @@ def test_sandboxes_send_uses_the_sandbox_token_instead_of_the_api_key(make_clien
233
233
  assert t.calls[0].headers["Authorization"] == "Bearer sc_sbx_abc"
234
234
 
235
235
 
236
+ PDF = b"%PDF-1.7\n\x00\xff binary"
237
+ PDF_B64 = "JVBERi0xLjcKAP8gYmluYXJ5"
238
+
239
+
240
+ def test_send_carries_reply_to_and_encodes_bytes_attachments_while_a_base64_str_is_sent_as_is(make_client: Any) -> None:
241
+ sc, t = make_client([json(201, SENT)])
242
+ params: Any = {
243
+ **MSG,
244
+ "replyTo": ["support@acme.io", "billing@acme.io"],
245
+ "attachments": [
246
+ {"filename": "booking-42.pdf", "content": PDF, "contentType": "application/pdf"},
247
+ {"filename": "notes.txt", "content": "aGVsbG8="},
248
+ ],
249
+ }
250
+ sc.emails.send(params)
251
+ assert t.calls[0].body == {
252
+ **MSG,
253
+ "replyTo": ["support@acme.io", "billing@acme.io"],
254
+ "attachments": [
255
+ {"filename": "booking-42.pdf", "content": PDF_B64, "contentType": "application/pdf"},
256
+ {"filename": "notes.txt", "content": "aGVsbG8="},
257
+ ],
258
+ }
259
+ # The caller's own dicts are left alone.
260
+ assert params["attachments"][0]["content"] == PDF
261
+
262
+
263
+ def test_bytearray_and_memoryview_content_are_encoded_too(make_client: Any) -> None:
264
+ sc, t = make_client([json(201, SENT)])
265
+ sc.emails.send(
266
+ {
267
+ **MSG,
268
+ "replyTo": "support@acme.io",
269
+ "attachments": [
270
+ {"filename": "a.bin", "content": bytearray(PDF)},
271
+ {"filename": "b.bin", "content": memoryview(PDF)},
272
+ ],
273
+ }
274
+ )
275
+ assert t.calls[0].body["replyTo"] == "support@acme.io"
276
+ assert [a["content"] for a in t.calls[0].body["attachments"]] == [PDF_B64, PDF_B64]
277
+
278
+
279
+ def test_sandboxes_send_encodes_attachments_like_emails_send(make_client: Any) -> None:
280
+ sc, t = make_client([json(201, {"id": "sbx-msg"})])
281
+ sc.sandboxes.send(
282
+ "sc_sbx_abc",
283
+ {**MSG, "replyTo": "support@acme.io", "attachments": [{"filename": "booking-42.pdf", "content": PDF}]},
284
+ )
285
+ assert t.calls[0].body == {
286
+ **MSG,
287
+ "replyTo": "support@acme.io",
288
+ "attachments": [{"filename": "booking-42.pdf", "content": PDF_B64}],
289
+ }
290
+
291
+
292
+ def test_batch_items_carry_reply_to_and_any_attachments_are_encoded(make_client: Any) -> None:
293
+ sc, t = make_client([json(200, {"data": [], "summary": {"submitted": 2, "accepted": 0, "failed": 0}})])
294
+ sc.batch.send(
295
+ [
296
+ {**MSG, "replyTo": "support@acme.io"},
297
+ {**MSG, "attachments": [{"filename": "x.pdf", "content": PDF}]},
298
+ ]
299
+ )
300
+ assert t.calls[0].body == {
301
+ "emails": [
302
+ {**MSG, "replyTo": "support@acme.io"},
303
+ {**MSG, "attachments": [{"filename": "x.pdf", "content": PDF_B64}]},
304
+ ]
305
+ }
306
+
307
+
236
308
  def test_contacts_import_is_multipart_carries_no_idempotency_key_and_is_never_retried(make_client: Any) -> None:
237
309
  sc, t = make_client([json(503, {"error": {"code": "unavailable", "message": "down"}})])
238
310
  _, error = sc.contacts.import_({"file": "name,email\nAda,ada@lumen.io\n", "filename": "contacts.csv", "onDuplicate": "update"})
@@ -1,12 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0 — 2026-10-16
4
-
5
- First release.
6
-
7
- - `emails.send`, `batch.send`, `emails.get`, `emails.list`, `emails.iterate`, `emails.stats`.
8
- - Templates (with tags and image assets), domains, sandboxes (including the JSON door and `wait_for`), webhooks and their dispatches, contacts, audiences, suppressions, mailboxes, automations, workspace and API keys.
9
- - Every method returns a `Result` with `data`, `error` and `headers`; it unpacks to `(data, error)`.
10
- - Automatic `Idempotency-Key` on every JSON write; retries on network errors, 429 and 5xx.
11
- - `verify_webhook()` for `X-SendClipper-Signature-V2`, rotation-aware.
12
- - No runtime dependencies; typed (`py.typed`), Python 3.9 or newer.
File without changes
File without changes
File without changes