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/_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}")
@@ -0,0 +1,3 @@
1
+ """Kept equal to the release tag by the publish workflow."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)