replylayer 0.16.0__tar.gz → 0.18.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 (45) hide show
  1. {replylayer-0.16.0 → replylayer-0.18.0}/.gitignore +4 -0
  2. {replylayer-0.16.0 → replylayer-0.18.0}/PKG-INFO +35 -5
  3. {replylayer-0.16.0 → replylayer-0.18.0}/README.md +34 -4
  4. {replylayer-0.16.0 → replylayer-0.18.0}/pyproject.toml +1 -1
  5. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/__init__.py +12 -1
  6. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/_http.py +1 -1
  7. replylayer-0.18.0/replylayer/resources/account.py +97 -0
  8. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/attachments.py +3 -2
  9. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/drafts.py +16 -4
  10. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/messages.py +172 -5
  11. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/threads.py +8 -0
  12. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/types.py +82 -2
  13. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_async.py +67 -1
  14. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_domains.py +2 -2
  15. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_drafts.py +63 -3
  16. replylayer-0.18.0/tests/test_messages_idempotency.py +285 -0
  17. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_resources.py +68 -5
  18. replylayer-0.18.0/tests/test_threads.py +96 -0
  19. replylayer-0.18.0/tests/test_version.py +35 -0
  20. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_web_risk_types.py +53 -0
  21. {replylayer-0.16.0 → replylayer-0.18.0}/uv.lock +1 -1
  22. replylayer-0.16.0/replylayer/resources/account.py +0 -36
  23. replylayer-0.16.0/tests/test_version.py +0 -18
  24. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/__main__.py +0 -0
  25. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/_client.py +0 -0
  26. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/_pagination.py +0 -0
  27. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/errors.py +0 -0
  28. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/py.typed +0 -0
  29. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/__init__.py +0 -0
  30. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/api_keys.py +0 -0
  31. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/domains.py +0 -0
  32. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/health.py +0 -0
  33. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/inbound_blocklist.py +0 -0
  34. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/legal_holds.py +0 -0
  35. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/mailboxes.py +0 -0
  36. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/recipients.py +0 -0
  37. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/suppressions.py +0 -0
  38. {replylayer-0.16.0 → replylayer-0.18.0}/replylayer/resources/webhooks.py +0 -0
  39. {replylayer-0.16.0 → replylayer-0.18.0}/tests/__init__.py +0 -0
  40. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_attachments.py +0 -0
  41. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_client.py +0 -0
  42. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_hitl_review_types.py +0 -0
  43. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_http.py +0 -0
  44. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_webhooks.py +0 -0
  45. {replylayer-0.16.0 → replylayer-0.18.0}/tests/test_ws1_ws6.py +0 -0
@@ -52,3 +52,7 @@ plans/fault-briefings/
52
52
  # under audits/ — these are signed-verdict-anchored evidence per
53
53
  # plans/granite-guardian-failover-saturation-stress-test.md §11.
54
54
  !audits/**/*.log
55
+ uat-grading-results*.json
56
+
57
+ # Scanner eval run outputs (corpora are tracked, results are not)
58
+ packages/scanner/src/eval/*-results.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: replylayer
3
- Version: 0.16.0
3
+ Version: 0.18.0
4
4
  Summary: Official Python SDK for ReplyLayer — email for AI agents
5
5
  License-Expression: MIT
6
6
  Keywords: agent,ai,email,mailbox,replylayer,sdk,webhook
@@ -126,7 +126,7 @@ contract — read it before relying on retries:
126
126
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
127
127
  | `rl.suppressions` | `list`, `delete` |
128
128
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
129
- | `rl.account` | `get_usage` |
129
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
130
130
  | `rl.health` | `check` |
131
131
 
132
132
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -152,7 +152,7 @@ The send/reply/draft-send response carries two additive, nullable keys that expl
152
152
  By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
153
153
 
154
154
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
155
- - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
155
+ - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
156
156
  - `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
157
157
 
158
158
  ```python
@@ -167,7 +167,7 @@ except ReplyLayerError as err:
167
167
 
168
168
  ## Outbound attachments (Pro+)
169
169
 
170
- Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** (a Pro+, session-gated dashboard action) — uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
170
+ Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
171
171
 
172
172
  ```python
173
173
  import time
@@ -426,6 +426,23 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
426
426
 
427
427
  The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
428
428
 
429
+ ## Malicious link scanning (URL reputation)
430
+
431
+ Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
432
+
433
+ Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
434
+
435
+ ```python
436
+ status = rl.account.get_link_scanning_status()
437
+ # {"active", "accepted_version", "current_version", "privacy_ok"}
438
+
439
+ if not status["active"] and status["privacy_ok"]:
440
+ res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
441
+ # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
442
+ ```
443
+
444
+ If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).
445
+
429
446
  ## Mailbox identifiers
430
447
 
431
448
  Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
@@ -470,7 +487,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
470
487
 
471
488
  ## Webhook signature verification
472
489
 
473
- > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see [`docs/webhooks.md`](../../docs/webhooks.md).
490
+ > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
474
491
 
475
492
  ```python
476
493
  from replylayer import verify_webhook_signature
@@ -483,6 +500,19 @@ verify_webhook_signature(
483
500
  )
484
501
  ```
485
502
 
503
+ Once verified, parse and dispatch on the event type. **The discriminator field is `event`, not `type`:**
504
+
505
+ ```python
506
+ import json
507
+
508
+ payload = json.loads(request.body)
509
+ # payload["event"] is the discriminator — NOT payload["type"]
510
+ if payload["event"] == "message.received":
511
+ # handle inbound message
512
+ elif payload["event"] == "message.dispatch_failed":
513
+ # handle failed outbound send
514
+ ```
515
+
486
516
  ## Context managers
487
517
 
488
518
  Both clients support context managers to properly close connection pools:
@@ -109,7 +109,7 @@ contract — read it before relying on retries:
109
109
  | `rl.recipients` | `create`, `list`, `delete`, `resend` |
110
110
  | `rl.suppressions` | `list`, `delete` |
111
111
  | `rl.api_keys` | `create`, `list`, `revoke`, `rotate`* |
112
- | `rl.account` | `get_usage` |
112
+ | `rl.account` | `get_usage`, `get_quota`, `get_link_scanning_status`, `enable_link_scanning` |
113
113
  | `rl.health` | `check` |
114
114
 
115
115
  *`api_keys.rotate()` revokes the calling API key and returns a new one. After calling it, this SDK instance's key is invalidated — create a new `ReplyLayer` instance with the returned key.
@@ -135,7 +135,7 @@ The send/reply/draft-send response carries two additive, nullable keys that expl
135
135
  By default `drafts.send()`, `messages.send()`, and `messages.reply()` return only once the scanner verdict is known, with `scan` and `hold_context` inline. Pass `async_dispatch=True` to `drafts.send()` to send the `Prefer: respond-async` hint. **The hint is advisory** — the server returns a `202 AsyncSendAck` only when `OUTBOUND_ASYNC_DISPATCH_ENABLED` is on; otherwise it ignores the hint and returns a normal `SendMessageResponse`. **Always branch on the result**: `result["status"] == "queued_for_dispatch"` ⇒ `AsyncSendAck`, otherwise `SendMessageResponse`. Poll `messages.get(message_id)` (or handle the lifecycle webhook) until `state` is terminal. Attachment-bearing drafts fail closed on the async path (`400 ATTACHMENTS_REQUIRE_SYNC_SEND`). (`messages.wait()` is a mailbox long-poll for new *inbound* mail, not a way to observe a specific message by ID.)
136
136
 
137
137
  The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
138
- - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan` and, when a policy/HITL decision drove the hold, `hold_context`.
138
+ - `DRAFT_REJECTED_BY_RESCAN` — send-time scan flipped the verdict to `block`/`quarantine`. The draft stays in `draft` state; edit the body and retry. `err.details` carries `scan`, `releasable` (`True` for a `quarantine` hold the customer can release via `POST /v1/drafts/:id/release-and-send`, `False` for a terminal `block`), and, when a policy/HITL decision drove the hold, `hold_context`.
139
139
  - `DRAFT_ALREADY_SENT` — the draft was already sent (race or retry after success).
140
140
 
141
141
  ```python
@@ -150,7 +150,7 @@ except ReplyLayerError as err:
150
150
 
151
151
  ## Outbound attachments (Pro+)
152
152
 
153
- Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** (a Pro+, session-gated dashboard action) — uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
153
+ Attaching a file is a **two-phase** flow: upload the bytes to stage a handle, then reference `handle["id"]` in a send/reply/draft `attachment_ids` list. Every attachment is scanned (byte-level family validation + AV + secrets/PII over extracted text **and** filename) before it leaves. The mailbox must have outbound attachments **explicitly enabled** by a human account owner in the dashboard (Pro+, mailbox Settings page, TOTP/password re-auth). Once enabled, API keys can send attachments; uploads to a non-enabled mailbox raise `ForbiddenError` with `code="OUTBOUND_ATTACHMENTS_DISABLED"`.
154
154
 
155
155
  ```python
156
156
  import time
@@ -409,6 +409,23 @@ Webhook deliveries are deduped server-side to at most one per `(account, mailbox
409
409
 
410
410
  The MCP tool `list_allowlist_blocked_attempts` exposes the same view to agents — read-only by design. There is no dismiss-attempt tool (the containment boundary would be moot if an agent could clear its own rejection history).
411
411
 
412
+ ## Malicious link scanning (URL reputation)
413
+
414
+ Malicious link scanning checks inbound links against Google Web Risk (only SHA-256 hash-prefixes are sent — full URLs never leave the platform). It is off by default and opt-in per account. Enabling it is an **admin action** (it turns on an account-wide sub-processor data flow), so agent-scoped keys can read status but not enable it (they raise `ForbiddenError`).
415
+
416
+ Pass the version you are acknowledging explicitly; the SDK does not auto-fetch it, so the consent is recorded against a version your code chose:
417
+
418
+ ```python
419
+ status = rl.account.get_link_scanning_status()
420
+ # {"active", "accepted_version", "current_version", "privacy_ok"}
421
+
422
+ if not status["active"] and status["privacy_ok"]:
423
+ res = rl.account.enable_link_scanning(accept_web_risk_version=status["current_version"])
424
+ # res["url_reputation"]["active"] is True; res["disclosure"]["notice"] / ["advisory_url"]
425
+ ```
426
+
427
+ If `privacy_ok` is `False` the account's accepted privacy policy predates the disclosed sub-processor — re-accept the current Privacy Policy first (`enable_link_scanning` would otherwise raise a `409 PRIVACY_VERSION_TOO_OLD`). The async client exposes the same methods (`await rl.account.get_link_scanning_status()` / `await rl.account.enable_link_scanning(...)`).
428
+
412
429
  ## Mailbox identifiers
413
430
 
414
431
  Every SDK method that takes a `mailbox_id` argument accepts **either the mailbox's UUID or its name**. The server resolves names against the authenticated account's active mailboxes. `rl.messages.list("support-bot")` and `rl.messages.list("a1b2-…")` are equivalent.
@@ -453,7 +470,7 @@ Error classes: `ReplyLayerError` (base), `AuthenticationError` (401), `Forbidden
453
470
 
454
471
  ## Webhook signature verification
455
472
 
456
- > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see [`docs/webhooks.md`](../../docs/webhooks.md).
473
+ > For a full integration guide (event catalog, retry behavior, idempotency, security, troubleshooting), see the hosted webhook docs (coming).
457
474
 
458
475
  ```python
459
476
  from replylayer import verify_webhook_signature
@@ -466,6 +483,19 @@ verify_webhook_signature(
466
483
  )
467
484
  ```
468
485
 
486
+ Once verified, parse and dispatch on the event type. **The discriminator field is `event`, not `type`:**
487
+
488
+ ```python
489
+ import json
490
+
491
+ payload = json.loads(request.body)
492
+ # payload["event"] is the discriminator — NOT payload["type"]
493
+ if payload["event"] == "message.received":
494
+ # handle inbound message
495
+ elif payload["event"] == "message.dispatch_failed":
496
+ # handle failed outbound send
497
+ ```
498
+
469
499
  ## Context managers
470
500
 
471
501
  Both clients support context managers to properly close connection pools:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "replylayer"
7
- version = "0.16.0"
7
+ version = "0.18.0"
8
8
  description = "Official Python SDK for ReplyLayer — email for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,5 +1,6 @@
1
1
  from ._client import ReplyLayer, AsyncReplyLayer
2
2
  from ._http import RetryInfo
3
+ from .resources.messages import IdempotencyReplayResult
3
4
  from .resources.webhooks import verify_webhook_signature
4
5
  from .errors import (
5
6
  ReplyLayerError,
@@ -57,6 +58,10 @@ from .types import (
57
58
  WebRiskLearnMore,
58
59
  WebRiskWarning,
59
60
  WebRiskThreatType,
61
+ LinkScanningStatus,
62
+ EnableLinkScanningResponse,
63
+ EnableLinkScanningDisclosure,
64
+ AgentSafetyContext,
60
65
  MessageReceivedSafetySignal,
61
66
  MessageReceivedWebhookPayload,
62
67
  # PR 6 — HITL review queue webhook payload types.
@@ -76,7 +81,7 @@ from .types import (
76
81
  ScannerPolicy,
77
82
  )
78
83
 
79
- __version__ = "0.16.0"
84
+ __version__ = "0.18.0"
80
85
 
81
86
  __all__ = [
82
87
  # WS1 — star response types (0.16.0).
@@ -84,6 +89,8 @@ __all__ = [
84
89
  "ThreadStarResponse",
85
90
  # WS6-SDK — async optimistic-ack (0.16.0).
86
91
  "AsyncSendAck",
92
+ # Track 1 — non-throwing idempotency replay probe result.
93
+ "IdempotencyReplayResult",
87
94
  "ReplyLayer",
88
95
  "AsyncReplyLayer",
89
96
  "RetryInfo",
@@ -132,6 +139,10 @@ __all__ = [
132
139
  "WebRiskLearnMore",
133
140
  "WebRiskWarning",
134
141
  "WebRiskThreatType",
142
+ "LinkScanningStatus",
143
+ "EnableLinkScanningResponse",
144
+ "EnableLinkScanningDisclosure",
145
+ "AgentSafetyContext",
135
146
  "MessageReceivedSafetySignal",
136
147
  "MessageReceivedWebhookPayload",
137
148
  # PR 6 — HITL review queue.
@@ -9,7 +9,7 @@ import httpx
9
9
 
10
10
  from .errors import ReplyLayerError, error_from_response
11
11
 
12
- _VERSION = "0.16.0"
12
+ _VERSION = "0.18.0"
13
13
  _USER_AGENT = f"replylayer-sdk-py/{_VERSION}"
14
14
  _PROTECTED_HEADER_KEYS = frozenset({"authorization", "content-type", "user-agent"})
15
15
 
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._http import AsyncHttpClient, SyncHttpClient
6
+ from ..errors import ReplyLayerError
7
+ from ..types import (
8
+ EnableLinkScanningResponse,
9
+ LinkScanningStatus,
10
+ QuotaResponse,
11
+ UsageResponse,
12
+ )
13
+
14
+ # Raised when the deployed API predates the malicious-link-scanning surface and
15
+ # omits the url_reputation block from GET /v1/auth/me. Mirrors the TS SDK / CLI
16
+ # clear "API outdated" error instead of leaking a raw KeyError.
17
+ _LINK_SCANNING_UNSUPPORTED_MSG = (
18
+ "Server did not return link-scanning status; the API may be outdated."
19
+ )
20
+
21
+
22
+ class SyncAccount:
23
+ def __init__(self, http: SyncHttpClient) -> None:
24
+ self._http = http
25
+
26
+ def get_usage(self) -> UsageResponse:
27
+ return self._http.request("GET", "/v1/accounts/usage")
28
+
29
+ def get_quota(self) -> QuotaResponse:
30
+ return self._http.request("GET", "/v1/accounts/quota")
31
+
32
+ def get_link_scanning_status(self) -> LinkScanningStatus:
33
+ """Malicious link scanning (URL reputation, Google Web Risk) status.
34
+
35
+ Reads the ``url_reputation`` block off GET /v1/auth/me. Works with any key.
36
+ """
37
+ me = self._http.request("GET", "/v1/auth/me")
38
+ if "url_reputation" not in me:
39
+ raise ReplyLayerError(0, "LINK_SCANNING_UNSUPPORTED", _LINK_SCANNING_UNSUPPORTED_MSG)
40
+ return me["url_reputation"]
41
+
42
+ def enable_link_scanning(self, accept_web_risk_version: str) -> EnableLinkScanningResponse:
43
+ """Enable malicious link scanning.
44
+
45
+ Pass the disclaimer version you are acknowledging (from
46
+ ``get_link_scanning_status()["current_version"]``) — the consent is explicit.
47
+ Requires an admin key or session (agent keys get 403). The response includes
48
+ the disclosure you accepted.
49
+ """
50
+ return self._http.request(
51
+ "POST",
52
+ "/v1/accounts/url-reputation",
53
+ body={"accept_web_risk_version": accept_web_risk_version},
54
+ )
55
+
56
+ def export(self) -> dict[str, Any]:
57
+ """GDPR Art. 20 data portability export — the full account-data object."""
58
+ return self._http.request("GET", "/v1/accounts/export")
59
+
60
+
61
+ class AsyncAccount:
62
+ def __init__(self, http: AsyncHttpClient) -> None:
63
+ self._http = http
64
+
65
+ async def get_usage(self) -> UsageResponse:
66
+ return await self._http.request("GET", "/v1/accounts/usage")
67
+
68
+ async def get_quota(self) -> QuotaResponse:
69
+ return await self._http.request("GET", "/v1/accounts/quota")
70
+
71
+ async def get_link_scanning_status(self) -> LinkScanningStatus:
72
+ """Malicious link scanning (URL reputation, Google Web Risk) status.
73
+
74
+ Reads the ``url_reputation`` block off GET /v1/auth/me. Works with any key.
75
+ """
76
+ me = await self._http.request("GET", "/v1/auth/me")
77
+ if "url_reputation" not in me:
78
+ raise ReplyLayerError(0, "LINK_SCANNING_UNSUPPORTED", _LINK_SCANNING_UNSUPPORTED_MSG)
79
+ return me["url_reputation"]
80
+
81
+ async def enable_link_scanning(self, accept_web_risk_version: str) -> EnableLinkScanningResponse:
82
+ """Enable malicious link scanning.
83
+
84
+ Pass the disclaimer version you are acknowledging (from
85
+ ``get_link_scanning_status()["current_version"]``) — the consent is explicit.
86
+ Requires an admin key or session (agent keys get 403). The response includes
87
+ the disclosure you accepted.
88
+ """
89
+ return await self._http.request(
90
+ "POST",
91
+ "/v1/accounts/url-reputation",
92
+ body={"accept_web_risk_version": accept_web_risk_version},
93
+ )
94
+
95
+ async def export(self) -> dict[str, Any]:
96
+ """GDPR Art. 20 data portability export — the full account-data object."""
97
+ return await self._http.request("GET", "/v1/accounts/export")
@@ -51,8 +51,9 @@ class SyncAttachments:
51
51
  content_type: str | None = None,
52
52
  ) -> UploadAttachmentResponse:
53
53
  """Stage an outbound attachment (phase 1). Returns an opaque handle;
54
- pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list. The
55
- mailbox must have outbound attachments enabled (Pro+). The returned
54
+ pass ``handle["id"]`` in a send/reply/draft ``attachment_ids`` list. A
55
+ human account owner must first enable outbound attachments for the
56
+ mailbox in the dashboard (Pro+, TOTP/password re-auth). The returned
56
57
  ``content_scan_status`` is ``"pending"`` — poll :meth:`get_upload` until
57
58
  terminal before referencing the handle, or the send fails with
58
59
  ``ATTACHMENT_SCAN_PENDING``.
@@ -146,8 +146,14 @@ class SyncDrafts:
146
146
  q = {**query, "before": cursor or query["before"]}
147
147
  res = self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
148
148
  drafts = res.get("drafts", [])
149
- next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
150
- return Page(data=drafts, has_more=len(drafts) == limit, cursor=next_cursor)
149
+ # UAT-22 — consume the server's authoritative `has_more` (#296) instead
150
+ # of the old `len(drafts) == limit` inference, which over-reported at
151
+ # the exact-`limit` boundary. Derive `next_cursor` FROM has_more so we
152
+ # only advance when the server says another page exists. Defensive
153
+ # default `False` for a pre-#296 server that omits the field.
154
+ has_more = res.get("has_more") is True
155
+ next_cursor = drafts[-1]["id"] if has_more and drafts else None
156
+ return Page(data=drafts, has_more=has_more, cursor=next_cursor)
151
157
 
152
158
  if auto_paginate:
153
159
  return sync_auto_paginate(fetch_page)
@@ -299,8 +305,14 @@ class AsyncDrafts:
299
305
  q = {**query, "before": cursor or query["before"]}
300
306
  res = await self._http.request("GET", f"/v1/mailboxes/{mailbox_id}/drafts", query=q)
301
307
  drafts = res.get("drafts", [])
302
- next_cursor = drafts[-1]["id"] if len(drafts) == limit else None
303
- return Page(data=drafts, has_more=len(drafts) == limit, cursor=next_cursor)
308
+ # UAT-22 — consume the server's authoritative `has_more` (#296) instead
309
+ # of the old `len(drafts) == limit` inference (byte-identical to the
310
+ # sync method). A sync-only fix would leave this async path still
311
+ # over-reporting at the exact-`limit` boundary. Defensive default
312
+ # `False` for a pre-#296 server that omits the field.
313
+ has_more = res.get("has_more") is True
314
+ next_cursor = drafts[-1]["id"] if has_more and drafts else None
315
+ return Page(data=drafts, has_more=has_more, cursor=next_cursor)
304
316
 
305
317
  if auto_paginate:
306
318
  return async_auto_paginate(fetch_page)
@@ -1,13 +1,72 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, AsyncIterator, Iterator
3
+ from dataclasses import dataclass
4
+ from typing import Any, AsyncIterator, Iterator, Literal
4
5
 
5
6
  from .._http import AsyncHttpClient, SyncHttpClient
6
7
  from .._pagination import async_auto_paginate, sync_auto_paginate
8
+ from ..errors import ReplyLayerError
7
9
  from ..types import MessageStarResponse, Page
8
10
 
9
11
  DEFAULT_LIMIT = 50
10
12
 
13
+ # Track 1 §3a — wire codes the replay-probe (GET /v1/messages/idempotency)
14
+ # returns on a 409. Matched on ``ReplyLayerError.code`` verbatim (NOT a
15
+ # substring-of-message match), since the base client exposes the wire code.
16
+ _IDEMPOTENT_REQUEST_IN_FLIGHT = "IDEMPOTENT_REQUEST_IN_FLIGHT"
17
+ _IDEMPOTENT_REQUEST_NOT_PROVEN_SENT = "IDEMPOTENT_REQUEST_NOT_PROVEN_SENT"
18
+ _IDEMPOTENCY_KEY_BOUND_TO_DRAFT = "IDEMPOTENCY_KEY_BOUND_TO_DRAFT"
19
+
20
+
21
+ @dataclass
22
+ class IdempotencyReplayResult:
23
+ """Discriminated result of the non-throwing replay probe (Track 1 §3a).
24
+
25
+ ``kind`` is the tag:
26
+ * ``'miss'`` — 404, no prior keyed row. Proceed: upload + keyed POST.
27
+ * ``'replay'`` — 200, a prior result exists. ``message`` holds it.
28
+ * ``'in_flight'`` — 409 IDEMPOTENT_REQUEST_IN_FLIGHT. ``retry_after``
29
+ (seconds) is sourced from the response BODY
30
+ ``details.retry_after`` (NOT the ``Retry-After``
31
+ header — the base client only parses that for 429).
32
+ * ``'not_proven_sent'`` — 409 IDEMPOTENT_REQUEST_NOT_PROVEN_SENT.
33
+ * ``'bound_to_draft'`` — 409 IDEMPOTENCY_KEY_BOUND_TO_DRAFT (cross-namespace).
34
+ """
35
+
36
+ kind: Literal["miss", "replay", "in_flight", "not_proven_sent", "bound_to_draft"]
37
+ message: dict[str, Any] | None = None
38
+ retry_after: int | None = None
39
+
40
+
41
+ def _classify_idempotency_probe_error(
42
+ err: ReplyLayerError,
43
+ ) -> IdempotencyReplayResult | None:
44
+ """Map a raised probe error to a discriminated result, or ``None`` to
45
+ signal the caller should RE-RAISE (every other non-2xx: 401/403/500/…).
46
+
47
+ Matched on the wire ``status_code`` + ``code`` (the typed error exposes
48
+ both), never on a string-in-message match.
49
+ """
50
+ if err.status_code == 404:
51
+ return IdempotencyReplayResult(kind="miss")
52
+ if err.status_code == 409:
53
+ if err.code == _IDEMPOTENT_REQUEST_IN_FLIGHT:
54
+ retry_after: int | None = None
55
+ details = err.details
56
+ if isinstance(details, dict):
57
+ raw = details.get("retry_after")
58
+ if isinstance(raw, bool):
59
+ raw = None
60
+ if isinstance(raw, (int, float)):
61
+ retry_after = int(raw)
62
+ return IdempotencyReplayResult(kind="in_flight", retry_after=retry_after)
63
+ if err.code == _IDEMPOTENT_REQUEST_NOT_PROVEN_SENT:
64
+ return IdempotencyReplayResult(kind="not_proven_sent")
65
+ if err.code == _IDEMPOTENCY_KEY_BOUND_TO_DRAFT:
66
+ return IdempotencyReplayResult(kind="bound_to_draft")
67
+ # Any other non-2xx (401/403/500/other-409-code) → re-raise.
68
+ return None
69
+
11
70
 
12
71
  class SyncMessages:
13
72
  def __init__(self, http: SyncHttpClient) -> None:
@@ -25,6 +84,7 @@ class SyncMessages:
25
84
  subaddress_instance_id: str | None = None,
26
85
  subaddress_mode: str | None = None,
27
86
  attachment_ids: list[str] | None = None,
87
+ idempotency_key: str | None = None,
28
88
  ) -> dict[str, Any]:
29
89
  """Send an outbound message.
30
90
 
@@ -37,6 +97,13 @@ class SyncMessages:
37
97
  to a thread participant is admitted past an ``allowlist`` mailbox
38
98
  via the thread-scoped bypass.
39
99
 
100
+ ``idempotency_key`` (Track 1) makes a retried same-key immediate send
101
+ produce at most one email + one charge: the server replays the prior
102
+ message instead of re-sending. The key travels in the ``Idempotency-Key``
103
+ request header; it is permanent (no expiry) and must be a stable string
104
+ per send intent. See :meth:`get_idempotency_replay` for the read-only
105
+ probe the attachment-taking wrappers use before re-uploading.
106
+
40
107
  Sandbox accounts are subject to a 250-cumulative-send trial
41
108
  budget. Once exhausted the API returns 403 with
42
109
  ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
@@ -65,7 +132,12 @@ class SyncMessages:
65
132
  payload["subaddress_mode"] = subaddress_mode
66
133
  if attachment_ids is not None:
67
134
  payload["attachment_ids"] = attachment_ids
68
- return self._http.request("POST", "/v1/messages/send", body=payload)
135
+ extra_headers: dict[str, str] | None = None
136
+ if idempotency_key is not None:
137
+ extra_headers = {"Idempotency-Key": idempotency_key}
138
+ return self._http.request(
139
+ "POST", "/v1/messages/send", body=payload, extra_headers=extra_headers
140
+ )
69
141
 
70
142
  def list(
71
143
  self,
@@ -136,7 +208,15 @@ class SyncMessages:
136
208
  subaddress_instance_id: str | None = None,
137
209
  subaddress_mode: str | None = None,
138
210
  attachment_ids: list[str] | None = None,
211
+ idempotency_key: str | None = None,
139
212
  ) -> dict[str, Any]:
213
+ """Reply to an inbound message.
214
+
215
+ ``idempotency_key`` (Track 1) makes a retried same-key reply produce at
216
+ most one email + one charge — the server replays the prior message
217
+ instead of re-sending. The key travels in the ``Idempotency-Key``
218
+ request header. See :meth:`get_idempotency_replay`.
219
+ """
140
220
  payload: dict[str, Any] = {"body": body}
141
221
  if html is not None:
142
222
  payload["html"] = html
@@ -146,7 +226,49 @@ class SyncMessages:
146
226
  payload["subaddress_mode"] = subaddress_mode
147
227
  if attachment_ids is not None:
148
228
  payload["attachment_ids"] = attachment_ids
149
- return self._http.request("POST", f"/v1/messages/{message_id}/reply", body=payload)
229
+ extra_headers: dict[str, str] | None = None
230
+ if idempotency_key is not None:
231
+ extra_headers = {"Idempotency-Key": idempotency_key}
232
+ return self._http.request(
233
+ "POST",
234
+ f"/v1/messages/{message_id}/reply",
235
+ body=payload,
236
+ extra_headers=extra_headers,
237
+ )
238
+
239
+ def get_idempotency_replay(self, key: str) -> "IdempotencyReplayResult":
240
+ """Probe whether an immediate-send/reply idempotency key already has a
241
+ prior result, WITHOUT side effects (Track 1, §3a).
242
+
243
+ The MCP/CLI attachment wrappers call this BEFORE re-uploading
244
+ attachments (or re-fetching the original message) on a same-key retry,
245
+ so a retry whose local file is gone still replays the prior result
246
+ instead of dying in local preflight.
247
+
248
+ Non-throwing + discriminated: the base client raises on every non-2xx,
249
+ but the load-bearing 404 *miss* (the common "no prior, proceed" case)
250
+ and the three 409 outcomes are caught here and returned as an
251
+ :class:`IdempotencyReplayResult` (``.kind`` one of ``'miss'``,
252
+ ``'replay'``, ``'in_flight'``, ``'not_proven_sent'``,
253
+ ``'bound_to_draft'``). Every OTHER non-2xx (401/403/500/other-409)
254
+ re-raises the original error.
255
+
256
+ The key travels in the ``Idempotency-Key`` request header (keys are
257
+ arbitrary client strings that may contain ``/``, ``?``, ``#`` — a header
258
+ sidesteps URL-encoding hazards).
259
+ """
260
+ try:
261
+ message = self._http.request(
262
+ "GET",
263
+ "/v1/messages/idempotency",
264
+ extra_headers={"Idempotency-Key": key},
265
+ )
266
+ except ReplyLayerError as err:
267
+ result = _classify_idempotency_probe_error(err)
268
+ if result is None:
269
+ raise
270
+ return result
271
+ return IdempotencyReplayResult(kind="replay", message=message)
150
272
 
151
273
  def wait(self, mailbox_id: str, *, timeout: int = 30, since: str | None = None) -> dict[str, Any]:
152
274
  # RL-UAT-018 — `since` is the monitoring cursor anchor (strict `>`
@@ -268,6 +390,7 @@ class AsyncMessages:
268
390
  subaddress_instance_id: str | None = None,
269
391
  subaddress_mode: str | None = None,
270
392
  attachment_ids: list[str] | None = None,
393
+ idempotency_key: str | None = None,
271
394
  ) -> dict[str, Any]:
272
395
  """Send an outbound message.
273
396
 
@@ -277,6 +400,11 @@ class AsyncMessages:
277
400
  ``from_mailbox``/``subject`` are derived and ``to`` becomes an
278
401
  optional participant selector.
279
402
 
403
+ ``idempotency_key`` (Track 1) makes a retried same-key immediate send
404
+ produce at most one email + one charge — the server replays the prior
405
+ message instead of re-sending. The key travels in the ``Idempotency-Key``
406
+ request header. See :meth:`get_idempotency_replay`.
407
+
280
408
  Sandbox accounts are subject to a 250-cumulative-send trial
281
409
  budget. Once exhausted the API returns 403 with
282
410
  ``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
@@ -301,7 +429,12 @@ class AsyncMessages:
301
429
  payload["subaddress_mode"] = subaddress_mode
302
430
  if attachment_ids is not None:
303
431
  payload["attachment_ids"] = attachment_ids
304
- return await self._http.request("POST", "/v1/messages/send", body=payload)
432
+ extra_headers: dict[str, str] | None = None
433
+ if idempotency_key is not None:
434
+ extra_headers = {"Idempotency-Key": idempotency_key}
435
+ return await self._http.request(
436
+ "POST", "/v1/messages/send", body=payload, extra_headers=extra_headers
437
+ )
305
438
 
306
439
  async def list(
307
440
  self,
@@ -373,7 +506,13 @@ class AsyncMessages:
373
506
  subaddress_instance_id: str | None = None,
374
507
  subaddress_mode: str | None = None,
375
508
  attachment_ids: list[str] | None = None,
509
+ idempotency_key: str | None = None,
376
510
  ) -> dict[str, Any]:
511
+ """Reply to an inbound message (async). See SyncMessages.reply.
512
+
513
+ ``idempotency_key`` (Track 1) makes a retried same-key reply produce at
514
+ most one email + one charge. See :meth:`get_idempotency_replay`.
515
+ """
377
516
  payload: dict[str, Any] = {"body": body}
378
517
  if html is not None:
379
518
  payload["html"] = html
@@ -383,7 +522,35 @@ class AsyncMessages:
383
522
  payload["subaddress_mode"] = subaddress_mode
384
523
  if attachment_ids is not None:
385
524
  payload["attachment_ids"] = attachment_ids
386
- return await self._http.request("POST", f"/v1/messages/{message_id}/reply", body=payload)
525
+ extra_headers: dict[str, str] | None = None
526
+ if idempotency_key is not None:
527
+ extra_headers = {"Idempotency-Key": idempotency_key}
528
+ return await self._http.request(
529
+ "POST",
530
+ f"/v1/messages/{message_id}/reply",
531
+ body=payload,
532
+ extra_headers=extra_headers,
533
+ )
534
+
535
+ async def get_idempotency_replay(self, key: str) -> "IdempotencyReplayResult":
536
+ """Async probe of an immediate-send/reply idempotency key (Track 1, §3a).
537
+
538
+ Non-throwing + discriminated — see SyncMessages.get_idempotency_replay
539
+ for the full contract. The key travels in the ``Idempotency-Key``
540
+ request header.
541
+ """
542
+ try:
543
+ message = await self._http.request(
544
+ "GET",
545
+ "/v1/messages/idempotency",
546
+ extra_headers={"Idempotency-Key": key},
547
+ )
548
+ except ReplyLayerError as err:
549
+ result = _classify_idempotency_probe_error(err)
550
+ if result is None:
551
+ raise
552
+ return result
553
+ return IdempotencyReplayResult(kind="replay", message=message)
387
554
 
388
555
  async def wait(self, mailbox_id: str, *, timeout: int = 30, since: str | None = None) -> dict[str, Any]:
389
556
  # RL-UAT-018 — `since` is the monitoring cursor anchor (strict `>`