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.
- sendclipper/__init__.py +65 -0
- sendclipper/_client.py +81 -0
- sendclipper/_core.py +454 -0
- sendclipper/_errors.py +118 -0
- sendclipper/_version.py +3 -0
- sendclipper/_webhooks.py +147 -0
- sendclipper/py.typed +0 -0
- sendclipper/resources/__init__.py +1 -0
- sendclipper/resources/_base.py +15 -0
- sendclipper/resources/audience.py +119 -0
- sendclipper/resources/domains.py +47 -0
- sendclipper/resources/emails.py +68 -0
- sendclipper/resources/mailboxes.py +89 -0
- sendclipper/resources/sandboxes.py +137 -0
- sendclipper/resources/templates.py +115 -0
- sendclipper/resources/webhooks.py +105 -0
- sendclipper/resources/workspace.py +30 -0
- sendclipper/types.py +875 -0
- sendclipper-0.1.0.dist-info/METADATA +233 -0
- sendclipper-0.1.0.dist-info/RECORD +22 -0
- sendclipper-0.1.0.dist-info/WHEEL +4 -0
- sendclipper-0.1.0.dist-info/licenses/LICENSE +21 -0
sendclipper/_errors.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SendClipperError",
|
|
8
|
+
"SendClipperApiError",
|
|
9
|
+
"SendClipperConnectionError",
|
|
10
|
+
"WebhookVerificationError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SendClipperError(Exception):
|
|
15
|
+
"""Base class for everything this SDK raises on purpose."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SendClipperApiError(SendClipperError):
|
|
19
|
+
"""
|
|
20
|
+
The API answered with a non-2xx status. ``code`` is the API's own
|
|
21
|
+
machine-readable reason (``domain_not_verified``, ``template_variables_missing``,
|
|
22
|
+
``rate_limited``, ...) and is the field to branch on; ``message`` is a
|
|
23
|
+
sentence meant for a person.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
status: int,
|
|
29
|
+
code: str,
|
|
30
|
+
message: str,
|
|
31
|
+
detail: Any = None,
|
|
32
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
self.detail = detail
|
|
39
|
+
self.headers: dict[str, str] = dict(headers or {})
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
return f"SendClipperApiError(status={self.status!r}, code={self.code!r}, message={self.message!r})"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SendClipperConnectionError(SendClipperError):
|
|
46
|
+
"""The request never got an answer: DNS, TLS, a reset socket or the timeout."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.message = message
|
|
51
|
+
self.__cause__ = cause
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WebhookVerificationError(SendClipperError):
|
|
55
|
+
"""
|
|
56
|
+
``verify_webhook()`` refused a delivery. ``reason`` says which check failed:
|
|
57
|
+
``missing_header``, ``malformed_header``, ``timestamp_out_of_tolerance``,
|
|
58
|
+
``signature_mismatch`` or ``invalid_payload``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, reason: str, message: str) -> None:
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.reason = reason
|
|
64
|
+
self.message = message
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def to_api_error(status: int, text: str, headers: Optional[Mapping[str, str]] = None) -> SendClipperApiError:
|
|
68
|
+
"""
|
|
69
|
+
The API answers refusals in two shapes: the house envelope
|
|
70
|
+
``{ error: { code, message, detail? } }`` and Nest's default
|
|
71
|
+
``{ statusCode, message: str | list[str], error }`` (request validation).
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
parsed: Any = json.loads(text)
|
|
75
|
+
except ValueError:
|
|
76
|
+
parsed = None
|
|
77
|
+
obj: dict[str, Any] = parsed if isinstance(parsed, dict) else {}
|
|
78
|
+
|
|
79
|
+
env = obj.get("error")
|
|
80
|
+
if isinstance(env, dict):
|
|
81
|
+
detail = env.get("detail", env.get("details"))
|
|
82
|
+
return SendClipperApiError(
|
|
83
|
+
status,
|
|
84
|
+
str(env.get("code") or _code_for_status(status)),
|
|
85
|
+
str(env.get("message") or f"HTTP {status}"),
|
|
86
|
+
detail,
|
|
87
|
+
headers,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
msg = obj.get("message")
|
|
91
|
+
if isinstance(msg, dict) and isinstance(msg.get("error"), dict):
|
|
92
|
+
inner = msg["error"]
|
|
93
|
+
return SendClipperApiError(
|
|
94
|
+
status,
|
|
95
|
+
str(inner.get("code") or _code_for_status(status)),
|
|
96
|
+
str(inner.get("message") or f"HTTP {status}"),
|
|
97
|
+
inner.get("detail"),
|
|
98
|
+
headers,
|
|
99
|
+
)
|
|
100
|
+
if isinstance(msg, list):
|
|
101
|
+
return SendClipperApiError(status, "validation_failed", "; ".join(str(m) for m in msg), msg, headers)
|
|
102
|
+
if isinstance(msg, str):
|
|
103
|
+
return SendClipperApiError(status, _code_for_status(status), msg, None, headers)
|
|
104
|
+
|
|
105
|
+
snippet = text.strip()[:300]
|
|
106
|
+
return SendClipperApiError(status, _code_for_status(status), snippet or f"HTTP {status}", None, headers)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _code_for_status(status: int) -> str:
|
|
110
|
+
return {
|
|
111
|
+
400: "bad_request",
|
|
112
|
+
401: "unauthorized",
|
|
113
|
+
403: "forbidden",
|
|
114
|
+
404: "not_found",
|
|
115
|
+
409: "conflict",
|
|
116
|
+
413: "payload_too_large",
|
|
117
|
+
429: "rate_limited",
|
|
118
|
+
}.get(status, f"http_{status}")
|
sendclipper/_version.py
ADDED
sendclipper/_webhooks.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Iterable, List, Mapping, Optional, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from ._errors import WebhookVerificationError
|
|
10
|
+
from .types import WebhookEvent
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"WEBHOOK_TOLERANCE_SECONDS",
|
|
14
|
+
"SIGNATURE_HEADER",
|
|
15
|
+
"verify_webhook",
|
|
16
|
+
"sign_payload",
|
|
17
|
+
"signature_header",
|
|
18
|
+
"parse_signature_header",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
#: SendClipper's documented tolerance: five minutes.
|
|
22
|
+
WEBHOOK_TOLERANCE_SECONDS = 300
|
|
23
|
+
|
|
24
|
+
SIGNATURE_HEADER = "x-sendclipper-signature-v2"
|
|
25
|
+
|
|
26
|
+
#: The request's headers (any mapping: Flask, Django, Starlette, a dict), raw
|
|
27
|
+
#: ASGI ``(name, value)`` pairs, or just the ``X-SendClipper-Signature-V2`` value.
|
|
28
|
+
HeaderSource = Union[str, Mapping[str, Any], Iterable[Tuple[Any, Any]]]
|
|
29
|
+
|
|
30
|
+
Payload = Union[str, bytes, bytearray, memoryview]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def verify_webhook(
|
|
34
|
+
payload: Payload,
|
|
35
|
+
headers: HeaderSource,
|
|
36
|
+
secret: str,
|
|
37
|
+
*,
|
|
38
|
+
tolerance_seconds: int = WEBHOOK_TOLERANCE_SECONDS,
|
|
39
|
+
now: Optional[float] = None,
|
|
40
|
+
) -> WebhookEvent:
|
|
41
|
+
"""
|
|
42
|
+
Verify a webhook delivery and return its parsed event.
|
|
43
|
+
|
|
44
|
+
``payload`` is the body EXACTLY as received (``request.get_data()`` in
|
|
45
|
+
Flask, ``request.body`` in Django, ``await request.body()`` in FastAPI),
|
|
46
|
+
never re-serialised JSON.
|
|
47
|
+
|
|
48
|
+
SendClipper signs ``f"{timestamp}.{raw_body}"`` with HMAC-SHA256 and sends
|
|
49
|
+
``X-SendClipper-Signature-V2: t=<seconds>,v1=<hex>[,v1=<hex>]``. During a
|
|
50
|
+
secret rotation there are two ``v1=`` values; any match is accepted.
|
|
51
|
+
|
|
52
|
+
Raises ``WebhookVerificationError`` when any check fails.
|
|
53
|
+
"""
|
|
54
|
+
if not secret:
|
|
55
|
+
raise WebhookVerificationError("missing_header", "No webhook signing secret was given.")
|
|
56
|
+
if isinstance(payload, str):
|
|
57
|
+
raw = payload.encode("utf-8")
|
|
58
|
+
elif isinstance(payload, (bytes, bytearray, memoryview)):
|
|
59
|
+
raw = bytes(payload)
|
|
60
|
+
else:
|
|
61
|
+
raise WebhookVerificationError(
|
|
62
|
+
"invalid_payload",
|
|
63
|
+
"payload must be the raw request body (str or bytes), not parsed JSON. "
|
|
64
|
+
"Use request.get_data() in Flask, request.body in Django or await request.body() in FastAPI.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
header = _signature_header_of(headers)
|
|
68
|
+
if not header:
|
|
69
|
+
raise WebhookVerificationError("missing_header", "The request carries no X-SendClipper-Signature-V2 header.")
|
|
70
|
+
timestamp, signatures = parse_signature_header(header)
|
|
71
|
+
if timestamp is None or not signatures:
|
|
72
|
+
raise WebhookVerificationError("malformed_header", "X-SendClipper-Signature-V2 has no t= or no v1= value.")
|
|
73
|
+
|
|
74
|
+
current = int(time.time()) if now is None else int(now)
|
|
75
|
+
drift = abs(current - timestamp)
|
|
76
|
+
if drift > tolerance_seconds:
|
|
77
|
+
raise WebhookVerificationError(
|
|
78
|
+
"timestamp_out_of_tolerance",
|
|
79
|
+
f"The delivery was signed {drift} s from now, outside the {tolerance_seconds} s tolerance.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expected = _sign(secret, timestamp, raw)
|
|
83
|
+
# ANY match, and every candidate is compared: a rotation sends two.
|
|
84
|
+
matched = False
|
|
85
|
+
for candidate in signatures:
|
|
86
|
+
if hmac.compare_digest(expected, candidate.encode("utf-8")):
|
|
87
|
+
matched = True
|
|
88
|
+
if not matched:
|
|
89
|
+
raise WebhookVerificationError("signature_mismatch", "The signature does not match this secret and body.")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
event: WebhookEvent = json.loads(raw.decode("utf-8"))
|
|
93
|
+
except (ValueError, UnicodeDecodeError):
|
|
94
|
+
raise WebhookVerificationError("invalid_payload", "The signed body is not JSON.") from None
|
|
95
|
+
return event
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sign_payload(secret: str, timestamp: int, raw_body: Union[str, bytes]) -> str:
|
|
99
|
+
"""``hex(HMAC-SHA256(secret, f"{timestamp}.{raw_body}"))``. Exported for tests of your own handler."""
|
|
100
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
101
|
+
return _sign(secret, timestamp, body).decode("ascii")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def signature_header(secret: str, raw_body: Union[str, bytes], timestamp: Optional[int] = None) -> str:
|
|
105
|
+
"""Build a valid header for a body, e.g. to test your endpoint locally."""
|
|
106
|
+
t = int(time.time()) if timestamp is None else timestamp
|
|
107
|
+
return f"t={t},v1={sign_payload(secret, t, raw_body)}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_signature_header(header: str) -> Tuple[Optional[int], List[str]]:
|
|
111
|
+
"""``(timestamp, [signatures])`` from ``t=<seconds>,v1=<hex>[,v1=<hex>]``."""
|
|
112
|
+
timestamp: Optional[int] = None
|
|
113
|
+
signatures: List[str] = []
|
|
114
|
+
for part in header.split(","):
|
|
115
|
+
key, eq, value = part.partition("=")
|
|
116
|
+
if not eq:
|
|
117
|
+
continue
|
|
118
|
+
key, value = key.strip(), value.strip()
|
|
119
|
+
if key == "t" and value.isdigit() and value.isascii():
|
|
120
|
+
timestamp = int(value)
|
|
121
|
+
elif key == "v1" and value:
|
|
122
|
+
signatures.append(value)
|
|
123
|
+
return timestamp, signatures
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _sign(secret: str, timestamp: int, raw: bytes) -> bytes:
|
|
127
|
+
return hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode("ascii") + raw, hashlib.sha256).hexdigest().encode("ascii")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _signature_header_of(source: HeaderSource) -> Optional[str]:
|
|
131
|
+
if isinstance(source, str):
|
|
132
|
+
return source
|
|
133
|
+
if isinstance(source, Mapping):
|
|
134
|
+
items: Iterable[Tuple[Any, Any]] = source.items()
|
|
135
|
+
else:
|
|
136
|
+
items = source
|
|
137
|
+
found: List[str] = []
|
|
138
|
+
for k, v in items:
|
|
139
|
+
name = k.decode("latin-1") if isinstance(k, (bytes, bytearray)) else str(k)
|
|
140
|
+
# Django's request.META spells it HTTP_X_SENDCLIPPER_SIGNATURE_V2.
|
|
141
|
+
normalised = name.lower().replace("_", "-")
|
|
142
|
+
if normalised in (SIGNATURE_HEADER, "http-" + SIGNATURE_HEADER):
|
|
143
|
+
if isinstance(v, (list, tuple)):
|
|
144
|
+
found.extend(str(x) for x in v)
|
|
145
|
+
else:
|
|
146
|
+
found.append(v.decode("latin-1") if isinstance(v, (bytes, bytearray)) else str(v))
|
|
147
|
+
return ",".join(found) if found else None
|
sendclipper/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One class per API resource. Reach them through ``SendClipper``: ``sc.emails``, ``sc.domains``, ..."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from typing_extensions import Unpack
|
|
7
|
+
|
|
8
|
+
from .._core import HttpClient, RequestOptions
|
|
9
|
+
|
|
10
|
+
__all__ = ["Resource", "Unpack", "RequestOptions"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Resource:
|
|
14
|
+
def __init__(self, http: HttpClient) -> None:
|
|
15
|
+
self._http = http
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple, cast
|
|
5
|
+
|
|
6
|
+
from .._core import Result, multipart, seg, strip_none
|
|
7
|
+
from ._base import Resource
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from typing_extensions import Unpack
|
|
11
|
+
|
|
12
|
+
from .._core import RequestOptions
|
|
13
|
+
from ..types import (
|
|
14
|
+
AudienceList,
|
|
15
|
+
Contact,
|
|
16
|
+
ContactList,
|
|
17
|
+
CreateContactParams,
|
|
18
|
+
CreateSuppressionParams,
|
|
19
|
+
ExportContactsParams,
|
|
20
|
+
ImportContactsParams,
|
|
21
|
+
ListContactsParams,
|
|
22
|
+
ListSuppressionsParams,
|
|
23
|
+
PreviewContactsParams,
|
|
24
|
+
Suppression,
|
|
25
|
+
SuppressionCreated,
|
|
26
|
+
SuppressionList,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Contacts(Resource):
|
|
31
|
+
def list(self, params: Optional[ListContactsParams] = None, **options: Unpack[RequestOptions]) -> Result[ContactList]:
|
|
32
|
+
return self._http.call("GET", "/contacts", query=strip_none(params), **options)
|
|
33
|
+
|
|
34
|
+
def iterate(self, params: Optional[ListContactsParams] = None, **options: Unpack[RequestOptions]) -> Iterator[Contact]:
|
|
35
|
+
"""Every contact, page after page. Raises ``SendClipperApiError`` on a refusal."""
|
|
36
|
+
query: Dict[str, Any] = strip_none(params)
|
|
37
|
+
page_size = int(query.get("pageSize") or 200)
|
|
38
|
+
page = 1
|
|
39
|
+
while True:
|
|
40
|
+
res = self.list(cast("ListContactsParams", {**query, "page": page, "pageSize": page_size}), **options).unwrap()
|
|
41
|
+
yield from res["items"]
|
|
42
|
+
if page >= res["totalPages"] or not res["items"]:
|
|
43
|
+
return
|
|
44
|
+
page += 1
|
|
45
|
+
|
|
46
|
+
def create(self, params: CreateContactParams, **options: Unpack[RequestOptions]) -> Result[Contact]:
|
|
47
|
+
return self._http.call("POST", "/contacts", body=dict(params), **options)
|
|
48
|
+
|
|
49
|
+
def import_(self, params: ImportContactsParams, **options: Unpack[RequestOptions]) -> Result[Dict[str, Any]]:
|
|
50
|
+
"""
|
|
51
|
+
Import a CSV or vCard file. Importing never suppresses anyone. Named
|
|
52
|
+
``import_`` because ``import`` is a Python keyword.
|
|
53
|
+
"""
|
|
54
|
+
return self._http.call("POST", "/contacts/import", form=_import_form(params), **options)
|
|
55
|
+
|
|
56
|
+
def import_preview(self, params: PreviewContactsParams, **options: Unpack[RequestOptions]) -> Result[Dict[str, Any]]:
|
|
57
|
+
"""What an import would do, without importing."""
|
|
58
|
+
return self._http.call("POST", "/contacts/import/preview", form=_import_form(params), **options)
|
|
59
|
+
|
|
60
|
+
def export(self, params: Optional[ExportContactsParams] = None, **options: Unpack[RequestOptions]) -> Result[str]:
|
|
61
|
+
"""Every contact as CSV (re-importable) or vCard 4.0 (``{"format": "vcf"}``)."""
|
|
62
|
+
return self._http.call("GET", "/contacts/export", query=strip_none(params), as_="text", **options)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Audiences(Resource):
|
|
66
|
+
def list(self, **options: Unpack[RequestOptions]) -> Result[AudienceList]:
|
|
67
|
+
return self._http.call("GET", "/audiences", **options)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Suppressions(Resource):
|
|
71
|
+
def list(self, params: Optional[ListSuppressionsParams] = None, **options: Unpack[RequestOptions]) -> Result[SuppressionList]:
|
|
72
|
+
return self._http.call("GET", "/suppressions", query=strip_none(params), **options)
|
|
73
|
+
|
|
74
|
+
def iterate(self, params: Optional[ListSuppressionsParams] = None, **options: Unpack[RequestOptions]) -> Iterator[Suppression]:
|
|
75
|
+
query: Dict[str, Any] = strip_none(params)
|
|
76
|
+
while True:
|
|
77
|
+
res = self.list(query, **options).unwrap() # type: ignore[arg-type]
|
|
78
|
+
yield from res["data"]
|
|
79
|
+
if not res["page"]["hasMore"] or not res["page"]["cursor"]:
|
|
80
|
+
return
|
|
81
|
+
query = {**query, "cursor": res["page"]["cursor"]}
|
|
82
|
+
|
|
83
|
+
def create(self, params: CreateSuppressionParams, **options: Unpack[RequestOptions]) -> Result[SuppressionCreated]:
|
|
84
|
+
"""
|
|
85
|
+
Stop all mail to an address (reason ``manual``). ``created`` is ``False``
|
|
86
|
+
when the address was already suppressed; ``row`` is the entry either way.
|
|
87
|
+
"""
|
|
88
|
+
return self._http.call("POST", "/suppressions", body=dict(params), **options)
|
|
89
|
+
|
|
90
|
+
def remove(self, id: str, reason: str, **options: Unpack[RequestOptions]) -> Result[Dict[str, Any]]:
|
|
91
|
+
"""
|
|
92
|
+
Lift a suppression. ``reason`` (10+ characters) is recorded. Complaint and
|
|
93
|
+
provider suppressions cannot be removed (``suppression_not_removable``).
|
|
94
|
+
"""
|
|
95
|
+
return self._http.call("DELETE", f"/suppressions/{seg(id)}", body={"reason": reason}, **options)
|
|
96
|
+
|
|
97
|
+
def export(self, params: Optional[ListSuppressionsParams] = None, **options: Unpack[RequestOptions]) -> Result[str]:
|
|
98
|
+
"""The list as CSV."""
|
|
99
|
+
query = {k: v for k, v in strip_none(params).items() if k not in ("cursor", "limit")}
|
|
100
|
+
return self._http.call("GET", "/suppressions/export", query=query, as_="text", **options)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _import_form(params: Any) -> Tuple[bytes, str]:
|
|
104
|
+
name = params.get("filename") or "contacts.csv"
|
|
105
|
+
ctype = "text/vcard" if name.lower().endswith(".vcf") else "text/csv"
|
|
106
|
+
data = params["file"]
|
|
107
|
+
content = data.encode("utf-8") if isinstance(data, str) else bytes(data)
|
|
108
|
+
fields: List[Tuple[str, str]] = []
|
|
109
|
+
if params.get("audienceId"):
|
|
110
|
+
fields.append(("audienceId", params["audienceId"]))
|
|
111
|
+
if params.get("newAudience"):
|
|
112
|
+
fields.append(("newAudience", params["newAudience"]))
|
|
113
|
+
if params.get("createAudiences") is not None:
|
|
114
|
+
fields.append(("createAudiences", "true" if params["createAudiences"] else "false"))
|
|
115
|
+
if params.get("onDuplicate"):
|
|
116
|
+
fields.append(("onDuplicate", params["onDuplicate"]))
|
|
117
|
+
if params.get("mapping"):
|
|
118
|
+
fields.append(("mapping", json.dumps(params["mapping"])))
|
|
119
|
+
return multipart(fields, [("file", name, ctype, content)])
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from .._core import Result, seg
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typing_extensions import Unpack
|
|
10
|
+
|
|
11
|
+
from .._core import RequestOptions
|
|
12
|
+
from ..types import CreateDomainParams, Domain, DomainRecords, DomainRegion
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Domains(Resource):
|
|
16
|
+
def list(self, **options: Unpack[RequestOptions]) -> Result[List[Domain]]:
|
|
17
|
+
return self._http.call("GET", "/domains", **options)
|
|
18
|
+
|
|
19
|
+
def create(self, params: CreateDomainParams, **options: Unpack[RequestOptions]) -> Result[Domain]:
|
|
20
|
+
"""Register a sending domain. Next: ``records()``, publish them, then ``verify()``."""
|
|
21
|
+
return self._http.call("POST", "/domains", body=dict(params), **options)
|
|
22
|
+
|
|
23
|
+
def records(self, id: str, **options: Unpack[RequestOptions]) -> Result[DomainRecords]:
|
|
24
|
+
"""The DNS records to publish, each with its current status."""
|
|
25
|
+
return self._http.call("GET", f"/domains/{seg(id)}/records", **options)
|
|
26
|
+
|
|
27
|
+
def verify(self, id: str, **options: Unpack[RequestOptions]) -> Result[Domain]:
|
|
28
|
+
"""Check DNS now instead of waiting for the background check."""
|
|
29
|
+
return self._http.call("POST", f"/domains/{seg(id)}/verify", **options)
|
|
30
|
+
|
|
31
|
+
def enable_receiving(self, id: str, **options: Unpack[RequestOptions]) -> Result[Domain]:
|
|
32
|
+
"""Start receiving mail on this domain. Adds receiving records; never blocks sending."""
|
|
33
|
+
return self._http.call("POST", f"/domains/{seg(id)}/receiving", **options)
|
|
34
|
+
|
|
35
|
+
def disable_receiving(self, id: str, **options: Unpack[RequestOptions]) -> Result[Domain]:
|
|
36
|
+
return self._http.call("DELETE", f"/domains/{seg(id)}/receiving", **options)
|
|
37
|
+
|
|
38
|
+
def set_region(self, id: str, region: DomainRegion, **options: Unpack[RequestOptions]) -> Result[Domain]:
|
|
39
|
+
"""Move a domain to another region. Refused (``region_immutable``) once it holds messages."""
|
|
40
|
+
return self._http.call("PATCH", f"/domains/{seg(id)}/region", body={"region": region}, **options)
|
|
41
|
+
|
|
42
|
+
def deletion_preview(self, id: str, **options: Unpack[RequestOptions]) -> Result[Dict[str, Any]]:
|
|
43
|
+
"""What deleting the domain would take with it."""
|
|
44
|
+
return self._http.call("GET", f"/domains/{seg(id)}/deletion", **options)
|
|
45
|
+
|
|
46
|
+
def remove(self, id: str, **options: Unpack[RequestOptions]) -> Result[None]:
|
|
47
|
+
return self._http.call("DELETE", f"/domains/{seg(id)}", **options)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Iterator, List, Optional, cast
|
|
4
|
+
|
|
5
|
+
from .._core import Result, seg, strip_none
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typing_extensions import Unpack
|
|
10
|
+
|
|
11
|
+
from .._core import RequestOptions
|
|
12
|
+
from ..types import EmailDetail, EmailList, EmailRow, EmailStats, ListEmailsParams, SendBatchResponse, SendEmailParams, SentEmail
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Emails(Resource):
|
|
16
|
+
def send(self, params: SendEmailParams, **options: Unpack[RequestOptions]) -> Result[SentEmail]:
|
|
17
|
+
"""
|
|
18
|
+
Send one message. Returns once SendClipper accepted it (status ``queued``);
|
|
19
|
+
delivery is reported later through ``emails.get()`` and webhooks.
|
|
20
|
+
|
|
21
|
+
Every call carries an ``Idempotency-Key``, so the SDK's own retries never
|
|
22
|
+
send twice. Pass ``idempotency_key=`` yourself to make YOUR retries safe too.
|
|
23
|
+
"""
|
|
24
|
+
return self._http.call("POST", "/emails", body=dict(params), **options)
|
|
25
|
+
|
|
26
|
+
def send_batch(self, emails: List[SendEmailParams], **options: Unpack[RequestOptions]) -> Result[SendBatchResponse]:
|
|
27
|
+
"""Same as ``batch.send()``."""
|
|
28
|
+
return _send_batch(self, emails, **options)
|
|
29
|
+
|
|
30
|
+
def get(self, id: str, **options: Unpack[RequestOptions]) -> Result[EmailDetail]:
|
|
31
|
+
"""One message with its body and event timeline."""
|
|
32
|
+
return self._http.call("GET", f"/emails/{seg(id)}", **options)
|
|
33
|
+
|
|
34
|
+
def list(self, params: Optional[ListEmailsParams] = None, **options: Unpack[RequestOptions]) -> Result[EmailList]:
|
|
35
|
+
"""One page of the sent-mail log, newest first."""
|
|
36
|
+
return self._http.call("GET", "/emails", query=strip_none(params), **options)
|
|
37
|
+
|
|
38
|
+
def iterate(self, params: Optional[ListEmailsParams] = None, **options: Unpack[RequestOptions]) -> Iterator[EmailRow]:
|
|
39
|
+
"""
|
|
40
|
+
Every message matching ``params``, page after page. Raises
|
|
41
|
+
``SendClipperApiError`` on a refusal, since a loop has no ``error`` to return.
|
|
42
|
+
"""
|
|
43
|
+
query = strip_none(params)
|
|
44
|
+
page_size = int(query.get("pageSize") or 100)
|
|
45
|
+
page = 1
|
|
46
|
+
while True:
|
|
47
|
+
res = self.list(cast("ListEmailsParams", {**query, "page": page, "pageSize": page_size}), **options).unwrap()
|
|
48
|
+
yield from res["items"]
|
|
49
|
+
if len(res["items"]) < page_size or page * page_size >= res["total"]:
|
|
50
|
+
return
|
|
51
|
+
page += 1
|
|
52
|
+
|
|
53
|
+
def stats(self, **options: Unpack[RequestOptions]) -> Result[EmailStats]:
|
|
54
|
+
"""Counts per status."""
|
|
55
|
+
return self._http.call("GET", "/emails/stats/summary", **options)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Batch(Resource):
|
|
59
|
+
def send(self, emails: List[SendEmailParams], **options: Unpack[RequestOptions]) -> Result[SendBatchResponse]:
|
|
60
|
+
"""
|
|
61
|
+
Up to 500 messages in one request (one recipient each). Every item is
|
|
62
|
+
accepted or refused on its own; a refused item costs its siblings nothing.
|
|
63
|
+
"""
|
|
64
|
+
return _send_batch(self, emails, **options)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
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)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional
|
|
4
|
+
|
|
5
|
+
from .._core import DownloadedFile, Result, seg, strip_none
|
|
6
|
+
from ._base import Resource
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typing_extensions import Unpack
|
|
10
|
+
|
|
11
|
+
from .._core import RequestOptions
|
|
12
|
+
from ..types import (
|
|
13
|
+
CreateMailboxParams,
|
|
14
|
+
ListMailboxesParams,
|
|
15
|
+
ListMailboxMessagesParams,
|
|
16
|
+
Mailbox,
|
|
17
|
+
MailboxMessage,
|
|
18
|
+
MailboxMessageList,
|
|
19
|
+
MailboxMessageRow,
|
|
20
|
+
UpdateMailboxParams,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MailboxMessages(Resource):
|
|
25
|
+
def list(
|
|
26
|
+
self,
|
|
27
|
+
mailbox_id: str,
|
|
28
|
+
params: Optional[ListMailboxMessagesParams] = None,
|
|
29
|
+
**options: Unpack[RequestOptions],
|
|
30
|
+
) -> Result[MailboxMessageList]:
|
|
31
|
+
"""Mail filed under one address. Reading never marks anything read."""
|
|
32
|
+
return self._http.call("GET", f"/mailboxes/{seg(mailbox_id)}/messages", query=strip_none(params), **options)
|
|
33
|
+
|
|
34
|
+
def iterate(
|
|
35
|
+
self,
|
|
36
|
+
mailbox_id: str,
|
|
37
|
+
params: Optional[ListMailboxMessagesParams] = None,
|
|
38
|
+
**options: Unpack[RequestOptions],
|
|
39
|
+
) -> Iterator[MailboxMessageRow]:
|
|
40
|
+
query: Dict[str, Any] = strip_none(params)
|
|
41
|
+
while True:
|
|
42
|
+
res = self.list(mailbox_id, query, **options).unwrap() # type: ignore[arg-type]
|
|
43
|
+
yield from res["items"]
|
|
44
|
+
if not res["page"]["hasMore"] or not res["page"]["cursor"]:
|
|
45
|
+
return
|
|
46
|
+
query = {**query, "cursor": res["page"]["cursor"]}
|
|
47
|
+
|
|
48
|
+
def get(self, mailbox_id: str, message_id: str, **options: Unpack[RequestOptions]) -> Result[MailboxMessage]:
|
|
49
|
+
return self._http.call("GET", f"/mailboxes/{seg(mailbox_id)}/messages/{seg(message_id)}", **options)
|
|
50
|
+
|
|
51
|
+
def attachment(
|
|
52
|
+
self,
|
|
53
|
+
mailbox_id: str,
|
|
54
|
+
message_id: str,
|
|
55
|
+
attachment_id: str,
|
|
56
|
+
**options: Unpack[RequestOptions],
|
|
57
|
+
) -> Result[DownloadedFile]:
|
|
58
|
+
"""Download one attachment: ``content`` (bytes), ``content_type`` and ``filename``."""
|
|
59
|
+
return self._http.call(
|
|
60
|
+
"GET",
|
|
61
|
+
f"/mailboxes/{seg(mailbox_id)}/messages/{seg(message_id)}/parts/{seg(attachment_id)}",
|
|
62
|
+
query={"download": 1},
|
|
63
|
+
as_="bytes",
|
|
64
|
+
**options,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Mailboxes(Resource):
|
|
69
|
+
def __init__(self, http: Any) -> None:
|
|
70
|
+
super().__init__(http)
|
|
71
|
+
self.messages = MailboxMessages(http)
|
|
72
|
+
|
|
73
|
+
def list(self, params: Optional[ListMailboxesParams] = None, **options: Unpack[RequestOptions]) -> Result[List[Mailbox]]:
|
|
74
|
+
"""``{"includeArchived": True}`` lists archived addresses too."""
|
|
75
|
+
return self._http.call("GET", "/mailboxes", query=strip_none(params), **options)
|
|
76
|
+
|
|
77
|
+
def get(self, id: str, **options: Unpack[RequestOptions]) -> Result[Mailbox]:
|
|
78
|
+
return self._http.call("GET", f"/mailboxes/{seg(id)}", **options)
|
|
79
|
+
|
|
80
|
+
def create(self, params: CreateMailboxParams, **options: Unpack[RequestOptions]) -> Result[Mailbox]:
|
|
81
|
+
"""A new address on a receiving domain."""
|
|
82
|
+
return self._http.call("POST", "/mailboxes", body=dict(params), **options)
|
|
83
|
+
|
|
84
|
+
def update(self, id: str, params: UpdateMailboxParams, **options: Unpack[RequestOptions]) -> Result[Mailbox]:
|
|
85
|
+
return self._http.call("PATCH", f"/mailboxes/{seg(id)}", body=dict(params), **options)
|
|
86
|
+
|
|
87
|
+
def archive(self, id: str, **options: Unpack[RequestOptions]) -> Result[Mailbox]:
|
|
88
|
+
"""Archives the address. Its mail is kept."""
|
|
89
|
+
return self._http.call("DELETE", f"/mailboxes/{seg(id)}", **options)
|