replylayer 0.14.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.
replylayer/errors.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ReplyLayerError(Exception):
7
+ def __init__(
8
+ self,
9
+ status_code: int,
10
+ code: str,
11
+ message: str,
12
+ details: dict[str, Any] | None = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+ self.code = code
17
+ self.details = details
18
+
19
+
20
+ class AuthenticationError(ReplyLayerError):
21
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
22
+ super().__init__(401, code, message, details)
23
+
24
+
25
+ class ForbiddenError(ReplyLayerError):
26
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
27
+ super().__init__(403, code, message, details)
28
+
29
+
30
+ class NotFoundError(ReplyLayerError):
31
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
32
+ super().__init__(404, code, message, details)
33
+
34
+
35
+ class ValidationError(ReplyLayerError):
36
+ def __init__(self, status_code: int, code: str, message: str, details: dict[str, Any] | None = None) -> None:
37
+ super().__init__(status_code, code, message, details)
38
+
39
+
40
+ class RateLimitError(ReplyLayerError):
41
+ def __init__(
42
+ self,
43
+ code: str,
44
+ message: str,
45
+ headers: dict[str, str],
46
+ details: dict[str, Any] | None = None,
47
+ ) -> None:
48
+ super().__init__(429, code, message, details)
49
+ ra = headers.get("retry-after")
50
+ self.retry_after: int | None = int(ra) if ra and ra.isdigit() else None
51
+ lim = headers.get("x-ratelimit-limit")
52
+ self.limit: int | None = int(lim) if lim and lim.isdigit() else None
53
+ rem = headers.get("x-ratelimit-remaining")
54
+ self.remaining: int | None = int(rem) if rem and rem.isdigit() else None
55
+ self.reset: str | None = headers.get("x-ratelimit-reset")
56
+
57
+
58
+ class WebhookSignatureError(ReplyLayerError):
59
+ def __init__(self, message: str) -> None:
60
+ super().__init__(0, "WEBHOOK_SIGNATURE_ERROR", message)
61
+
62
+
63
+ # Migration 040 — scheduled-send error surface. Raised when scheduled-send
64
+ # routes reject a request (invalid TZ / too-soon / too-far / quota breach /
65
+ # Idempotency-Key without send_at). Subclass of ReplyLayerError so generic
66
+ # catches still work; typed .reason_code narrows for per-reason handling.
67
+ #
68
+ # NOT raised for runtime dispatch failures — those surface as
69
+ # message.dispatch_failed webhook events, not SDK exceptions.
70
+ _SCHEDULING_REASON_CODES: frozenset[str] = frozenset({
71
+ "TIMEZONE_REQUIRED",
72
+ "SEND_AT_TOO_SOON",
73
+ "SEND_AT_TOO_FAR",
74
+ "SCHEDULED_SEND_QUOTA_EXCEEDED",
75
+ "IDEMPOTENCY_KEY_REQUIRES_SEND_AT",
76
+ })
77
+
78
+
79
+ class SchedulingError(ReplyLayerError):
80
+ def __init__(
81
+ self,
82
+ status_code: int,
83
+ code: str,
84
+ message: str,
85
+ details: dict[str, Any] | None = None,
86
+ ) -> None:
87
+ super().__init__(status_code, code, message, details)
88
+ self.reason_code: str = code
89
+
90
+
91
+ class TimezoneRequiredError(SchedulingError):
92
+ """
93
+ Raised CLIENT-SIDE (before the HTTP call) when a caller passes a
94
+ ``datetime`` without tzinfo on ``send_at``. The TS SDK equivalent is a
95
+ 400 TIMEZONE_REQUIRED from the server; in Python we fail fast because
96
+ ``datetime.isoformat()`` on a naive datetime produces a naive ISO
97
+ string that the server would then reject anyway — better to raise at
98
+ the call site where the stack trace points at the bug.
99
+
100
+ The ``status_code`` is set to 400 for API parity, but no HTTP call was
101
+ actually made.
102
+ """
103
+
104
+ def __init__(self, message: str | None = None) -> None:
105
+ super().__init__(
106
+ 400,
107
+ "TIMEZONE_REQUIRED",
108
+ message or (
109
+ "send_at must be timezone-aware. Use datetime.now(timezone.utc) "
110
+ "or attach tzinfo before passing. An ISO string with offset or "
111
+ "'Z' is also accepted."
112
+ ),
113
+ )
114
+
115
+
116
+ def error_from_response(
117
+ status: int,
118
+ body: dict[str, Any],
119
+ headers: dict[str, str],
120
+ ) -> ReplyLayerError:
121
+ message = body.get("error", f"HTTP {status}")
122
+ # Coerce a non-string ``code`` to a string. ``body["code"]`` is typed as a
123
+ # string but the wire carries whatever JSON parsed — an edge gateway (e.g.
124
+ # a proxy on a long-poll 502) can put a NUMERIC code on the body, surprising
125
+ # any caller doing ``isinstance(err.code, str)``. ``is not None`` preserves a
126
+ # legitimate ``0`` (-> "0"); the ``or`` catches None/empty -> HTTP_<status>.
127
+ # Mirrors the CLI client + the TS SDK.
128
+ raw_code = body.get("code")
129
+ code = (str(raw_code) if raw_code is not None else "") or f"HTTP_{status}"
130
+ details = body.get("details")
131
+
132
+ # Migration 040 — scheduling errors are a narrow subclass of
133
+ # status-generic errors. Map BEFORE the status switch so a 429 with
134
+ # SCHEDULED_SEND_QUOTA_EXCEEDED becomes a SchedulingError, not a
135
+ # RateLimitError (consumers filtering on .retry_after wouldn't expect
136
+ # quota-type 429s anyway). 400s likewise become SchedulingError
137
+ # rather than ValidationError so `isinstance(err, SchedulingError)`
138
+ # works uniformly across statuses.
139
+ if code in _SCHEDULING_REASON_CODES:
140
+ return SchedulingError(status, code, message, details)
141
+
142
+ if status == 401:
143
+ return AuthenticationError(code, message, details)
144
+ if status == 403:
145
+ return ForbiddenError(code, message, details)
146
+ if status == 404:
147
+ return NotFoundError(code, message, details)
148
+ if status == 429:
149
+ return RateLimitError(code, message, headers, details)
150
+ if status in (400, 422):
151
+ return ValidationError(status, code, message, details)
152
+ return ReplyLayerError(status, code, message, details)
replylayer/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+ from ..types import QuotaResponse, UsageResponse
7
+
8
+
9
+ class SyncAccount:
10
+ def __init__(self, http: SyncHttpClient) -> None:
11
+ self._http = http
12
+
13
+ def get_usage(self) -> UsageResponse:
14
+ return self._http.request("GET", "/v1/accounts/usage")
15
+
16
+ def get_quota(self) -> QuotaResponse:
17
+ return self._http.request("GET", "/v1/accounts/quota")
18
+
19
+ def export(self) -> dict[str, Any]:
20
+ """GDPR Art. 20 data portability export — the full account-data object."""
21
+ return self._http.request("GET", "/v1/accounts/export")
22
+
23
+
24
+ class AsyncAccount:
25
+ def __init__(self, http: AsyncHttpClient) -> None:
26
+ self._http = http
27
+
28
+ async def get_usage(self) -> UsageResponse:
29
+ return await self._http.request("GET", "/v1/accounts/usage")
30
+
31
+ async def get_quota(self) -> QuotaResponse:
32
+ return await self._http.request("GET", "/v1/accounts/quota")
33
+
34
+ async def export(self) -> dict[str, Any]:
35
+ """GDPR Art. 20 data portability export — the full account-data object."""
36
+ return await self._http.request("GET", "/v1/accounts/export")
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+
7
+
8
+ class SyncApiKeys:
9
+ def __init__(self, http: SyncHttpClient) -> None:
10
+ self._http = http
11
+
12
+ def create(self, *, role: str, label: str | None = None, mailbox_ids: list[str] | None = None) -> dict[str, Any]:
13
+ payload: dict[str, Any] = {"role": role}
14
+ if label is not None:
15
+ payload["label"] = label
16
+ if mailbox_ids is not None:
17
+ payload["mailbox_ids"] = mailbox_ids
18
+ return self._http.request("POST", "/v1/accounts/api-keys", body=payload)
19
+
20
+ def list(self, *, include_revoked: bool = False) -> dict[str, Any]:
21
+ # D4 / RL-UAT-019 — active-only by default; include_revoked=True returns
22
+ # revoked keys with revoked_at/revoked_by metadata.
23
+ query = {"include_revoked": "true"} if include_revoked else None
24
+ return self._http.request("GET", "/v1/accounts/api-keys", query=query)
25
+
26
+ def revoke(self, id: str) -> dict[str, Any]:
27
+ return self._http.request("DELETE", f"/v1/accounts/api-keys/{id}")
28
+
29
+ def rotate(self) -> dict[str, Any]:
30
+ """Rotate the calling API key. After this call, this SDK instance's key
31
+ is invalidated. Create a new ReplyLayer instance with the returned key."""
32
+ return self._http.request("POST", "/v1/accounts/api-keys/rotate")
33
+
34
+
35
+ class AsyncApiKeys:
36
+ def __init__(self, http: AsyncHttpClient) -> None:
37
+ self._http = http
38
+
39
+ async def create(self, *, role: str, label: str | None = None, mailbox_ids: list[str] | None = None) -> dict[str, Any]:
40
+ payload: dict[str, Any] = {"role": role}
41
+ if label is not None:
42
+ payload["label"] = label
43
+ if mailbox_ids is not None:
44
+ payload["mailbox_ids"] = mailbox_ids
45
+ return await self._http.request("POST", "/v1/accounts/api-keys", body=payload)
46
+
47
+ async def list(self, *, include_revoked: bool = False) -> dict[str, Any]:
48
+ # D4 / RL-UAT-019 — active-only by default; include_revoked=True returns
49
+ # revoked keys with revoked_at/revoked_by metadata.
50
+ query = {"include_revoked": "true"} if include_revoked else None
51
+ return await self._http.request("GET", "/v1/accounts/api-keys", query=query)
52
+
53
+ async def revoke(self, id: str) -> dict[str, Any]:
54
+ return await self._http.request("DELETE", f"/v1/accounts/api-keys/{id}")
55
+
56
+ async def rotate(self) -> dict[str, Any]:
57
+ """Rotate the calling API key. After this call, this SDK instance's key
58
+ is invalidated. Create a new AsyncReplyLayer instance with the returned key."""
59
+ return await self._http.request("POST", "/v1/accounts/api-keys/rotate")
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import IO, Any, cast
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+ from ..types import (
7
+ AttachmentPreviewResponse,
8
+ GetUploadAttachmentResponse,
9
+ UploadAttachmentResponse,
10
+ )
11
+
12
+
13
+ def _upload_kwargs(
14
+ *,
15
+ mailbox_id: str,
16
+ file: bytes | IO[bytes],
17
+ filename: str,
18
+ content_type: str | None,
19
+ ) -> dict[str, Any]:
20
+ """Build the httpx files=/data= kwargs for POST /v1/attachments.
21
+
22
+ The file part is a (filename, content[, content_type]) tuple; the
23
+ mailbox_id is a plain multipart text field. content_type is advisory — the
24
+ server re-sniffs the bytes and decides the trusted family regardless.
25
+ """
26
+ file_part: tuple[Any, ...] = (
27
+ (filename, file, content_type) if content_type else (filename, file)
28
+ )
29
+ return {"files": {"file": file_part}, "data": {"mailbox_id": mailbox_id}}
30
+
31
+
32
+ class SyncAttachments:
33
+ def __init__(self, http: SyncHttpClient) -> None:
34
+ self._http = http
35
+
36
+ def get_download_url(self, message_id: str, index: int) -> dict[str, Any]:
37
+ return self._http.request("GET", f"/v1/messages/{message_id}/attachments/{index}")
38
+
39
+ def get_preview(self, message_id: str, index: int) -> AttachmentPreviewResponse:
40
+ return cast(
41
+ AttachmentPreviewResponse,
42
+ self._http.request("GET", f"/v1/messages/{message_id}/attachments/{index}/preview"),
43
+ )
44
+
45
+ def upload(
46
+ self,
47
+ *,
48
+ mailbox_id: str,
49
+ file: bytes | IO[bytes],
50
+ filename: str,
51
+ content_type: str | None = None,
52
+ ) -> UploadAttachmentResponse:
53
+ """Stage an outbound attachment (phase 1). Returns an opaque handle;
54
+ pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list. The
55
+ mailbox must have outbound attachments enabled (Pro+). The returned
56
+ ``content_scan_status`` is ``"pending"`` — poll :meth:`get_upload` until
57
+ terminal before referencing the handle, or the send fails with
58
+ ``ATTACHMENT_SCAN_PENDING``.
59
+ """
60
+ kwargs = _upload_kwargs(
61
+ mailbox_id=mailbox_id, file=file, filename=filename, content_type=content_type
62
+ )
63
+ return cast(UploadAttachmentResponse, self._http.request("POST", "/v1/attachments", **kwargs))
64
+
65
+ def get_upload(self, attachment_id: str) -> GetUploadAttachmentResponse:
66
+ """Poll a staged-attachment handle for its content-scan status (or
67
+ consumed state)."""
68
+ return cast(
69
+ GetUploadAttachmentResponse,
70
+ self._http.request("GET", f"/v1/attachments/{attachment_id}"),
71
+ )
72
+
73
+ def delete_upload(self, attachment_id: str) -> None:
74
+ """Delete an un-consumed staged-attachment handle. A consumed handle → 409."""
75
+ self._http.request("DELETE", f"/v1/attachments/{attachment_id}")
76
+
77
+
78
+ class AsyncAttachments:
79
+ def __init__(self, http: AsyncHttpClient) -> None:
80
+ self._http = http
81
+
82
+ async def get_download_url(self, message_id: str, index: int) -> dict[str, Any]:
83
+ return await self._http.request("GET", f"/v1/messages/{message_id}/attachments/{index}")
84
+
85
+ async def get_preview(self, message_id: str, index: int) -> AttachmentPreviewResponse:
86
+ return cast(
87
+ AttachmentPreviewResponse,
88
+ await self._http.request("GET", f"/v1/messages/{message_id}/attachments/{index}/preview"),
89
+ )
90
+
91
+ async def upload(
92
+ self,
93
+ *,
94
+ mailbox_id: str,
95
+ file: bytes | IO[bytes],
96
+ filename: str,
97
+ content_type: str | None = None,
98
+ ) -> UploadAttachmentResponse:
99
+ """Async variant of :meth:`SyncAttachments.upload`."""
100
+ kwargs = _upload_kwargs(
101
+ mailbox_id=mailbox_id, file=file, filename=filename, content_type=content_type
102
+ )
103
+ return cast(
104
+ UploadAttachmentResponse,
105
+ await self._http.request("POST", "/v1/attachments", **kwargs),
106
+ )
107
+
108
+ async def get_upload(self, attachment_id: str) -> GetUploadAttachmentResponse:
109
+ return cast(
110
+ GetUploadAttachmentResponse,
111
+ await self._http.request("GET", f"/v1/attachments/{attachment_id}"),
112
+ )
113
+
114
+ async def delete_upload(self, attachment_id: str) -> None:
115
+ await self._http.request("DELETE", f"/v1/attachments/{attachment_id}")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+
7
+
8
+ class SyncDomains:
9
+ def __init__(self, http: SyncHttpClient) -> None:
10
+ self._http = http
11
+
12
+ def create(self, **params: Any) -> dict[str, Any]:
13
+ return self._http.request("POST", "/v1/domains", body=params)
14
+
15
+ def list(self) -> dict[str, Any]:
16
+ return self._http.request("GET", "/v1/domains")
17
+
18
+ def get(self, id: str) -> dict[str, Any]:
19
+ return self._http.request("GET", f"/v1/domains/{id}")
20
+
21
+ def verify(self, id: str) -> dict[str, Any]:
22
+ return self._http.request("POST", f"/v1/domains/{id}/verify")
23
+
24
+ def update_self_hosted_config(
25
+ self,
26
+ id: str,
27
+ **params: Any,
28
+ ) -> dict[str, Any]:
29
+ return self._http.request(
30
+ "PATCH",
31
+ f"/v1/domains/{id}/self-hosted-config",
32
+ body=params,
33
+ )
34
+
35
+ def delete(self, id: str) -> dict[str, Any]:
36
+ return self._http.request("DELETE", f"/v1/domains/{id}")
37
+
38
+ def set_default(self, id: str) -> dict[str, Any]:
39
+ return self._http.request("PATCH", f"/v1/domains/{id}/set-default")
40
+
41
+ def recheck(self, id: str) -> dict[str, Any]:
42
+ return self._http.request("POST", f"/v1/domains/{id}/self-hosted-recheck")
43
+
44
+
45
+ class AsyncDomains:
46
+ def __init__(self, http: AsyncHttpClient) -> None:
47
+ self._http = http
48
+
49
+ async def create(self, **params: Any) -> dict[str, Any]:
50
+ return await self._http.request("POST", "/v1/domains", body=params)
51
+
52
+ async def list(self) -> dict[str, Any]:
53
+ return await self._http.request("GET", "/v1/domains")
54
+
55
+ async def get(self, id: str) -> dict[str, Any]:
56
+ return await self._http.request("GET", f"/v1/domains/{id}")
57
+
58
+ async def verify(self, id: str) -> dict[str, Any]:
59
+ return await self._http.request("POST", f"/v1/domains/{id}/verify")
60
+
61
+ async def update_self_hosted_config(
62
+ self,
63
+ id: str,
64
+ **params: Any,
65
+ ) -> dict[str, Any]:
66
+ return await self._http.request(
67
+ "PATCH",
68
+ f"/v1/domains/{id}/self-hosted-config",
69
+ body=params,
70
+ )
71
+
72
+ async def delete(self, id: str) -> dict[str, Any]:
73
+ return await self._http.request("DELETE", f"/v1/domains/{id}")
74
+
75
+ async def set_default(self, id: str) -> dict[str, Any]:
76
+ return await self._http.request("PATCH", f"/v1/domains/{id}/set-default")
77
+
78
+ async def recheck(self, id: str) -> dict[str, Any]:
79
+ return await self._http.request("POST", f"/v1/domains/{id}/self-hosted-recheck")