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.
- {replylayer-0.18.0 → replylayer-0.21.0}/PKG-INFO +1 -1
- {replylayer-0.18.0 → replylayer-0.21.0}/pyproject.toml +1 -1
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/__init__.py +22 -1
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/_client.py +11 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/_http.py +9 -1
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/errors.py +104 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/messages.py +117 -13
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/types.py +73 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_async.py +38 -0
- replylayer-0.21.0/tests/test_governed_email_effect.py +399 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_resources.py +52 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/.gitignore +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/README.md +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/__main__.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/py.typed +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/drafts.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/threads.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/__init__.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_attachments.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_client.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_domains.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_drafts.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_http.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_messages_idempotency.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_threads.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_version.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_webhooks.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/tests/test_ws1_ws6.py +0 -0
- {replylayer-0.18.0 → replylayer-0.21.0}/uv.lock +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|