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,768 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, overload
4
+ from urllib.parse import quote
5
+
6
+ from .._http import AsyncHttpClient, SyncHttpClient
7
+ from ..types import (
8
+ AttachmentAccessResponse,
9
+ AttachmentAllowedFileFamilyRequest,
10
+ AttachmentExposureMode,
11
+ HitlMode,
12
+ PiiMode,
13
+ PiiRedactionConfig,
14
+ RecipientPolicyMode,
15
+ ScannerPolicy,
16
+ SenderPolicyMode,
17
+ SubaddressMode,
18
+ )
19
+
20
+
21
+ def _build_attachment_access_body(
22
+ *,
23
+ enable: bool | None = None,
24
+ mode: AttachmentExposureMode | None = None,
25
+ allowed_file_families: list[AttachmentAllowedFileFamilyRequest] | None = None,
26
+ accept_disclaimer_version: str | None = None,
27
+ accept_image_risk_version: str | None = None,
28
+ reauth_token: str | None = None,
29
+ ) -> dict[str, Any]:
30
+ body: dict[str, Any] = {}
31
+ if enable is not None:
32
+ body["enable"] = enable
33
+ if mode is not None:
34
+ body["mode"] = mode
35
+ if allowed_file_families is not None:
36
+ body["allowed_file_families"] = allowed_file_families
37
+ if accept_disclaimer_version is not None:
38
+ body["accept_disclaimer_version"] = accept_disclaimer_version
39
+ if accept_image_risk_version is not None:
40
+ body["accept_image_risk_version"] = accept_image_risk_version
41
+ if reauth_token is not None:
42
+ body["reauth_token"] = reauth_token
43
+ return body
44
+
45
+
46
+ class SyncMailboxAllowlist:
47
+ """Migration 036 — mailbox-scoped recipient allowlist (sync).
48
+
49
+ Reached via `client.mailboxes.allowlist`. Mutations are admin-only; an
50
+ `agent`-role key that calls `add`/`add_bulk`/`delete` gets 403
51
+ `INSUFFICIENT_SCOPE`. See ``docs/allowlist.md`` for the rationale.
52
+ """
53
+
54
+ def __init__(self, http: SyncHttpClient) -> None:
55
+ self._http = http
56
+
57
+ def list(
58
+ self,
59
+ mailbox_id: str,
60
+ *,
61
+ limit: int | None = None,
62
+ cursor: str | None = None,
63
+ all: bool | None = None,
64
+ ) -> dict[str, Any]:
65
+ query: dict[str, Any] = {}
66
+ if limit is not None:
67
+ query["limit"] = str(limit)
68
+ if cursor is not None:
69
+ query["cursor"] = cursor
70
+ if all:
71
+ query["all"] = "true"
72
+ return self._http.request(
73
+ "GET", f"/v1/mailboxes/{mailbox_id}/allowlist", query=query
74
+ )
75
+
76
+ def add(self, mailbox_id: str, *, email: str) -> dict[str, Any]:
77
+ return self._http.request(
78
+ "POST", f"/v1/mailboxes/{mailbox_id}/allowlist", body={"email": email}
79
+ )
80
+
81
+ def add_bulk(self, mailbox_id: str, *, emails: list[str]) -> dict[str, Any]:
82
+ return self._http.request(
83
+ "POST",
84
+ f"/v1/mailboxes/{mailbox_id}/allowlist/bulk",
85
+ body={"emails": emails},
86
+ )
87
+
88
+ def delete(
89
+ self,
90
+ mailbox_id: str,
91
+ email: str,
92
+ *,
93
+ force_empty: bool = False,
94
+ ) -> dict[str, Any]:
95
+ query: dict[str, Any] = {}
96
+ if force_empty:
97
+ query["force_empty"] = "true"
98
+ return self._http.request(
99
+ "DELETE",
100
+ f"/v1/mailboxes/{mailbox_id}/allowlist/{quote(email, safe='')}",
101
+ query=query,
102
+ )
103
+
104
+ def list_blocked_attempts(
105
+ self,
106
+ mailbox_id: str,
107
+ *,
108
+ limit: int | None = None,
109
+ cursor: str | None = None,
110
+ all: bool | None = None,
111
+ aggregate: bool | None = None,
112
+ within_days: int | None = None,
113
+ ) -> dict[str, Any]:
114
+ """Migration 038 — blocked attempts log.
115
+
116
+ Default ``aggregate=True`` returns a top-N grouped view without
117
+ pagination (``next_cursor`` is always ``None``). Pass
118
+ ``aggregate=False`` for raw per-attempt history with tuple-cursor
119
+ pagination. Pass ``within_days=7`` for a "blocked this week" filter
120
+ (1..365).
121
+ """
122
+ query: dict[str, Any] = {}
123
+ if limit is not None:
124
+ query["limit"] = str(limit)
125
+ if cursor is not None:
126
+ query["cursor"] = cursor
127
+ if all:
128
+ query["all"] = "true"
129
+ if aggregate is False:
130
+ query["aggregate"] = "false"
131
+ if within_days is not None:
132
+ query["within_days"] = str(within_days)
133
+ return self._http.request(
134
+ "GET",
135
+ f"/v1/mailboxes/{mailbox_id}/allowlist/blocked-attempts",
136
+ query=query,
137
+ )
138
+
139
+
140
+ class AsyncMailboxAllowlist:
141
+ """Migration 036 — mailbox-scoped recipient allowlist (async)."""
142
+
143
+ def __init__(self, http: AsyncHttpClient) -> None:
144
+ self._http = http
145
+
146
+ async def list(
147
+ self,
148
+ mailbox_id: str,
149
+ *,
150
+ limit: int | None = None,
151
+ cursor: str | None = None,
152
+ all: bool | None = None,
153
+ ) -> dict[str, Any]:
154
+ query: dict[str, Any] = {}
155
+ if limit is not None:
156
+ query["limit"] = str(limit)
157
+ if cursor is not None:
158
+ query["cursor"] = cursor
159
+ if all:
160
+ query["all"] = "true"
161
+ return await self._http.request(
162
+ "GET", f"/v1/mailboxes/{mailbox_id}/allowlist", query=query
163
+ )
164
+
165
+ async def add(self, mailbox_id: str, *, email: str) -> dict[str, Any]:
166
+ return await self._http.request(
167
+ "POST", f"/v1/mailboxes/{mailbox_id}/allowlist", body={"email": email}
168
+ )
169
+
170
+ async def add_bulk(
171
+ self, mailbox_id: str, *, emails: list[str]
172
+ ) -> dict[str, Any]:
173
+ return await self._http.request(
174
+ "POST",
175
+ f"/v1/mailboxes/{mailbox_id}/allowlist/bulk",
176
+ body={"emails": emails},
177
+ )
178
+
179
+ async def delete(
180
+ self,
181
+ mailbox_id: str,
182
+ email: str,
183
+ *,
184
+ force_empty: bool = False,
185
+ ) -> dict[str, Any]:
186
+ query: dict[str, Any] = {}
187
+ if force_empty:
188
+ query["force_empty"] = "true"
189
+ return await self._http.request(
190
+ "DELETE",
191
+ f"/v1/mailboxes/{mailbox_id}/allowlist/{quote(email, safe='')}",
192
+ query=query,
193
+ )
194
+
195
+ async def list_blocked_attempts(
196
+ self,
197
+ mailbox_id: str,
198
+ *,
199
+ limit: int | None = None,
200
+ cursor: str | None = None,
201
+ all: bool | None = None,
202
+ aggregate: bool | None = None,
203
+ within_days: int | None = None,
204
+ ) -> dict[str, Any]:
205
+ """Migration 038 — blocked attempts log (async).
206
+
207
+ Default ``aggregate=True`` returns the grouped top-N view; pass
208
+ ``aggregate=False`` for raw rows. ``within_days=7`` applies a
209
+ recency filter (1..365).
210
+ """
211
+ query: dict[str, Any] = {}
212
+ if limit is not None:
213
+ query["limit"] = str(limit)
214
+ if cursor is not None:
215
+ query["cursor"] = cursor
216
+ if all:
217
+ query["all"] = "true"
218
+ if aggregate is False:
219
+ query["aggregate"] = "false"
220
+ if within_days is not None:
221
+ query["within_days"] = str(within_days)
222
+ return await self._http.request(
223
+ "GET",
224
+ f"/v1/mailboxes/{mailbox_id}/allowlist/blocked-attempts",
225
+ query=query,
226
+ )
227
+
228
+
229
+ class SyncMailboxInboundAllowlist:
230
+ """Migration 047 — mailbox-scoped INBOUND sender allowlist (sync).
231
+
232
+ Reached via ``client.mailboxes.inbound_allowlist``. Mirrors
233
+ ``SyncMailboxAllowlist`` structurally but auth differs: BOTH admin
234
+ and agent keys can mutate (the firewall protects the customer FROM
235
+ senders, not against their own agents).
236
+ """
237
+
238
+ def __init__(self, http: SyncHttpClient) -> None:
239
+ self._http = http
240
+
241
+ def list(
242
+ self,
243
+ mailbox_id: str,
244
+ *,
245
+ limit: int | None = None,
246
+ cursor: str | None = None,
247
+ all: bool | None = None,
248
+ ) -> dict[str, Any]:
249
+ query: dict[str, Any] = {}
250
+ if limit is not None:
251
+ query["limit"] = str(limit)
252
+ if cursor is not None:
253
+ query["cursor"] = cursor
254
+ if all:
255
+ query["all"] = "true"
256
+ return self._http.request(
257
+ "GET", f"/v1/mailboxes/{mailbox_id}/inbound-allowlist", query=query
258
+ )
259
+
260
+ def add(self, mailbox_id: str, *, email: str) -> dict[str, Any]:
261
+ return self._http.request(
262
+ "POST",
263
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist",
264
+ body={"email": email},
265
+ )
266
+
267
+ def add_bulk(self, mailbox_id: str, *, emails: list[str]) -> dict[str, Any]:
268
+ return self._http.request(
269
+ "POST",
270
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist/bulk",
271
+ body={"emails": emails},
272
+ )
273
+
274
+ def delete(
275
+ self,
276
+ mailbox_id: str,
277
+ email: str,
278
+ *,
279
+ force_empty: bool = False,
280
+ ) -> dict[str, Any]:
281
+ query: dict[str, Any] = {}
282
+ if force_empty:
283
+ query["force_empty"] = "true"
284
+ return self._http.request(
285
+ "DELETE",
286
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist/{quote(email, safe='')}",
287
+ query=query,
288
+ )
289
+
290
+ def list_blocked_attempts(
291
+ self,
292
+ mailbox_id: str,
293
+ *,
294
+ limit: int | None = None,
295
+ cursor: str | None = None,
296
+ all: bool | None = None,
297
+ aggregate: bool | None = None,
298
+ within_days: int | None = None,
299
+ ) -> dict[str, Any]:
300
+ """Migration 047 — inbound firewall blocked-attempts log.
301
+
302
+ Mirrors the outbound ``list_blocked_attempts`` shape; the audit
303
+ rows under inspection here are
304
+ ``action='mailbox.inbound_firewall.blocked'``.
305
+ """
306
+ query: dict[str, Any] = {}
307
+ if limit is not None:
308
+ query["limit"] = str(limit)
309
+ if cursor is not None:
310
+ query["cursor"] = cursor
311
+ if all:
312
+ query["all"] = "true"
313
+ if aggregate is False:
314
+ query["aggregate"] = "false"
315
+ if within_days is not None:
316
+ query["within_days"] = str(within_days)
317
+ return self._http.request(
318
+ "GET",
319
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist/blocked-attempts",
320
+ query=query,
321
+ )
322
+
323
+
324
+ class AsyncMailboxInboundAllowlist:
325
+ """Migration 047 — mailbox-scoped INBOUND sender allowlist (async)."""
326
+
327
+ def __init__(self, http: AsyncHttpClient) -> None:
328
+ self._http = http
329
+
330
+ async def list(
331
+ self,
332
+ mailbox_id: str,
333
+ *,
334
+ limit: int | None = None,
335
+ cursor: str | None = None,
336
+ all: bool | None = None,
337
+ ) -> dict[str, Any]:
338
+ query: dict[str, Any] = {}
339
+ if limit is not None:
340
+ query["limit"] = str(limit)
341
+ if cursor is not None:
342
+ query["cursor"] = cursor
343
+ if all:
344
+ query["all"] = "true"
345
+ return await self._http.request(
346
+ "GET", f"/v1/mailboxes/{mailbox_id}/inbound-allowlist", query=query
347
+ )
348
+
349
+ async def add(self, mailbox_id: str, *, email: str) -> dict[str, Any]:
350
+ return await self._http.request(
351
+ "POST",
352
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist",
353
+ body={"email": email},
354
+ )
355
+
356
+ async def add_bulk(
357
+ self, mailbox_id: str, *, emails: list[str]
358
+ ) -> dict[str, Any]:
359
+ return await self._http.request(
360
+ "POST",
361
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist/bulk",
362
+ body={"emails": emails},
363
+ )
364
+
365
+ async def delete(
366
+ self,
367
+ mailbox_id: str,
368
+ email: str,
369
+ *,
370
+ force_empty: bool = False,
371
+ ) -> dict[str, Any]:
372
+ query: dict[str, Any] = {}
373
+ if force_empty:
374
+ query["force_empty"] = "true"
375
+ return await self._http.request(
376
+ "DELETE",
377
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist/{quote(email, safe='')}",
378
+ query=query,
379
+ )
380
+
381
+ async def list_blocked_attempts(
382
+ self,
383
+ mailbox_id: str,
384
+ *,
385
+ limit: int | None = None,
386
+ cursor: str | None = None,
387
+ all: bool | None = None,
388
+ aggregate: bool | None = None,
389
+ within_days: int | None = None,
390
+ ) -> dict[str, Any]:
391
+ query: dict[str, Any] = {}
392
+ if limit is not None:
393
+ query["limit"] = str(limit)
394
+ if cursor is not None:
395
+ query["cursor"] = cursor
396
+ if all:
397
+ query["all"] = "true"
398
+ if aggregate is False:
399
+ query["aggregate"] = "false"
400
+ if within_days is not None:
401
+ query["within_days"] = str(within_days)
402
+ return await self._http.request(
403
+ "GET",
404
+ f"/v1/mailboxes/{mailbox_id}/inbound-allowlist/blocked-attempts",
405
+ query=query,
406
+ )
407
+
408
+
409
+ class SyncMailboxes:
410
+ def __init__(self, http: SyncHttpClient) -> None:
411
+ self._http = http
412
+ self.allowlist = SyncMailboxAllowlist(http)
413
+ self.inbound_allowlist = SyncMailboxInboundAllowlist(http)
414
+
415
+ def create(
416
+ self,
417
+ *,
418
+ name: str,
419
+ self_hosted_imap_folder: str | None = None,
420
+ ) -> dict[str, Any]:
421
+ body: dict[str, Any] = {"name": name}
422
+ if self_hosted_imap_folder is not None:
423
+ body["self_hosted_imap_folder"] = self_hosted_imap_folder
424
+ return self._http.request("POST", "/v1/mailboxes", body=body)
425
+
426
+ def list(self) -> dict[str, Any]:
427
+ return self._http.request("GET", "/v1/mailboxes")
428
+
429
+ def get_mailbox(self, id: str) -> dict[str, Any]:
430
+ """Single-mailbox detail.
431
+
432
+ Returns the same shape as a list entry plus
433
+ ``legacy_wildcard_active`` (true when the row is on the legacy
434
+ attachment-access compat path; the dashboard banner reads this).
435
+
436
+ Auth: session, admin keys, and mailbox-bound agent keys may read.
437
+ Unbound agent keys see 404 NOT_FOUND.
438
+ """
439
+ return self._http.request("GET", f"/v1/mailboxes/{id}")
440
+
441
+ def delete(self, id: str) -> dict[str, Any]:
442
+ return self._http.request("DELETE", f"/v1/mailboxes/{id}")
443
+
444
+ def update(
445
+ self,
446
+ id: str,
447
+ *,
448
+ # Round-1 c3: scanner_policy is now optional on the PATCH wire.
449
+ # Pass None (default) to leave the existing policy untouched.
450
+ scanner_policy: ScannerPolicy | None = None,
451
+ pii_mode: PiiMode | None = None,
452
+ default_subaddress_mode: SubaddressMode | None = None,
453
+ recipient_policy_mode: RecipientPolicyMode | None = None,
454
+ force_empty: bool = False,
455
+ hitl_mode: HitlMode | None = None,
456
+ # Migration 085 — thread-scoped reply bypass toggle. None means "do not
457
+ # include the field in the PATCH".
458
+ allow_thread_replies: bool | None = None,
459
+ # PR 8.1 — per-detector redaction visibility. Pass {} to reset to
460
+ # platform default; pass a partial map (e.g.
461
+ # {"EMAIL_ADDRESS": {"redact": False}}) to disable redaction on
462
+ # selected detectors. Tier-gated Pro+ for any redact:false entry.
463
+ # NOTE: None means "do not include the field in the PATCH"; pass {}
464
+ # to explicitly reset to platform default.
465
+ pii_redaction_config: PiiRedactionConfig | None = None,
466
+ ) -> dict[str, Any]:
467
+ body: dict[str, Any] = {}
468
+ if scanner_policy is not None:
469
+ body["scanner_policy"] = scanner_policy
470
+ if pii_mode is not None:
471
+ body["pii_mode"] = pii_mode
472
+ if default_subaddress_mode is not None:
473
+ body["default_subaddress_mode"] = default_subaddress_mode
474
+ if recipient_policy_mode is not None:
475
+ body["recipient_policy_mode"] = recipient_policy_mode
476
+ if force_empty:
477
+ body["force_empty"] = True
478
+ if hitl_mode is not None:
479
+ body["hitl_mode"] = hitl_mode
480
+ if allow_thread_replies is not None:
481
+ body["allow_thread_replies"] = allow_thread_replies
482
+ if pii_redaction_config is not None:
483
+ body["pii_redaction_config"] = pii_redaction_config
484
+ return self._http.request("PATCH", f"/v1/mailboxes/{id}", body=body)
485
+
486
+ def set_recipient_policy(
487
+ self,
488
+ id: str,
489
+ mode: RecipientPolicyMode,
490
+ *,
491
+ force_empty: bool = False,
492
+ scanner_policy: ScannerPolicy | None = None,
493
+ ) -> dict[str, Any]:
494
+ """Convenience wrapper for the recipient-policy flip.
495
+
496
+ Round-1 c3: scanner_policy is now optional on the PATCH wire.
497
+ Pass it here only if you want to update it alongside the flip;
498
+ otherwise leave None and the server preserves the existing
499
+ value via COALESCE.
500
+ """
501
+ return self.update(
502
+ id,
503
+ scanner_policy=scanner_policy,
504
+ recipient_policy_mode=mode,
505
+ force_empty=force_empty,
506
+ )
507
+
508
+ def set_thread_replies(self, id: str, allow: bool) -> dict[str, Any]:
509
+ """Migration 085 — toggle the thread-scoped reply bypass.
510
+
511
+ Inert in ``blocklist`` mode; in ``allowlist`` mode, ``True`` lets an
512
+ agent reply to / follow up with any visible inbound participant of a
513
+ thread without that address gaining standing send-authority. ``False``
514
+ restores strict allowlist-only sends.
515
+ """
516
+ return self.update(id, allow_thread_replies=allow)
517
+
518
+ def set_sender_policy(
519
+ self,
520
+ id: str,
521
+ mode: SenderPolicyMode,
522
+ *,
523
+ force_empty: bool = False,
524
+ ) -> dict[str, Any]:
525
+ """Migration 047 — flip the inbound firewall mode (per-mailbox).
526
+
527
+ Pass ``force_empty=True`` to flip to ``allowlist`` while the
528
+ inbound allowlist is empty; the server otherwise returns 409
529
+ ``SENDER_POLICY_FLIP_EMPTY_ALLOWLIST``. Auth: admin + agent.
530
+ """
531
+ body: dict[str, Any] = {"mode": mode}
532
+ if force_empty:
533
+ body["force_empty"] = True
534
+ return self._http.request(
535
+ "PATCH", f"/v1/mailboxes/{id}/sender-policy", body=body
536
+ )
537
+
538
+ @overload
539
+ def set_attachment_access(
540
+ self,
541
+ id: str,
542
+ *,
543
+ enable: bool,
544
+ accept_disclaimer_version: str | None = None,
545
+ ) -> AttachmentAccessResponse: ...
546
+
547
+ @overload
548
+ def set_attachment_access(
549
+ self,
550
+ id: str,
551
+ *,
552
+ mode: AttachmentExposureMode,
553
+ allowed_file_families: list[AttachmentAllowedFileFamilyRequest] | None = None,
554
+ accept_disclaimer_version: str | None = None,
555
+ accept_image_risk_version: str | None = None,
556
+ reauth_token: str | None = None,
557
+ ) -> AttachmentAccessResponse: ...
558
+
559
+ def set_attachment_access(
560
+ self,
561
+ id: str,
562
+ *,
563
+ enable: bool | None = None,
564
+ mode: AttachmentExposureMode | None = None,
565
+ allowed_file_families: list[AttachmentAllowedFileFamilyRequest] | None = None,
566
+ accept_disclaimer_version: str | None = None,
567
+ accept_image_risk_version: str | None = None,
568
+ reauth_token: str | None = None,
569
+ ) -> AttachmentAccessResponse:
570
+ """Set the per-mailbox agent attachment-access policy.
571
+
572
+ Approved downloads (``mode='raw_download_selected_types'``) **enablement** —
573
+ including any widening of ``allowed_file_families`` against an
574
+ explicit raw-download row —
575
+ is rejected for Bearer-key callers with
576
+ ``403 REAUTH_REQUIRES_SESSION``. The route requires session-cookie
577
+ auth + a fresh TOTP code (or password if MFA is not enabled)
578
+ supplied as ``reauth_token``. The SDK's HTTP client does not
579
+ carry session cookies, so approved-download enablement must go through the
580
+ dashboard. The SDK still works for:
581
+
582
+ - reading state (see ``get_mailbox``)
583
+ - flipping between ``metadata_only`` and ``derived_content``
584
+ - same-or-narrower writes within an already-explicit raw-download row
585
+ - disabling via the legacy ``enable=False`` shape
586
+
587
+ If ``allowed_file_families`` includes ``"image"``, the server also
588
+ requires ``accept_image_risk_version`` to match
589
+ ``current_image_risk_version`` unless the mailbox already has current
590
+ image-risk acceptance.
591
+
592
+ See docs/inbox-attachment-integration-briefing.md.
593
+ """
594
+ body = _build_attachment_access_body(
595
+ enable=enable,
596
+ mode=mode,
597
+ allowed_file_families=allowed_file_families,
598
+ accept_disclaimer_version=accept_disclaimer_version,
599
+ accept_image_risk_version=accept_image_risk_version,
600
+ reauth_token=reauth_token,
601
+ )
602
+ return self._http.request(
603
+ "POST",
604
+ f"/v1/mailboxes/{id}/attachment-access",
605
+ body=body,
606
+ )
607
+
608
+
609
+ class AsyncMailboxes:
610
+ def __init__(self, http: AsyncHttpClient) -> None:
611
+ self._http = http
612
+ self.allowlist = AsyncMailboxAllowlist(http)
613
+ self.inbound_allowlist = AsyncMailboxInboundAllowlist(http)
614
+
615
+ async def create(
616
+ self,
617
+ *,
618
+ name: str,
619
+ self_hosted_imap_folder: str | None = None,
620
+ ) -> dict[str, Any]:
621
+ body: dict[str, Any] = {"name": name}
622
+ if self_hosted_imap_folder is not None:
623
+ body["self_hosted_imap_folder"] = self_hosted_imap_folder
624
+ return await self._http.request("POST", "/v1/mailboxes", body=body)
625
+
626
+ async def list(self) -> dict[str, Any]:
627
+ return await self._http.request("GET", "/v1/mailboxes")
628
+
629
+ async def get_mailbox(self, id: str) -> dict[str, Any]:
630
+ """Single-mailbox detail (async). See SyncMailboxes.get_mailbox."""
631
+ return await self._http.request("GET", f"/v1/mailboxes/{id}")
632
+
633
+ async def delete(self, id: str) -> dict[str, Any]:
634
+ return await self._http.request("DELETE", f"/v1/mailboxes/{id}")
635
+
636
+ async def update(
637
+ self,
638
+ id: str,
639
+ *,
640
+ # Round-1 c3: scanner_policy is now optional on the PATCH wire.
641
+ # Pass None (default) to leave the existing policy untouched.
642
+ scanner_policy: ScannerPolicy | None = None,
643
+ pii_mode: PiiMode | None = None,
644
+ default_subaddress_mode: SubaddressMode | None = None,
645
+ recipient_policy_mode: RecipientPolicyMode | None = None,
646
+ force_empty: bool = False,
647
+ hitl_mode: HitlMode | None = None,
648
+ # Migration 085 — thread-scoped reply bypass toggle. None = do not
649
+ # include in the PATCH.
650
+ allow_thread_replies: bool | None = None,
651
+ # PR 8.1 — per-detector redaction visibility. Pass {} to reset to
652
+ # platform default; pass a partial map to disable redaction on
653
+ # selected detectors. None = do not include in the PATCH.
654
+ pii_redaction_config: PiiRedactionConfig | None = None,
655
+ ) -> dict[str, Any]:
656
+ body: dict[str, Any] = {}
657
+ if scanner_policy is not None:
658
+ body["scanner_policy"] = scanner_policy
659
+ if pii_mode is not None:
660
+ body["pii_mode"] = pii_mode
661
+ if default_subaddress_mode is not None:
662
+ body["default_subaddress_mode"] = default_subaddress_mode
663
+ if recipient_policy_mode is not None:
664
+ body["recipient_policy_mode"] = recipient_policy_mode
665
+ if force_empty:
666
+ body["force_empty"] = True
667
+ if hitl_mode is not None:
668
+ body["hitl_mode"] = hitl_mode
669
+ if allow_thread_replies is not None:
670
+ body["allow_thread_replies"] = allow_thread_replies
671
+ if pii_redaction_config is not None:
672
+ body["pii_redaction_config"] = pii_redaction_config
673
+ return await self._http.request("PATCH", f"/v1/mailboxes/{id}", body=body)
674
+
675
+ async def set_recipient_policy(
676
+ self,
677
+ id: str,
678
+ mode: RecipientPolicyMode,
679
+ *,
680
+ force_empty: bool = False,
681
+ scanner_policy: ScannerPolicy | None = None,
682
+ ) -> dict[str, Any]:
683
+ """Convenience wrapper for the recipient-policy flip (async).
684
+
685
+ Round-1 c3: scanner_policy is now optional on the PATCH wire.
686
+ Pass it here only if you want to update it alongside the flip;
687
+ otherwise leave None and the server preserves the existing
688
+ value via COALESCE. (Earlier draft of this wrapper sent {}
689
+ as a passthrough, which under the relaxed schema would have
690
+ been a real scanner-policy update overwriting the existing
691
+ value — round-2 audit caught the async-only regression.)
692
+ """
693
+ return await self.update(
694
+ id,
695
+ scanner_policy=scanner_policy,
696
+ recipient_policy_mode=mode,
697
+ force_empty=force_empty,
698
+ )
699
+
700
+ async def set_thread_replies(self, id: str, allow: bool) -> dict[str, Any]:
701
+ """Migration 085 — toggle the thread-scoped reply bypass (async).
702
+
703
+ Inert in ``blocklist`` mode; in ``allowlist`` mode, ``True`` lets an
704
+ agent reply to / follow up with any visible inbound participant of a
705
+ thread without that address gaining standing send-authority.
706
+ """
707
+ return await self.update(id, allow_thread_replies=allow)
708
+
709
+ async def set_sender_policy(
710
+ self,
711
+ id: str,
712
+ mode: SenderPolicyMode,
713
+ *,
714
+ force_empty: bool = False,
715
+ ) -> dict[str, Any]:
716
+ """Migration 047 — flip the inbound firewall mode (async)."""
717
+ body: dict[str, Any] = {"mode": mode}
718
+ if force_empty:
719
+ body["force_empty"] = True
720
+ return await self._http.request(
721
+ "PATCH", f"/v1/mailboxes/{id}/sender-policy", body=body
722
+ )
723
+
724
+ @overload
725
+ async def set_attachment_access(
726
+ self,
727
+ id: str,
728
+ *,
729
+ enable: bool,
730
+ accept_disclaimer_version: str | None = None,
731
+ ) -> AttachmentAccessResponse: ...
732
+
733
+ @overload
734
+ async def set_attachment_access(
735
+ self,
736
+ id: str,
737
+ *,
738
+ mode: AttachmentExposureMode,
739
+ allowed_file_families: list[AttachmentAllowedFileFamilyRequest] | None = None,
740
+ accept_disclaimer_version: str | None = None,
741
+ accept_image_risk_version: str | None = None,
742
+ reauth_token: str | None = None,
743
+ ) -> AttachmentAccessResponse: ...
744
+
745
+ async def set_attachment_access(
746
+ self,
747
+ id: str,
748
+ *,
749
+ enable: bool | None = None,
750
+ mode: AttachmentExposureMode | None = None,
751
+ allowed_file_families: list[AttachmentAllowedFileFamilyRequest] | None = None,
752
+ accept_disclaimer_version: str | None = None,
753
+ accept_image_risk_version: str | None = None,
754
+ reauth_token: str | None = None,
755
+ ) -> AttachmentAccessResponse:
756
+ body = _build_attachment_access_body(
757
+ enable=enable,
758
+ mode=mode,
759
+ allowed_file_families=allowed_file_families,
760
+ accept_disclaimer_version=accept_disclaimer_version,
761
+ accept_image_risk_version=accept_image_risk_version,
762
+ reauth_token=reauth_token,
763
+ )
764
+ return await self._http.request(
765
+ "POST",
766
+ f"/v1/mailboxes/{id}/attachment-access",
767
+ body=body,
768
+ )