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.
@@ -0,0 +1,342 @@
1
+ """
2
+ Drafts resource — scan-then-review-then-send flow.
3
+
4
+ A draft is a message in state='draft'. create() and update() run the scanner
5
+ synchronously and attach the verdict to the row. send() re-runs the scanner
6
+ authoritatively; it may raise ReplyLayerError with code DRAFT_REJECTED_BY_RESCAN
7
+ (409) if the latest policy would block, or DRAFT_ALREADY_SENT (409) if the
8
+ draft is already in-flight or sent.
9
+
10
+ Migration 040 — scheduled send: pass ``send_at`` on ``create()`` to schedule
11
+ for future dispatch. The server runs the full send-time gate stack at
12
+ dispatch time (not at schedule time) — policy changes between schedule and
13
+ dispatch are honored. Dispatch failures surface as
14
+ ``message.dispatch_failed`` webhook events, not exceptions.
15
+
16
+ ``send_at`` accepts either an ISO-8601 string WITH explicit offset/Z, or a
17
+ timezone-aware ``datetime`` object (converted to ISO via .isoformat()).
18
+ Naive datetime inputs raise :class:`TimezoneRequiredError` BEFORE the HTTP
19
+ call — failing at the call site where the stack trace points at the bug is
20
+ cheaper than a 400 round-trip from the server.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from datetime import datetime
25
+ from typing import Any, AsyncIterator, Iterator, Union
26
+
27
+ from .._http import AsyncHttpClient, SyncHttpClient
28
+ from .._pagination import async_auto_paginate, sync_auto_paginate
29
+ from ..errors import TimezoneRequiredError
30
+ from ..types import Page
31
+
32
+ DEFAULT_LIMIT = 50
33
+
34
+
35
+ def _normalize_send_at(value: str | datetime | None) -> str | None:
36
+ """
37
+ Migration 040 — accept both string + datetime send_at inputs, reject
38
+ naive datetimes fail-fast with TimezoneRequiredError. Returns the
39
+ wire-format ISO string (always carries offset/Z) or None if the caller
40
+ passed None.
41
+
42
+ Strings are passed through verbatim — the server's validate-send-at.ts
43
+ is authoritative on string-format checks. We only fail-fast on the
44
+ one case the server can't recover from without a round-trip (naive
45
+ datetime → silently serialized as naive string).
46
+ """
47
+ if value is None:
48
+ return None
49
+ if isinstance(value, datetime):
50
+ if value.tzinfo is None:
51
+ raise TimezoneRequiredError()
52
+ return value.isoformat()
53
+ return value
54
+
55
+
56
+ # Sentinel to distinguish "not passed" from "pass null" on update() send_at.
57
+ # send_at=None means "clear the schedule"; send_at=<missing> means "no change".
58
+ # Python has no native way to spell this; a sentinel object is the standard
59
+ # approach (matches e.g. typing's Unset sentinel pattern).
60
+ class _Unset:
61
+ def __repr__(self) -> str: # pragma: no cover
62
+ return "<UNSET>"
63
+
64
+
65
+ _UNSET: Any = _Unset()
66
+
67
+
68
+ class SyncDrafts:
69
+ def __init__(self, http: SyncHttpClient) -> None:
70
+ self._http = http
71
+
72
+ def create(
73
+ self,
74
+ *,
75
+ body: str,
76
+ mailbox_id: str | None = None,
77
+ to: str | None = None,
78
+ subject: str | None = None,
79
+ html: str | None = None,
80
+ in_reply_to_message_id: str | None = None,
81
+ # Migration 085 — thread mode (mutually exclusive with
82
+ # in_reply_to_message_id). Derives mailbox_id/subject from the thread;
83
+ # `to` is then an optional participant selector.
84
+ thread_id: str | None = None,
85
+ subaddress_instance_id: str | None = None,
86
+ subaddress_mode: str | None = None,
87
+ # Migration 040 — scheduled-send.
88
+ send_at: str | datetime | None = None,
89
+ # Outbound attachment handles — held on the draft, consumed at dispatch.
90
+ attachment_ids: list[str] | None = None,
91
+ idempotency_key: str | None = None,
92
+ ) -> dict[str, Any]:
93
+ send_at_iso = _normalize_send_at(send_at)
94
+
95
+ payload: dict[str, Any] = {"body": body}
96
+ if mailbox_id is not None:
97
+ payload["mailbox_id"] = mailbox_id
98
+ if to is not None:
99
+ payload["to"] = to
100
+ if subject is not None:
101
+ payload["subject"] = subject
102
+ if html is not None:
103
+ payload["html"] = html
104
+ if in_reply_to_message_id is not None:
105
+ payload["in_reply_to_message_id"] = in_reply_to_message_id
106
+ if thread_id is not None:
107
+ payload["thread_id"] = thread_id
108
+ if subaddress_instance_id is not None:
109
+ payload["subaddress_instance_id"] = subaddress_instance_id
110
+ if subaddress_mode is not None:
111
+ payload["subaddress_mode"] = subaddress_mode
112
+ if send_at_iso is not None:
113
+ payload["send_at"] = send_at_iso
114
+ if attachment_ids is not None:
115
+ payload["attachment_ids"] = attachment_ids
116
+
117
+ extra_headers: dict[str, str] | None = None
118
+ if idempotency_key is not None:
119
+ extra_headers = {"Idempotency-Key": idempotency_key}
120
+
121
+ return self._http.request(
122
+ "POST",
123
+ "/v1/drafts",
124
+ body=payload,
125
+ extra_headers=extra_headers,
126
+ )
127
+
128
+ def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
129
+ query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
130
+ return self._http.request("GET", f"/v1/drafts/{id}", query=query)
131
+
132
+ def list(
133
+ self,
134
+ mailbox_id: str,
135
+ *,
136
+ limit: int = DEFAULT_LIMIT,
137
+ before: str | None = None,
138
+ auto_paginate: bool = False,
139
+ ) -> Union[Page, Iterator[dict[str, Any]]]:
140
+ query: dict[str, str | None] = {
141
+ "limit": str(limit),
142
+ "before": before,
143
+ }
144
+
145
+ def fetch_page(cursor: str | None) -> Page:
146
+ q = {**query, "before": cursor or query["before"]}
147
+ res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
148
+ drafts = res.get("drafts", [])
149
+ next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
150
+ return Page(data=drafts, has_more=len(drafts) == limit, cursor=next_cursor)
151
+
152
+ if auto_paginate:
153
+ return sync_auto_paginate(fetch_page)
154
+ return fetch_page(None)
155
+
156
+ def update(
157
+ self,
158
+ id: str,
159
+ *,
160
+ to: str | None = None,
161
+ subject: str | None = None,
162
+ body: str | None = None,
163
+ html: str | None = None,
164
+ subaddress_instance_id: str | None = None,
165
+ subaddress_mode: str | None = None,
166
+ # Migration 040 — scheduled-send. _UNSET = "no change"; None = "clear".
167
+ send_at: Any = _UNSET,
168
+ # Outbound attachments — _UNSET = no change; None = clear all; list = replace.
169
+ attachment_ids: Any = _UNSET,
170
+ ) -> dict[str, Any]:
171
+ payload: dict[str, Any] = {}
172
+ if to is not None:
173
+ payload["to"] = to
174
+ if subject is not None:
175
+ payload["subject"] = subject
176
+ if body is not None:
177
+ payload["body"] = body
178
+ if html is not None:
179
+ payload["html"] = html
180
+ if subaddress_instance_id is not None:
181
+ payload["subaddress_instance_id"] = subaddress_instance_id
182
+ if subaddress_mode is not None:
183
+ payload["subaddress_mode"] = subaddress_mode
184
+ if send_at is not _UNSET:
185
+ payload["send_at"] = _normalize_send_at(send_at)
186
+ if attachment_ids is not _UNSET:
187
+ payload["attachment_ids"] = attachment_ids
188
+ return self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
189
+
190
+ def send(self, id: str) -> dict[str, Any]:
191
+ """Dispatch a draft.
192
+
193
+ Re-runs the scanner authoritatively + the full send-time gate
194
+ stack (suppressions, reply-loop, budget, etc) before handing the
195
+ message to the outbound provider.
196
+
197
+ Sandbox accounts are subject to a 250-cumulative-send trial
198
+ budget. Once exhausted the API returns 403 with
199
+ ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
200
+ payload carrying ``feature='sandbox_cumulative_send_cap'``. Same
201
+ cap fires here as on ``messages.send()`` — the cumulative
202
+ counter is shared across both surfaces.
203
+ """
204
+ return self._http.request("POST", f"/v1/drafts/{id}/send", body={})
205
+
206
+ def delete(self, id: str) -> None:
207
+ self._http.request("DELETE", f"/v1/drafts/{id}")
208
+
209
+
210
+ class AsyncDrafts:
211
+ def __init__(self, http: AsyncHttpClient) -> None:
212
+ self._http = http
213
+
214
+ async def create(
215
+ self,
216
+ *,
217
+ body: str,
218
+ mailbox_id: str | None = None,
219
+ to: str | None = None,
220
+ subject: str | None = None,
221
+ html: str | None = None,
222
+ in_reply_to_message_id: str | None = None,
223
+ # Migration 085 — thread mode (mutually exclusive with
224
+ # in_reply_to_message_id). Derives mailbox_id/subject; `to` optional.
225
+ thread_id: str | None = None,
226
+ subaddress_instance_id: str | None = None,
227
+ subaddress_mode: str | None = None,
228
+ # Migration 040 — scheduled-send.
229
+ send_at: str | datetime | None = None,
230
+ # Outbound attachment handles — held on the draft, consumed at dispatch.
231
+ attachment_ids: list[str] | None = None,
232
+ idempotency_key: str | None = None,
233
+ ) -> dict[str, Any]:
234
+ send_at_iso = _normalize_send_at(send_at)
235
+
236
+ payload: dict[str, Any] = {"body": body}
237
+ if mailbox_id is not None:
238
+ payload["mailbox_id"] = mailbox_id
239
+ if to is not None:
240
+ payload["to"] = to
241
+ if subject is not None:
242
+ payload["subject"] = subject
243
+ if html is not None:
244
+ payload["html"] = html
245
+ if in_reply_to_message_id is not None:
246
+ payload["in_reply_to_message_id"] = in_reply_to_message_id
247
+ if thread_id is not None:
248
+ payload["thread_id"] = thread_id
249
+ if subaddress_instance_id is not None:
250
+ payload["subaddress_instance_id"] = subaddress_instance_id
251
+ if subaddress_mode is not None:
252
+ payload["subaddress_mode"] = subaddress_mode
253
+ if send_at_iso is not None:
254
+ payload["send_at"] = send_at_iso
255
+ if attachment_ids is not None:
256
+ payload["attachment_ids"] = attachment_ids
257
+
258
+ extra_headers: dict[str, str] | None = None
259
+ if idempotency_key is not None:
260
+ extra_headers = {"Idempotency-Key": idempotency_key}
261
+
262
+ return await self._http.request(
263
+ "POST",
264
+ "/v1/drafts",
265
+ body=payload,
266
+ extra_headers=extra_headers,
267
+ )
268
+
269
+ async def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
270
+ query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
271
+ return await self._http.request("GET", f"/v1/drafts/{id}", query=query)
272
+
273
+ async def list(
274
+ self,
275
+ mailbox_id: str,
276
+ *,
277
+ limit: int = DEFAULT_LIMIT,
278
+ before: str | None = None,
279
+ auto_paginate: bool = False,
280
+ ) -> Page | AsyncIterator[dict[str, Any]]:
281
+ query: dict[str, str | None] = {
282
+ "limit": str(limit),
283
+ "before": before,
284
+ }
285
+
286
+ async def fetch_page(cursor: str | None) -> Page:
287
+ q = {**query, "before": cursor or query["before"]}
288
+ res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
289
+ drafts = res.get("drafts", [])
290
+ next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
291
+ return Page(data=drafts, has_more=len(drafts) == limit, cursor=next_cursor)
292
+
293
+ if auto_paginate:
294
+ return async_auto_paginate(fetch_page)
295
+ return await fetch_page(None)
296
+
297
+ async def update(
298
+ self,
299
+ id: str,
300
+ *,
301
+ to: str | None = None,
302
+ subject: str | None = None,
303
+ body: str | None = None,
304
+ html: str | None = None,
305
+ subaddress_instance_id: str | None = None,
306
+ subaddress_mode: str | None = None,
307
+ # Migration 040 — scheduled-send. _UNSET = "no change"; None = "clear".
308
+ send_at: Any = _UNSET,
309
+ # Outbound attachments — _UNSET = no change; None = clear all; list = replace.
310
+ attachment_ids: Any = _UNSET,
311
+ ) -> dict[str, Any]:
312
+ payload: dict[str, Any] = {}
313
+ if to is not None:
314
+ payload["to"] = to
315
+ if subject is not None:
316
+ payload["subject"] = subject
317
+ if body is not None:
318
+ payload["body"] = body
319
+ if html is not None:
320
+ payload["html"] = html
321
+ if subaddress_instance_id is not None:
322
+ payload["subaddress_instance_id"] = subaddress_instance_id
323
+ if subaddress_mode is not None:
324
+ payload["subaddress_mode"] = subaddress_mode
325
+ if send_at is not _UNSET:
326
+ payload["send_at"] = _normalize_send_at(send_at)
327
+ if attachment_ids is not _UNSET:
328
+ payload["attachment_ids"] = attachment_ids
329
+ return await self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
330
+
331
+ async def send(self, id: str) -> dict[str, Any]:
332
+ """Dispatch a draft.
333
+
334
+ Sandbox accounts are subject to a 250-cumulative-send trial
335
+ budget. Once exhausted the API returns 403 with
336
+ ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
337
+ payload carrying ``feature='sandbox_cumulative_send_cap'``.
338
+ """
339
+ return await self._http.request("POST", f"/v1/drafts/{id}/send", body={})
340
+
341
+ async def delete(self, id: str) -> None:
342
+ await self._http.request("DELETE", f"/v1/drafts/{id}")
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+
7
+
8
+ class SyncHealth:
9
+ def __init__(self, http: SyncHttpClient) -> None:
10
+ self._http = http
11
+
12
+ def check(self) -> dict[str, Any]:
13
+ return self._http.request("GET", "/v1/health", no_auth=True)
14
+
15
+
16
+ class AsyncHealth:
17
+ def __init__(self, http: AsyncHttpClient) -> None:
18
+ self._http = http
19
+
20
+ async def check(self) -> dict[str, Any]:
21
+ return await self._http.request("GET", "/v1/health", no_auth=True)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ from .._http import AsyncHttpClient, SyncHttpClient
7
+
8
+
9
+ class SyncInboundBlocklist:
10
+ """Migration 047 — account-scoped inbound sender blocklist (sync).
11
+
12
+ Reached via ``client.inbound_blocklist``. Mirrors ``client.suppressions``
13
+ but for the inbound side: customer-managed do-not-receive list. Both
14
+ ``add`` and ``delete`` accept admin AND agent keys (the threat model
15
+ that makes the outbound suppression DELETE admin-only does not apply
16
+ inbound — the customer is being protected from external senders).
17
+
18
+ Server forces ``reason='manual'``, ``source='customer'`` on every insert.
19
+ """
20
+
21
+ def __init__(self, http: SyncHttpClient) -> None:
22
+ self._http = http
23
+
24
+ def list(
25
+ self,
26
+ *,
27
+ limit: int | None = None,
28
+ cursor: str | None = None,
29
+ all: bool | None = None, # noqa: A002 — match REST query name
30
+ ) -> dict[str, Any]:
31
+ query: dict[str, Any] = {}
32
+ if limit is not None:
33
+ query["limit"] = str(limit)
34
+ if cursor is not None:
35
+ query["cursor"] = cursor
36
+ if all:
37
+ query["all"] = "true"
38
+ return self._http.request("GET", "/v1/inbound-blocklist", query=query)
39
+
40
+ def add(self, *, email: str) -> dict[str, Any]:
41
+ """Idempotent — repeat calls return ``already_existed: true``."""
42
+ return self._http.request(
43
+ "POST", "/v1/inbound-blocklist", body={"email": email}
44
+ )
45
+
46
+ def add_bulk(self, *, emails: list[str]) -> dict[str, Any]:
47
+ """Up to 1000 entries; partial-success buckets. Rate-limited by entry-count."""
48
+ return self._http.request(
49
+ "POST", "/v1/inbound-blocklist/bulk", body={"emails": emails}
50
+ )
51
+
52
+ def delete(self, email: str) -> dict[str, Any]:
53
+ return self._http.request(
54
+ "DELETE", f"/v1/inbound-blocklist/{quote(email, safe='')}"
55
+ )
56
+
57
+
58
+ class AsyncInboundBlocklist:
59
+ """Migration 047 — account-scoped inbound sender blocklist (async)."""
60
+
61
+ def __init__(self, http: AsyncHttpClient) -> None:
62
+ self._http = http
63
+
64
+ async def list(
65
+ self,
66
+ *,
67
+ limit: int | None = None,
68
+ cursor: str | None = None,
69
+ all: bool | None = None, # noqa: A002
70
+ ) -> dict[str, Any]:
71
+ query: dict[str, Any] = {}
72
+ if limit is not None:
73
+ query["limit"] = str(limit)
74
+ if cursor is not None:
75
+ query["cursor"] = cursor
76
+ if all:
77
+ query["all"] = "true"
78
+ return await self._http.request("GET", "/v1/inbound-blocklist", query=query)
79
+
80
+ async def add(self, *, email: str) -> dict[str, Any]:
81
+ return await self._http.request(
82
+ "POST", "/v1/inbound-blocklist", body={"email": email}
83
+ )
84
+
85
+ async def add_bulk(self, *, emails: list[str]) -> dict[str, Any]:
86
+ return await self._http.request(
87
+ "POST", "/v1/inbound-blocklist/bulk", body={"emails": emails}
88
+ )
89
+
90
+ async def delete(self, email: str) -> dict[str, Any]:
91
+ return await self._http.request(
92
+ "DELETE", f"/v1/inbound-blocklist/{quote(email, safe='')}"
93
+ )
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ from .._http import AsyncHttpClient, SyncHttpClient
7
+
8
+
9
+ class SyncLegalHolds:
10
+ """Customer-facing legal holds (PR 3 — Pro+ tier-gated MVP, sync).
11
+
12
+ Reached via ``client.legal_holds``. ``apply`` and ``list_holds`` are
13
+ gated by the ``customer_legal_hold`` feature; ``release`` and ``get``
14
+ are NOT gated so a downgraded customer can still lift their own holds
15
+ and inspect prior holds. Agent-role API keys 403 — legal hold is
16
+ admin-only.
17
+ """
18
+
19
+ def __init__(self, http: SyncHttpClient) -> None:
20
+ self._http = http
21
+
22
+ def apply(
23
+ self,
24
+ *,
25
+ scope: str,
26
+ reason: str,
27
+ mailbox_id: str | None = None,
28
+ case_reference: str | None = None,
29
+ ) -> dict[str, Any]:
30
+ """Apply a hold. Tier-gated — 403 TIER_LIMIT below Pro."""
31
+ body: dict[str, Any] = {"scope": scope, "reason": reason}
32
+ if mailbox_id is not None:
33
+ body["mailbox_id"] = mailbox_id
34
+ if case_reference is not None:
35
+ body["case_reference"] = case_reference
36
+ return self._http.request("POST", "/v1/legal-holds", body=body)
37
+
38
+ def release(self, hold_id: str, *, release_reason: str) -> dict[str, Any]:
39
+ """Release a hold. Not tier-gated — downgrade-friendly."""
40
+ return self._http.request(
41
+ "POST",
42
+ f"/v1/legal-holds/{quote(hold_id, safe='')}/release",
43
+ body={"release_reason": release_reason},
44
+ )
45
+
46
+ def list( # noqa: A003 — list is the canonical resource verb
47
+ self,
48
+ *,
49
+ include_released: bool | None = None,
50
+ limit: int | None = None,
51
+ ) -> dict[str, Any]:
52
+ """List customer holds. Tier-gated. Silent admin holds are filtered server-side."""
53
+ query: dict[str, Any] = {}
54
+ if include_released:
55
+ query["include_released"] = "true"
56
+ if limit is not None:
57
+ query["limit"] = str(limit)
58
+ return self._http.request("GET", "/v1/legal-holds", query=query)
59
+
60
+ def get(self, hold_id: str) -> dict[str, Any]:
61
+ """Read a single hold. Not tier-gated. 404 for cross-account / silent / unknown."""
62
+ return self._http.request("GET", f"/v1/legal-holds/{quote(hold_id, safe='')}")
63
+
64
+
65
+ class AsyncLegalHolds:
66
+ """Customer-facing legal holds (PR 3 — Pro+ tier-gated MVP, async)."""
67
+
68
+ def __init__(self, http: AsyncHttpClient) -> None:
69
+ self._http = http
70
+
71
+ async def apply(
72
+ self,
73
+ *,
74
+ scope: str,
75
+ reason: str,
76
+ mailbox_id: str | None = None,
77
+ case_reference: str | None = None,
78
+ ) -> dict[str, Any]:
79
+ body: dict[str, Any] = {"scope": scope, "reason": reason}
80
+ if mailbox_id is not None:
81
+ body["mailbox_id"] = mailbox_id
82
+ if case_reference is not None:
83
+ body["case_reference"] = case_reference
84
+ return await self._http.request("POST", "/v1/legal-holds", body=body)
85
+
86
+ async def release(self, hold_id: str, *, release_reason: str) -> dict[str, Any]:
87
+ return await self._http.request(
88
+ "POST",
89
+ f"/v1/legal-holds/{quote(hold_id, safe='')}/release",
90
+ body={"release_reason": release_reason},
91
+ )
92
+
93
+ async def list( # noqa: A003
94
+ self,
95
+ *,
96
+ include_released: bool | None = None,
97
+ limit: int | None = None,
98
+ ) -> dict[str, Any]:
99
+ query: dict[str, Any] = {}
100
+ if include_released:
101
+ query["include_released"] = "true"
102
+ if limit is not None:
103
+ query["limit"] = str(limit)
104
+ return await self._http.request("GET", "/v1/legal-holds", query=query)
105
+
106
+ async def get(self, hold_id: str) -> dict[str, Any]:
107
+ return await self._http.request("GET", f"/v1/legal-holds/{quote(hold_id, safe='')}")