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/types.py ADDED
@@ -0,0 +1,1578 @@
1
+ """SDK types — self-contained for PyPI publishing.
2
+
3
+ Derived from packages/sdk/src/types.ts — keep in sync.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, Literal, TypedDict, Union
10
+ # Required/NotRequired land in `typing` proper at 3.11; package targets 3.10
11
+ # (per pyproject.toml requires-python). typing_extensions backports them.
12
+ from typing_extensions import Required, NotRequired
13
+
14
+ # === Enums (Literal unions) ===
15
+
16
+ MessageDirection = Literal["inbound", "outbound"]
17
+ MessageState = Literal[
18
+ "draft", "received", "scanning", "available", "quarantined",
19
+ # Migration 059 / PR 6 — HITL review queue (Pro+); non-terminal
20
+ # (approve→'available', deny→'blocked').
21
+ "pending_review",
22
+ "blocked", "delivered", "bounced", "deleted",
23
+ # Migration 047 — inbound firewall verdict; non-terminal (releasable to scanning).
24
+ "firewall_blocked",
25
+ ]
26
+ MailboxStatus = Literal["active", "paused", "deleted"]
27
+ RecipientStatus = Literal["pending", "confirmed", "expired"]
28
+ SuppressionReason = Literal["hard_bounce", "complaint", "manual", "unsubscribe"]
29
+ PolicyDecision = Literal[
30
+ "allow", "allow_with_warning", "quarantine",
31
+ "require_human_approval", "block",
32
+ ]
33
+ AttachmentPolicyAction = Literal["deliver", "metadata_only", "quarantine", "block"]
34
+ AvVerdict = Literal["clean", "infected", "error", "skipped"]
35
+ AttachmentExposureMode = Literal["metadata_only", "derived_content", "raw_download_selected_types"]
36
+ AttachmentAllowedFileFamily = Literal["pdf", "text", "csv", "image", "*"]
37
+ AttachmentAllowedFileFamilyRequest = Literal["pdf", "text", "csv", "image"]
38
+ AttachmentDerivativeVariant = Literal["preview_text"]
39
+ AttachmentDerivativeStatus = Literal["pending", "ready", "blocked", "failed"]
40
+ AttachmentPreviewKind = Literal["text"]
41
+ AttachmentRawRetentionStatus = Literal[
42
+ "not_retained",
43
+ "temporary_processing",
44
+ "retained_for_raw_download",
45
+ ]
46
+ WebhookEventType = Literal[
47
+ "message.received", "message.delivered", "message.bounced",
48
+ "message.quarantined", "message.scanner_blocked",
49
+ "attachment.preview.queued", "attachment.preview.ready",
50
+ "attachment.preview.blocked", "attachment.preview.failed",
51
+ # Migration 051 — vocabulary alignment: was suppression.* / allowlist.*;
52
+ # renamed to recipient_blocklist.* / recipient_allowlist.* to mirror
53
+ # the inbound side. recipient_blocklist.complaint_override is new.
54
+ "recipient_blocklist.added", "recipient_blocklist.removed",
55
+ "recipient_blocklist.complaint_override",
56
+ "recipient_allowlist.added", "recipient_allowlist.removed",
57
+ "mailbox.recipient_policy_changed",
58
+ "recipient_allowlist.blocked_attempt",
59
+ # Migration 040 — scheduled-send lifecycle events.
60
+ "message.scheduled", "message.rescheduled",
61
+ "message.schedule_cancelled", "message.dispatch_failed",
62
+ # Migration 047 — inbound firewall events.
63
+ "inbound_sender.blocked",
64
+ "sender_allowlist.added", "sender_allowlist.removed",
65
+ "sender_blocklist.added", "sender_blocklist.removed",
66
+ "mailbox.sender_policy_changed",
67
+ # PR 3 — customer legal hold lifecycle (Pro+ apply; release available
68
+ # on every plan, so subscribing to .released stays useful post-downgrade).
69
+ "legal_hold.applied", "legal_hold.released",
70
+ # PR 6 — HITL review queue. queued fires post-commit when a message
71
+ # routes to state='pending_review'; approved/denied fire when the
72
+ # matching endpoint commits the state transition. Subscribable via
73
+ # POST/PATCH /v1/webhooks.enabled_events.
74
+ "message.review.queued", "message.review.approved", "message.review.denied",
75
+ "webhook.test",
76
+ ]
77
+ ApiKeyRole = Literal["admin", "agent"]
78
+ PiiMode = Literal["passthrough", "redacted"]
79
+ OutboundPiiType = Literal["ssn", "credit_card", "phone_number"]
80
+ OutboundPiiAction = Literal["allow", "allow_with_warning", "review", "quarantine", "block"]
81
+ OutboundPiiPolicy = dict[OutboundPiiType, OutboundPiiAction]
82
+ OutboundReviewApprovalNotePolicy = Literal["optional", "required_for_sensitive_pii"]
83
+
84
+
85
+ class OutboundReviewPolicy(TypedDict, total=False):
86
+ approval_note: OutboundReviewApprovalNotePolicy
87
+
88
+
89
+ # PR 8.2 — per-detector redaction operator kinds.
90
+ # - replace_with_type (default): replaces the span with <TYPE>.
91
+ # - partial_mask: masks alphanumerics from the LEFT, keeps last
92
+ # keep_last (1-6), preserves separators verbatim. Pro+ gated;
93
+ # whitelisted detectors only (PERSON/EMAIL_ADDRESS rejected).
94
+ # - hash_replace: replaces span with <TYPE:hash> using HMAC-SHA256
95
+ # with the per-account salt. Pro+ gated.
96
+ PiiOperatorKind = Literal["replace_with_type", "partial_mask", "hash_replace"]
97
+
98
+
99
+ # Operator config for replace_with_type — the explicit default. Carries
100
+ # only the discriminator. Validator rejects keep_last/mask_char on this
101
+ # kind (round-3 audit fix #4 — prevents dead config persisting).
102
+ class PiiReplaceWithTypeOperator(TypedDict):
103
+ kind: Literal["replace_with_type"]
104
+
105
+
106
+ # Operator config for partial_mask. keep_last is REQUIRED integer in
107
+ # [1, 6]; mask_char is OPTIONAL single character (defaults to '*' when
108
+ # omitted). Validator rejects partial_mask on PERSON / EMAIL_ADDRESS
109
+ # (briefing §6.1 whitelist) — partial-masking a name produces nonsense
110
+ # and partial-masking an email is hard to do well in v1.
111
+ class PiiPartialMaskOperator(TypedDict, total=False):
112
+ kind: Required[Literal["partial_mask"]]
113
+ keep_last: Required[int]
114
+ mask_char: str # NotRequired by virtue of total=False
115
+
116
+
117
+ # Operator config for hash_replace — replaces the span with
118
+ # <TYPE:HMAC-SHA256(account_salt, span)[0..8]>. No per-detector config
119
+ # in v1; the per-account salt lives on accounts.pii_hash_salt and is
120
+ # lazy-initialized by the mailbox PATCH route on first hash_replace use.
121
+ # Validator rejects keep_last/mask_char on this kind (round-3 audit fix #4).
122
+ class PiiHashReplaceOperator(TypedDict):
123
+ kind: Literal["hash_replace"]
124
+
125
+
126
+ # PR 8.2 — per-detector redaction operator. Concrete Union over the three
127
+ # kinds gives static type-checkers a real contract to verify (mirrors the
128
+ # TS SDK's discriminated union). Round-1 PR 8.2 audit fix #1: prior shape
129
+ # was `Dict[str, Any]` which collapsed to no static guarantee — invalid
130
+ # configs like {"kind": "hash_replace", "keep_last": 4} would slip past
131
+ # typing despite the server's 422 rejection.
132
+ PiiOperator = Union[
133
+ PiiReplaceWithTypeOperator,
134
+ PiiPartialMaskOperator,
135
+ PiiHashReplaceOperator,
136
+ ]
137
+
138
+
139
+ # PR 8.1 — per-detector redaction visibility entry. NULL/absent = platform
140
+ # default (redact at delivery with `<TYPE>` placeholder). PR 8.2 adds the
141
+ # optional `operator` field.
142
+ class PiiRedactionConfigEntry(TypedDict, total=False):
143
+ redact: Required[bool]
144
+ operator: PiiOperator
145
+
146
+
147
+ # PR 8.1 — partial map keyed by detector type (e.g. `EMAIL_ADDRESS`,
148
+ # `CREDIT_CARD`). Persisted to `mailboxes.pii_redaction_config`. Omitted
149
+ # keys default to platform behaviour (redacted).
150
+ PiiRedactionConfig = Dict[str, PiiRedactionConfigEntry]
151
+
152
+ # Per-send / per-mailbox sub-addressing mode (migration 035).
153
+ # - reply_to (default): only Reply-To is rewritten to
154
+ # mailbox+instance_id@domain. From stays clean.
155
+ # - from: also rewrites From to the subaddressed form.
156
+ # - none: no address rewrite. HMAC secure-reply headers are still
157
+ # injected when an instance_id is passed on the send.
158
+ SubaddressMode = Literal["reply_to", "from", "none"]
159
+
160
+ # Per-mailbox recipient policy mode (migration 036).
161
+ # - blocklist (default): suppressed_addresses is the only pre-send gate.
162
+ # - allowlist: recipient MUST be on recipient_allowlists for this mailbox
163
+ # or the send is rejected with 403 RECIPIENT_NOT_ON_ALLOWLIST. Blocklist
164
+ # still runs first (wins on overlap).
165
+ RecipientPolicyMode = Literal["blocklist", "allowlist"]
166
+
167
+ # Per-mailbox INBOUND sender firewall mode (migration 047). Symmetric
168
+ # counterpart to RecipientPolicyMode for the inbound side.
169
+ # - blocklist (default): inbound_sender_blocklists is the only ingest gate.
170
+ # - allowlist: incoming sender MUST be on inbound_sender_allowlists for
171
+ # this mailbox or the message lands in state='firewall_blocked'.
172
+ # Blocklist still runs first (wins on overlap).
173
+ # HMAC-verified agent replies bypass on both Mailgun and IMAP ingress.
174
+ SenderPolicyMode = Literal["blocklist", "allowlist"]
175
+
176
+
177
+ class FirewallBlock(TypedDict):
178
+ """Snapshot of the inbound firewall verdict that landed a message in
179
+ state='firewall_blocked' (migration 047). Surfaced on message read APIs
180
+ only when the row was firewall-blocked. Sender-PII fields
181
+ (envelope_sender, from_address, matched_pattern) are redacted by the
182
+ server when mailboxes.pii_mode='redacted'. Categorical fields pass through.
183
+ """
184
+
185
+ envelope_sender: str | None
186
+ from_address: str | None
187
+ matched_field: Literal["envelope", "from"] | None
188
+ matched_pattern: str | None
189
+ reason_code: Literal["SENDER_BLOCKED", "SENDER_NOT_ON_ALLOWLIST"]
190
+ source_table: Literal["inbound_sender_blocklists", "inbound_sender_allowlists"] | None
191
+ mode: SenderPolicyMode
192
+
193
+ # Sprint 039 — allowlist + suppression entries may be either an exact email
194
+ # (`alice@corp.com`) or a domain pattern (`@corp.com`). Server derives
195
+ # `pattern_type` from the stored string. Optional on the wire (older server
196
+ # versions omit it) so clients targeting pre-0.5.0 servers still typecheck.
197
+ RecipientPatternType = Literal["email", "domain"]
198
+
199
+
200
+ class _HasPatternType(TypedDict, total=False):
201
+ """Mixin: `pattern_type` is present on 0.5.0+ server responses, absent on
202
+ older servers. Inherited by every list/add/bulk-added/delete entry that
203
+ can now carry a domain pattern."""
204
+
205
+ pattern_type: RecipientPatternType
206
+
207
+ # === Pagination ===
208
+
209
+ @dataclass
210
+ class Page:
211
+ data: list[dict[str, Any]]
212
+ has_more: bool
213
+ cursor: str | None
214
+
215
+
216
+ # === Domains ===
217
+
218
+ DomainType = Literal["platform", "delegated"]
219
+ DomainVerificationStatus = Literal[
220
+ "pending", "verified", "failed",
221
+ "requested", "pending_dns", "provider_verified",
222
+ "pending_verification", "unhealthy", "rejected",
223
+ ]
224
+ TransportMode = Literal["mailgun", "ses", "self_hosted"]
225
+ AdminReviewStatus = Literal["pending_review", "approved", "rejected"]
226
+ SelfHostedSecurity = Literal["starttls", "tls"]
227
+ SelfHostedNetworkMode = Literal["public", "tailnet"]
228
+ SelfHostedProbeGate = Literal["ownership_txt", "imap_auth", "smtp_auth", "folder_missing"]
229
+
230
+
231
+ class SelfHostedEndpointConfig(TypedDict):
232
+ host: str
233
+ port: int
234
+ security: SelfHostedSecurity
235
+ username: str
236
+ password: str
237
+
238
+
239
+ class SelfHostedConfig(TypedDict):
240
+ smtp: SelfHostedEndpointConfig
241
+ imap: SelfHostedEndpointConfig
242
+ network_mode: SelfHostedNetworkMode
243
+
244
+
245
+ class SelfHostedProbeResult(TypedDict):
246
+ gate: SelfHostedProbeGate
247
+ ok: bool
248
+ error: str | None
249
+
250
+
251
+ class DnsRecord(TypedDict, total=False):
252
+ type: Literal["TXT", "CNAME", "MX"]
253
+ name: str
254
+ value: str
255
+ priority: int
256
+ purpose: Literal["ownership_proof", "spf", "dkim", "tracking", "inbound_mx", "dmarc"]
257
+ mailgun_valid: bool | None
258
+
259
+
260
+ class Domain(TypedDict, total=False):
261
+ id: str
262
+ domain_name: str
263
+ domain_type: DomainType
264
+ transport_mode: TransportMode
265
+ is_default: bool
266
+ verification_status: DomainVerificationStatus
267
+ dns_records_json: list[DnsRecord] | None
268
+ admin_review_status: AdminReviewStatus | None
269
+ suspended_at: str | None
270
+ created_at: str
271
+ verified_at: str | None
272
+ account_id: str
273
+ mailgun_domain_id: str | None
274
+ ses_verified: bool
275
+ claim_token: str | None
276
+ mailgun_route_id: str | None
277
+ https_tracking_enabled_at: str | None
278
+ admin_review_note: str | None
279
+ admin_reviewed_at: str | None
280
+ last_health_check_at: str | None
281
+ health_failure_count: int
282
+ suspended_by: str | None
283
+ suspension_reason: str | None
284
+ updated_at: str
285
+
286
+
287
+ class CreateDomainRequest(TypedDict, total=False):
288
+ domain: str
289
+ transport_mode: TransportMode
290
+ self_hosted_config: SelfHostedConfig
291
+
292
+
293
+ class UpdateSelfHostedDomainConfigRequest(TypedDict, total=False):
294
+ smtp: dict[str, Any]
295
+ imap: dict[str, Any]
296
+ network_mode: SelfHostedNetworkMode
297
+ tailnet_auth_key: str
298
+
299
+
300
+ class CreateDomainResponse(TypedDict, total=False):
301
+ id: str
302
+ domain_id: str
303
+ domain_name: str
304
+ domain_type: DomainType
305
+ transport_mode: TransportMode
306
+ verification_status: DomainVerificationStatus
307
+ dns_records_json: list[DnsRecord] | None
308
+ admin_review_status: AdminReviewStatus | None
309
+ is_default: bool
310
+ created_at: str
311
+ message: str
312
+ claim_token: str | None
313
+
314
+
315
+ class ListDomainsResponse(TypedDict):
316
+ domains: list[Domain]
317
+
318
+
319
+ class VerifyDomainResponse(TypedDict, total=False):
320
+ verification_status: DomainVerificationStatus
321
+ dns_records_json: list[DnsRecord] | None
322
+ probe_results: list[SelfHostedProbeResult]
323
+ failed_gate: SelfHostedProbeGate | None
324
+ failure_reason: str | None
325
+ message: str
326
+
327
+
328
+ class SelfHostedConfigResponse(TypedDict):
329
+ domain_id: str
330
+ network_mode: SelfHostedNetworkMode
331
+ smtp: dict[str, Any]
332
+ imap: dict[str, Any]
333
+ updated_at: str
334
+
335
+
336
+ class DeleteDomainResponse(TypedDict):
337
+ deleted: bool
338
+
339
+
340
+ class SetDefaultDomainResponse(TypedDict):
341
+ id: str
342
+ domain_name: str
343
+ is_default: bool
344
+
345
+
346
+ # === Mailboxes ===
347
+
348
+ # PR 6 — per-mailbox HITL trigger (migration 059). Defined before
349
+ # CreateMailboxResponse / Mailbox / UpdateMailboxResponse / UpdateMailboxRequest
350
+ # to keep Python module-load order valid (type aliases must precede use).
351
+ HitlMode = Literal["disabled", "all_outbound"]
352
+
353
+ # PR 7 — discriminator for which surface(s) routed an outbound message
354
+ # into the HITL review queue. Surfaced on Message.review_trigger_source
355
+ # (read-side responses) and on MessageReviewQueuedWebhookPayload.trigger_source
356
+ # (webhook payload). Persisted in messages.review_trigger_source
357
+ # (migration 061); RETAINED across approve/deny.
358
+ ReviewQueueTriggerSource = Literal["mailbox_policy", "scanner", "both"]
359
+
360
+
361
+ class ScannerPolicy(TypedDict, total=False):
362
+ language_mode: Literal["english_only", "allow_all_languages", "disabled"]
363
+ disabled_scanners: list[str]
364
+ disabled_proxy_criteria: list[str]
365
+ # PR 9 — per-type outbound PII send-safety tuning. Omitted keys use
366
+ # platform defaults; relaxed values are Pro+ gated by pii_advanced_controls.
367
+ outbound_pii_policy: OutboundPiiPolicy
368
+ # Approval workflow settings for outbound scanner-emitted review.
369
+ outbound_review_policy: OutboundReviewPolicy
370
+
371
+
372
+ class CreateMailboxResponse(TypedDict):
373
+ id: str
374
+ name: str
375
+ address: str
376
+ # Migration 036 — new mailboxes default to 'blocklist'.
377
+ recipient_policy_mode: RecipientPolicyMode
378
+ # Migration 047 — inbound firewall mode; new mailboxes default to 'blocklist'.
379
+ sender_policy_mode: SenderPolicyMode
380
+ # PR 6 — per-mailbox HITL trigger; new mailboxes default to 'disabled'.
381
+ hitl_mode: HitlMode
382
+ # Migration 085 — thread-scoped reply bypass flag; new mailboxes default True.
383
+ allow_thread_replies: bool
384
+
385
+
386
+ class Mailbox(TypedDict):
387
+ id: str
388
+ name: str
389
+ address: str
390
+ status: MailboxStatus
391
+ scanner_policy: ScannerPolicy | None
392
+ pii_mode: PiiMode
393
+ # Legacy attachment-access acceptance flag (migration 034). Retained for
394
+ # backward-compatible reads and audit history, but effective attachment
395
+ # exposure now comes from `attachment_exposure_mode` when set; otherwise
396
+ # the mailbox behaves as `metadata_only`.
397
+ attachment_access_enabled: bool
398
+ attachment_access_accepted_at: str | None
399
+ attachment_access_accepted_version: str | None
400
+ attachment_exposure_mode: AttachmentExposureMode
401
+ attachment_allowed_file_families: list[AttachmentAllowedFileFamily]
402
+ attachment_reauth_at: str | None
403
+ attachment_policy_version: str | None
404
+ image_raw_download_confirmed: bool
405
+ current_image_risk_version: str
406
+ attachment_image_access_accepted_at: str | None
407
+ attachment_image_access_accepted_version: str | None
408
+ # Always the current disclaimer version. Compare to accepted_version to
409
+ # detect when a "disclaimer updated" banner should render.
410
+ current_disclaimer_version: str
411
+ # True when the mailbox is on the legacy compat path
412
+ # (attachment_exposure_mode is null but attachment_access_enabled is true).
413
+ # Drives the dashboard re-acceptance banner; flips to false after the
414
+ # customer accepts an explicit mode. See
415
+ # docs/runbooks/legacy-attachment-access-migration.md.
416
+ legacy_wildcard_active: bool
417
+ # Migration 035 — default outbound sub-addressing rewrite mode.
418
+ default_subaddress_mode: SubaddressMode
419
+ # Migration 036 — recipient policy mode.
420
+ recipient_policy_mode: RecipientPolicyMode
421
+ # Migration 047 — inbound firewall mode (per-mailbox).
422
+ sender_policy_mode: SenderPolicyMode
423
+ # PR 6 — per-mailbox HITL trigger.
424
+ hitl_mode: HitlMode
425
+ # Migration 085 — thread-scoped reply bypass flag. Inert in blocklist mode;
426
+ # in allowlist mode permits a reply/follow-up to a visible inbound thread
427
+ # participant without a standing grant.
428
+ allow_thread_replies: bool
429
+ # PR 8.1 — per-detector redaction visibility. None = platform default
430
+ # (all 13 detectors redact with `<TYPE>` placeholder). Populated value
431
+ # is a partial map keyed by detector type; tier-gated Pro+ for any
432
+ # `redact: false` entry.
433
+ pii_redaction_config: PiiRedactionConfig | None
434
+ created_at: str
435
+
436
+
437
+ class ListMailboxesResponse(TypedDict):
438
+ mailboxes: list[Mailbox]
439
+
440
+
441
+ class UpdateMailboxResponse(TypedDict):
442
+ id: str
443
+ name: str
444
+ address: str
445
+ scanner_policy: ScannerPolicy | None
446
+ pii_mode: PiiMode
447
+ attachment_access_enabled: bool
448
+ attachment_access_accepted_at: str | None
449
+ attachment_access_accepted_version: str | None
450
+ attachment_exposure_mode: AttachmentExposureMode
451
+ attachment_allowed_file_families: list[AttachmentAllowedFileFamily]
452
+ attachment_reauth_at: str | None
453
+ attachment_policy_version: str | None
454
+ image_raw_download_confirmed: bool
455
+ current_image_risk_version: str
456
+ attachment_image_access_accepted_at: str | None
457
+ attachment_image_access_accepted_version: str | None
458
+ current_disclaimer_version: str
459
+ legacy_wildcard_active: bool
460
+ # Migration 035.
461
+ default_subaddress_mode: SubaddressMode
462
+ # Migration 036.
463
+ recipient_policy_mode: RecipientPolicyMode
464
+ # Migration 047.
465
+ sender_policy_mode: SenderPolicyMode
466
+ # PR 6 — per-mailbox HITL trigger.
467
+ hitl_mode: HitlMode
468
+ # Migration 085 — thread-scoped reply bypass flag.
469
+ allow_thread_replies: bool
470
+ # PR 8.1 — per-detector redaction visibility (None = platform default).
471
+ pii_redaction_config: PiiRedactionConfig | None
472
+
473
+
474
+ # Mailbox attachment-access route request shapes.
475
+ # Legacy `enable=False` remains the disable path. Legacy `enable=True` and
476
+ # explicit `raw_download_selected_types` enablement/widening requires Pro+
477
+ # entitlement plus a dashboard/session-cookie re-auth flow; Bearer-key SDK
478
+ # callers can still disable, use safer modes, and perform same-or-narrower
479
+ # raw-download writes.
480
+ class AttachmentAccessLegacyRequest(TypedDict, total=False):
481
+ enable: bool
482
+ accept_disclaimer_version: str
483
+
484
+
485
+ class AttachmentAccessPolicyRequest(TypedDict, total=False):
486
+ mode: AttachmentExposureMode
487
+ allowed_file_families: list[AttachmentAllowedFileFamilyRequest]
488
+ accept_disclaimer_version: str
489
+ accept_image_risk_version: str
490
+ reauth_token: str
491
+
492
+
493
+ class AttachmentAccessResponse(TypedDict):
494
+ mailbox_id: str
495
+ mode: AttachmentExposureMode
496
+ allowed_file_families: list[AttachmentAllowedFileFamily]
497
+ accepted_at: str | None
498
+ accepted_version: str | None
499
+ attachment_access_enabled: bool
500
+ attachment_access_accepted_at: str | None
501
+ attachment_access_accepted_version: str | None
502
+ image_raw_download_confirmed: bool
503
+ current_image_risk_version: str
504
+ attachment_image_access_accepted_at: str | None
505
+ attachment_image_access_accepted_version: str | None
506
+ current_disclaimer_version: str
507
+ # Mirrors Mailbox.legacy_wildcard_active; flips to false after a
508
+ # successful explicit-shape write.
509
+ legacy_wildcard_active: bool
510
+
511
+
512
+ # Migration 036 — allowlist entries.
513
+ class _AllowlistEntryRequired(TypedDict):
514
+ email: str
515
+ mailbox_id: str
516
+ created_at: str
517
+ added_by_actor_type: str | None
518
+ added_by_actor_id: str | None
519
+
520
+
521
+ class AllowlistEntry(_AllowlistEntryRequired, _HasPatternType):
522
+ """Allowlist entry. `pattern_type` present on 0.5.0+ server responses."""
523
+
524
+
525
+ class ListAllowlistResponse(TypedDict):
526
+ allowlist: list[AllowlistEntry]
527
+ next_cursor: str | None
528
+
529
+
530
+ class _AddAllowlistResponseRequired(TypedDict):
531
+ email: str
532
+ mailbox_id: str
533
+ created_at: str | None
534
+ already_existed: bool
535
+ added_by_actor_type: str | None
536
+ added_by_actor_id: str | None
537
+
538
+
539
+ class AddAllowlistResponse(_AddAllowlistResponseRequired, _HasPatternType):
540
+ """Response of POST /v1/mailboxes/:id/allowlist."""
541
+
542
+
543
+ class _BulkAllowlistAddedRequired(TypedDict):
544
+ email: str
545
+ created_at: str
546
+
547
+
548
+ class _BulkAllowlistAdded(_BulkAllowlistAddedRequired, _HasPatternType):
549
+ """Bulk-added row — `pattern_type` present on 0.5.0+ servers."""
550
+
551
+
552
+ class _BulkAllowlistInvalid(TypedDict):
553
+ email: str
554
+ reason: str
555
+
556
+
557
+ class _BulkAllowlistCounts(TypedDict):
558
+ added: int
559
+ already_existed: int
560
+ invalid: int
561
+ total: int
562
+
563
+
564
+ class BulkAddAllowlistResponse(TypedDict):
565
+ added: list[_BulkAllowlistAdded]
566
+ already_existed: list[str]
567
+ invalid: list[_BulkAllowlistInvalid]
568
+ counts: _BulkAllowlistCounts
569
+
570
+
571
+ class DeleteAllowlistResponse(TypedDict, total=False):
572
+ status: str
573
+ email: str
574
+ mailbox_id: str
575
+ created_at: str
576
+ # Sprint 039 — `email` or `@domain.com`. Optional for older servers.
577
+ pattern_type: RecipientPatternType
578
+
579
+
580
+ # === Messages ===
581
+
582
+ class HoldContext(TypedDict):
583
+ """inline-scan-verdict-on-send §2b — policy/HITL hold channel.
584
+
585
+ Present (non-null) only when applyHitlPolicy changed the scanner's
586
+ decision so delivery `status` diverges from `scan.verdict`; when present,
587
+ `summary_reasons` carries >=1 customer-safe line.
588
+ """
589
+ trigger_source: Literal["mailbox_policy", "scanner", "both"]
590
+ summary_reasons: list[str]
591
+
592
+
593
+ class SendMessageResponse(TypedDict):
594
+ message_id: str
595
+ status: Literal["sent", "quarantined", "blocked", "pending_review"]
596
+ warning: str | None
597
+ # Customer-facing scanner verdict; null when no scan results.
598
+ scan: NotRequired["ScanSummary | None"]
599
+ # Policy/HITL hold reason; null when scan explains the hold or status='sent'.
600
+ hold_context: NotRequired["HoldContext | None"]
601
+ daily_limit: int
602
+ sends_remaining: int
603
+
604
+
605
+ class MessageSummary(TypedDict, total=False):
606
+ """List-response item shape.
607
+
608
+ PR 7 marked the type total=False so the additive review_trigger_source
609
+ + body_preview fields can be modeled as optional without breaking
610
+ existing total=True consumers. The four core required fields below
611
+ are still emitted by the server on every response.
612
+ """
613
+ id: Required[str]
614
+ direction: Required[MessageDirection]
615
+ state: Required[MessageState]
616
+ sender: Required[str]
617
+ recipient: Required[str]
618
+ subject: Required[str]
619
+ # Owning mailbox name (mailboxes.name), resolved so agents don't see a
620
+ # bare mailbox UUID.
621
+ mailbox_name: NotRequired[str | None]
622
+ # Customer-facing scan summary; null when scan_result_json was null at-rest.
623
+ # Replaces the legacy per-row scan_results array.
624
+ scan: NotRequired["ScanSummary | None"]
625
+ # Migration 035 — may be `<REDACTED>` under mailbox pii_mode=redacted.
626
+ subaddress_instance_id: Required[str | None]
627
+ # Migration 047 — present (non-null) only on state='firewall_blocked'.
628
+ firewall_block: Required[FirewallBlock | None]
629
+ # S7a — thread key. Stripped Message-Id for threaded mail; null for
630
+ # standalone rows where messages.thread_id IS NULL. Surfaced so search-
631
+ # result navigation can route inbound results to the correct thread
632
+ # without a follow-up GET.
633
+ thread_id: Required[str | None]
634
+ created_at: Required[str]
635
+ read_at: Required[str | None]
636
+ # PR 7 (migration 061) — discriminator for which surface routed this
637
+ # row into pending_review. None for any row that never went through
638
+ # pending_review, AND for legacy pre-PR-7.1 pending_review rows
639
+ # (those came from PR 6 mailbox-policy promotion only — dashboards
640
+ # render None → "Policy" by inference). RETAINED across approve/deny.
641
+ review_trigger_source: NotRequired[ReviewQueueTriggerSource | None]
642
+ # Plaintext, whitespace-normalized, 200-character list excerpt.
643
+ body_preview: NotRequired[str | None]
644
+ # S7 NTH-003 — server-computed deep link into the web inbox shell, or None
645
+ # when PUBLIC_LINK_BASE_URL is unset on the server (fail-closed).
646
+ dashboard_url: NotRequired[str | None]
647
+
648
+
649
+ class MarkMessageReadResponse(TypedDict):
650
+ """S7a — POST /v1/messages/:id/read response."""
651
+ message_id: str
652
+ # The row's read_at after the call. For ineligible rows (outbound,
653
+ # deleted, firewall_blocked) this is the existing value (possibly null).
654
+ read_at: str | None
655
+
656
+
657
+ class MarkThreadReadResponse(TypedDict):
658
+ """S7a — POST /v1/mailboxes/:id/threads/:thread_id/read response."""
659
+ thread_id: str
660
+ # Number of rows newly stamped this call (excludes already-read).
661
+ marked_count: int
662
+
663
+
664
+ class AttachmentMeta(TypedDict, total=False):
665
+ filename: str
666
+ content_type: str
667
+ size: int
668
+ hash: str
669
+ policy_action: AttachmentPolicyAction
670
+ sniffed_mime_type: str
671
+ declared_mime_type: str
672
+ mime_mismatch: bool
673
+ av_verdict: AvVerdict
674
+ av_signature: str
675
+ av_scanned_at: str
676
+ stored: bool
677
+ content_id: str
678
+ content_disposition: str
679
+ inline: bool
680
+ raw_retention: AttachmentRawRetentionStatus
681
+ raw_deleted_at: str
682
+ raw_retention_reason: str
683
+
684
+
685
+ class AttachmentPreviewSummary(TypedDict):
686
+ preview_status: AttachmentDerivativeStatus | None
687
+ preview_kind: AttachmentPreviewKind | None
688
+ preview_reason_code: str | None
689
+ preview_char_count: int | None
690
+ preview_page_count: int | None
691
+ preview_truncated: bool | None
692
+ preview_generated_at: str | None
693
+
694
+
695
+ class MessageAttachment(AttachmentMeta, total=False):
696
+ preview_status: AttachmentDerivativeStatus | None
697
+ preview_kind: AttachmentPreviewKind | None
698
+ preview_reason_code: str | None
699
+ preview_char_count: int | None
700
+ preview_page_count: int | None
701
+ preview_truncated: bool | None
702
+ preview_generated_at: str | None
703
+
704
+
705
+ # === URL reputation (Google Web Risk) — additive ScanResult fields ===
706
+ #
707
+ # Mirror of packages/sdk/src/types.ts WebRiskLearnMore + WebRiskWarning +
708
+ # the additive ScanResult.expires_at / ScanResult.warning fields.
709
+ # Populated only on unexpired url-reputation malicious verdicts; absent
710
+ # everywhere else (the server-side sanitizer scrubs these fields on
711
+ # expiry, so consumers don't need to implement client-side expiry logic).
712
+
713
+ # Optional documentation literal — `WebRiskLearnMore.threat_type` keeps
714
+ # the wider `str` type (matches the TS SDK and is forward-compatible
715
+ # with future Web Risk threat types). Use this Literal in code that
716
+ # wants exhaustive checking of the four currently-defined threat types.
717
+ WebRiskThreatType = Literal[
718
+ "MALWARE",
719
+ "SOCIAL_ENGINEERING",
720
+ "UNWANTED_SOFTWARE",
721
+ "SOCIAL_ENGINEERING_EXTENDED_COVERAGE",
722
+ ]
723
+
724
+
725
+ class WebRiskLearnMore(TypedDict):
726
+ """Per-threat-type learn-more link inside `WebRiskWarning.learn_more`.
727
+
728
+ `threat_type` is `str` (not the `WebRiskThreatType` literal) to match
729
+ the TS SDK contract and stay forward-compatible with new threat
730
+ classes Google may add. Render the URL as an explicit `<a>` element
731
+ when surfacing the warning to end users.
732
+ """
733
+
734
+ threat_type: str
735
+ url: str
736
+
737
+
738
+ class WebRiskWarning(TypedDict):
739
+ """Structured Google Web Risk warning payload.
740
+
741
+ Present on `ScanResult.warning` only for unexpired url-reputation
742
+ malicious verdicts. The server-side sanitizer clears this field
743
+ once `expires_at` passes, so any warning reaching the SDK is safe
744
+ to render without a client-side expiry re-check.
745
+
746
+ Render `attribution_url` and each `learn_more[].url` as explicit
747
+ `<a>` elements. Never auto-linkify the parent `ScanResult.reason`
748
+ string — it's the plain-text fallback for surfaces that can't
749
+ render structured links (admin CLI, plain-text logs).
750
+ """
751
+
752
+ qualified_wording: str
753
+ attribution_text: str
754
+ attribution_url: str
755
+ learn_more: list[WebRiskLearnMore]
756
+ affected_urls: list[str]
757
+
758
+
759
+ class _ScanResultRequired(TypedDict):
760
+ scanner: str
761
+ decision: PolicyDecision
762
+ confidence: float
763
+ reason: str
764
+
765
+
766
+ class ScanResult(_ScanResultRequired, total=False):
767
+ """Per-scanner verdict on a message.
768
+
769
+ The four required fields (scanner, decision, confidence, reason)
770
+ are emitted by every scanner. `expires_at` and `warning` are
771
+ additive and only appear on url-reputation results — see
772
+ `WebRiskWarning` for the contract. `pii_type` (PR 9) is present only
773
+ on local outbound `scanner="pii"` results and identifies the stable
774
+ local category (`ssn`, `credit_card`, or `phone_number`).
775
+ """
776
+
777
+ # Vendor-mandated lifetime (currently only url-reputation carries
778
+ # this via Google Web Risk `expireTime`). The server-side sanitizer
779
+ # clears it alongside `warning` + rewrites `reason` to a neutral
780
+ # placeholder once the timestamp passes. Absence means either the
781
+ # entry is unexpiring (non-url-reputation) or it was already
782
+ # scrubbed by the sanitizer.
783
+ expires_at: str
784
+ pii_type: OutboundPiiType
785
+ # Structured Web Risk warning. Populated only on unexpired
786
+ # url-reputation malicious verdicts. See `WebRiskWarning`.
787
+ warning: WebRiskWarning
788
+ # Structural pointer into attachments[] for per-attachment findings (e.g.
789
+ # the attachment-type-mismatch scanner). Surfaced as
790
+ # ScanFinding.attachment_index; never parsed from reason.
791
+ attachment_index: int
792
+
793
+
794
+ # === Customer-facing scan summary (renderScanSummary output) ===
795
+ #
796
+ # Mirror of packages/shared/src/scan-summary.ts. Replaces the legacy
797
+ # `scan_results: list[ScanResult]` shape on every read endpoint. Default
798
+ # view returns non-allow findings inline with typed subfields; `view='verbose'`
799
+ # adds clean-allow findings for the full audit trail. Vendor names never
800
+ # appear in any field; Web Risk attribution survives inside findings[i].warning.
801
+ ScanVerdict = Literal["clean", "warning", "review_required", "blocked", "quarantined"]
802
+
803
+ ScanCategory = Literal[
804
+ "prompt_injection",
805
+ "function_call_risk",
806
+ "harmful_content",
807
+ "liability_risk",
808
+ "pii",
809
+ "phishing_url",
810
+ "image_exfil",
811
+ "malware",
812
+ "attachment_policy",
813
+ "mime_mismatch",
814
+ "attachment_type_mismatch",
815
+ "spam",
816
+ "language_policy",
817
+ "recipient_policy",
818
+ "secret_detected",
819
+ "content_similarity",
820
+ "scan_incomplete",
821
+ ]
822
+
823
+ ScanSubtype = Literal[
824
+ # prompt_injection
825
+ "jailbreak",
826
+ "instruction_injection",
827
+ # harmful_content
828
+ "toxicity",
829
+ "violence",
830
+ "sexual_content",
831
+ "hate_speech",
832
+ "harassment",
833
+ "self_harm",
834
+ "profanity",
835
+ # secret_detected
836
+ "secret_value",
837
+ "outbound_confidentiality_leak",
838
+ ]
839
+
840
+ ScanAttachmentPolicyAction = Literal[
841
+ "quarantine_message",
842
+ "metadata_only_after_rewrite",
843
+ "derived_text_finding",
844
+ ]
845
+
846
+
847
+ class ScanCategorySummary(TypedDict):
848
+ category: ScanCategory
849
+ decision: PolicyDecision
850
+
851
+
852
+ class ScanFinding(TypedDict, total=False):
853
+ category: Required[ScanCategory]
854
+ decision: Required[PolicyDecision]
855
+ reason: Required[str | None]
856
+ warning: WebRiskWarning
857
+ expires_at: str
858
+ pii_type: OutboundPiiType
859
+ subtype: ScanSubtype
860
+ attachment_index: int
861
+ # Filename of the attachment this finding refers to
862
+ # (attachments[attachment_index].filename). Stamped at scan time so it
863
+ # reaches list/wait/send/draft surfaces that carry no attachments[] array.
864
+ attachment_filename: str
865
+ attachment_policy_action: ScanAttachmentPolicyAction
866
+ # Inference-failure-transparency plan §5.5 — discriminates infrastructure
867
+ # failures from model judgments. Present only on non-allow findings backed
868
+ # by external inference.
869
+ failure_class: Literal["inference_error", "model_judgment"]
870
+ # RL-UAT-012/013 — structural handling guidance for the agent.
871
+ agent_instructions: list[str]
872
+
873
+
874
+ class ScanSummary(TypedDict):
875
+ verdict: ScanVerdict
876
+ categories: list[ScanCategorySummary]
877
+ findings: list[ScanFinding]
878
+
879
+
880
+ ScanView = Literal["summary", "verbose"]
881
+
882
+
883
+ class MessageBody(TypedDict):
884
+ format: Literal["text", "html"]
885
+ content: str | None
886
+ char_count: int
887
+ returned_char_count: int
888
+ truncated: bool
889
+
890
+
891
+ class GetMessageResponse(TypedDict, total=False):
892
+ """GET /v1/messages/:id detail response.
893
+
894
+ total=False so PR 6 review-resolution metadata + PR 7 review trigger
895
+ source can be modeled as optional fields. The required-keys subset
896
+ (everything except review_outcome / reviewed_* / review_reason /
897
+ review_trigger_source) is enforced by the server.
898
+ """
899
+ id: Required[str]
900
+ # Owning mailbox UUID (lets clients derive the mailbox, e.g. reply attachments).
901
+ mailbox_id: Required[str]
902
+ # Owning mailbox name (mailboxes.name), resolved so agents don't see a
903
+ # bare mailbox UUID.
904
+ mailbox_name: NotRequired[str | None]
905
+ direction: Required[MessageDirection]
906
+ state: Required[MessageState]
907
+ sender: Required[str]
908
+ recipient: Required[str]
909
+ subject: Required[str]
910
+ body: Required[MessageBody]
911
+ attachments: Required[list[MessageAttachment]]
912
+ scan: Required[ScanSummary | None]
913
+ thread_id: Required[str | None]
914
+ in_reply_to: Required[str | None]
915
+ # Migration 035 — may be `<REDACTED>` under mailbox pii_mode=redacted.
916
+ subaddress_instance_id: Required[str | None]
917
+ # Migration 047 — present (non-null) only on state='firewall_blocked'.
918
+ firewall_block: Required[FirewallBlock | None]
919
+ read_at: Required[str | None]
920
+ created_at: Required[str]
921
+ # S7 NTH-003 — server-computed deep link into the web inbox shell, or None
922
+ # when PUBLIC_LINK_BASE_URL is unset on the server (fail-closed).
923
+ dashboard_url: NotRequired[str | None]
924
+ # PR 6 — review-resolution metadata. Populated only after a row passes
925
+ # through approve/deny; None on pending_review and pre-PR-6 rows.
926
+ review_outcome: NotRequired[Literal["approved", "denied"] | None]
927
+ reviewed_at: NotRequired[str | None]
928
+ reviewed_by_type: NotRequired[str | None]
929
+ reviewed_by_id: NotRequired[str | None]
930
+ review_reason: NotRequired[str | None]
931
+ # PR 7 (migration 061) — discriminator for which surface routed this
932
+ # row into pending_review. See MessageSummary above for the None-fallback
933
+ # semantics (legacy → "Policy" by inference). RETAINED across approve/deny.
934
+ review_trigger_source: NotRequired[ReviewQueueTriggerSource | None]
935
+
936
+
937
+ # === message.received webhook payload (with optional URL-reputation safety) ===
938
+ #
939
+ # Mirror of packages/sdk/src/types.ts MessageReceivedSafetySignal +
940
+ # MessageReceivedWebhookPayload. The `safety` field is additive — it
941
+ # appears only when the url-reputation scanner returned
942
+ # `allow_with_warning` (typically because Web Risk's lists were stale,
943
+ # the confirm API timed out, or the kill switch was on). It is NOT
944
+ # populated from worstDecision across all scanners — other warning
945
+ # scanners' reasons embed customer content that's kept off the
946
+ # message.received wire. `reason` is a ReplyLayer-authored count-only
947
+ # string ("Could not verify reputation for N URL(s) — treat with
948
+ # caution"); it never contains customer URLs.
949
+
950
+ class MessageReceivedSafetySignal(TypedDict):
951
+ """Optional `data.safety` block on `message.received` webhook events."""
952
+
953
+ decision: Literal["allow_with_warning"]
954
+ reason: str
955
+
956
+
957
+ class _MessageReceivedWebhookPayloadRequired(TypedDict):
958
+ message_id: str
959
+ mailbox_id: str
960
+ sender: str
961
+ recipient: str
962
+ subject: str
963
+ received_at: str
964
+ # Migration 035 — `<REDACTED>` under mailbox pii_mode=redacted.
965
+ subaddress_instance_id: str | None
966
+
967
+
968
+ class MessageReceivedWebhookPayload(
969
+ _MessageReceivedWebhookPayloadRequired, total=False,
970
+ ):
971
+ """Body of a `message.received` webhook event.
972
+
973
+ `safety` appears only when a url-reputation `allow_with_warning`
974
+ ScanResult was present on the message. Absent for "all clean" or
975
+ when only non-url-reputation scanners returned warnings.
976
+ """
977
+
978
+ safety: MessageReceivedSafetySignal
979
+
980
+
981
+ class AttachmentPreviewWebhookPayloadBase(TypedDict):
982
+ message_id: str
983
+ mailbox_id: str
984
+ attachment_index: int
985
+ variant: AttachmentDerivativeVariant
986
+
987
+
988
+ class AttachmentPreviewQueuedWebhookPayload(AttachmentPreviewWebhookPayloadBase):
989
+ queued_at: str
990
+
991
+
992
+ class AttachmentPreviewReadyWebhookPayload(AttachmentPreviewWebhookPayloadBase):
993
+ ready_at: str
994
+ kind: AttachmentPreviewKind
995
+ char_count: int | None
996
+ page_count: int | None
997
+ truncated: bool
998
+
999
+
1000
+ class AttachmentPreviewBlockedWebhookPayload(AttachmentPreviewWebhookPayloadBase):
1001
+ blocked_at: str
1002
+ reason_code: str | None
1003
+
1004
+
1005
+ class AttachmentPreviewFailedWebhookPayload(AttachmentPreviewWebhookPayloadBase):
1006
+ failed_at: str
1007
+ reason_code: str | None
1008
+
1009
+
1010
+ # PR 6 — HITL review queue webhook payloads. Source-of-truth contract:
1011
+ # docs/webhooks.md (PR 6 rows) + plans/tier-feature-gating-pr6-hitl-review-queue.md §9.
1012
+ #
1013
+ # `message.review.queued` carries subject + recipient (subject to PII
1014
+ # redaction when the source mailbox's pii_mode is 'redacted'); `summary_reasons`
1015
+ # is always non-empty (the helper synthesizes a policy reason when the trigger
1016
+ # is mailbox-policy and there are no scanner findings). Raw `scan_results`
1017
+ # is intentionally NOT included.
1018
+ MessageReviewOrigin = Literal["fresh_send", "reply", "draft"]
1019
+
1020
+
1021
+ class _MessageReviewQueuedWebhookPayloadRequired(TypedDict):
1022
+ """Required keys for the queued review webhook payload.
1023
+
1024
+ Round-2 audit P3 fix — TS SDK and the runtime contract require all
1025
+ these fields on every emit; only `trigger_source` is optional for
1026
+ wire-additive compatibility with pre-PR-7 emit sites. Mirrors the
1027
+ `MessageReceivedWebhookPayload` required-base pattern.
1028
+ """
1029
+ message_id: str
1030
+ mailbox_id: str
1031
+ direction: Literal["outbound"]
1032
+ subject: str
1033
+ recipient: str
1034
+ summary_reasons: list[str]
1035
+ origin: MessageReviewOrigin
1036
+
1037
+
1038
+ class MessageReviewQueuedWebhookPayload(
1039
+ _MessageReviewQueuedWebhookPayloadRequired, total=False,
1040
+ ):
1041
+ """v1 outbound-only HITL review queue webhook payload.
1042
+
1043
+ PR 7 added the optional `trigger_source` discriminator (which
1044
+ surface(s) routed the message into pending_review). Distinct from
1045
+ `origin` (call-site valued); they are independent dimensions.
1046
+ PR 7.1 + PR 7.3 always populate `trigger_source` server-side, but
1047
+ the type stays NotRequired for wire-additive compatibility with
1048
+ pre-PR-7 emit sites a customer may still receive during a deploy
1049
+ rollout window.
1050
+ """
1051
+ trigger_source: ReviewQueueTriggerSource
1052
+
1053
+
1054
+ # `.approved` and `.denied` carry no PII fields — only operator-context.
1055
+ # Both fire regardless of dispatch outcome (action is the signal; Mailgun
1056
+ # result is captured by message.delivered / .bounced / .dispatch_failed).
1057
+ class MessageReviewApprovedWebhookPayload(TypedDict):
1058
+ message_id: str
1059
+ mailbox_id: str
1060
+ reviewed_by_type: Literal["admin", "user"]
1061
+ reviewed_at: str # ISO-8601
1062
+ reason: str | None
1063
+ prior_state: Literal["pending_review"]
1064
+
1065
+
1066
+ class MessageReviewDeniedWebhookPayload(TypedDict):
1067
+ message_id: str
1068
+ mailbox_id: str
1069
+ reviewed_by_type: Literal["admin", "user"]
1070
+ reviewed_at: str
1071
+ reason: str | None
1072
+ prior_state: Literal["pending_review"]
1073
+
1074
+
1075
+ class WaitResponse(TypedDict):
1076
+ message: MessageSummary | None
1077
+
1078
+
1079
+ class ReleaseResponse(TypedDict):
1080
+ status: Literal["released", "sent", "blocked"]
1081
+ message_id: str
1082
+
1083
+
1084
+ class BlockResponse(TypedDict):
1085
+ status: Literal["blocked"]
1086
+ message_id: str
1087
+
1088
+
1089
+ # === Drafts ===
1090
+ #
1091
+ # A draft is a message in state='draft'. Scanner runs at create + update and
1092
+ # authoritatively re-runs on send. See SyncDrafts / AsyncDrafts in
1093
+ # resources/drafts.py.
1094
+
1095
+ class CreateDraftRequest(TypedDict, total=False):
1096
+ # Fresh mode requires mailbox_id/to/subject/body; thread mode (thread_id
1097
+ # present, migration 085) derives mailbox_id/subject and makes `to` an
1098
+ # optional participant selector. TypedDict total=False marks everything
1099
+ # optional for IDE ergonomics — the server enforces the mode rules.
1100
+ mailbox_id: str
1101
+ to: str
1102
+ subject: str
1103
+ body: str
1104
+ html: str
1105
+ in_reply_to_message_id: str
1106
+ # Migration 085 — continue an existing thread; mutually exclusive with
1107
+ # in_reply_to_message_id.
1108
+ thread_id: str
1109
+ # Migration 035 — sub-addressing routing hint, persisted on the draft.
1110
+ subaddress_instance_id: str
1111
+ subaddress_mode: SubaddressMode
1112
+ # Outbound attachment handles (plans/outbound-attachment-ux.md) — held on
1113
+ # the draft and consumed once at dispatch. Requires the mailbox to have
1114
+ # outbound attachments enabled (Pro+).
1115
+ attachment_ids: list[str]
1116
+
1117
+
1118
+ class UpdateDraftRequest(TypedDict, total=False):
1119
+ # All optional — server rejects an empty body with 400.
1120
+ to: str
1121
+ subject: str
1122
+ body: str
1123
+ html: str
1124
+ # Migration 035 — 3-way PATCH sigil: str sets/updates, None clears
1125
+ # (auto-clears subaddress_mode when instance is cleared), omitted is
1126
+ # no-change. Type parity with TS SDK UpdateDraftRequest.
1127
+ subaddress_instance_id: str | None
1128
+ subaddress_mode: SubaddressMode | None
1129
+ # Outbound attachment handles: list replaces the set, None clears all,
1130
+ # omitted is no-change (mirror of the JSON-schema ['array','null']).
1131
+ attachment_ids: list[str] | None
1132
+
1133
+
1134
+ class ListDraftsParams(TypedDict, total=False):
1135
+ limit: int
1136
+ before: str
1137
+ auto_paginate: bool
1138
+
1139
+
1140
+ class DraftResponse(TypedDict):
1141
+ id: str
1142
+ mailbox_id: str
1143
+ # Owning mailbox name (mailboxes.name). Additive/optional.
1144
+ mailbox_name: NotRequired[str | None]
1145
+ state: Literal["draft", "available", "deleted"]
1146
+ sender: str
1147
+ recipient: str
1148
+ subject: str
1149
+ body: MessageBody
1150
+ scan: ScanSummary
1151
+ worst_decision: PolicyDecision
1152
+ thread_id: str | None
1153
+ in_reply_to: str | None
1154
+ # Migration 035 — may be `<REDACTED>` under mailbox pii_mode=redacted.
1155
+ subaddress_instance_id: str | None
1156
+ subaddress_mode: SubaddressMode | None
1157
+ created_at: str
1158
+ updated_at: str
1159
+
1160
+
1161
+ class DraftSummary(TypedDict):
1162
+ id: str
1163
+ mailbox_id: str
1164
+ # Owning mailbox name (mailboxes.name). Additive/optional.
1165
+ mailbox_name: NotRequired[str | None]
1166
+ state: Literal["draft"]
1167
+ sender: str
1168
+ recipient: str
1169
+ subject: str
1170
+ worst_decision: PolicyDecision
1171
+ # Migration 035 — may be `<REDACTED>` under mailbox pii_mode=redacted.
1172
+ subaddress_instance_id: str | None
1173
+ created_at: str
1174
+ updated_at: str
1175
+
1176
+
1177
+ class ListDraftsResponse(TypedDict):
1178
+ drafts: list[DraftSummary]
1179
+
1180
+
1181
+ # === Threads ===
1182
+
1183
+ class ThreadSummary(TypedDict):
1184
+ id: str
1185
+ subject: str
1186
+ first_message_at: str
1187
+ last_message_at: str
1188
+ message_count: int
1189
+ unread_count: int
1190
+ participants: list[str]
1191
+
1192
+
1193
+ class GetThreadResponse(TypedDict):
1194
+ id: str
1195
+ mailbox_id: str
1196
+ # Owning mailbox name (mailboxes.name). Additive/optional.
1197
+ mailbox_name: NotRequired[str | None]
1198
+ subject: str
1199
+ message_count: int
1200
+ messages: list[GetMessageResponse]
1201
+
1202
+
1203
+ # === Attachments ===
1204
+
1205
+ class AttachmentDownloadResponse(TypedDict):
1206
+ url: str
1207
+ expires_at: str
1208
+ content_type: str
1209
+ filename: str
1210
+ size: int
1211
+ av_verdict: str | None
1212
+
1213
+
1214
+ class AttachmentPreviewMetadata(TypedDict):
1215
+ filename: str
1216
+ content_type: str
1217
+ size: int
1218
+ hash: str
1219
+
1220
+
1221
+ class AttachmentPreviewBody(TypedDict):
1222
+ kind: AttachmentPreviewKind
1223
+ content: str
1224
+ truncated: bool
1225
+ char_count: int | None
1226
+ page_count: int | None
1227
+ extractor: str
1228
+ generated_at: str
1229
+
1230
+
1231
+ class AttachmentPreviewResponse(TypedDict):
1232
+ attachment: AttachmentPreviewMetadata
1233
+ preview: AttachmentPreviewBody
1234
+
1235
+
1236
+ # Outbound-attachment content-scan lifecycle (POST /v1/attachments):
1237
+ # pending → async text content-scan not yet run; referencing on a send → 409
1238
+ # clean → no findings; sendable
1239
+ # flagged → secret/PII finding(s); still sendable, findings flow to the
1240
+ # message verdict (block/quarantine by severity, like a body finding)
1241
+ # error → scan failed; fail-closed (cannot be sent)
1242
+ OutboundAttachmentScanStatus = Literal["pending", "clean", "flagged", "error"]
1243
+
1244
+
1245
+ class UploadAttachmentResponse(TypedDict):
1246
+ """201 body of POST /v1/attachments. ``id`` is the opaque handle to put in a
1247
+ send/reply/draft ``attachment_ids`` list. ``scan`` is present only when the
1248
+ synchronous filename scan produced findings. ``content_scan_status`` is
1249
+ always ``"pending"`` at upload time — poll ``get_upload(id)`` until terminal
1250
+ before referencing the handle."""
1251
+ id: str
1252
+ filename: str
1253
+ content_type: str
1254
+ size: int
1255
+ hash: str
1256
+ scan: NotRequired[ScanSummary]
1257
+ content_scan_status: OutboundAttachmentScanStatus
1258
+
1259
+
1260
+ class ConsumedAttachmentResponse(TypedDict):
1261
+ """GET /v1/attachments/:id once the handle has been consumed by a send."""
1262
+ id: str
1263
+ status: Literal["consumed"]
1264
+ consumed_at: str
1265
+ consumed_message_id: str | None
1266
+
1267
+
1268
+ # GET /v1/attachments/:id returns the active-handle shape (UploadAttachmentResponse)
1269
+ # or, once consumed, ConsumedAttachmentResponse — discriminate on the `status` key.
1270
+ GetUploadAttachmentResponse = Union[UploadAttachmentResponse, ConsumedAttachmentResponse]
1271
+
1272
+
1273
+ # === Webhooks ===
1274
+
1275
+ class CreateWebhookResponse(TypedDict):
1276
+ id: str
1277
+ url: str
1278
+ description: str | None
1279
+ enabled: bool
1280
+ enabled_events: list[WebhookEventType]
1281
+ signing_secret: str
1282
+ created_at: str
1283
+
1284
+
1285
+ class WebhookSummary(TypedDict):
1286
+ id: str
1287
+ url: str
1288
+ description: str | None
1289
+ enabled: bool
1290
+ enabled_events: list[WebhookEventType]
1291
+ created_at: str
1292
+ updated_at: str
1293
+ # D6: health fields surfaced by the delivery worker
1294
+ consecutive_failures: int
1295
+ last_success_at: str | None
1296
+ last_failure_at: str | None
1297
+ last_error: str | None
1298
+ disabled_at: str | None
1299
+ disabled_reason: str | None
1300
+
1301
+
1302
+ class ListWebhooksResponse(TypedDict):
1303
+ webhooks: list[WebhookSummary]
1304
+
1305
+
1306
+ class RotateWebhookSecretResponse(TypedDict):
1307
+ signing_secret: str
1308
+
1309
+
1310
+ class TestWebhookResponse(TypedDict):
1311
+ delivery_id: str
1312
+
1313
+
1314
+ WebhookDeliveryStatus = Literal["pending", "delivering", "delivered", "failed"]
1315
+
1316
+
1317
+ class WebhookDeliverySummary(TypedDict):
1318
+ id: str
1319
+ event_type: str
1320
+ status: WebhookDeliveryStatus
1321
+ http_status: int | None
1322
+ attempt_count: int
1323
+ created_at: str
1324
+ delivered_at: str | None
1325
+ failed_at: str | None
1326
+ next_retry_at: str | None
1327
+ response_preview: str | None
1328
+
1329
+
1330
+ # deliveries + has_more are always present; cursor fields appear only
1331
+ # when has_more is True. Split into a required base + total=False extension
1332
+ # so consumers don't need `if 'deliveries' in resp` defensive checks.
1333
+ class _ListDeliveriesRequired(TypedDict):
1334
+ deliveries: list[WebhookDeliverySummary]
1335
+ has_more: bool
1336
+
1337
+
1338
+ class ListDeliveriesResponse(_ListDeliveriesRequired, total=False):
1339
+ next_before_at: str
1340
+ next_before_id: str
1341
+
1342
+
1343
+ class RetryDeliveryResponse(TypedDict):
1344
+ delivery_id: str
1345
+ status: Literal["pending"]
1346
+
1347
+
1348
+ # === Recipients ===
1349
+
1350
+ class AddRecipientResponse(TypedDict):
1351
+ id: str
1352
+ email: str
1353
+ status: RecipientStatus
1354
+ created_at: str
1355
+
1356
+
1357
+ class Recipient(TypedDict):
1358
+ id: str
1359
+ email: str
1360
+ status: RecipientStatus
1361
+ created_at: str
1362
+ confirmed_at: str | None
1363
+
1364
+
1365
+ class ListRecipientsResponse(TypedDict):
1366
+ recipients: list[Recipient]
1367
+
1368
+
1369
+ # === Suppressions ===
1370
+
1371
+ class _SuppressionRequired(TypedDict):
1372
+ email: str
1373
+ reason: SuppressionReason
1374
+ source: str
1375
+ created_at: str
1376
+ added_by_actor_type: str | None
1377
+ added_by_actor_id: str | None
1378
+
1379
+
1380
+ class _SuppressionOptional(TypedDict, total=False):
1381
+ # Migration 051 — complaint history columns.
1382
+ #
1383
+ # `latest_complaint_at` is the LOCK SIGNAL: the most recent moment a
1384
+ # complaint signal was recorded (worker-received provider event OR
1385
+ # Mailgun-sync observation). Customer DELETE on rows with non-null
1386
+ # latest_complaint_at is rejected with 409
1387
+ # RECIPIENT_BLOCKLIST_COMPLAINT_LOCKED. complaint_count does NOT
1388
+ # affect lock behavior.
1389
+ #
1390
+ # `complaint_count` is the count of distinct provider complaint
1391
+ # events received by the worker. Mailgun-sync observations do NOT
1392
+ # increment it — a row locked by sync alone may show
1393
+ # complaint_count=0 while latest_complaint_at is set.
1394
+ latest_complaint_at: str | None
1395
+ complaint_count: int
1396
+
1397
+
1398
+ class Suppression(_SuppressionRequired, _HasPatternType, _SuppressionOptional):
1399
+ """Suppression entry. `pattern_type` present on 0.5.0+ server responses;
1400
+ `latest_complaint_at` and `complaint_count` present on migration-051+ servers."""
1401
+
1402
+
1403
+ class ListSuppressionsResponse(TypedDict):
1404
+ suppressions: list[Suppression]
1405
+ next_cursor: str | None
1406
+
1407
+
1408
+ class AddSuppressionRequest(TypedDict):
1409
+ email: str
1410
+
1411
+
1412
+ class _AddSuppressionResponseRequired(TypedDict):
1413
+ email: str
1414
+ reason: Literal["manual"]
1415
+ source: Literal["customer"]
1416
+ created_at: str | None
1417
+ already_existed: bool
1418
+ added_by_actor_type: str | None
1419
+ added_by_actor_id: str | None
1420
+
1421
+
1422
+ class AddSuppressionResponse(_AddSuppressionResponseRequired, _HasPatternType):
1423
+ """Response of POST /v1/suppressions."""
1424
+
1425
+
1426
+ class BulkAddSuppressionsRequest(TypedDict):
1427
+ emails: list[str]
1428
+
1429
+
1430
+ class _BulkAddedRowRequired(TypedDict):
1431
+ email: str
1432
+ created_at: str
1433
+
1434
+
1435
+ class BulkAddedRow(_BulkAddedRowRequired, _HasPatternType):
1436
+ """Bulk-added row. `pattern_type` present on 0.5.0+ server responses."""
1437
+
1438
+
1439
+ class BulkAddInvalidEntry(TypedDict):
1440
+ email: str
1441
+ reason: str
1442
+
1443
+
1444
+ class BulkAddCounts(TypedDict):
1445
+ added: int
1446
+ already_existed: int
1447
+ invalid: int
1448
+ total: int
1449
+
1450
+
1451
+ class BulkAddSuppressionsResponse(TypedDict):
1452
+ added: list[BulkAddedRow]
1453
+ already_existed: list[str]
1454
+ invalid: list[BulkAddInvalidEntry]
1455
+ counts: BulkAddCounts
1456
+
1457
+
1458
+ class _DeleteSuppressionResponseRequired(TypedDict):
1459
+ status: str
1460
+ email: str
1461
+ reason: str
1462
+ source: str
1463
+ created_at: str
1464
+
1465
+
1466
+ class DeleteSuppressionResponse(_DeleteSuppressionResponseRequired, _HasPatternType):
1467
+ """Response of DELETE /v1/suppressions/:email."""
1468
+
1469
+
1470
+ # === API Keys ===
1471
+
1472
+ class CreateApiKeyResponse(TypedDict):
1473
+ id: str
1474
+ api_key: str
1475
+ role: ApiKeyRole
1476
+ label: str | None
1477
+ mailbox_ids: list[str]
1478
+
1479
+
1480
+ class ApiKeySummary(TypedDict):
1481
+ id: str
1482
+ prefix: str
1483
+ status: str
1484
+ role: str | None
1485
+ label: str | None
1486
+ mailbox_ids: list[str]
1487
+ created_at: str
1488
+ last_used_at: str | None
1489
+
1490
+
1491
+ class ListApiKeysResponse(TypedDict):
1492
+ keys: list[ApiKeySummary]
1493
+
1494
+
1495
+ class RotateKeyResponse(TypedDict):
1496
+ api_key: str
1497
+
1498
+
1499
+ # === Account ===
1500
+
1501
+ StorageUsageState = Literal["normal", "warning", "near_full", "over_limit"]
1502
+
1503
+
1504
+ class UsageToday(TypedDict):
1505
+ count: int
1506
+ limit: int
1507
+ day: str
1508
+
1509
+
1510
+ class StorageUsageBreakdown(TypedDict):
1511
+ raw_mime_bytes: int
1512
+ derivative_bytes: int
1513
+
1514
+
1515
+ class StorageUsage(TypedDict):
1516
+ used_bytes: int
1517
+ limit_bytes: int | None
1518
+ percent_used: float | None
1519
+ state: StorageUsageState
1520
+ breakdown: StorageUsageBreakdown
1521
+
1522
+
1523
+ class UsageResponse(TypedDict, total=False):
1524
+ today: UsageToday
1525
+ history: list[dict[str, Any]]
1526
+ mailbox_count: int
1527
+ mailbox_limit: int
1528
+ storage: Required[StorageUsage]
1529
+ # PR 6 — REQUIRED. Mirrors the TS SDK + API schema: a missing field
1530
+ # surfaces as schema validation failure (500) on the server, never
1531
+ # silent zero. Other fields stay total=False (optional) for backwards
1532
+ # compat with consumers that relied on partial-dict semantics.
1533
+ pending_review_count: Required[int]
1534
+ rates: dict[str, float]
1535
+ health: dict[str, Any]
1536
+ trust: dict[str, Any]
1537
+
1538
+
1539
+ # Agent-accessible send-budget preflight (GET /v1/accounts/quota, RL-UAT-015/D6).
1540
+ # `today.limit` is the EFFECTIVE (trust-derived) daily cap, not the raw tier cap.
1541
+ # The `scope` discriminator disambiguates the two []-mailbox cases: "admin" with
1542
+ # [] => ALL mailboxes; "agent" with [] => a zero-bound agent key with NO send
1543
+ # capability.
1544
+ class QuotaToday(TypedDict):
1545
+ count: int
1546
+ limit: int
1547
+ day: str
1548
+
1549
+
1550
+ class QuotaResponse(TypedDict):
1551
+ today: QuotaToday
1552
+ sends_remaining: int
1553
+ reset_at: str
1554
+ scope: Literal["admin", "agent"]
1555
+ bound_mailbox_ids: list[str]
1556
+
1557
+
1558
+ # === Health ===
1559
+
1560
+ class ProviderHealth(TypedDict, total=False):
1561
+ healthy: bool
1562
+ configured: bool
1563
+ events_configured: bool
1564
+
1565
+
1566
+ class HealthResponse(TypedDict, total=False):
1567
+ status: Literal["ok", "degraded", "down"]
1568
+ database: bool
1569
+ providers: dict[str, ProviderHealth]
1570
+ kms: bool
1571
+ scanner: bool
1572
+ spam_filter: bool
1573
+ queue_depth: int
1574
+ timestamp: str
1575
+ # Additive capability tokens advertised by the server (e.g.
1576
+ # "messages.has_attachment_filter"). Absent on older servers — treat a
1577
+ # missing token as "feature unsupported".
1578
+ capabilities: list[str]