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/__init__.py +143 -0
- replylayer/_client.py +128 -0
- replylayer/_http.py +326 -0
- replylayer/_pagination.py +34 -0
- replylayer/errors.py +152 -0
- replylayer/py.typed +0 -0
- replylayer/resources/__init__.py +0 -0
- replylayer/resources/account.py +36 -0
- replylayer/resources/api_keys.py +59 -0
- replylayer/resources/attachments.py +115 -0
- replylayer/resources/domains.py +79 -0
- replylayer/resources/drafts.py +342 -0
- replylayer/resources/health.py +21 -0
- replylayer/resources/inbound_blocklist.py +93 -0
- replylayer/resources/legal_holds.py +107 -0
- replylayer/resources/mailboxes.py +768 -0
- replylayer/resources/messages.py +425 -0
- replylayer/resources/recipients.py +59 -0
- replylayer/resources/suppressions.py +84 -0
- replylayer/resources/threads.py +117 -0
- replylayer/resources/webhooks.py +175 -0
- replylayer/types.py +1578 -0
- replylayer-0.14.0.dist-info/METADATA +502 -0
- replylayer-0.14.0.dist-info/RECORD +25 -0
- replylayer-0.14.0.dist-info/WHEEL +4 -0
|
@@ -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='')}")
|