replylayer 0.18.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 (44) hide show
  1. {replylayer-0.18.0 → replylayer-0.21.0}/PKG-INFO +1 -1
  2. {replylayer-0.18.0 → replylayer-0.21.0}/pyproject.toml +1 -1
  3. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/__init__.py +22 -1
  4. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/_client.py +11 -0
  5. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/_http.py +9 -1
  6. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/errors.py +104 -0
  7. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/messages.py +117 -13
  8. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/types.py +73 -0
  9. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_async.py +38 -0
  10. replylayer-0.21.0/tests/test_governed_email_effect.py +399 -0
  11. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_resources.py +52 -0
  12. {replylayer-0.18.0 → replylayer-0.21.0}/.gitignore +0 -0
  13. {replylayer-0.18.0 → replylayer-0.21.0}/README.md +0 -0
  14. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/__main__.py +0 -0
  15. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/_pagination.py +0 -0
  16. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/py.typed +0 -0
  17. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/__init__.py +0 -0
  18. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/account.py +0 -0
  19. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/api_keys.py +0 -0
  20. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/attachments.py +0 -0
  21. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/domains.py +0 -0
  22. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/drafts.py +0 -0
  23. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/health.py +0 -0
  24. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/inbound_blocklist.py +0 -0
  25. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/legal_holds.py +0 -0
  26. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/mailboxes.py +0 -0
  27. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/recipients.py +0 -0
  28. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/suppressions.py +0 -0
  29. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/threads.py +0 -0
  30. {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/webhooks.py +0 -0
  31. {replylayer-0.18.0 → replylayer-0.21.0}/tests/__init__.py +0 -0
  32. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_attachments.py +0 -0
  33. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_client.py +0 -0
  34. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_domains.py +0 -0
  35. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_drafts.py +0 -0
  36. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_hitl_review_types.py +0 -0
  37. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_http.py +0 -0
  38. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_messages_idempotency.py +0 -0
  39. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_threads.py +0 -0
  40. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_version.py +0 -0
  41. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_web_risk_types.py +0 -0
  42. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_webhooks.py +0 -0
  43. {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_ws1_ws6.py +0 -0
  44. {replylayer-0.18.0 → replylayer-0.21.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replylayer
3
- Version: 0.18.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.18.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"
@@ -13,8 +13,16 @@ from .errors import (
13
13
  # Migration 040 — scheduled-send error surface.
14
14
  SchedulingError,
15
15
  TimezoneRequiredError,
16
+ # Governed Email-Effect Contract v1 (Track 2) — strict-outcome errors.
17
+ EmailEffectError,
18
+ EmailEffectRejectedError,
19
+ EmailEffectHeldError,
20
+ EmailEffectRetryableError,
16
21
  )
17
22
  from .types import (
23
+ # Message delete response (0.20.0).
24
+ DeleteMessageResponse,
25
+ ReportResponse,
18
26
  # WS1 — star response types (0.16.0).
19
27
  MessageStarResponse,
20
28
  ThreadStarResponse,
@@ -62,6 +70,9 @@ from .types import (
62
70
  EnableLinkScanningResponse,
63
71
  EnableLinkScanningDisclosure,
64
72
  AgentSafetyContext,
73
+ # Governed Email-Effect Contract v1 (Track 2).
74
+ EmailEffect,
75
+ EffectStatus,
65
76
  MessageReceivedSafetySignal,
66
77
  MessageReceivedWebhookPayload,
67
78
  # PR 6 — HITL review queue webhook payload types.
@@ -81,9 +92,12 @@ from .types import (
81
92
  ScannerPolicy,
82
93
  )
83
94
 
84
- __version__ = "0.18.0"
95
+ __version__ = "0.21.0"
85
96
 
86
97
  __all__ = [
98
+ # Message delete response (0.20.0).
99
+ "DeleteMessageResponse",
100
+ "ReportResponse",
87
101
  # WS1 — star response types (0.16.0).
88
102
  "MessageStarResponse",
89
103
  "ThreadStarResponse",
@@ -105,6 +119,13 @@ __all__ = [
105
119
  # Migration 040 — scheduled-send.
106
120
  "SchedulingError",
107
121
  "TimezoneRequiredError",
122
+ # Governed Email-Effect Contract v1 (Track 2).
123
+ "EmailEffectError",
124
+ "EmailEffectRejectedError",
125
+ "EmailEffectHeldError",
126
+ "EmailEffectRetryableError",
127
+ "EmailEffect",
128
+ "EffectStatus",
108
129
  "WebhookSummary",
109
130
  "WebhookDeliverySummary",
110
131
  "WebhookDeliveryStatus",
@@ -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.18.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:
@@ -6,7 +6,7 @@ from typing import Any, AsyncIterator, Iterator, Literal
6
6
  from .._http import AsyncHttpClient, SyncHttpClient
7
7
  from .._pagination import async_auto_paginate, sync_auto_paginate
8
8
  from ..errors import ReplyLayerError
9
- from ..types import MessageStarResponse, Page
9
+ from ..types import DeleteMessageResponse, MessageStarResponse, Page, ReportResponse
10
10
 
11
11
  DEFAULT_LIMIT = 50
12
12
 
@@ -68,6 +68,26 @@ def _classify_idempotency_probe_error(
68
68
  return None
69
69
 
70
70
 
71
+ def _send_extra_headers(
72
+ *,
73
+ idempotency_key: str | None,
74
+ strict_outcome: bool,
75
+ ) -> dict[str, str] | None:
76
+ """Build the per-call header dict for send/reply (Governed Email-Effect
77
+ Contract v1, Track 2). Adds ``Idempotency-Key`` (Track 1) and, when
78
+ ``strict_outcome`` resolved True, ``Prefer: outcome=strict`` — which opts
79
+ the request into the 503/422/409 strict mapping. ``Prefer`` is not a
80
+ protected header, so it threads through the existing ``extra_headers`` path.
81
+ Returns ``None`` when neither header applies (no behavior change).
82
+ """
83
+ headers: dict[str, str] = {}
84
+ if idempotency_key is not None:
85
+ headers["Idempotency-Key"] = idempotency_key
86
+ if strict_outcome:
87
+ headers["Prefer"] = "outcome=strict"
88
+ return headers or None
89
+
90
+
71
91
  class SyncMessages:
72
92
  def __init__(self, http: SyncHttpClient) -> None:
73
93
  self._http = http
@@ -85,6 +105,7 @@ class SyncMessages:
85
105
  subaddress_mode: str | None = None,
86
106
  attachment_ids: list[str] | None = None,
87
107
  idempotency_key: str | None = None,
108
+ strict_outcome: bool | None = None,
88
109
  ) -> dict[str, Any]:
89
110
  """Send an outbound message.
90
111
 
@@ -97,6 +118,15 @@ class SyncMessages:
97
118
  to a thread participant is admitted past an ``allowlist`` mailbox
98
119
  via the thread-scoped bypass.
99
120
 
121
+ ``strict_outcome`` (Governed Email-Effect Contract v1, Track 2) opts the
122
+ request into ``Prefer: outcome=strict``: a held/blocked outcome then
123
+ returns a non-2xx (503 infra-hold / 422 content-block / 409 releasable-
124
+ hold) and the SDK raises the matching :class:`EmailEffectError` subclass
125
+ carrying ``email_effect`` + ``scan`` from ``details``, instead of
126
+ resolving a 200 with the outcome hidden in ``status``. ``None`` (default)
127
+ inherits the client-level ``strict_outcome``; ``False`` forces today's
128
+ 200 behavior for this call.
129
+
100
130
  ``idempotency_key`` (Track 1) makes a retried same-key immediate send
101
131
  produce at most one email + one charge: the server replays the prior
102
132
  message instead of re-sending. The key travels in the ``Idempotency-Key``
@@ -132,9 +162,12 @@ class SyncMessages:
132
162
  payload["subaddress_mode"] = subaddress_mode
133
163
  if attachment_ids is not None:
134
164
  payload["attachment_ids"] = attachment_ids
135
- extra_headers: dict[str, str] | None = None
136
- if idempotency_key is not None:
137
- extra_headers = {"Idempotency-Key": idempotency_key}
165
+ effective_strict = (
166
+ strict_outcome if strict_outcome is not None else self._http._strict_outcome
167
+ )
168
+ extra_headers = _send_extra_headers(
169
+ idempotency_key=idempotency_key, strict_outcome=effective_strict
170
+ )
138
171
  return self._http.request(
139
172
  "POST", "/v1/messages/send", body=payload, extra_headers=extra_headers
140
173
  )
@@ -209,6 +242,7 @@ class SyncMessages:
209
242
  subaddress_mode: str | None = None,
210
243
  attachment_ids: list[str] | None = None,
211
244
  idempotency_key: str | None = None,
245
+ strict_outcome: bool | None = None,
212
246
  ) -> dict[str, Any]:
213
247
  """Reply to an inbound message.
214
248
 
@@ -216,6 +250,10 @@ class SyncMessages:
216
250
  most one email + one charge — the server replays the prior message
217
251
  instead of re-sending. The key travels in the ``Idempotency-Key``
218
252
  request header. See :meth:`get_idempotency_replay`.
253
+
254
+ ``strict_outcome`` (Governed Email-Effect Contract v1, Track 2) opts the
255
+ request into ``Prefer: outcome=strict`` — see :meth:`send`. ``None``
256
+ inherits the client-level default; ``False`` forces the 200 path.
219
257
  """
220
258
  payload: dict[str, Any] = {"body": body}
221
259
  if html is not None:
@@ -226,9 +264,12 @@ class SyncMessages:
226
264
  payload["subaddress_mode"] = subaddress_mode
227
265
  if attachment_ids is not None:
228
266
  payload["attachment_ids"] = attachment_ids
229
- extra_headers: dict[str, str] | None = None
230
- if idempotency_key is not None:
231
- extra_headers = {"Idempotency-Key": idempotency_key}
267
+ effective_strict = (
268
+ strict_outcome if strict_outcome is not None else self._http._strict_outcome
269
+ )
270
+ extra_headers = _send_extra_headers(
271
+ idempotency_key=idempotency_key, strict_outcome=effective_strict
272
+ )
232
273
  return self._http.request(
233
274
  "POST",
234
275
  f"/v1/messages/{message_id}/reply",
@@ -296,6 +337,40 @@ class SyncMessages:
296
337
  body["reason"] = reason
297
338
  return self._http.request("POST", f"/v1/messages/{message_id}/block", body=body)
298
339
 
340
+ def report(self, message_id: str, *, reason: str | None = None) -> "ReportResponse":
341
+ """Atomic report-and-block for an INBOUND message.
342
+
343
+ Blocks a held message and adds the resolved sender to the account-wide
344
+ inbound blocklist (idempotent); optional reason is persisted to audit.
345
+ An outbound target raises ``ReplyLayerError`` (422
346
+ REPORT_OUTBOUND_UNSUPPORTED) — an outbound message has no inbound sender
347
+ to block.
348
+ """
349
+ body: dict[str, Any] = {}
350
+ if reason is not None:
351
+ body["reason"] = reason
352
+ return self._http.request("POST", f"/v1/messages/{message_id}/report", body=body)
353
+
354
+ def delete(self, message_id: str) -> "DeleteMessageResponse":
355
+ """Soft-delete a message and purge its raw MIME from object storage.
356
+
357
+ Direction-agnostic — works on inbound or outbound rows in a deletable
358
+ state (e.g. available / quarantined / delivered / bounced); draft /
359
+ scheduled / dispatching rows return 409. Idempotent: re-deleting an
360
+ already-deleted row returns 200 again.
361
+
362
+ Auth: admin keys and session cookies always; mailbox-bound agent keys
363
+ only when the account has agent message-deletion enabled — otherwise a
364
+ 403 MESSAGE_DELETE_NOT_PERMITTED raises ``ForbiddenError`` carrying the
365
+ machine-readable denial envelope in ``.details`` (reason_axis /
366
+ remedy / cheapest_next_step / upgrade_url). 404 raises ``NotFoundError``
367
+ when not found or not visible; 409 ``ReplyLayerError`` on a
368
+ non-deletable state or an active legal hold.
369
+
370
+ Bodyless DELETE — no request body is sent.
371
+ """
372
+ return self._http.request("DELETE", f"/v1/messages/{message_id}")
373
+
299
374
  def firewall_release(self, message_id: str) -> dict[str, Any]:
300
375
  """Migration 047 — release a state='firewall_blocked' message.
301
376
 
@@ -391,6 +466,7 @@ class AsyncMessages:
391
466
  subaddress_mode: str | None = None,
392
467
  attachment_ids: list[str] | None = None,
393
468
  idempotency_key: str | None = None,
469
+ strict_outcome: bool | None = None,
394
470
  ) -> dict[str, Any]:
395
471
  """Send an outbound message.
396
472
 
@@ -405,6 +481,12 @@ class AsyncMessages:
405
481
  message instead of re-sending. The key travels in the ``Idempotency-Key``
406
482
  request header. See :meth:`get_idempotency_replay`.
407
483
 
484
+ ``strict_outcome`` (Governed Email-Effect Contract v1, Track 2) opts the
485
+ request into ``Prefer: outcome=strict`` — a held/blocked outcome returns
486
+ a non-2xx (503/422/409) raising the matching :class:`EmailEffectError`
487
+ subclass. See :meth:`SyncMessages.send`. ``None`` inherits the
488
+ client-level default; ``False`` forces the 200 path.
489
+
408
490
  Sandbox accounts are subject to a 250-cumulative-send trial
409
491
  budget. Once exhausted the API returns 403 with
410
492
  ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
@@ -429,9 +511,12 @@ class AsyncMessages:
429
511
  payload["subaddress_mode"] = subaddress_mode
430
512
  if attachment_ids is not None:
431
513
  payload["attachment_ids"] = attachment_ids
432
- extra_headers: dict[str, str] | None = None
433
- if idempotency_key is not None:
434
- extra_headers = {"Idempotency-Key": idempotency_key}
514
+ effective_strict = (
515
+ strict_outcome if strict_outcome is not None else self._http._strict_outcome
516
+ )
517
+ extra_headers = _send_extra_headers(
518
+ idempotency_key=idempotency_key, strict_outcome=effective_strict
519
+ )
435
520
  return await self._http.request(
436
521
  "POST", "/v1/messages/send", body=payload, extra_headers=extra_headers
437
522
  )
@@ -507,11 +592,16 @@ class AsyncMessages:
507
592
  subaddress_mode: str | None = None,
508
593
  attachment_ids: list[str] | None = None,
509
594
  idempotency_key: str | None = None,
595
+ strict_outcome: bool | None = None,
510
596
  ) -> dict[str, Any]:
511
597
  """Reply to an inbound message (async). See SyncMessages.reply.
512
598
 
513
599
  ``idempotency_key`` (Track 1) makes a retried same-key reply produce at
514
600
  most one email + one charge. See :meth:`get_idempotency_replay`.
601
+
602
+ ``strict_outcome`` (Governed Email-Effect Contract v1, Track 2) opts the
603
+ request into ``Prefer: outcome=strict`` — see :meth:`SyncMessages.send`.
604
+ ``None`` inherits the client-level default; ``False`` forces the 200 path.
515
605
  """
516
606
  payload: dict[str, Any] = {"body": body}
517
607
  if html is not None:
@@ -522,9 +612,12 @@ class AsyncMessages:
522
612
  payload["subaddress_mode"] = subaddress_mode
523
613
  if attachment_ids is not None:
524
614
  payload["attachment_ids"] = attachment_ids
525
- extra_headers: dict[str, str] | None = None
526
- if idempotency_key is not None:
527
- extra_headers = {"Idempotency-Key": idempotency_key}
615
+ effective_strict = (
616
+ strict_outcome if strict_outcome is not None else self._http._strict_outcome
617
+ )
618
+ extra_headers = _send_extra_headers(
619
+ idempotency_key=idempotency_key, strict_outcome=effective_strict
620
+ )
528
621
  return await self._http.request(
529
622
  "POST",
530
623
  f"/v1/messages/{message_id}/reply",
@@ -584,6 +677,17 @@ class AsyncMessages:
584
677
  body["reason"] = reason
585
678
  return await self._http.request("POST", f"/v1/messages/{message_id}/block", body=body)
586
679
 
680
+ async def report(self, message_id: str, *, reason: str | None = None) -> "ReportResponse":
681
+ """Atomic report-and-block for an INBOUND message (async). See SyncMessages.report."""
682
+ body: dict[str, Any] = {}
683
+ if reason is not None:
684
+ body["reason"] = reason
685
+ return await self._http.request("POST", f"/v1/messages/{message_id}/report", body=body)
686
+
687
+ async def delete(self, message_id: str) -> "DeleteMessageResponse":
688
+ """Soft-delete a message and purge its raw MIME (async). See SyncMessages.delete."""
689
+ return await self._http.request("DELETE", f"/v1/messages/{message_id}")
690
+
587
691
  async def mark_read(self, message_id: str) -> dict[str, Any]:
588
692
  """S7a — mark a single message as read (async). See SyncMessages.mark_read."""
589
693
  return await self._http.request(
@@ -590,6 +590,46 @@ class HoldContext(TypedDict):
590
590
  summary_reasons: list[str]
591
591
 
592
592
 
593
+ EffectStatus = Literal["sent", "held_for_review", "held_infrastructure", "blocked"]
594
+
595
+
596
+ class EmailEffect(TypedDict):
597
+ """Governed Email-Effect Contract v1 (Track 2) — the thin derived VIEW that
598
+ lets an agent branch retry-vs-edit-vs-escalate-vs-release in one read,
599
+ instead of walking ``status -> scan.verdict -> hold_context ->
600
+ findings[].failure_class``.
601
+
602
+ Outbound-only: present on send/reply success bodies (and on the opt-in
603
+ strict 4xx/5xx error body's ``details``), and on outbound message reads
604
+ (``GET /v1/messages/:id`` + thread-detail items). Inbound rows carry
605
+ ``agent_safety_context`` instead and never carry ``email_effect``; a
606
+ never-sent compose draft / pre-dispatch transient read OMITS the field
607
+ entirely (the server returns no ``email_effect`` key).
608
+
609
+ ``effect_status`` discriminates the four outcome classes:
610
+ * ``sent`` — dispatched (or in-flight/optimistic), not held.
611
+ * ``held_for_review`` — quarantine / pending_review (content or HITL);
612
+ releasable=True, terminal=False.
613
+ * ``held_infrastructure``— a transient ``inference_error`` infra hold with
614
+ NO genuine model-judgment block/quarantine;
615
+ retryable=True, terminal=False (retry-later).
616
+ * ``blocked`` — a genuine terminal content rejection;
617
+ terminal=True, releasable=False (edit/escalate).
618
+
619
+ Open enum: tolerant readers MUST treat an unknown ``effect_status``
620
+ fail-closed (never as ``sent``).
621
+ """
622
+ effect_status: EffectStatus
623
+ # True iff the row is human-releasable (status in {quarantined, pending_review}).
624
+ releasable: bool
625
+ # True iff the outcome is terminal (status in {blocked, delivered, bounced}).
626
+ terminal: bool
627
+ # True iff this is a transient infra hold (inference_error, no genuine
628
+ # model judgment) OR an indeterminate keyed-dispatch — the agent's correct
629
+ # action is retry-later, never edit-content.
630
+ retryable: bool
631
+
632
+
593
633
  class SendMessageResponse(TypedDict):
594
634
  message_id: str
595
635
  status: Literal["sent", "quarantined", "blocked", "pending_review"]
@@ -598,6 +638,10 @@ class SendMessageResponse(TypedDict):
598
638
  scan: NotRequired["ScanSummary | None"]
599
639
  # Policy/HITL hold reason; null when scan explains the hold or status='sent'.
600
640
  hold_context: NotRequired["HoldContext | None"]
641
+ # Governed Email-Effect Contract v1 (Track 2). Outbound-only derived VIEW —
642
+ # present on send/reply success bodies; OMITTED (key absent) on a never-sent
643
+ # compose draft. Carried in the strict-mode 4xx/5xx error body's `details`.
644
+ email_effect: NotRequired["EmailEffect | None"]
601
645
  daily_limit: int
602
646
  sends_remaining: int
603
647
  # Outbound HTML sanitization (Phase 2, D3). Additive — populated ONLY on the
@@ -973,6 +1017,10 @@ class GetMessageResponse(TypedDict, total=False):
973
1017
  attachments: Required[list[MessageAttachment]]
974
1018
  scan: Required[ScanSummary | None]
975
1019
  agent_safety_context: NotRequired["AgentSafetyContext | None"]
1020
+ # Governed Email-Effect Contract v1 (Track 2). Outbound-only derived VIEW;
1021
+ # OMITTED (key absent) on inbound rows (which carry agent_safety_context) and
1022
+ # on never-sent / pre-dispatch-transient outbound rows.
1023
+ email_effect: NotRequired["EmailEffect | None"]
976
1024
  thread_id: Required[str | None]
977
1025
  in_reply_to: Required[str | None]
978
1026
  # Migration 035 — may be `<REDACTED>` under mailbox pii_mode=redacted.
@@ -1159,6 +1207,24 @@ class BlockResponse(TypedDict):
1159
1207
  message_id: str
1160
1208
 
1161
1209
 
1210
+ class ReportResponse(TypedDict):
1211
+ message_id: str
1212
+ state: str
1213
+ blocked: bool
1214
+ already_blocked: bool
1215
+ sender_blocklisted: str | None
1216
+ already_blocklisted: bool
1217
+ pattern_type: str | None
1218
+
1219
+
1220
+ class DeleteMessageResponse(TypedDict):
1221
+ status: Literal["deleted"]
1222
+ message_id: str
1223
+ raw_mime_deleted: bool
1224
+ derivatives_tombstoned: int
1225
+ r2_objects_failed: list[str]
1226
+
1227
+
1162
1228
  # === Drafts ===
1163
1229
  #
1164
1230
  # A draft is a message in state='draft'. Scanner runs at create + update and
@@ -1279,6 +1345,13 @@ class ThreadSummary(TypedDict):
1279
1345
  # Required — mirrors TS SDK ThreadSummary.starred (required, not optional).
1280
1346
  starred: bool
1281
1347
  participants: list[str]
1348
+ # Inbox redesign §6.2 — additive/optional row enrichment (absent from an
1349
+ # older API). scan: worst-across-thread summary with held delivery states
1350
+ # folded in; body_preview: latest-message PII-redacted snippet;
1351
+ # has_attachment: any member message has an attachment.
1352
+ scan: NotRequired["ScanSummary | None"]
1353
+ body_preview: NotRequired[str | None]
1354
+ has_attachment: NotRequired[bool]
1282
1355
 
1283
1356
 
1284
1357
  class GetThreadResponse(TypedDict):
@@ -159,6 +159,44 @@ async def test_async_domains_recheck():
159
159
  await rl.aclose()
160
160
 
161
161
 
162
+ @respx.mock
163
+ @pytest.mark.asyncio
164
+ async def test_async_messages_report():
165
+ respx.post(f"{BASE}/v1/messages/msg1/report").mock(
166
+ return_value=httpx.Response(200, json={
167
+ "message_id": "msg1", "state": "blocked", "blocked": True, "already_blocked": False,
168
+ "sender_blocklisted": "x@evil.test", "already_blocklisted": False, "pattern_type": "email",
169
+ })
170
+ )
171
+ rl = async_sdk()
172
+ res = await rl.messages.report("msg1")
173
+ assert res["blocked"] is True
174
+ assert res["sender_blocklisted"] == "x@evil.test"
175
+ await rl.aclose()
176
+
177
+
178
+ @respx.mock
179
+ @pytest.mark.asyncio
180
+ async def test_async_messages_delete():
181
+ respx.delete(f"{BASE}/v1/messages/msg1").mock(
182
+ return_value=httpx.Response(
183
+ 200,
184
+ json={
185
+ "status": "deleted",
186
+ "message_id": "msg1",
187
+ "raw_mime_deleted": True,
188
+ "derivatives_tombstoned": 0,
189
+ "r2_objects_failed": [],
190
+ },
191
+ )
192
+ )
193
+ rl = async_sdk()
194
+ res = await rl.messages.delete("msg1")
195
+ assert res["status"] == "deleted"
196
+ assert res["raw_mime_deleted"] is True
197
+ await rl.aclose()
198
+
199
+
162
200
  @respx.mock
163
201
  @pytest.mark.asyncio
164
202
  async def test_async_mailboxes_create():
@@ -0,0 +1,399 @@
1
+ """
2
+ Python SDK Track 2 — Governed Email-Effect Contract v1 tests.
3
+
4
+ Covers:
5
+ * The ``strict_outcome`` opt-in header (``Prefer: outcome=strict``) — per-call,
6
+ client-default, ``None`` inherit, and ``False`` per-call override — on
7
+ send / reply, sync + async (parity with the Idempotency-Key forwarding tests).
8
+ * The strict-mode typed error family: 503 → EmailEffectRetryableError (with
9
+ retry_after parse), 422 → EmailEffectRejectedError, 409 → EmailEffectHeldError,
10
+ plus email_effect / scan extraction from details and the EmailEffectError
11
+ base catch.
12
+ * The ``email_effect`` body field riding through on the default (200) path.
13
+ * EffectStatus enum members frozen (the TS↔Python contract sweep anchor).
14
+
15
+ Mirrors the respx mock style of test_messages_idempotency.py.
16
+ """
17
+ import typing
18
+
19
+ import httpx
20
+ import pytest
21
+ import respx
22
+
23
+ from replylayer import (
24
+ AsyncReplyLayer,
25
+ EffectStatus,
26
+ EmailEffect,
27
+ EmailEffectError,
28
+ EmailEffectHeldError,
29
+ EmailEffectRejectedError,
30
+ EmailEffectRetryableError,
31
+ ReplyLayer,
32
+ ReplyLayerError,
33
+ )
34
+
35
+ BASE = "https://api.test.replylayer.ai"
36
+
37
+
38
+ def sdk(*, strict_outcome: bool = False) -> ReplyLayer:
39
+ return ReplyLayer(
40
+ api_key="rl_live_test", base_url=BASE, max_retries=0, strict_outcome=strict_outcome
41
+ )
42
+
43
+
44
+ def async_sdk(*, strict_outcome: bool = False) -> AsyncReplyLayer:
45
+ return AsyncReplyLayer(
46
+ api_key="rl_live_test", base_url=BASE, max_retries=0, strict_outcome=strict_outcome
47
+ )
48
+
49
+
50
+ def _sent_fixture() -> dict:
51
+ return {
52
+ "message_id": "msg1",
53
+ "status": "sent",
54
+ "daily_limit": 15,
55
+ "sends_remaining": 14,
56
+ "email_effect": {
57
+ "effect_status": "sent",
58
+ "releasable": False,
59
+ "terminal": False,
60
+ "retryable": False,
61
+ },
62
+ }
63
+
64
+
65
+ def _strict_error_body(code: str, effect_status: str, *, releasable: bool, terminal: bool, retryable: bool) -> dict:
66
+ return {
67
+ "error": "outcome held by governance",
68
+ "code": code,
69
+ "details": {
70
+ "message_id": "msg1",
71
+ "status": "blocked" if effect_status == "blocked" else "quarantined",
72
+ "scan": {"verdict": "blocked", "categories": [], "findings": []},
73
+ "email_effect": {
74
+ "effect_status": effect_status,
75
+ "releasable": releasable,
76
+ "terminal": terminal,
77
+ "retryable": retryable,
78
+ },
79
+ },
80
+ }
81
+
82
+
83
+ # --------------------------------------------------------------------------
84
+ # Header forwarding — Prefer: outcome=strict.
85
+ # --------------------------------------------------------------------------
86
+
87
+
88
+ @respx.mock
89
+ def test_send_per_call_strict_forwards_prefer_header():
90
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
91
+ return_value=httpx.Response(200, json=_sent_fixture())
92
+ )
93
+ sdk().messages.send(
94
+ from_mailbox="support", to="u@example.com", subject="Hi", body="Hello", strict_outcome=True
95
+ )
96
+ assert route.calls.last.request.headers.get("Prefer") == "outcome=strict"
97
+
98
+
99
+ @respx.mock
100
+ def test_send_default_omits_prefer_header():
101
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
102
+ return_value=httpx.Response(200, json=_sent_fixture())
103
+ )
104
+ sdk().messages.send(from_mailbox="support", to="u@example.com", subject="Hi", body="Hello")
105
+ assert route.calls.last.request.headers.get("Prefer") is None
106
+
107
+
108
+ @respx.mock
109
+ def test_send_client_default_strict_forwards_prefer_header():
110
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
111
+ return_value=httpx.Response(200, json=_sent_fixture())
112
+ )
113
+ sdk(strict_outcome=True).messages.send(
114
+ from_mailbox="support", to="u@example.com", subject="Hi", body="Hello"
115
+ )
116
+ assert route.calls.last.request.headers.get("Prefer") == "outcome=strict"
117
+
118
+
119
+ @respx.mock
120
+ def test_send_per_call_false_overrides_client_default():
121
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
122
+ return_value=httpx.Response(200, json=_sent_fixture())
123
+ )
124
+ sdk(strict_outcome=True).messages.send(
125
+ from_mailbox="support", to="u@example.com", subject="Hi", body="Hello", strict_outcome=False
126
+ )
127
+ assert route.calls.last.request.headers.get("Prefer") is None
128
+
129
+
130
+ @respx.mock
131
+ def test_send_none_inherits_client_default():
132
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
133
+ return_value=httpx.Response(200, json=_sent_fixture())
134
+ )
135
+ # strict_outcome left at its None default → inherits the client-level True.
136
+ sdk(strict_outcome=True).messages.send(
137
+ from_mailbox="support", to="u@example.com", subject="Hi", body="Hello"
138
+ )
139
+ assert route.calls.last.request.headers.get("Prefer") == "outcome=strict"
140
+
141
+
142
+ @respx.mock
143
+ def test_reply_per_call_strict_forwards_prefer_header():
144
+ route = respx.post(f"{BASE}/v1/messages/orig1/reply").mock(
145
+ return_value=httpx.Response(200, json=_sent_fixture())
146
+ )
147
+ sdk().messages.reply("orig1", body="Thanks", strict_outcome=True)
148
+ assert route.calls.last.request.headers.get("Prefer") == "outcome=strict"
149
+
150
+
151
+ @respx.mock
152
+ def test_strict_coexists_with_idempotency_key():
153
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
154
+ return_value=httpx.Response(200, json=_sent_fixture())
155
+ )
156
+ sdk().messages.send(
157
+ from_mailbox="support",
158
+ to="u@example.com",
159
+ subject="Hi",
160
+ body="Hello",
161
+ idempotency_key="k1",
162
+ strict_outcome=True,
163
+ )
164
+ req = route.calls.last.request
165
+ assert req.headers.get("Prefer") == "outcome=strict"
166
+ assert req.headers.get("Idempotency-Key") == "k1"
167
+
168
+
169
+ @respx.mock
170
+ async def test_async_send_client_default_strict_forwards_prefer_header():
171
+ route = respx.post(f"{BASE}/v1/messages/send").mock(
172
+ return_value=httpx.Response(200, json=_sent_fixture())
173
+ )
174
+ await async_sdk(strict_outcome=True).messages.send(
175
+ from_mailbox="support", to="u@example.com", subject="Hi", body="Hello"
176
+ )
177
+ assert route.calls.last.request.headers.get("Prefer") == "outcome=strict"
178
+
179
+
180
+ @respx.mock
181
+ async def test_async_reply_per_call_strict_forwards_prefer_header():
182
+ route = respx.post(f"{BASE}/v1/messages/orig1/reply").mock(
183
+ return_value=httpx.Response(200, json=_sent_fixture())
184
+ )
185
+ await async_sdk().messages.reply("orig1", body="Thanks", strict_outcome=True)
186
+ assert route.calls.last.request.headers.get("Prefer") == "outcome=strict"
187
+
188
+
189
+ # --------------------------------------------------------------------------
190
+ # Strict-mode typed errors.
191
+ # --------------------------------------------------------------------------
192
+
193
+
194
+ @respx.mock
195
+ def test_strict_422_raises_rejected_error_with_effect():
196
+ respx.post(f"{BASE}/v1/messages/send").mock(
197
+ return_value=httpx.Response(
198
+ 422,
199
+ json=_strict_error_body(
200
+ "EMAIL_EFFECT_REJECTED", "blocked", releasable=False, terminal=True, retryable=False
201
+ ),
202
+ )
203
+ )
204
+ with pytest.raises(EmailEffectRejectedError) as ei:
205
+ sdk().messages.send(
206
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
207
+ )
208
+ err = ei.value
209
+ assert isinstance(err, EmailEffectError)
210
+ assert err.status_code == 422
211
+ assert err.code == "EMAIL_EFFECT_REJECTED"
212
+ assert err.email_effect is not None
213
+ assert err.email_effect["effect_status"] == "blocked"
214
+ assert err.email_effect["terminal"] is True
215
+ assert err.scan is not None
216
+ assert err.scan["verdict"] == "blocked"
217
+
218
+
219
+ @respx.mock
220
+ def test_strict_409_raises_held_error():
221
+ respx.post(f"{BASE}/v1/messages/send").mock(
222
+ return_value=httpx.Response(
223
+ 409,
224
+ json=_strict_error_body(
225
+ "EMAIL_EFFECT_HELD", "held_for_review", releasable=True, terminal=False, retryable=False
226
+ ),
227
+ )
228
+ )
229
+ with pytest.raises(EmailEffectHeldError) as ei:
230
+ sdk().messages.send(
231
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
232
+ )
233
+ err = ei.value
234
+ assert isinstance(err, EmailEffectError)
235
+ assert err.status_code == 409
236
+ assert err.email_effect["releasable"] is True
237
+
238
+
239
+ @respx.mock
240
+ def test_strict_503_raises_retryable_error_with_retry_after():
241
+ respx.post(f"{BASE}/v1/messages/send").mock(
242
+ return_value=httpx.Response(
243
+ 503,
244
+ headers={"Retry-After": "30"},
245
+ json=_strict_error_body(
246
+ "EMAIL_EFFECT_HELD_INFRA",
247
+ "held_infrastructure",
248
+ releasable=False,
249
+ terminal=False,
250
+ retryable=True,
251
+ ),
252
+ )
253
+ )
254
+ with pytest.raises(EmailEffectRetryableError) as ei:
255
+ sdk().messages.send(
256
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
257
+ )
258
+ err = ei.value
259
+ assert isinstance(err, EmailEffectError)
260
+ assert err.status_code == 503
261
+ assert err.retry_after == 30
262
+ assert err.email_effect["retryable"] is True
263
+
264
+
265
+ @respx.mock
266
+ def test_strict_503_missing_retry_after_is_none():
267
+ respx.post(f"{BASE}/v1/messages/send").mock(
268
+ return_value=httpx.Response(
269
+ 503,
270
+ json=_strict_error_body(
271
+ "EMAIL_EFFECT_HELD_INFRA",
272
+ "held_infrastructure",
273
+ releasable=False,
274
+ terminal=False,
275
+ retryable=True,
276
+ ),
277
+ )
278
+ )
279
+ with pytest.raises(EmailEffectRetryableError) as ei:
280
+ sdk().messages.send(
281
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
282
+ )
283
+ assert ei.value.retry_after is None
284
+
285
+
286
+ @respx.mock
287
+ def test_strict_503_nonnumeric_retry_after_is_none():
288
+ respx.post(f"{BASE}/v1/messages/send").mock(
289
+ return_value=httpx.Response(
290
+ 503,
291
+ headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"},
292
+ json=_strict_error_body(
293
+ "EMAIL_EFFECT_HELD_INFRA",
294
+ "held_infrastructure",
295
+ releasable=False,
296
+ terminal=False,
297
+ retryable=True,
298
+ ),
299
+ )
300
+ )
301
+ with pytest.raises(EmailEffectRetryableError) as ei:
302
+ sdk().messages.send(
303
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
304
+ )
305
+ assert ei.value.retry_after is None
306
+
307
+
308
+ @respx.mock
309
+ def test_strict_reply_422_raises_rejected_error():
310
+ respx.post(f"{BASE}/v1/messages/orig1/reply").mock(
311
+ return_value=httpx.Response(
312
+ 422,
313
+ json=_strict_error_body(
314
+ "EMAIL_EFFECT_REJECTED", "blocked", releasable=False, terminal=True, retryable=False
315
+ ),
316
+ )
317
+ )
318
+ with pytest.raises(EmailEffectRejectedError):
319
+ sdk().messages.reply("orig1", body="x", strict_outcome=True)
320
+
321
+
322
+ @respx.mock
323
+ def test_non_effect_422_stays_validation_error():
324
+ """A 422 WITHOUT an email-effect code must NOT become an EmailEffectError —
325
+ it stays the generic ValidationError path (no false-positive remap)."""
326
+ from replylayer import ValidationError
327
+
328
+ respx.post(f"{BASE}/v1/messages/send").mock(
329
+ return_value=httpx.Response(422, json={"error": "bad", "code": "VALIDATION_ERROR"})
330
+ )
331
+ with pytest.raises(ValidationError) as ei:
332
+ sdk().messages.send(from_mailbox="s", to="u@example.com", subject="Hi", body="x")
333
+ assert not isinstance(ei.value, EmailEffectError)
334
+
335
+
336
+ @respx.mock
337
+ def test_strict_422_with_email_effect_but_other_code_still_remaps():
338
+ """The remap is gated on the body carrying `email_effect`, not the exact
339
+ `code` string (parity with the TS `details?.email_effect` gate). A 422 that
340
+ carries email_effect still becomes an EmailEffectRejectedError."""
341
+ body = _strict_error_body(
342
+ "SOME_FUTURE_CODE", "blocked", releasable=False, terminal=True, retryable=False
343
+ )
344
+ respx.post(f"{BASE}/v1/messages/send").mock(return_value=httpx.Response(422, json=body))
345
+ with pytest.raises(EmailEffectRejectedError) as ei:
346
+ sdk().messages.send(
347
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
348
+ )
349
+ assert ei.value.code == "SOME_FUTURE_CODE"
350
+ assert ei.value.email_effect["effect_status"] == "blocked"
351
+
352
+
353
+ @respx.mock
354
+ async def test_async_strict_422_raises_rejected_error():
355
+ respx.post(f"{BASE}/v1/messages/send").mock(
356
+ return_value=httpx.Response(
357
+ 422,
358
+ json=_strict_error_body(
359
+ "EMAIL_EFFECT_REJECTED", "blocked", releasable=False, terminal=True, retryable=False
360
+ ),
361
+ )
362
+ )
363
+ with pytest.raises(EmailEffectRejectedError):
364
+ await async_sdk().messages.send(
365
+ from_mailbox="s", to="u@example.com", subject="Hi", body="x", strict_outcome=True
366
+ )
367
+
368
+
369
+ # --------------------------------------------------------------------------
370
+ # Default (200) path carries email_effect in the body.
371
+ # --------------------------------------------------------------------------
372
+
373
+
374
+ @respx.mock
375
+ def test_default_200_body_carries_email_effect():
376
+ respx.post(f"{BASE}/v1/messages/send").mock(
377
+ return_value=httpx.Response(200, json=_sent_fixture())
378
+ )
379
+ res = sdk().messages.send(from_mailbox="s", to="u@example.com", subject="Hi", body="x")
380
+ assert res["email_effect"]["effect_status"] == "sent"
381
+ assert res["email_effect"]["terminal"] is False
382
+
383
+
384
+ # --------------------------------------------------------------------------
385
+ # Contract sweep anchor — EffectStatus enum members frozen.
386
+ # --------------------------------------------------------------------------
387
+
388
+
389
+ def test_effect_status_enum_members_frozen():
390
+ """TS↔Python contract: EffectStatus literal members must match the four the
391
+ server emits. New members are additive (open enum) — this guards against an
392
+ accidental DROP / typo, not against a future ADD."""
393
+ members = set(typing.get_args(EffectStatus))
394
+ assert members == {"sent", "held_for_review", "held_infrastructure", "blocked"}
395
+
396
+
397
+ def test_email_effect_is_typeddict_with_expected_keys():
398
+ keys = set(EmailEffect.__annotations__.keys())
399
+ assert keys == {"effect_status", "releasable", "terminal", "retryable"}
@@ -314,6 +314,58 @@ def test_messages_block():
314
314
  assert res["status"] == "blocked"
315
315
 
316
316
 
317
+ @respx.mock
318
+ def test_messages_report():
319
+ respx.post(f"{BASE}/v1/messages/msg1/report").mock(return_value=httpx.Response(200, json={
320
+ "message_id": "msg1", "state": "blocked", "blocked": True, "already_blocked": False,
321
+ "sender_blocklisted": "spammer@evil.test", "already_blocklisted": False, "pattern_type": "email",
322
+ }))
323
+ res = sdk().messages.report("msg1", reason="spam")
324
+ assert res["blocked"] is True
325
+ assert res["sender_blocklisted"] == "spammer@evil.test"
326
+ assert res["pattern_type"] == "email"
327
+
328
+
329
+ @respx.mock
330
+ def test_messages_delete():
331
+ respx.delete(f"{BASE}/v1/messages/msg1").mock(
332
+ return_value=httpx.Response(
333
+ 200,
334
+ json={
335
+ "status": "deleted",
336
+ "message_id": "msg1",
337
+ "raw_mime_deleted": True,
338
+ "derivatives_tombstoned": 2,
339
+ "r2_objects_failed": [],
340
+ },
341
+ )
342
+ )
343
+ res = sdk().messages.delete("msg1")
344
+ assert res["status"] == "deleted"
345
+ assert res["raw_mime_deleted"] is True
346
+ assert res["derivatives_tombstoned"] == 2
347
+ assert res["r2_objects_failed"] == []
348
+
349
+
350
+ @respx.mock
351
+ def test_messages_delete_403_raises_forbidden():
352
+ respx.delete(f"{BASE}/v1/messages/msg1").mock(
353
+ return_value=httpx.Response(
354
+ 403,
355
+ json={
356
+ "error": "Agent keys are not permitted to delete messages on this account.",
357
+ "code": "MESSAGE_DELETE_NOT_PERMITTED",
358
+ "details": {"reason_axis": "account_policy", "remedy": "enable_account_policy"},
359
+ },
360
+ )
361
+ )
362
+ with pytest.raises(ForbiddenError) as exc:
363
+ sdk().messages.delete("msg1")
364
+ assert exc.value.code == "MESSAGE_DELETE_NOT_PERMITTED"
365
+ assert exc.value.details is not None
366
+ assert exc.value.details["reason_axis"] == "account_policy"
367
+
368
+
317
369
  @respx.mock
318
370
  def test_messages_list_with_filters():
319
371
  respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(return_value=httpx.Response(200, json={"messages": []}))
File without changes
File without changes
File without changes