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,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Iterator
|
|
4
|
+
|
|
5
|
+
from .._http import AsyncHttpClient, SyncHttpClient
|
|
6
|
+
from .._pagination import async_auto_paginate, sync_auto_paginate
|
|
7
|
+
from ..types import Page
|
|
8
|
+
|
|
9
|
+
DEFAULT_LIMIT = 50
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SyncMessages:
|
|
13
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
14
|
+
self._http = http
|
|
15
|
+
|
|
16
|
+
def send(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
body: str,
|
|
20
|
+
from_mailbox: str | None = None,
|
|
21
|
+
to: str | None = None,
|
|
22
|
+
subject: str | None = None,
|
|
23
|
+
html: str | None = None,
|
|
24
|
+
thread_id: str | None = None,
|
|
25
|
+
subaddress_instance_id: str | None = None,
|
|
26
|
+
subaddress_mode: str | None = None,
|
|
27
|
+
attachment_ids: list[str] | None = None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Send an outbound message.
|
|
30
|
+
|
|
31
|
+
Two modes (migration 085):
|
|
32
|
+
* Fresh send — pass ``from_mailbox``, ``to``, ``subject``, ``body``.
|
|
33
|
+
* Thread continuation — pass ``thread_id`` (a thread ID or root
|
|
34
|
+
message UUID) + ``body``. ``from_mailbox``/``subject`` are derived
|
|
35
|
+
from the thread; ``to`` becomes an optional participant selector
|
|
36
|
+
(required only when the thread has >1 inbound participant). A reply
|
|
37
|
+
to a thread participant is admitted past an ``allowlist`` mailbox
|
|
38
|
+
via the thread-scoped bypass.
|
|
39
|
+
|
|
40
|
+
Sandbox accounts are subject to a 250-cumulative-send trial
|
|
41
|
+
budget. Once exhausted the API returns 403 with
|
|
42
|
+
``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
|
|
43
|
+
payload carrying ``feature='sandbox_cumulative_send_cap'``,
|
|
44
|
+
``current_count``, ``max_allowed``. The cap fires from BOTH the
|
|
45
|
+
immediate-send path and the scheduled-send dispatcher (via
|
|
46
|
+
drafts) — the same code surfaces via
|
|
47
|
+
``message.dispatch_failed.reason_code = 'SANDBOX_TRIAL_BUDGET_EXHAUSTED'``
|
|
48
|
+
for scheduled dispatches. Sandbox sends additionally have a
|
|
49
|
+
watermark footer appended to text + HTML bodies.
|
|
50
|
+
"""
|
|
51
|
+
payload: dict[str, Any] = {"body": body}
|
|
52
|
+
if from_mailbox is not None:
|
|
53
|
+
payload["from_mailbox"] = from_mailbox
|
|
54
|
+
if to is not None:
|
|
55
|
+
payload["to"] = to
|
|
56
|
+
if subject is not None:
|
|
57
|
+
payload["subject"] = subject
|
|
58
|
+
if html is not None:
|
|
59
|
+
payload["html"] = html
|
|
60
|
+
if thread_id is not None:
|
|
61
|
+
payload["thread_id"] = thread_id
|
|
62
|
+
if subaddress_instance_id is not None:
|
|
63
|
+
payload["subaddress_instance_id"] = subaddress_instance_id
|
|
64
|
+
if subaddress_mode is not None:
|
|
65
|
+
payload["subaddress_mode"] = subaddress_mode
|
|
66
|
+
if attachment_ids is not None:
|
|
67
|
+
payload["attachment_ids"] = attachment_ids
|
|
68
|
+
return self._http.request("POST", "/v1/messages/send", body=payload)
|
|
69
|
+
|
|
70
|
+
def list(
|
|
71
|
+
self,
|
|
72
|
+
mailbox_id: str,
|
|
73
|
+
*,
|
|
74
|
+
limit: int = DEFAULT_LIMIT,
|
|
75
|
+
before: str | None = None,
|
|
76
|
+
unread: bool | None = None,
|
|
77
|
+
status: str | None = None,
|
|
78
|
+
direction: str | None = None,
|
|
79
|
+
sender: str | None = None,
|
|
80
|
+
since: str | None = None,
|
|
81
|
+
until: str | None = None,
|
|
82
|
+
search: str | None = None,
|
|
83
|
+
view: str | None = None,
|
|
84
|
+
auto_paginate: bool = False,
|
|
85
|
+
) -> Union[Page, Iterator[dict[str, Any]]]:
|
|
86
|
+
"""List messages in a mailbox.
|
|
87
|
+
|
|
88
|
+
``search`` does a substring search over subject + body. It must be at
|
|
89
|
+
least 3 characters (after Unicode NFKC normalization + trim) — the
|
|
90
|
+
server's blind-trigram index has no shorter form. A 1-2 character
|
|
91
|
+
``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
|
|
92
|
+
(``details.min_search_length=3``).
|
|
93
|
+
"""
|
|
94
|
+
query: dict[str, str | None] = {
|
|
95
|
+
"limit": str(limit),
|
|
96
|
+
"before": before,
|
|
97
|
+
"unread": str(unread).lower() if unread is not None else None,
|
|
98
|
+
"status": status,
|
|
99
|
+
"direction": direction,
|
|
100
|
+
"sender": sender,
|
|
101
|
+
"since": since,
|
|
102
|
+
"until": until,
|
|
103
|
+
"search": search,
|
|
104
|
+
"view": view,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def fetch_page(cursor: str | None) -> Page:
|
|
108
|
+
q = {**query, "before": cursor or query["before"]}
|
|
109
|
+
res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/messages", query=q)
|
|
110
|
+
msgs = res.get("messages", [])
|
|
111
|
+
next_cursor = msgs[-1]["id"] if len(msgs) == limit else None
|
|
112
|
+
return Page(data=msgs, has_more=len(msgs) == limit, cursor=next_cursor)
|
|
113
|
+
|
|
114
|
+
if auto_paginate:
|
|
115
|
+
return sync_auto_paginate(fetch_page)
|
|
116
|
+
return fetch_page(None)
|
|
117
|
+
|
|
118
|
+
def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
|
|
119
|
+
query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
|
|
120
|
+
return self._http.request("GET", f"/v1/messages/{id}", query=query)
|
|
121
|
+
|
|
122
|
+
def reply(
|
|
123
|
+
self,
|
|
124
|
+
message_id: str,
|
|
125
|
+
*,
|
|
126
|
+
body: str,
|
|
127
|
+
html: str | None = None,
|
|
128
|
+
subaddress_instance_id: str | None = None,
|
|
129
|
+
subaddress_mode: str | None = None,
|
|
130
|
+
attachment_ids: list[str] | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
payload: dict[str, Any] = {"body": body}
|
|
133
|
+
if html is not None:
|
|
134
|
+
payload["html"] = html
|
|
135
|
+
if subaddress_instance_id is not None:
|
|
136
|
+
payload["subaddress_instance_id"] = subaddress_instance_id
|
|
137
|
+
if subaddress_mode is not None:
|
|
138
|
+
payload["subaddress_mode"] = subaddress_mode
|
|
139
|
+
if attachment_ids is not None:
|
|
140
|
+
payload["attachment_ids"] = attachment_ids
|
|
141
|
+
return self._http.request("POST", f"/v1/messages/{message_id}/reply", body=payload)
|
|
142
|
+
|
|
143
|
+
def wait(self, mailbox_id: str, *, timeout: int = 30, since: str | None = None) -> dict[str, Any]:
|
|
144
|
+
# RL-UAT-018 — `since` is the monitoring cursor anchor (strict `>`
|
|
145
|
+
# server-side); included only when set so the legacy query is unchanged.
|
|
146
|
+
query: dict[str, str] = {"timeout": str(timeout)}
|
|
147
|
+
if since is not None:
|
|
148
|
+
query["since"] = since
|
|
149
|
+
return self._http.request(
|
|
150
|
+
"GET", f"/v1/mailboxes/{mailbox_id}/messages/wait",
|
|
151
|
+
query=query,
|
|
152
|
+
timeout=float(timeout + 5),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def release(self, message_id: str, *, reason: str | None = None) -> dict[str, Any]:
|
|
156
|
+
# G7 — optional reason persisted to audit_log.detail_json.reason.
|
|
157
|
+
body: dict[str, Any] = {}
|
|
158
|
+
if reason is not None:
|
|
159
|
+
body["reason"] = reason
|
|
160
|
+
return self._http.request("POST", f"/v1/messages/{message_id}/release", body=body)
|
|
161
|
+
|
|
162
|
+
def block(self, message_id: str, *, reason: str | None = None) -> dict[str, Any]:
|
|
163
|
+
# G7 — optional reason persisted to audit_log.detail_json.reason.
|
|
164
|
+
body: dict[str, Any] = {}
|
|
165
|
+
if reason is not None:
|
|
166
|
+
body["reason"] = reason
|
|
167
|
+
return self._http.request("POST", f"/v1/messages/{message_id}/block", body=body)
|
|
168
|
+
|
|
169
|
+
def firewall_release(self, message_id: str) -> dict[str, Any]:
|
|
170
|
+
"""Migration 047 — release a state='firewall_blocked' message.
|
|
171
|
+
|
|
172
|
+
Atomic: state moves to 'scanning' and the worker scanner job is
|
|
173
|
+
enqueued in the same transaction. Returns 202 immediately;
|
|
174
|
+
actual scanner verdict observed via message detail / webhook.
|
|
175
|
+
"""
|
|
176
|
+
return self._http.request(
|
|
177
|
+
"POST", f"/v1/messages/{message_id}/firewall-release", body={}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def mark_read(self, message_id: str) -> dict[str, Any]:
|
|
181
|
+
"""S7a — mark a single message as read.
|
|
182
|
+
|
|
183
|
+
Eligible only for inbound, visible (state NOT IN ('deleted',
|
|
184
|
+
'firewall_blocked')) rows. Outbound / deleted / firewall_blocked
|
|
185
|
+
rows return 200 no-op with the row's existing read_at. Idempotent:
|
|
186
|
+
a second eligible call returns the same read_at.
|
|
187
|
+
|
|
188
|
+
Auth: admin + agent (mailbox-scope checked) OR session cookie.
|
|
189
|
+
|
|
190
|
+
Note: as of S7a, GET /v1/messages/:id is side-effect-free and no
|
|
191
|
+
longer auto-stamps read_at. Customers must call this method
|
|
192
|
+
explicitly to advance read state.
|
|
193
|
+
"""
|
|
194
|
+
return self._http.request(
|
|
195
|
+
"POST", f"/v1/messages/{message_id}/read", body={}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def approve_review(
|
|
199
|
+
self, message_id: str, *, reason: str | None = None
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""PR 6 — approve a state='pending_review' message and dispatch.
|
|
202
|
+
|
|
203
|
+
Mirrors the /release endpoint contract: the wire status reports
|
|
204
|
+
the Mailgun dispatch outcome (release-style; 'sent' or 'blocked').
|
|
205
|
+
Audit log + webhook fire regardless of dispatch outcome.
|
|
206
|
+
|
|
207
|
+
Auth: admin API key OR session cookie. Agent keys 403 (HITL
|
|
208
|
+
approval is privileged per master spec §5.4 #7).
|
|
209
|
+
|
|
210
|
+
Optional reason (max 500 chars, server-trimmed) is persisted to
|
|
211
|
+
messages.review_reason and included in the audit_log + webhook.
|
|
212
|
+
"""
|
|
213
|
+
body: dict[str, Any] = {}
|
|
214
|
+
if reason is not None:
|
|
215
|
+
body["reason"] = reason
|
|
216
|
+
return self._http.request(
|
|
217
|
+
"POST", f"/v1/messages/{message_id}/approve", body=body
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def deny_review(
|
|
221
|
+
self, message_id: str, *, reason: str | None = None
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
"""PR 6 — deny a state='pending_review' message; terminal block.
|
|
224
|
+
|
|
225
|
+
No dispatch. Response is { status: 'denied', message_id }.
|
|
226
|
+
Audit log + webhook fire on commit.
|
|
227
|
+
"""
|
|
228
|
+
body: dict[str, Any] = {}
|
|
229
|
+
if reason is not None:
|
|
230
|
+
body["reason"] = reason
|
|
231
|
+
return self._http.request(
|
|
232
|
+
"POST", f"/v1/messages/{message_id}/deny", body=body
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class AsyncMessages:
|
|
237
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
238
|
+
self._http = http
|
|
239
|
+
|
|
240
|
+
async def send(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
body: str,
|
|
244
|
+
from_mailbox: str | None = None,
|
|
245
|
+
to: str | None = None,
|
|
246
|
+
subject: str | None = None,
|
|
247
|
+
html: str | None = None,
|
|
248
|
+
thread_id: str | None = None,
|
|
249
|
+
subaddress_instance_id: str | None = None,
|
|
250
|
+
subaddress_mode: str | None = None,
|
|
251
|
+
attachment_ids: list[str] | None = None,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""Send an outbound message.
|
|
254
|
+
|
|
255
|
+
Two modes (migration 085):
|
|
256
|
+
* Fresh send — pass ``from_mailbox``, ``to``, ``subject``, ``body``.
|
|
257
|
+
* Thread continuation — pass ``thread_id`` + ``body``;
|
|
258
|
+
``from_mailbox``/``subject`` are derived and ``to`` becomes an
|
|
259
|
+
optional participant selector.
|
|
260
|
+
|
|
261
|
+
Sandbox accounts are subject to a 250-cumulative-send trial
|
|
262
|
+
budget. Once exhausted the API returns 403 with
|
|
263
|
+
``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
|
|
264
|
+
payload carrying ``feature='sandbox_cumulative_send_cap'``,
|
|
265
|
+
``current_count``, ``max_allowed``. Sandbox sends additionally
|
|
266
|
+
have a watermark footer appended to text + HTML bodies.
|
|
267
|
+
"""
|
|
268
|
+
payload: dict[str, Any] = {"body": body}
|
|
269
|
+
if from_mailbox is not None:
|
|
270
|
+
payload["from_mailbox"] = from_mailbox
|
|
271
|
+
if to is not None:
|
|
272
|
+
payload["to"] = to
|
|
273
|
+
if subject is not None:
|
|
274
|
+
payload["subject"] = subject
|
|
275
|
+
if html is not None:
|
|
276
|
+
payload["html"] = html
|
|
277
|
+
if thread_id is not None:
|
|
278
|
+
payload["thread_id"] = thread_id
|
|
279
|
+
if subaddress_instance_id is not None:
|
|
280
|
+
payload["subaddress_instance_id"] = subaddress_instance_id
|
|
281
|
+
if subaddress_mode is not None:
|
|
282
|
+
payload["subaddress_mode"] = subaddress_mode
|
|
283
|
+
if attachment_ids is not None:
|
|
284
|
+
payload["attachment_ids"] = attachment_ids
|
|
285
|
+
return await self._http.request("POST", "/v1/messages/send", body=payload)
|
|
286
|
+
|
|
287
|
+
async def list(
|
|
288
|
+
self,
|
|
289
|
+
mailbox_id: str,
|
|
290
|
+
*,
|
|
291
|
+
limit: int = DEFAULT_LIMIT,
|
|
292
|
+
before: str | None = None,
|
|
293
|
+
unread: bool | None = None,
|
|
294
|
+
status: str | None = None,
|
|
295
|
+
direction: str | None = None,
|
|
296
|
+
sender: str | None = None,
|
|
297
|
+
since: str | None = None,
|
|
298
|
+
until: str | None = None,
|
|
299
|
+
search: str | None = None,
|
|
300
|
+
view: str | None = None,
|
|
301
|
+
auto_paginate: bool = False,
|
|
302
|
+
) -> Page | AsyncIterator[dict[str, Any]]:
|
|
303
|
+
"""List messages in a mailbox.
|
|
304
|
+
|
|
305
|
+
``search`` does a substring search over subject + body. It must be at
|
|
306
|
+
least 3 characters (after Unicode NFKC normalization + trim) — the
|
|
307
|
+
server's blind-trigram index has no shorter form. A 1-2 character
|
|
308
|
+
``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
|
|
309
|
+
(``details.min_search_length=3``).
|
|
310
|
+
"""
|
|
311
|
+
query: dict[str, str | None] = {
|
|
312
|
+
"limit": str(limit),
|
|
313
|
+
"before": before,
|
|
314
|
+
"unread": str(unread).lower() if unread is not None else None,
|
|
315
|
+
"status": status,
|
|
316
|
+
"direction": direction,
|
|
317
|
+
"sender": sender,
|
|
318
|
+
"since": since,
|
|
319
|
+
"until": until,
|
|
320
|
+
"search": search,
|
|
321
|
+
"view": view,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async def fetch_page(cursor: str | None) -> Page:
|
|
325
|
+
q = {**query, "before": cursor or query["before"]}
|
|
326
|
+
res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/messages", query=q)
|
|
327
|
+
msgs = res.get("messages", [])
|
|
328
|
+
next_cursor = msgs[-1]["id"] if len(msgs) == limit else None
|
|
329
|
+
return Page(data=msgs, has_more=len(msgs) == limit, cursor=next_cursor)
|
|
330
|
+
|
|
331
|
+
if auto_paginate:
|
|
332
|
+
return async_auto_paginate(fetch_page)
|
|
333
|
+
|
|
334
|
+
return await fetch_page(None)
|
|
335
|
+
|
|
336
|
+
async def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
|
|
337
|
+
query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
|
|
338
|
+
return await self._http.request("GET", f"/v1/messages/{id}", query=query)
|
|
339
|
+
|
|
340
|
+
async def reply(
|
|
341
|
+
self,
|
|
342
|
+
message_id: str,
|
|
343
|
+
*,
|
|
344
|
+
body: str,
|
|
345
|
+
html: str | None = None,
|
|
346
|
+
subaddress_instance_id: str | None = None,
|
|
347
|
+
subaddress_mode: str | None = None,
|
|
348
|
+
attachment_ids: list[str] | None = None,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
payload: dict[str, Any] = {"body": body}
|
|
351
|
+
if html is not None:
|
|
352
|
+
payload["html"] = html
|
|
353
|
+
if subaddress_instance_id is not None:
|
|
354
|
+
payload["subaddress_instance_id"] = subaddress_instance_id
|
|
355
|
+
if subaddress_mode is not None:
|
|
356
|
+
payload["subaddress_mode"] = subaddress_mode
|
|
357
|
+
if attachment_ids is not None:
|
|
358
|
+
payload["attachment_ids"] = attachment_ids
|
|
359
|
+
return await self._http.request("POST", f"/v1/messages/{message_id}/reply", body=payload)
|
|
360
|
+
|
|
361
|
+
async def wait(self, mailbox_id: str, *, timeout: int = 30, since: str | None = None) -> dict[str, Any]:
|
|
362
|
+
# RL-UAT-018 — `since` is the monitoring cursor anchor (strict `>`
|
|
363
|
+
# server-side); included only when set so the legacy query is unchanged.
|
|
364
|
+
query: dict[str, str] = {"timeout": str(timeout)}
|
|
365
|
+
if since is not None:
|
|
366
|
+
query["since"] = since
|
|
367
|
+
return await self._http.request(
|
|
368
|
+
"GET", f"/v1/mailboxes/{mailbox_id}/messages/wait",
|
|
369
|
+
query=query,
|
|
370
|
+
timeout=float(timeout + 5),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
async def release(self, message_id: str, *, reason: str | None = None) -> dict[str, Any]:
|
|
374
|
+
# G7 — optional reason persisted to audit_log.detail_json.reason.
|
|
375
|
+
body: dict[str, Any] = {}
|
|
376
|
+
if reason is not None:
|
|
377
|
+
body["reason"] = reason
|
|
378
|
+
return await self._http.request("POST", f"/v1/messages/{message_id}/release", body=body)
|
|
379
|
+
|
|
380
|
+
async def firewall_release(self, message_id: str) -> dict[str, Any]:
|
|
381
|
+
"""Migration 047 — release a state='firewall_blocked' message (async)."""
|
|
382
|
+
return await self._http.request(
|
|
383
|
+
"POST", f"/v1/messages/{message_id}/firewall-release", body={}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
async def block(self, message_id: str, *, reason: str | None = None) -> dict[str, Any]:
|
|
387
|
+
# G7 — optional reason persisted to audit_log.detail_json.reason.
|
|
388
|
+
body: dict[str, Any] = {}
|
|
389
|
+
if reason is not None:
|
|
390
|
+
body["reason"] = reason
|
|
391
|
+
return await self._http.request("POST", f"/v1/messages/{message_id}/block", body=body)
|
|
392
|
+
|
|
393
|
+
async def mark_read(self, message_id: str) -> dict[str, Any]:
|
|
394
|
+
"""S7a — mark a single message as read (async). See SyncMessages.mark_read."""
|
|
395
|
+
return await self._http.request(
|
|
396
|
+
"POST", f"/v1/messages/{message_id}/read", body={}
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
async def approve_review(
|
|
400
|
+
self, message_id: str, *, reason: str | None = None
|
|
401
|
+
) -> dict[str, Any]:
|
|
402
|
+
"""PR 6 — approve a pending_review message and dispatch (async).
|
|
403
|
+
|
|
404
|
+
See sync SyncMessages.approve_review for the full contract.
|
|
405
|
+
"""
|
|
406
|
+
body: dict[str, Any] = {}
|
|
407
|
+
if reason is not None:
|
|
408
|
+
body["reason"] = reason
|
|
409
|
+
return await self._http.request(
|
|
410
|
+
"POST", f"/v1/messages/{message_id}/approve", body=body
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
async def deny_review(
|
|
414
|
+
self, message_id: str, *, reason: str | None = None
|
|
415
|
+
) -> dict[str, Any]:
|
|
416
|
+
"""PR 6 — deny a pending_review message; terminal block (async).
|
|
417
|
+
|
|
418
|
+
See sync SyncMessages.deny_review for the full contract.
|
|
419
|
+
"""
|
|
420
|
+
body: dict[str, Any] = {}
|
|
421
|
+
if reason is not None:
|
|
422
|
+
body["reason"] = reason
|
|
423
|
+
return await self._http.request(
|
|
424
|
+
"POST", f"/v1/messages/{message_id}/deny", body=body
|
|
425
|
+
)
|
|
@@ -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 SyncRecipients:
|
|
9
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
10
|
+
self._http = http
|
|
11
|
+
|
|
12
|
+
def create(self, *, email: str) -> dict[str, Any]:
|
|
13
|
+
"""Add a verified recipient (sandbox tier only).
|
|
14
|
+
|
|
15
|
+
Sandbox accounts are subject to a verified-recipient ramp: 5 in
|
|
16
|
+
the first 72 hours, 25 thereafter. When the cap is hit, the API
|
|
17
|
+
returns 403 with ``code='TIER_LIMIT'`` and a ``details`` payload
|
|
18
|
+
carrying ``feature='sandbox_recipient_confirmation_required'``,
|
|
19
|
+
``current_count``, ``max_allowed``, and (in the pre-ramp window)
|
|
20
|
+
``ramp_until`` (ISO timestamp). Catch with :class:`ForbiddenError`
|
|
21
|
+
and inspect ``err.details`` for the actionable fields.
|
|
22
|
+
"""
|
|
23
|
+
return self._http.request("POST", "/v1/recipients", body={"email": email})
|
|
24
|
+
|
|
25
|
+
def list(self) -> dict[str, Any]:
|
|
26
|
+
return self._http.request("GET", "/v1/recipients")
|
|
27
|
+
|
|
28
|
+
def delete(self, id: str) -> None:
|
|
29
|
+
self._http.request("DELETE", f"/v1/recipients/{id}")
|
|
30
|
+
|
|
31
|
+
def resend(self, id: str) -> dict[str, Any]:
|
|
32
|
+
return self._http.request("POST", f"/v1/recipients/{id}/resend")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncRecipients:
|
|
36
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
37
|
+
self._http = http
|
|
38
|
+
|
|
39
|
+
async def create(self, *, email: str) -> dict[str, Any]:
|
|
40
|
+
"""Add a verified recipient (sandbox tier only).
|
|
41
|
+
|
|
42
|
+
Sandbox accounts are subject to a verified-recipient ramp: 5 in
|
|
43
|
+
the first 72 hours, 25 thereafter. When the cap is hit, the API
|
|
44
|
+
returns 403 with ``code='TIER_LIMIT'`` and a ``details`` payload
|
|
45
|
+
carrying ``feature='sandbox_recipient_confirmation_required'``,
|
|
46
|
+
``current_count``, ``max_allowed``, and (in the pre-ramp window)
|
|
47
|
+
``ramp_until`` (ISO timestamp). Catch with :class:`ForbiddenError`
|
|
48
|
+
and inspect ``err.details`` for the actionable fields.
|
|
49
|
+
"""
|
|
50
|
+
return await self._http.request("POST", "/v1/recipients", body={"email": email})
|
|
51
|
+
|
|
52
|
+
async def list(self) -> dict[str, Any]:
|
|
53
|
+
return await self._http.request("GET", "/v1/recipients")
|
|
54
|
+
|
|
55
|
+
async def delete(self, id: str) -> None:
|
|
56
|
+
await self._http.request("DELETE", f"/v1/recipients/{id}")
|
|
57
|
+
|
|
58
|
+
async def resend(self, id: str) -> dict[str, Any]:
|
|
59
|
+
return await self._http.request("POST", f"/v1/recipients/{id}/resend")
|
|
@@ -0,0 +1,84 @@
|
|
|
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 SyncSuppressions:
|
|
10
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
11
|
+
self._http = http
|
|
12
|
+
|
|
13
|
+
def list(
|
|
14
|
+
self,
|
|
15
|
+
*,
|
|
16
|
+
reason: str | None = None,
|
|
17
|
+
limit: int | None = None,
|
|
18
|
+
cursor: str | None = None,
|
|
19
|
+
all: bool | None = None, # noqa: A002 — match REST query name
|
|
20
|
+
) -> dict[str, Any]:
|
|
21
|
+
query: dict[str, Any] = {}
|
|
22
|
+
if reason is not None:
|
|
23
|
+
query["reason"] = reason
|
|
24
|
+
if limit is not None:
|
|
25
|
+
query["limit"] = str(limit)
|
|
26
|
+
if cursor is not None:
|
|
27
|
+
query["cursor"] = cursor
|
|
28
|
+
if all:
|
|
29
|
+
query["all"] = "true"
|
|
30
|
+
return self._http.request("GET", "/v1/suppressions", query=query)
|
|
31
|
+
|
|
32
|
+
def add(self, *, email: str) -> dict[str, Any]:
|
|
33
|
+
"""
|
|
34
|
+
Add an address to the do-not-contact list. Server forces
|
|
35
|
+
``reason='manual'`` + ``source='customer'``. Idempotent — a repeat
|
|
36
|
+
call returns ``already_existed: true`` with no second audit row and
|
|
37
|
+
no second webhook delivery. Agent + admin keys both accepted.
|
|
38
|
+
"""
|
|
39
|
+
return self._http.request("POST", "/v1/suppressions", body={"email": email})
|
|
40
|
+
|
|
41
|
+
def add_bulk(self, *, emails: list[str]) -> dict[str, Any]:
|
|
42
|
+
"""
|
|
43
|
+
Add up to 1000 addresses in one request. Returns partial-success
|
|
44
|
+
buckets: ``added``, ``already_existed``, ``invalid``. Server normalizes
|
|
45
|
+
(trim + lowercase) and dedupes (case-variant-aware) before validation.
|
|
46
|
+
Rate-limited by emails added (entry-count), not requests made.
|
|
47
|
+
"""
|
|
48
|
+
return self._http.request("POST", "/v1/suppressions/bulk", body={"emails": emails})
|
|
49
|
+
|
|
50
|
+
def delete(self, email: str) -> dict[str, Any]:
|
|
51
|
+
return self._http.request("DELETE", f"/v1/suppressions/{quote(email, safe='')}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AsyncSuppressions:
|
|
55
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
56
|
+
self._http = http
|
|
57
|
+
|
|
58
|
+
async def list(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
reason: str | None = None,
|
|
62
|
+
limit: int | None = None,
|
|
63
|
+
cursor: str | None = None,
|
|
64
|
+
all: bool | None = None, # noqa: A002
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
query: dict[str, Any] = {}
|
|
67
|
+
if reason is not None:
|
|
68
|
+
query["reason"] = reason
|
|
69
|
+
if limit is not None:
|
|
70
|
+
query["limit"] = str(limit)
|
|
71
|
+
if cursor is not None:
|
|
72
|
+
query["cursor"] = cursor
|
|
73
|
+
if all:
|
|
74
|
+
query["all"] = "true"
|
|
75
|
+
return await self._http.request("GET", "/v1/suppressions", query=query)
|
|
76
|
+
|
|
77
|
+
async def add(self, *, email: str) -> dict[str, Any]:
|
|
78
|
+
return await self._http.request("POST", "/v1/suppressions", body={"email": email})
|
|
79
|
+
|
|
80
|
+
async def add_bulk(self, *, emails: list[str]) -> dict[str, Any]:
|
|
81
|
+
return await self._http.request("POST", "/v1/suppressions/bulk", body={"emails": emails})
|
|
82
|
+
|
|
83
|
+
async def delete(self, email: str) -> dict[str, Any]:
|
|
84
|
+
return await self._http.request("DELETE", f"/v1/suppressions/{quote(email, safe='')}")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Iterator
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from .._http import AsyncHttpClient, SyncHttpClient
|
|
7
|
+
from .._pagination import async_auto_paginate, sync_auto_paginate
|
|
8
|
+
from ..types import Page
|
|
9
|
+
|
|
10
|
+
DEFAULT_LIMIT = 50
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SyncThreads:
|
|
14
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def list(
|
|
18
|
+
self,
|
|
19
|
+
mailbox_id: str,
|
|
20
|
+
*,
|
|
21
|
+
limit: int = DEFAULT_LIMIT,
|
|
22
|
+
before_ts: str | None = None,
|
|
23
|
+
since_ts: str | None = None,
|
|
24
|
+
has_inbound: bool | None = None,
|
|
25
|
+
include_firewall_blocked: bool = False,
|
|
26
|
+
view: str | None = None,
|
|
27
|
+
auto_paginate: bool = False,
|
|
28
|
+
) -> Union[Page, Iterator[dict[str, Any]]]:
|
|
29
|
+
def fetch_page(cursor: str | None) -> Page:
|
|
30
|
+
query: dict[str, str | None] = {
|
|
31
|
+
"limit": str(limit),
|
|
32
|
+
"before_ts": cursor or before_ts,
|
|
33
|
+
"since_ts": since_ts,
|
|
34
|
+
"has_inbound": ("true" if has_inbound else "false") if has_inbound is not None else None,
|
|
35
|
+
"include_firewall_blocked": "true" if include_firewall_blocked else None,
|
|
36
|
+
"view": view,
|
|
37
|
+
}
|
|
38
|
+
res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/threads", query=query)
|
|
39
|
+
threads = res.get("threads", [])
|
|
40
|
+
next_cursor = threads[-1]["last_message_at"] if len(threads) == limit else None
|
|
41
|
+
return Page(data=threads, has_more=len(threads) == limit, cursor=next_cursor)
|
|
42
|
+
|
|
43
|
+
if auto_paginate:
|
|
44
|
+
return sync_auto_paginate(fetch_page)
|
|
45
|
+
return fetch_page(None)
|
|
46
|
+
|
|
47
|
+
def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
|
|
48
|
+
query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
|
|
49
|
+
return self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
|
|
50
|
+
|
|
51
|
+
def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
|
|
52
|
+
"""S7a — bulk-mark every visible inbound unread message as read.
|
|
53
|
+
|
|
54
|
+
mailbox_id accepts a name OR UUID. thread_id is the thread key
|
|
55
|
+
(Message-Id or message UUID for standalone NULL-thread rows).
|
|
56
|
+
|
|
57
|
+
Distinct 404 codes:
|
|
58
|
+
* 'Mailbox not found' — mailbox doesn't exist or wrong account
|
|
59
|
+
* 'Thread not found' — mailbox exists but thread doesn't
|
|
60
|
+
|
|
61
|
+
Returns marked_count of rows newly stamped this call (excludes
|
|
62
|
+
already-read, outbound, deleted, firewall_blocked). A thread with
|
|
63
|
+
all messages already read returns 200 marked_count: 0 (NOT 404).
|
|
64
|
+
"""
|
|
65
|
+
return self._http.request(
|
|
66
|
+
"POST",
|
|
67
|
+
f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/read",
|
|
68
|
+
body={},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AsyncThreads:
|
|
73
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
74
|
+
self._http = http
|
|
75
|
+
|
|
76
|
+
async def list(
|
|
77
|
+
self,
|
|
78
|
+
mailbox_id: str,
|
|
79
|
+
*,
|
|
80
|
+
limit: int = DEFAULT_LIMIT,
|
|
81
|
+
before_ts: str | None = None,
|
|
82
|
+
since_ts: str | None = None,
|
|
83
|
+
has_inbound: bool | None = None,
|
|
84
|
+
include_firewall_blocked: bool = False,
|
|
85
|
+
view: str | None = None,
|
|
86
|
+
auto_paginate: bool = False,
|
|
87
|
+
) -> Page | AsyncIterator[dict[str, Any]]:
|
|
88
|
+
async def fetch_page(cursor: str | None) -> Page:
|
|
89
|
+
query: dict[str, str | None] = {
|
|
90
|
+
"limit": str(limit),
|
|
91
|
+
"before_ts": cursor or before_ts,
|
|
92
|
+
"since_ts": since_ts,
|
|
93
|
+
"has_inbound": ("true" if has_inbound else "false") if has_inbound is not None else None,
|
|
94
|
+
"include_firewall_blocked": "true" if include_firewall_blocked else None,
|
|
95
|
+
"view": view,
|
|
96
|
+
}
|
|
97
|
+
res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/threads", query=query)
|
|
98
|
+
threads = res.get("threads", [])
|
|
99
|
+
next_cursor = threads[-1]["last_message_at"] if len(threads) == limit else None
|
|
100
|
+
return Page(data=threads, has_more=len(threads) == limit, cursor=next_cursor)
|
|
101
|
+
|
|
102
|
+
if auto_paginate:
|
|
103
|
+
return async_auto_paginate(fetch_page)
|
|
104
|
+
|
|
105
|
+
return await fetch_page(None)
|
|
106
|
+
|
|
107
|
+
async def get(self, id: str, *, view: str | None = None, body_format: str | None = None) -> dict[str, Any]:
|
|
108
|
+
query = {k: v for k, v in {"view": view, "body_format": body_format}.items() if v is not None}
|
|
109
|
+
return await self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
|
|
110
|
+
|
|
111
|
+
async def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
|
|
112
|
+
"""S7a — bulk-mark every visible inbound unread message (async). See SyncThreads.mark_read."""
|
|
113
|
+
return await self._http.request(
|
|
114
|
+
"POST",
|
|
115
|
+
f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/read",
|
|
116
|
+
body={},
|
|
117
|
+
)
|