replylayer 0.17.0__tar.gz → 0.21.0__tar.gz

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.
Files changed (46) hide show
  1. {replylayer-0.17.0 → replylayer-0.21.0}/.gitignore +4 -0
  2. {replylayer-0.17.0 → replylayer-0.21.0}/PKG-INFO +20 -3
  3. {replylayer-0.17.0 → replylayer-0.21.0}/README.md +19 -2
  4. {replylayer-0.17.0 → replylayer-0.21.0}/pyproject.toml +1 -1
  5. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/__init__.py +33 -1
  6. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/_client.py +11 -0
  7. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/_http.py +9 -1
  8. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/errors.py +104 -0
  9. replylayer-0.21.0/replylayer/resources/account.py +97 -0
  10. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/messages.py +277 -6
  11. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/types.py +152 -0
  12. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_async.py +105 -1
  13. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_domains.py +2 -2
  14. replylayer-0.21.0/tests/test_governed_email_effect.py +399 -0
  15. replylayer-0.21.0/tests/test_messages_idempotency.py +285 -0
  16. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_resources.py +120 -5
  17. replylayer-0.21.0/tests/test_version.py +35 -0
  18. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_web_risk_types.py +53 -0
  19. {replylayer-0.17.0 → replylayer-0.21.0}/uv.lock +1 -1
  20. replylayer-0.17.0/replylayer/resources/account.py +0 -36
  21. replylayer-0.17.0/tests/test_version.py +0 -18
  22. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/__main__.py +0 -0
  23. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/_pagination.py +0 -0
  24. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/py.typed +0 -0
  25. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/__init__.py +0 -0
  26. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/api_keys.py +0 -0
  27. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/attachments.py +0 -0
  28. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/domains.py +0 -0
  29. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/drafts.py +0 -0
  30. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/health.py +0 -0
  31. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/inbound_blocklist.py +0 -0
  32. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/legal_holds.py +0 -0
  33. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/mailboxes.py +0 -0
  34. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/recipients.py +0 -0
  35. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/suppressions.py +0 -0
  36. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/threads.py +0 -0
  37. {replylayer-0.17.0 → replylayer-0.21.0}/replylayer/resources/webhooks.py +0 -0
  38. {replylayer-0.17.0 → replylayer-0.21.0}/tests/__init__.py +0 -0
  39. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_attachments.py +0 -0
  40. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_client.py +0 -0
  41. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_drafts.py +0 -0
  42. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_hitl_review_types.py +0 -0
  43. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_http.py +0 -0
  44. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_threads.py +0 -0
  45. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_webhooks.py +0 -0
  46. {replylayer-0.17.0 → replylayer-0.21.0}/tests/test_ws1_ws6.py +0 -0
@@ -52,3 +52,7 @@ plans/fault-briefings/
52
52
  # under audits/ — these are signed-verdict-anchored evidence per
53
53
  # plans/granite-guardian-failover-saturation-stress-test.md §11.
54
54
  !audits/**/*.log
55
+ uat-grading-results*.json
56
+
57
+ # Scanner eval run outputs (corpora are tracked, results are not)
58
+ packages/scanner/src/eval/*-results.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replylayer
3
- Version: 0.17.0
3
+ Version: 0.21.0
4
4
  Summary: Official Python SDK for ReplyLayer — email for AI agents
5
5
  License-Expression: MIT
6
6
  Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
@@ -126,7 +126,7 @@ contract — read it before relying on retries:
126
126
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
127
127
  | `rl.suppressions` | `list`, `delete` |
128
128
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
129
- | `rl.account` | `get_usage` |
129
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
130
130
  | `rl.health` | `check` |
131
131
 
132
132
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -152,7 +152,7 @@ The send/reply/draft-send response carries two additive, nullable keys that expl
152
152
  By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
153
153
 
154
154
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
155
- - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
155
+ - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
156
156
  - `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
157
157
 
158
158
  ```python
@@ -426,6 +426,23 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
426
426
 
427
427
  The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
428
428
 
429
+ ## Malicious link scanning (URL reputation)
430
+
431
+ Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
432
+
433
+ Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
434
+
435
+ ```python
436
+ status = rl.account.get_link_scanning_status()
437
+ # {"active", "accepted_version", "current_version", "privacy_ok"}
438
+
439
+ if not status["active"] and status["privacy_ok"]:
440
+ res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
441
+ # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
442
+ ```
443
+
444
+ If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).
445
+
429
446
  ## Mailbox identifiers
430
447
 
431
448
  Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
@@ -109,7 +109,7 @@ contract — read it before relying on retries:
109
109
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
110
110
  | `rl.suppressions` | `list`, `delete` |
111
111
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
112
- | `rl.account` | `get_usage` |
112
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
113
113
  | `rl.health` | `check` |
114
114
 
115
115
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -135,7 +135,7 @@ The send/reply/draft-send response carries two additive, nullable keys that expl
135
135
  By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
136
136
 
137
137
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
138
- - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
138
+ - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
139
139
  - `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
140
140
 
141
141
  ```python
@@ -409,6 +409,23 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
409
409
 
410
410
  The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
411
411
 
412
+ ## Malicious link scanning (URL reputation)
413
+
414
+ Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
415
+
416
+ Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
417
+
418
+ ```python
419
+ status = rl.account.get_link_scanning_status()
420
+ # {"active", "accepted_version", "current_version", "privacy_ok"}
421
+
422
+ if not status["active"] and status["privacy_ok"]:
423
+ res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
424
+ # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
425
+ ```
426
+
427
+ If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).
428
+
412
429
  ## Mailbox identifiers
413
430
 
414
431
  Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.17.0"
7
+ version = "0.21.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,5 +1,6 @@
1
1
  from ._client import ReplyLayer, AsyncReplyLayer
2
2
  from ._http import RetryInfo
3
+ from .resources.messages import IdempotencyReplayResult
3
4
  from .resources.webhooks import verify_webhook_signature
4
5
  from .errors import (
5
6
  ReplyLayerError,
@@ -12,8 +13,16 @@ from .errors import (
12
13
  # Migration 040 — scheduled-send error surface.
13
14
  SchedulingError,
14
15
  TimezoneRequiredError,
16
+ # Governed Email-Effect Contract v1 (Track 2) — strict-outcome errors.
17
+ EmailEffectError,
18
+ EmailEffectRejectedError,
19
+ EmailEffectHeldError,
20
+ EmailEffectRetryableError,
15
21
  )
16
22
  from .types import (
23
+ # Message delete response (0.20.0).
24
+ DeleteMessageResponse,
25
+ ReportResponse,
17
26
  # WS1 — star response types (0.16.0).
18
27
  MessageStarResponse,
19
28
  ThreadStarResponse,
@@ -57,6 +66,13 @@ from .types import (
57
66
  WebRiskLearnMore,
58
67
  WebRiskWarning,
59
68
  WebRiskThreatType,
69
+ LinkScanningStatus,
70
+ EnableLinkScanningResponse,
71
+ EnableLinkScanningDisclosure,
72
+ AgentSafetyContext,
73
+ # Governed Email-Effect Contract v1 (Track 2).
74
+ EmailEffect,
75
+ EffectStatus,
60
76
  MessageReceivedSafetySignal,
61
77
  MessageReceivedWebhookPayload,
62
78
  # PR 6 — HITL review queue webhook payload types.
@@ -76,14 +92,19 @@ from .types import (
76
92
  ScannerPolicy,
77
93
  )
78
94
 
79
- __version__ = "0.17.0"
95
+ __version__ = "0.21.0"
80
96
 
81
97
  __all__ = [
98
+ # Message delete response (0.20.0).
99
+ "DeleteMessageResponse",
100
+ "ReportResponse",
82
101
  # WS1 — star response types (0.16.0).
83
102
  "MessageStarResponse",
84
103
  "ThreadStarResponse",
85
104
  # WS6-SDK — async optimistic-ack (0.16.0).
86
105
  "AsyncSendAck",
106
+ # Track 1 — non-throwing idempotency replay probe result.
107
+ "IdempotencyReplayResult",
87
108
  "ReplyLayer",
88
109
  "AsyncReplyLayer",
89
110
  "RetryInfo",
@@ -98,6 +119,13 @@ __all__ = [
98
119
  # Migration 040 — scheduled-send.
99
120
  "SchedulingError",
100
121
  "TimezoneRequiredError",
122
+ # Governed Email-Effect Contract v1 (Track 2).
123
+ "EmailEffectError",
124
+ "EmailEffectRejectedError",
125
+ "EmailEffectHeldError",
126
+ "EmailEffectRetryableError",
127
+ "EmailEffect",
128
+ "EffectStatus",
101
129
  "WebhookSummary",
102
130
  "WebhookDeliverySummary",
103
131
  "WebhookDeliveryStatus",
@@ -132,6 +160,10 @@ __all__ = [
132
160
  "WebRiskLearnMore",
133
161
  "WebRiskWarning",
134
162
  "WebRiskThreatType",
163
+ "LinkScanningStatus",
164
+ "EnableLinkScanningResponse",
165
+ "EnableLinkScanningDisclosure",
166
+ "AgentSafetyContext",
135
167
  "MessageReceivedSafetySignal",
136
168
  "MessageReceivedWebhookPayload",
137
169
  # PR 6 — HITL review queue.
@@ -36,6 +36,7 @@ class ReplyLayer:
36
36
  timeout: float = 30.0,
37
37
  max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
38
38
  on_retry: OnRetry | None = None,
39
+ strict_outcome: bool = False,
39
40
  ) -> None:
40
41
  if not api_key:
41
42
  raise ReplyLayerError(
@@ -43,6 +44,12 @@ class ReplyLayer:
43
44
  "api_key is required. Get yours at https://app.replylayer.ai/connect",
44
45
  )
45
46
 
47
+ # Governed Email-Effect Contract v1 (Track 2) — client-default for the
48
+ # `Prefer: outcome=strict` opt-in. When True, messages.send / .reply
49
+ # request a non-2xx (503/422/409) for a held/blocked outcome, raising the
50
+ # matching EmailEffectError subclass instead of resolving a 200. Default
51
+ # False is byte-identical to today (200 with `email_effect` in the body,
52
+ # no throw). Override per-call via `messages.send(strict_outcome=...)`.
46
53
  self._http = SyncHttpClient(
47
54
  api_key=api_key,
48
55
  base_url=base_url.rstrip("/"),
@@ -50,6 +57,7 @@ class ReplyLayer:
50
57
  timeout=timeout,
51
58
  max_retry_after_seconds=max_retry_after_seconds,
52
59
  on_retry=on_retry,
60
+ strict_outcome=strict_outcome,
53
61
  )
54
62
 
55
63
  self.domains = SyncDomains(self._http)
@@ -87,6 +95,7 @@ class AsyncReplyLayer:
87
95
  timeout: float = 30.0,
88
96
  max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
89
97
  on_retry: OnRetry | None = None,
98
+ strict_outcome: bool = False,
90
99
  ) -> None:
91
100
  if not api_key:
92
101
  raise ReplyLayerError(
@@ -94,6 +103,7 @@ class AsyncReplyLayer:
94
103
  "api_key is required. Get yours at https://app.replylayer.ai/connect",
95
104
  )
96
105
 
106
+ # Governed Email-Effect Contract v1 (Track 2) — see ReplyLayer above.
97
107
  self._http = AsyncHttpClient(
98
108
  api_key=api_key,
99
109
  base_url=base_url.rstrip("/"),
@@ -101,6 +111,7 @@ class AsyncReplyLayer:
101
111
  timeout=timeout,
102
112
  max_retry_after_seconds=max_retry_after_seconds,
103
113
  on_retry=on_retry,
114
+ strict_outcome=strict_outcome,
104
115
  )
105
116
 
106
117
  self.domains = AsyncDomains(self._http)
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.17.0"
12
+ _VERSION = "0.21.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -47,6 +47,7 @@ class SyncHttpClient:
47
47
  timeout: float,
48
48
  max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
49
49
  on_retry: OnRetry | None = None,
50
+ strict_outcome: bool = False,
50
51
  ) -> None:
51
52
  self._api_key = api_key
52
53
  self._base_url = base_url
@@ -54,6 +55,10 @@ class SyncHttpClient:
54
55
  self._timeout = timeout
55
56
  self._max_retry_after_seconds = max_retry_after_seconds
56
57
  self._on_retry = on_retry
58
+ # Governed Email-Effect Contract v1 (Track 2) — client default for the
59
+ # `Prefer: outcome=strict` opt-in. Read by SyncMessages/AsyncMessages
60
+ # send/reply as the fallback when the per-call `strict_outcome` is None.
61
+ self._strict_outcome = strict_outcome
57
62
  self._client = httpx.Client(timeout=timeout)
58
63
 
59
64
  def close(self) -> None:
@@ -194,6 +199,7 @@ class AsyncHttpClient:
194
199
  timeout: float,
195
200
  max_retry_after_seconds: float = _MAX_RETRY_AFTER_SECONDS,
196
201
  on_retry: OnRetry | None = None,
202
+ strict_outcome: bool = False,
197
203
  ) -> None:
198
204
  self._api_key = api_key
199
205
  self._base_url = base_url
@@ -201,6 +207,8 @@ class AsyncHttpClient:
201
207
  self._timeout = timeout
202
208
  self._max_retry_after_seconds = max_retry_after_seconds
203
209
  self._on_retry = on_retry
210
+ # Governed Email-Effect Contract v1 (Track 2) — see SyncHttpClient.
211
+ self._strict_outcome = strict_outcome
204
212
  self._client = httpx.AsyncClient(timeout=timeout)
205
213
 
206
214
  async def aclose(self) -> None:
@@ -113,6 +113,92 @@ class TimezoneRequiredError(SchedulingError):
113
113
  )
114
114
 
115
115
 
116
+ # Governed Email-Effect Contract v1 (Track 2) — strict-outcome opt-in errors.
117
+ #
118
+ # Raised ONLY when the caller opted in via `strict_outcome=True`
119
+ # (Prefer: outcome=strict) AND the server maps a non-delivered terminal/held
120
+ # outcome to a non-2xx. The error body carries the full governed payload
121
+ # (`message_id`, `status`, `scan`, `hold_context`, `email_effect`) in
122
+ # `details`, so a caller can branch retry-vs-edit-vs-escalate from the throw.
123
+ #
124
+ # Default (no `strict_outcome`) is byte-identical to today: the send/reply path
125
+ # returns HTTP 200 with `email_effect` in the body and NONE of these is raised.
126
+ #
127
+ # Subclass of ReplyLayerError so generic catches still work; isinstance
128
+ # EmailEffectError narrows to the strict-outcome family. Status-code → subclass:
129
+ # 503 EMAIL_EFFECT_HELD_INFRA → EmailEffectRetryableError (carries retry_after)
130
+ # 422 EMAIL_EFFECT_REJECTED → EmailEffectRejectedError (terminal content block)
131
+ # 409 EMAIL_EFFECT_HELD → EmailEffectHeldError (releasable hold)
132
+ # The factory recognizes the family by the body carrying `details.email_effect`
133
+ # (mirroring the TS SDK), not by the `code` string.
134
+
135
+
136
+ class EmailEffectError(ReplyLayerError):
137
+ """Base for the strict-outcome (``Prefer: outcome=strict``) error family.
138
+
139
+ ``email_effect`` is the parsed ``details.email_effect`` block (the governed
140
+ discriminator + booleans); ``scan`` is the parsed ``details.scan`` summary.
141
+ Both may be None if the server omitted them.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ status_code: int,
147
+ code: str,
148
+ message: str,
149
+ details: dict[str, Any] | None = None,
150
+ ) -> None:
151
+ super().__init__(status_code, code, message, details)
152
+ d = details or {}
153
+ self.email_effect: dict[str, Any] | None = d.get("email_effect")
154
+ self.scan: dict[str, Any] | None = d.get("scan")
155
+
156
+
157
+ class EmailEffectRejectedError(EmailEffectError):
158
+ """422 EMAIL_EFFECT_REJECTED — a genuine terminal content rejection.
159
+
160
+ ``email_effect.effect_status == 'blocked'`` (terminal=True). The agent's
161
+ correct action is edit-and-resend or escalate — NEVER retry the same body.
162
+ """
163
+
164
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
165
+ super().__init__(422, code, message, details)
166
+
167
+
168
+ class EmailEffectHeldError(EmailEffectError):
169
+ """409 EMAIL_EFFECT_HELD — accepted into governance, releasable.
170
+
171
+ ``email_effect.effect_status == 'held_for_review'`` (releasable=True). The
172
+ send was quarantined / queued for human review — report "awaiting approval",
173
+ do not treat as a content error to fix by editing.
174
+ """
175
+
176
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
177
+ super().__init__(409, code, message, details)
178
+
179
+
180
+ class EmailEffectRetryableError(EmailEffectError):
181
+ """503 EMAIL_EFFECT_HELD_INFRA — a transient infrastructure hold.
182
+
183
+ ``email_effect.effect_status == 'held_infrastructure'`` (retryable=True): an
184
+ ``inference_error`` with NO genuine model judgment, so the content was never
185
+ judged. The agent's correct action is retry-later (back off on repeated
186
+ 503s). ``retry_after`` mirrors the ``Retry-After`` header (None on absent /
187
+ non-numeric), reusing the same null-on-NaN parse as RateLimitError.
188
+ """
189
+
190
+ def __init__(
191
+ self,
192
+ code: str,
193
+ message: str,
194
+ headers: dict[str, str],
195
+ details: dict[str, Any] | None = None,
196
+ ) -> None:
197
+ super().__init__(503, code, message, details)
198
+ ra = headers.get("retry-after")
199
+ self.retry_after: int | None = int(ra) if ra and ra.isdigit() else None
200
+
201
+
116
202
  def error_from_response(
117
203
  status: int,
118
204
  body: dict[str, Any],
@@ -139,6 +225,24 @@ def error_from_response(
139
225
  if code in _SCHEDULING_REASON_CODES:
140
226
  return SchedulingError(status, code, message, details)
141
227
 
228
+ # Governed Email-Effect Contract v1 (Track 2) — strict-outcome opt-in.
229
+ # Precedence-first BEFORE the generic status switch so a 503 becomes an
230
+ # EmailEffectRetryableError (not a generic ReplyLayerError), a 422 an
231
+ # EmailEffectRejectedError (not a plain ValidationError), and a 409 an
232
+ # EmailEffectHeldError — so `isinstance(err, EmailEffectError)` works
233
+ # uniformly across statuses. Gated on the body carrying `email_effect`
234
+ # (present ONLY when the request opted in AND the server applied the strict
235
+ # mapping), mirroring the TS SDK's `details?.email_effect` gate — robust to
236
+ # the exact `code` string. Any other status falls through to the generic
237
+ # switch unchanged.
238
+ if isinstance(details, dict) and details.get("email_effect") is not None:
239
+ if status == 422:
240
+ return EmailEffectRejectedError(code, message, details)
241
+ if status == 409:
242
+ return EmailEffectHeldError(code, message, details)
243
+ if status == 503:
244
+ return EmailEffectRetryableError(code, message, headers, details)
245
+
142
246
  if status == 401:
143
247
  return AuthenticationError(code, message, details)
144
248
  if status == 403:
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+ from ..errors import ReplyLayerError
7
+ from ..types import (
8
+ EnableLinkScanningResponse,
9
+ LinkScanningStatus,
10
+ QuotaResponse,
11
+ UsageResponse,
12
+ )
13
+
14
+ # Raised when the deployed API predates the malicious-link-scanning surface and
15
+ # omits the url_reputation block from GET /v1/auth/me. Mirrors the TS SDK / CLI
16
+ # clear "API outdated" error instead of leaking a raw KeyError.
17
+ _LINK_SCANNING_UNSUPPORTED_MSG = (
18
+ "Server did not return link-scanning status; the API may be outdated."
19
+ )
20
+
21
+
22
+ class SyncAccount:
23
+ def __init__(self, http: SyncHttpClient) -> None:
24
+ self._http = http
25
+
26
+ def get_usage(self) -> UsageResponse:
27
+ return self._http.request("GET", "/v1/accounts/usage")
28
+
29
+ def get_quota(self) -> QuotaResponse:
30
+ return self._http.request("GET", "/v1/accounts/quota")
31
+
32
+ def get_link_scanning_status(self) -> LinkScanningStatus:
33
+ """Malicious link scanning (URL reputation, Google Web Risk) status.
34
+
35
+ Reads the ``url_reputation`` block off GET /v1/auth/me. Works with any key.
36
+ """
37
+ me = self._http.request("GET", "/v1/auth/me")
38
+ if "url_reputation" not in me:
39
+ raise ReplyLayerError(0, "LINK_SCANNING_UNSUPPORTED", _LINK_SCANNING_UNSUPPORTED_MSG)
40
+ return me["url_reputation"]
41
+
42
+ def enable_link_scanning(self, accept_web_risk_version: str) -> EnableLinkScanningResponse:
43
+ """Enable malicious link scanning.
44
+
45
+ Pass the disclaimer version you are acknowledging (from
46
+ ``get_link_scanning_status()["current_version"]``) — the consent is explicit.
47
+ Requires an admin key or session (agent keys get 403). The response includes
48
+ the disclosure you accepted.
49
+ """
50
+ return self._http.request(
51
+ "POST",
52
+ "/v1/accounts/url-reputation",
53
+ body={"accept_web_risk_version": accept_web_risk_version},
54
+ )
55
+
56
+ def export(self) -> dict[str, Any]:
57
+ """GDPR Art. 20 data portability export — the full account-data object."""
58
+ return self._http.request("GET", "/v1/accounts/export")
59
+
60
+
61
+ class AsyncAccount:
62
+ def __init__(self, http: AsyncHttpClient) -> None:
63
+ self._http = http
64
+
65
+ async def get_usage(self) -> UsageResponse:
66
+ return await self._http.request("GET", "/v1/accounts/usage")
67
+
68
+ async def get_quota(self) -> QuotaResponse:
69
+ return await self._http.request("GET", "/v1/accounts/quota")
70
+
71
+ async def get_link_scanning_status(self) -> LinkScanningStatus:
72
+ """Malicious link scanning (URL reputation, Google Web Risk) status.
73
+
74
+ Reads the ``url_reputation`` block off GET /v1/auth/me. Works with any key.
75
+ """
76
+ me = await self._http.request("GET", "/v1/auth/me")
77
+ if "url_reputation" not in me:
78
+ raise ReplyLayerError(0, "LINK_SCANNING_UNSUPPORTED", _LINK_SCANNING_UNSUPPORTED_MSG)
79
+ return me["url_reputation"]
80
+
81
+ async def enable_link_scanning(self, accept_web_risk_version: str) -> EnableLinkScanningResponse:
82
+ """Enable malicious link scanning.
83
+
84
+ Pass the disclaimer version you are acknowledging (from
85
+ ``get_link_scanning_status()["current_version"]``) — the consent is explicit.
86
+ Requires an admin key or session (agent keys get 403). The response includes
87
+ the disclosure you accepted.
88
+ """
89
+ return await self._http.request(
90
+ "POST",
91
+ "/v1/accounts/url-reputation",
92
+ body={"accept_web_risk_version": accept_web_risk_version},
93
+ )
94
+
95
+ async def export(self) -> dict[str, Any]:
96
+ """GDPR Art. 20 data portability export — the full account-data object."""
97
+ return await self._http.request("GET", "/v1/accounts/export")