sendclipper 0.1.0__py3-none-any.whl

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,65 @@
1
+ """
2
+ The official Python SDK for the SendClipper email API.
3
+
4
+ ::
5
+
6
+ from sendclipper import SendClipper
7
+
8
+ sc = SendClipper("sc_live_...")
9
+ data, error = sc.emails.send({
10
+ "from": "hello@send.acme.io",
11
+ "to": "ada@lumen.io",
12
+ "subject": "Your receipt",
13
+ "html": "<p>Thanks for your order.</p>",
14
+ })
15
+ """
16
+
17
+ from ._client import SendClipper
18
+ from ._core import (
19
+ DEFAULT_BASE_URL,
20
+ DownloadedFile,
21
+ ErrorResponse,
22
+ RequestOptions,
23
+ Result,
24
+ Transport,
25
+ TransportRequest,
26
+ TransportResponse,
27
+ urllib_transport,
28
+ )
29
+ from ._errors import (
30
+ SendClipperApiError,
31
+ SendClipperConnectionError,
32
+ SendClipperError,
33
+ WebhookVerificationError,
34
+ )
35
+ from ._version import __version__
36
+ from ._webhooks import (
37
+ WEBHOOK_TOLERANCE_SECONDS,
38
+ parse_signature_header,
39
+ sign_payload,
40
+ signature_header,
41
+ verify_webhook,
42
+ )
43
+
44
+ __all__ = [
45
+ "SendClipper",
46
+ "DEFAULT_BASE_URL",
47
+ "DownloadedFile",
48
+ "ErrorResponse",
49
+ "RequestOptions",
50
+ "Result",
51
+ "Transport",
52
+ "TransportRequest",
53
+ "TransportResponse",
54
+ "urllib_transport",
55
+ "SendClipperError",
56
+ "SendClipperApiError",
57
+ "SendClipperConnectionError",
58
+ "WebhookVerificationError",
59
+ "verify_webhook",
60
+ "sign_payload",
61
+ "signature_header",
62
+ "parse_signature_header",
63
+ "WEBHOOK_TOLERANCE_SECONDS",
64
+ "__version__",
65
+ ]
sendclipper/_client.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Mapping, Optional
4
+
5
+ from ._core import HttpClient, Transport
6
+ from .resources.audience import Audiences, Contacts, Suppressions
7
+ from .resources.domains import Domains
8
+ from .resources.emails import Batch, Emails
9
+ from .resources.mailboxes import Mailboxes
10
+ from .resources.sandboxes import Sandboxes
11
+ from .resources.templates import Templates
12
+ from .resources.webhooks import Webhooks
13
+ from .resources.workspace import ApiKeys, Automations, WorkspaceResource
14
+
15
+ __all__ = ["SendClipper"]
16
+
17
+
18
+ class SendClipper:
19
+ """
20
+ The SendClipper API client.
21
+
22
+ ::
23
+
24
+ from sendclipper import SendClipper
25
+
26
+ sc = SendClipper("sc_live_...") # or SendClipper(): reads SENDCLIPPER_API_KEY
27
+ data, error = sc.emails.send({
28
+ "from": "hello@acme.io",
29
+ "to": "ada@lumen.io",
30
+ "subject": "Hi",
31
+ "html": "<p>Hi</p>",
32
+ })
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: Optional[str] = None,
38
+ *,
39
+ base_url: Optional[str] = None,
40
+ timeout: float = 30.0,
41
+ max_retries: int = 2,
42
+ default_headers: Optional[Mapping[str, str]] = None,
43
+ transport: Optional[Transport] = None,
44
+ ) -> None:
45
+ """
46
+ :param api_key: A ``sc_live_...`` key. Defaults to ``SENDCLIPPER_API_KEY``.
47
+ :param base_url: Defaults to ``SENDCLIPPER_BASE_URL``, then ``https://sendclipper.com``. No ``/v1``.
48
+ :param timeout: Per attempt, in seconds.
49
+ :param max_retries: Retries after a network error, a 429 or a 5xx. A write is
50
+ only retried with an ``Idempotency-Key``, which the SDK adds to every JSON
51
+ write, so a retried send can never go out twice.
52
+ :param default_headers: Sent on every request.
53
+ :param transport: Your own HTTP sender (a proxy, a test double). Defaults to the standard library.
54
+ """
55
+ http = HttpClient(
56
+ api_key,
57
+ base_url=base_url,
58
+ timeout=timeout,
59
+ max_retries=max_retries,
60
+ default_headers=default_headers,
61
+ transport=transport,
62
+ )
63
+ self._http = http
64
+ #: The API origin requests go to, without ``/v1``.
65
+ self.base_url = http.base_url
66
+ self.emails = Emails(http)
67
+ self.batch = Batch(http)
68
+ self.domains = Domains(http)
69
+ self.templates = Templates(http)
70
+ self.sandboxes = Sandboxes(http)
71
+ self.webhooks = Webhooks(http)
72
+ self.contacts = Contacts(http)
73
+ self.audiences = Audiences(http)
74
+ self.suppressions = Suppressions(http)
75
+ self.mailboxes = Mailboxes(http)
76
+ self.automations = Automations(http)
77
+ self.workspace = WorkspaceResource(http)
78
+ self.api_keys = ApiKeys(http)
79
+
80
+ def __repr__(self) -> str:
81
+ return f"SendClipper(base_url={self.base_url!r})"
sendclipper/_core.py ADDED
@@ -0,0 +1,454 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import random
6
+ import re
7
+ import socket
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from email.utils import parsedate_to_datetime
15
+ from typing import (
16
+ Any,
17
+ Callable,
18
+ Dict,
19
+ Generic,
20
+ Iterator,
21
+ List,
22
+ Mapping,
23
+ Optional,
24
+ Tuple,
25
+ TypedDict,
26
+ TypeVar,
27
+ Union,
28
+ )
29
+
30
+ from ._errors import SendClipperApiError, SendClipperConnectionError, to_api_error
31
+ from ._version import __version__
32
+
33
+ __all__ = [
34
+ "DEFAULT_BASE_URL",
35
+ "DownloadedFile",
36
+ "ErrorResponse",
37
+ "Query",
38
+ "RequestOptions",
39
+ "Result",
40
+ "Transport",
41
+ "TransportRequest",
42
+ "TransportResponse",
43
+ "urllib_transport",
44
+ ]
45
+
46
+ DEFAULT_BASE_URL = "https://sendclipper.com"
47
+
48
+ T = TypeVar("T")
49
+
50
+ Query = Mapping[str, Union[str, int, float, bool, None]]
51
+
52
+
53
+ class RequestOptions(TypedDict, total=False):
54
+ """Keyword arguments every API method takes after its own parameters."""
55
+
56
+ #: Your own key, reused when YOU retry the same logical call later.
57
+ idempotency_key: str
58
+ #: Override the client's per-attempt timeout (seconds) for this call.
59
+ timeout: float
60
+ #: Override the client's retry count for this call.
61
+ max_retries: int
62
+ #: Extra headers for this call.
63
+ headers: Mapping[str, str]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ErrorResponse:
68
+ """What an API refusal or a network failure looks like."""
69
+
70
+ #: SendClipper's machine-readable code: ``domain_not_verified``, ``validation_failed``,
71
+ #: ``rate_limited``, ``network_error``, ...
72
+ name: str
73
+ #: A sentence meant for a person.
74
+ message: str
75
+ #: The HTTP status, or ``None`` when no answer arrived (network, timeout).
76
+ status_code: Optional[int]
77
+ #: Extra, code-specific facts (e.g. the missing template tags).
78
+ detail: Any = None
79
+
80
+
81
+ class Result(Generic[T]):
82
+ """
83
+ Every API method returns one. Exactly one of ``data`` and ``error`` is set.
84
+
85
+ It unpacks to ``(data, error)``::
86
+
87
+ data, error = sc.emails.send({...})
88
+ """
89
+
90
+ __slots__ = ("data", "error", "headers")
91
+
92
+ def __init__(self, data: Optional[T], error: Optional[ErrorResponse], headers: Optional[Dict[str, str]]) -> None:
93
+ self.data = data
94
+ self.error = error
95
+ #: The response headers (lower-case names), or ``None`` when no answer arrived.
96
+ self.headers = headers
97
+
98
+ @property
99
+ def ok(self) -> bool:
100
+ return self.error is None
101
+
102
+ def unwrap(self) -> T:
103
+ """Return ``data``, or raise the error as ``SendClipperApiError``."""
104
+ if self.error is not None:
105
+ e = self.error
106
+ raise SendClipperApiError(e.status_code or 0, e.name, e.message, e.detail, self.headers)
107
+ return self.data # type: ignore[return-value]
108
+
109
+ def __iter__(self) -> Iterator[Any]:
110
+ yield self.data
111
+ yield self.error
112
+
113
+ def __repr__(self) -> str:
114
+ if self.error is not None:
115
+ return f"Result(error={self.error!r})"
116
+ return f"Result(data={self.data!r})"
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class DownloadedFile:
121
+ """What a download returns."""
122
+
123
+ content: bytes
124
+ content_type: Optional[str]
125
+ filename: Optional[str]
126
+
127
+
128
+ @dataclass
129
+ class TransportRequest:
130
+ method: str
131
+ url: str
132
+ headers: Dict[str, str]
133
+ body: Optional[bytes]
134
+ #: Seconds.
135
+ timeout: float
136
+
137
+
138
+ @dataclass
139
+ class TransportResponse:
140
+ status: int
141
+ #: Lower-case header names.
142
+ headers: Dict[str, str] = field(default_factory=dict)
143
+ body: bytes = b""
144
+
145
+
146
+ #: Sends one request. Raise ``TimeoutError`` for a timeout and any other
147
+ #: exception when no answer arrived; return the response for every status.
148
+ Transport = Callable[[TransportRequest], TransportResponse]
149
+
150
+
151
+ class _SameHostRedirects(urllib.request.HTTPRedirectHandler):
152
+ """Follow redirects, but never carry the API key to another host."""
153
+
154
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
155
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
156
+ if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
157
+ new.remove_header("Authorization")
158
+ return new
159
+
160
+
161
+ _opener = urllib.request.build_opener(_SameHostRedirects())
162
+
163
+
164
+ def urllib_transport(req: TransportRequest) -> TransportResponse:
165
+ """The default transport: the standard library, no dependencies."""
166
+ r = urllib.request.Request(req.url, data=req.body, method=req.method, headers=req.headers)
167
+ try:
168
+ with _opener.open(r, timeout=req.timeout) as resp:
169
+ return TransportResponse(resp.status, _lower_headers(resp.headers.items()), resp.read())
170
+ except urllib.error.HTTPError as e:
171
+ try:
172
+ body = e.read()
173
+ finally:
174
+ e.close()
175
+ return TransportResponse(e.code, _lower_headers(e.headers.items() if e.headers else []), body)
176
+ except urllib.error.URLError as e:
177
+ if isinstance(e.reason, (socket.timeout, TimeoutError)):
178
+ raise TimeoutError(str(e.reason)) from e
179
+ raise ConnectionError(str(e.reason)) from e
180
+ except socket.timeout as e: # a read that stalled after the headers
181
+ raise TimeoutError(str(e)) from e
182
+
183
+
184
+ _RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
185
+ _MAX_RETRY_AFTER_SECONDS = 60.0
186
+
187
+
188
+ class HttpClient:
189
+ def __init__(
190
+ self,
191
+ api_key: Optional[str] = None,
192
+ *,
193
+ base_url: Optional[str] = None,
194
+ timeout: float = 30.0,
195
+ max_retries: int = 2,
196
+ default_headers: Optional[Mapping[str, str]] = None,
197
+ transport: Optional[Transport] = None,
198
+ ) -> None:
199
+ self.api_key = api_key if api_key is not None else os.environ.get("SENDCLIPPER_API_KEY")
200
+ raw = base_url or os.environ.get("SENDCLIPPER_BASE_URL") or DEFAULT_BASE_URL
201
+ self.base_url = re.sub(r"/v1$", "", raw.rstrip("/"))
202
+ self.timeout = timeout
203
+ self.max_retries = max(0, max_retries)
204
+ self.default_headers = dict(default_headers or {})
205
+ self.transport: Transport = transport or urllib_transport
206
+
207
+ def call(
208
+ self,
209
+ method: str,
210
+ path: str,
211
+ *,
212
+ query: Optional[Query] = None,
213
+ body: Any = None,
214
+ form: Optional[Tuple[bytes, str]] = None,
215
+ as_: str = "json",
216
+ bearer: Optional[str] = None,
217
+ idempotency_key: Optional[str] = None,
218
+ timeout: Optional[float] = None,
219
+ max_retries: Optional[int] = None,
220
+ headers: Optional[Mapping[str, str]] = None,
221
+ ) -> Result[Any]:
222
+ """The public face of every API method: never raises for an API refusal or a network failure."""
223
+ try:
224
+ data, response_headers = self.request(
225
+ method,
226
+ path,
227
+ query=query,
228
+ body=body,
229
+ form=form,
230
+ as_=as_,
231
+ bearer=bearer,
232
+ idempotency_key=idempotency_key,
233
+ timeout=timeout,
234
+ max_retries=max_retries,
235
+ headers=headers,
236
+ )
237
+ return Result(data, None, response_headers)
238
+ except SendClipperApiError as err:
239
+ return Result(
240
+ None,
241
+ ErrorResponse(err.code, err.message, err.status, err.detail),
242
+ dict(err.headers) if err.headers else None,
243
+ )
244
+ except SendClipperConnectionError as err:
245
+ return Result(None, ErrorResponse("network_error", err.message, None), None)
246
+
247
+ def request(
248
+ self,
249
+ method: str,
250
+ path: str,
251
+ *,
252
+ query: Optional[Query] = None,
253
+ body: Any = None,
254
+ form: Optional[Tuple[bytes, str]] = None,
255
+ as_: str = "json",
256
+ bearer: Optional[str] = None,
257
+ idempotency_key: Optional[str] = None,
258
+ timeout: Optional[float] = None,
259
+ max_retries: Optional[int] = None,
260
+ headers: Optional[Mapping[str, str]] = None,
261
+ ) -> Tuple[Any, Dict[str, str]]:
262
+ """Like ``call()``, but raises ``SendClipperApiError`` / ``SendClipperConnectionError``."""
263
+ token = bearer or self.api_key
264
+ if not token:
265
+ raise SendClipperApiError(
266
+ 401,
267
+ "api_key_missing",
268
+ 'No SendClipper API key. Pass one to SendClipper("sc_live_...") or set SENDCLIPPER_API_KEY.',
269
+ )
270
+
271
+ url = f"{self.base_url}/v1{path if path.startswith('/') else '/' + path}"
272
+ pairs = [(k, _query_value(v)) for k, v in (query or {}).items() if v is not None and v != ""]
273
+ if pairs:
274
+ url += "?" + urllib.parse.urlencode(pairs)
275
+
276
+ out_headers: Dict[str, str] = {
277
+ "Authorization": f"Bearer {token}",
278
+ "Accept": "application/json" if as_ == "json" else "*/*",
279
+ "User-Agent": f"sendclipper-python/{__version__}",
280
+ **self.default_headers,
281
+ **dict(headers or {}),
282
+ }
283
+
284
+ payload: Optional[bytes] = None
285
+ if form is not None:
286
+ payload, out_headers["Content-Type"] = form
287
+ elif body is not None:
288
+ out_headers["Content-Type"] = "application/json"
289
+ payload = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
290
+
291
+ is_read = method in ("GET", "HEAD")
292
+ # The API de-duplicates every JSON mutation on this header for 24 hours.
293
+ # A multipart body is not fingerprinted server-side, so it gets no key
294
+ # and is never retried.
295
+ if not is_read and form is None:
296
+ out_headers["Idempotency-Key"] = idempotency_key or f"sc-python-{uuid.uuid4()}"
297
+ elif idempotency_key:
298
+ out_headers["Idempotency-Key"] = idempotency_key
299
+ can_retry = is_read or "Idempotency-Key" in out_headers
300
+ retries = max(0, self.max_retries if max_retries is None else max_retries) if can_retry else 0
301
+ per_attempt = self.timeout if timeout is None else timeout
302
+
303
+ attempt = 0
304
+ while True:
305
+ try:
306
+ res = self.transport(TransportRequest(method, url, out_headers, payload, per_attempt))
307
+ except TimeoutError as err:
308
+ if attempt < retries:
309
+ time.sleep(_backoff(attempt))
310
+ attempt += 1
311
+ continue
312
+ raise SendClipperConnectionError(
313
+ f"SendClipper did not answer within {per_attempt:g} s ({method} {urllib.parse.urlsplit(url).path}).",
314
+ err,
315
+ ) from err
316
+ except Exception as err: # noqa: BLE001 - any transport failure is "no answer"
317
+ if attempt < retries:
318
+ time.sleep(_backoff(attempt))
319
+ attempt += 1
320
+ continue
321
+ raise SendClipperConnectionError(f"Could not reach SendClipper at {self.base_url}: {err}", err) from err
322
+
323
+ res_headers = {k.lower(): v for k, v in res.headers.items()}
324
+ ok = 200 <= res.status < 300
325
+
326
+ if not ok and res.status in _RETRYABLE_STATUS and attempt < retries:
327
+ wait = _retry_after(res_headers)
328
+ time.sleep(wait if wait is not None else _backoff(attempt))
329
+ attempt += 1
330
+ continue
331
+
332
+ if not ok:
333
+ api_err = to_api_error(res.status, _decode(res.body, res_headers), res_headers)
334
+ # Our own earlier attempt is still running server-side (a timeout
335
+ # that was not a failure). Its result is replayed once it finishes.
336
+ if api_err.code == "idempotency_key_in_flight" and attempt < retries:
337
+ wait = _retry_after(res_headers)
338
+ time.sleep(wait if wait is not None else _backoff(attempt))
339
+ attempt += 1
340
+ continue
341
+ raise api_err
342
+
343
+ if as_ == "bytes":
344
+ return (
345
+ DownloadedFile(
346
+ res.body,
347
+ res_headers.get("content-type"),
348
+ _filename_of(res_headers.get("content-disposition")),
349
+ ),
350
+ res_headers,
351
+ )
352
+ text = _decode(res.body, res_headers)
353
+ if as_ == "text":
354
+ return text, res_headers
355
+ if not text:
356
+ return None, res_headers
357
+ try:
358
+ return json.loads(text), res_headers
359
+ except ValueError:
360
+ return text, res_headers
361
+
362
+
363
+ def _query_value(v: Union[str, int, float, bool]) -> str:
364
+ # The API reads booleans the way JavaScript writes them.
365
+ if isinstance(v, bool):
366
+ return "true" if v else "false"
367
+ return str(v)
368
+
369
+
370
+ def _backoff(attempt: int) -> float:
371
+ base = min(8.0, 0.5 * 2.0**attempt)
372
+ return base / 2 + random.random() * (base / 2)
373
+
374
+
375
+ def _retry_after(headers: Mapping[str, str]) -> Optional[float]:
376
+ raw = headers.get("retry-after")
377
+ if not raw:
378
+ return None
379
+ try:
380
+ seconds = float(raw)
381
+ if seconds >= 0:
382
+ return min(_MAX_RETRY_AFTER_SECONDS, seconds)
383
+ except ValueError:
384
+ pass
385
+ try:
386
+ at = parsedate_to_datetime(raw).timestamp()
387
+ except (TypeError, ValueError, IndexError):
388
+ return None
389
+ return min(_MAX_RETRY_AFTER_SECONDS, max(0.0, at - time.time()))
390
+
391
+
392
+ def _decode(body: bytes, headers: Mapping[str, str]) -> str:
393
+ m = re.search(r"charset=([\w-]+)", headers.get("content-type", ""), re.I)
394
+ try:
395
+ return body.decode(m.group(1) if m else "utf-8", errors="replace")
396
+ except LookupError:
397
+ return body.decode("utf-8", errors="replace")
398
+
399
+
400
+ def _filename_of(disposition: Optional[str]) -> Optional[str]:
401
+ if not disposition:
402
+ return None
403
+ star = re.search(r"filename\*\s*=\s*(?:UTF-8'')?([^;]+)", disposition, re.I)
404
+ if star:
405
+ try:
406
+ return urllib.parse.unquote(star.group(1).strip().strip('"'), errors="strict")
407
+ except UnicodeDecodeError:
408
+ pass
409
+ plain = re.search(r'filename\s*=\s*"?([^";]+)"?', disposition, re.I)
410
+ return plain.group(1).strip() if plain else None
411
+
412
+
413
+ def _lower_headers(items: Any) -> Dict[str, str]:
414
+ out: Dict[str, str] = {}
415
+ for k, v in items:
416
+ key = k.lower()
417
+ out[key] = f"{out[key]}, {v}" if key in out else v
418
+ return out
419
+
420
+
421
+ def seg(value: str) -> str:
422
+ """Percent-encode one path segment."""
423
+ return urllib.parse.quote(str(value), safe="")
424
+
425
+
426
+ def strip_none(params: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
427
+ return {k: v for k, v in (params or {}).items() if v is not None}
428
+
429
+
430
+ def multipart(fields: List[Tuple[str, str]], files: List[Tuple[str, str, str, bytes]]) -> Tuple[bytes, str]:
431
+ """``multipart/form-data``: ``fields`` are (name, value), ``files`` are (name, filename, type, bytes)."""
432
+ boundary = f"----sendclipper{uuid.uuid4().hex}"
433
+ parts: List[bytes] = []
434
+ for name, value in fields:
435
+ parts.append(
436
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{_quote(name)}"\r\n\r\n'.encode("utf-8")
437
+ + value.encode("utf-8")
438
+ + b"\r\n"
439
+ )
440
+ for name, filename, ctype, content in files:
441
+ parts.append(
442
+ (
443
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{_quote(name)}"; filename="{_quote(filename)}"\r\n'
444
+ f"Content-Type: {ctype}\r\n\r\n"
445
+ ).encode("utf-8")
446
+ + content
447
+ + b"\r\n"
448
+ )
449
+ parts.append(f"--{boundary}--\r\n".encode("utf-8"))
450
+ return b"".join(parts), f"multipart/form-data; boundary={boundary}"
451
+
452
+
453
+ def _quote(value: str) -> str:
454
+ return value.replace("\\", "\\\\").replace('"', '\\"').replace("\r", " ").replace("\n", " ")