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,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
+ )