replylayer 0.14.0__tar.gz → 0.16.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.14.0 → replylayer-0.16.0}/PKG-INFO +8 -4
- {replylayer-0.14.0 → replylayer-0.16.0}/README.md +5 -3
- {replylayer-0.14.0 → replylayer-0.16.0}/pyproject.toml +6 -1
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/__init__.py +11 -1
- replylayer-0.16.0/replylayer/__main__.py +25 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/_http.py +1 -1
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/drafts.py +24 -10
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/messages.py +34 -1
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/threads.py +57 -5
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/types.py +29 -0
- replylayer-0.16.0/tests/test_version.py +18 -0
- replylayer-0.16.0/tests/test_ws1_ws6.py +369 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/uv.lock +21 -2
- {replylayer-0.14.0 → replylayer-0.16.0}/.gitignore +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/_client.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/_pagination.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/errors.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/py.typed +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/__init__.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/account.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/api_keys.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/attachments.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/domains.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/health.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/inbound_blocklist.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/legal_holds.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/mailboxes.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/recipients.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/suppressions.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/replylayer/resources/webhooks.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/__init__.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_async.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_attachments.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_client.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_domains.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_drafts.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_hitl_review_types.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_http.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_resources.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_web_risk_types.py +0 -0
- {replylayer-0.14.0 → replylayer-0.16.0}/tests/test_webhooks.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: replylayer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.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
|
|
7
7
|
Requires-Python: >=3.10
|
|
8
8
|
Requires-Dist: httpx>=0.27
|
|
9
9
|
Requires-Dist: typing-extensions>=4.0
|
|
10
|
+
Provides-Extra: cli
|
|
11
|
+
Requires-Dist: rly>=0.6.3; extra == 'cli'
|
|
10
12
|
Provides-Extra: dev
|
|
11
13
|
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
12
14
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -17,6 +19,8 @@ Description-Content-Type: text/markdown
|
|
|
17
19
|
|
|
18
20
|
Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
|
|
19
21
|
|
|
22
|
+
> **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.
|
|
23
|
+
|
|
20
24
|
## Install
|
|
21
25
|
|
|
22
26
|
```bash
|
|
@@ -114,9 +118,9 @@ contract — read it before relying on retries:
|
|
|
114
118
|
|----------|---------|
|
|
115
119
|
| `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
|
|
116
120
|
| `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
|
|
117
|
-
| `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
|
|
121
|
+
| `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
|
|
118
122
|
| `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
|
|
119
|
-
| `rl.threads` | `list`, `get` |
|
|
123
|
+
| `rl.threads` | `list`, `get`, `set_starred` |
|
|
120
124
|
| `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
|
|
121
125
|
| `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
|
|
122
126
|
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
@@ -145,7 +149,7 @@ if draft["worst_decision"] == "allow":
|
|
|
145
149
|
|
|
146
150
|
The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
|
|
147
151
|
|
|
148
|
-
|
|
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.)
|
|
149
153
|
|
|
150
154
|
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
151
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`.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Official Python SDK for [ReplyLayer](https://replylayer.ai) — secure email for AI agents.
|
|
4
4
|
|
|
5
|
+
> **Looking for the command-line tool?** This package is the SDK *library* (`import replylayer`). For the `rly` / `replylayer` CLI, install [`rly`](https://pypi.org/project/rly/) instead: `pipx install rly`.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -99,9 +101,9 @@ contract — read it before relying on retries:
|
|
|
99
101
|
|----------|---------|
|
|
100
102
|
| `rl.mailboxes` | `create`, `list`, `delete`, `update`, `set_recipient_policy` |
|
|
101
103
|
| `rl.mailboxes.allowlist` | `list`, `add`, `add_bulk`, `delete`, `list_blocked_attempts` |
|
|
102
|
-
| `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block` |
|
|
104
|
+
| `rl.messages` | `send`, `list`, `get`, `reply`, `wait`, `release`, `block`, `set_starred` |
|
|
103
105
|
| `rl.drafts` | `create`, `get`, `list`, `update`, `send`, `delete` |
|
|
104
|
-
| `rl.threads` | `list`, `get` |
|
|
106
|
+
| `rl.threads` | `list`, `get`, `set_starred` |
|
|
105
107
|
| `rl.attachments` | `get_download_url`, `get_preview`, `upload`, `get_upload`, `delete_upload` |
|
|
106
108
|
| `rl.webhooks` | `create`, `list`, `get`, `update`, `delete`, `rotate_secret`, `test`, `list_deliveries`, `retry_delivery` |
|
|
107
109
|
| `rl.recipients` | `create`, `list`, `delete`, `resend` |
|
|
@@ -130,7 +132,7 @@ if draft["worst_decision"] == "allow":
|
|
|
130
132
|
|
|
131
133
|
The send/reply/draft-send response carries two additive, nullable keys that explain a held send inline (no second `messages.get` call). `result["scan"]` is the vendor-neutral scanner verdict (`ScanSummary`); `result["hold_context"]` (`{"trigger_source", "summary_reasons"}` or `None`) is the policy/HITL reason, non-null only when the delivery `status` diverges from `scan["verdict"]` because of a policy/HITL hold — a clean scan held for review by your mailbox policy, or a scanner review-flag held as quarantine on a plan without the review queue (`trigger_source`: `mailbox_policy` | `scanner` | `both`).
|
|
132
134
|
|
|
133
|
-
|
|
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.)
|
|
134
136
|
|
|
135
137
|
The send endpoint raises `ReplyLayerError` with distinct `.code` values on 409:
|
|
136
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`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "replylayer"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.16.0"
|
|
8
8
|
description = "Official Python SDK for ReplyLayer — email for AI agents"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -18,6 +18,11 @@ dependencies = [
|
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.optional-dependencies]
|
|
21
|
+
# Optional CLI convenience: `pip install "replylayer[cli]"` also installs the
|
|
22
|
+
# `rly` launcher (the `rly` / `replylayer` command-line tools). The SDK itself
|
|
23
|
+
# is a pure library; this extra is opt-in and is never pulled by a plain
|
|
24
|
+
# `pip install replylayer`.
|
|
25
|
+
cli = ["rly>=0.6.3"]
|
|
21
26
|
dev = [
|
|
22
27
|
"pytest>=8.0",
|
|
23
28
|
"pytest-asyncio>=0.24",
|
|
@@ -14,6 +14,11 @@ from .errors import (
|
|
|
14
14
|
TimezoneRequiredError,
|
|
15
15
|
)
|
|
16
16
|
from .types import (
|
|
17
|
+
# WS1 — star response types (0.16.0).
|
|
18
|
+
MessageStarResponse,
|
|
19
|
+
ThreadStarResponse,
|
|
20
|
+
# WS6-SDK — async optimistic-ack (0.16.0).
|
|
21
|
+
AsyncSendAck,
|
|
17
22
|
WebhookSummary,
|
|
18
23
|
WebhookDeliverySummary,
|
|
19
24
|
WebhookDeliveryStatus,
|
|
@@ -71,9 +76,14 @@ from .types import (
|
|
|
71
76
|
ScannerPolicy,
|
|
72
77
|
)
|
|
73
78
|
|
|
74
|
-
__version__ = "0.
|
|
79
|
+
__version__ = "0.16.0"
|
|
75
80
|
|
|
76
81
|
__all__ = [
|
|
82
|
+
# WS1 — star response types (0.16.0).
|
|
83
|
+
"MessageStarResponse",
|
|
84
|
+
"ThreadStarResponse",
|
|
85
|
+
# WS6-SDK — async optimistic-ack (0.16.0).
|
|
86
|
+
"AsyncSendAck",
|
|
77
87
|
"ReplyLayer",
|
|
78
88
|
"AsyncReplyLayer",
|
|
79
89
|
"RetryInfo",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Entry point for ``python -m replylayer``.
|
|
2
|
+
|
|
3
|
+
The ``replylayer`` PyPI package is the ReplyLayer Python **SDK** — a library you
|
|
4
|
+
``import``, not a command-line tool. This module exists only to redirect anyone
|
|
5
|
+
who tries to "run" the package toward the actual CLI (the separate ``rly``
|
|
6
|
+
package), rather than failing silently.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
print(
|
|
14
|
+
"replylayer is the ReplyLayer Python SDK (a library, not a CLI).\n"
|
|
15
|
+
"\n"
|
|
16
|
+
" Use it in code: import replylayer\n"
|
|
17
|
+
" Install the CLI: pipx install rly "
|
|
18
|
+
"# provides the `rly` and `replylayer` commands\n"
|
|
19
|
+
"\n"
|
|
20
|
+
"Docs: https://replylayer.ai"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
|
@@ -27,7 +27,7 @@ from typing import Any, AsyncIterator, Iterator, Union
|
|
|
27
27
|
from .._http import AsyncHttpClient, SyncHttpClient
|
|
28
28
|
from .._pagination import async_auto_paginate, sync_auto_paginate
|
|
29
29
|
from ..errors import TimezoneRequiredError
|
|
30
|
-
from ..types import Page
|
|
30
|
+
from ..types import AsyncSendAck, Page, SendMessageResponse
|
|
31
31
|
|
|
32
32
|
DEFAULT_LIMIT = 50
|
|
33
33
|
|
|
@@ -187,13 +187,24 @@ class SyncDrafts:
|
|
|
187
187
|
payload["attachment_ids"] = attachment_ids
|
|
188
188
|
return self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
|
|
189
189
|
|
|
190
|
-
def send(self, id: str) ->
|
|
190
|
+
def send(self, id: str, *, async_dispatch: bool = False) -> "SendMessageResponse | AsyncSendAck":
|
|
191
191
|
"""Dispatch a draft.
|
|
192
192
|
|
|
193
193
|
Re-runs the scanner authoritatively + the full send-time gate
|
|
194
194
|
stack (suppressions, reply-loop, budget, etc) before handing the
|
|
195
195
|
message to the outbound provider.
|
|
196
196
|
|
|
197
|
+
Pass ``async_dispatch=True`` to send the ``Prefer: respond-async``
|
|
198
|
+
hint. **The hint is advisory** — the server returns a 202
|
|
199
|
+
``AsyncSendAck`` (``status == "queued_for_dispatch"``) only when
|
|
200
|
+
``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it ignores the
|
|
201
|
+
hint and returns a normal ``SendMessageResponse``. **Always branch on
|
|
202
|
+
the result**: ``status == "queued_for_dispatch"`` ⇒ ``AsyncSendAck``,
|
|
203
|
+
otherwise ``SendMessageResponse``. Attachment-bearing drafts fail
|
|
204
|
+
closed on the async path (``400 ATTACHMENTS_REQUIRE_SYNC_SEND``).
|
|
205
|
+
Poll ``messages.get(message_id)`` until ``state`` is terminal to
|
|
206
|
+
observe the final outcome.
|
|
207
|
+
|
|
197
208
|
Sandbox accounts are subject to a 250-cumulative-send trial
|
|
198
209
|
budget. Once exhausted the API returns 403 with
|
|
199
210
|
``code='SANDBOX_TRIAL_BUDGET_EXHAUSTED'`` and a ``details``
|
|
@@ -201,7 +212,8 @@ class SyncDrafts:
|
|
|
201
212
|
cap fires here as on ``messages.send()`` — the cumulative
|
|
202
213
|
counter is shared across both surfaces.
|
|
203
214
|
"""
|
|
204
|
-
|
|
215
|
+
extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
|
|
216
|
+
return self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
|
|
205
217
|
|
|
206
218
|
def delete(self, id: str) -> None:
|
|
207
219
|
self._http.request("DELETE", f"/v1/drafts/{id}")
|
|
@@ -328,15 +340,17 @@ class AsyncDrafts:
|
|
|
328
340
|
payload["attachment_ids"] = attachment_ids
|
|
329
341
|
return await self._http.request("PATCH", f"/v1/drafts/{id}", body=payload)
|
|
330
342
|
|
|
331
|
-
async def send(self, id: str) ->
|
|
332
|
-
"""Dispatch a draft.
|
|
343
|
+
async def send(self, id: str, *, async_dispatch: bool = False) -> "SendMessageResponse | AsyncSendAck":
|
|
344
|
+
"""Dispatch a draft (async). See SyncDrafts.send for the full contract.
|
|
333
345
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
``
|
|
337
|
-
|
|
346
|
+
Pass ``async_dispatch=True`` to send the ``Prefer: respond-async``
|
|
347
|
+
hint. The hint is advisory — the server returns a 202 ``AsyncSendAck``
|
|
348
|
+
only when ``OUTBOUND_ASYNC_DISPATCH_ENABLED`` is on; otherwise it
|
|
349
|
+
ignores the hint and returns a normal ``SendMessageResponse``. Always
|
|
350
|
+
branch on ``status == "queued_for_dispatch"`` to distinguish the two.
|
|
338
351
|
"""
|
|
339
|
-
|
|
352
|
+
extra_headers: dict[str, str] | None = {"Prefer": "respond-async"} if async_dispatch else None
|
|
353
|
+
return await self._http.request("POST", f"/v1/drafts/{id}/send", body={}, extra_headers=extra_headers)
|
|
340
354
|
|
|
341
355
|
async def delete(self, id: str) -> None:
|
|
342
356
|
await self._http.request("DELETE", f"/v1/drafts/{id}")
|
|
@@ -4,7 +4,7 @@ from typing import Any, AsyncIterator, Iterator
|
|
|
4
4
|
|
|
5
5
|
from .._http import AsyncHttpClient, SyncHttpClient
|
|
6
6
|
from .._pagination import async_auto_paginate, sync_auto_paginate
|
|
7
|
-
from ..types import Page
|
|
7
|
+
from ..types import MessageStarResponse, Page
|
|
8
8
|
|
|
9
9
|
DEFAULT_LIMIT = 50
|
|
10
10
|
|
|
@@ -81,6 +81,8 @@ class SyncMessages:
|
|
|
81
81
|
until: str | None = None,
|
|
82
82
|
search: str | None = None,
|
|
83
83
|
view: str | None = None,
|
|
84
|
+
starred: bool | None = None,
|
|
85
|
+
has_attachment: bool | None = None,
|
|
84
86
|
auto_paginate: bool = False,
|
|
85
87
|
) -> Union[Page, Iterator[dict[str, Any]]]:
|
|
86
88
|
"""List messages in a mailbox.
|
|
@@ -90,6 +92,10 @@ class SyncMessages:
|
|
|
90
92
|
server's blind-trigram index has no shorter form. A 1-2 character
|
|
91
93
|
``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
|
|
92
94
|
(``details.min_search_length=3``).
|
|
95
|
+
|
|
96
|
+
``has_attachment`` requires a server advertising
|
|
97
|
+
``messages.has_attachment_filter`` in ``GET /v1/health``'s
|
|
98
|
+
``capabilities``; older servers reject the param.
|
|
93
99
|
"""
|
|
94
100
|
query: dict[str, str | None] = {
|
|
95
101
|
"limit": str(limit),
|
|
@@ -102,6 +108,8 @@ class SyncMessages:
|
|
|
102
108
|
"until": until,
|
|
103
109
|
"search": search,
|
|
104
110
|
"view": view,
|
|
111
|
+
"starred": str(starred).lower() if starred is not None else None,
|
|
112
|
+
"has_attachment": str(has_attachment).lower() if has_attachment is not None else None,
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
def fetch_page(cursor: str | None) -> Page:
|
|
@@ -195,6 +203,17 @@ class SyncMessages:
|
|
|
195
203
|
"POST", f"/v1/messages/{message_id}/read", body={}
|
|
196
204
|
)
|
|
197
205
|
|
|
206
|
+
def set_starred(self, message_id: str, *, starred: bool) -> "MessageStarResponse":
|
|
207
|
+
"""Star or unstar a message.
|
|
208
|
+
|
|
209
|
+
``starred=True`` marks the message as starred (favorited);
|
|
210
|
+
``starred=False`` clears the star. Idempotent.
|
|
211
|
+
Wraps ``PATCH /v1/messages/:id/star``.
|
|
212
|
+
"""
|
|
213
|
+
return self._http.request(
|
|
214
|
+
"PATCH", f"/v1/messages/{message_id}/star", body={"starred": starred}
|
|
215
|
+
)
|
|
216
|
+
|
|
198
217
|
def approve_review(
|
|
199
218
|
self, message_id: str, *, reason: str | None = None
|
|
200
219
|
) -> dict[str, Any]:
|
|
@@ -298,6 +317,8 @@ class AsyncMessages:
|
|
|
298
317
|
until: str | None = None,
|
|
299
318
|
search: str | None = None,
|
|
300
319
|
view: str | None = None,
|
|
320
|
+
starred: bool | None = None,
|
|
321
|
+
has_attachment: bool | None = None,
|
|
301
322
|
auto_paginate: bool = False,
|
|
302
323
|
) -> Page | AsyncIterator[dict[str, Any]]:
|
|
303
324
|
"""List messages in a mailbox.
|
|
@@ -307,6 +328,10 @@ class AsyncMessages:
|
|
|
307
328
|
server's blind-trigram index has no shorter form. A 1-2 character
|
|
308
329
|
``search`` is rejected with HTTP 400 ``code='SEARCH_TERM_TOO_SHORT'``
|
|
309
330
|
(``details.min_search_length=3``).
|
|
331
|
+
|
|
332
|
+
``has_attachment`` requires a server advertising
|
|
333
|
+
``messages.has_attachment_filter`` in ``GET /v1/health``'s
|
|
334
|
+
``capabilities``; older servers reject the param.
|
|
310
335
|
"""
|
|
311
336
|
query: dict[str, str | None] = {
|
|
312
337
|
"limit": str(limit),
|
|
@@ -319,6 +344,8 @@ class AsyncMessages:
|
|
|
319
344
|
"until": until,
|
|
320
345
|
"search": search,
|
|
321
346
|
"view": view,
|
|
347
|
+
"starred": str(starred).lower() if starred is not None else None,
|
|
348
|
+
"has_attachment": str(has_attachment).lower() if has_attachment is not None else None,
|
|
322
349
|
}
|
|
323
350
|
|
|
324
351
|
async def fetch_page(cursor: str | None) -> Page:
|
|
@@ -396,6 +423,12 @@ class AsyncMessages:
|
|
|
396
423
|
"POST", f"/v1/messages/{message_id}/read", body={}
|
|
397
424
|
)
|
|
398
425
|
|
|
426
|
+
async def set_starred(self, message_id: str, *, starred: bool) -> "MessageStarResponse":
|
|
427
|
+
"""Star or unstar a message (async). See SyncMessages.set_starred."""
|
|
428
|
+
return await self._http.request(
|
|
429
|
+
"PATCH", f"/v1/messages/{message_id}/star", body={"starred": starred}
|
|
430
|
+
)
|
|
431
|
+
|
|
399
432
|
async def approve_review(
|
|
400
433
|
self, message_id: str, *, reason: str | None = None
|
|
401
434
|
) -> dict[str, Any]:
|
|
@@ -5,7 +5,7 @@ from urllib.parse import quote
|
|
|
5
5
|
|
|
6
6
|
from .._http import AsyncHttpClient, SyncHttpClient
|
|
7
7
|
from .._pagination import async_auto_paginate, sync_auto_paginate
|
|
8
|
-
from ..types import Page
|
|
8
|
+
from ..types import Page, ThreadStarResponse
|
|
9
9
|
|
|
10
10
|
DEFAULT_LIMIT = 50
|
|
11
11
|
|
|
@@ -44,8 +44,26 @@ class SyncThreads:
|
|
|
44
44
|
return sync_auto_paginate(fetch_page)
|
|
45
45
|
return fetch_page(None)
|
|
46
46
|
|
|
47
|
-
def get(
|
|
48
|
-
|
|
47
|
+
def get(
|
|
48
|
+
self,
|
|
49
|
+
id: str,
|
|
50
|
+
*,
|
|
51
|
+
view: str | None = None,
|
|
52
|
+
body_format: str | None = None,
|
|
53
|
+
mailbox: str | None = None,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Read a full thread (ordered messages).
|
|
56
|
+
|
|
57
|
+
Account-wide by default; pass ``mailbox`` (name or UUID) to scope the
|
|
58
|
+
lookup to one mailbox when the same thread key collides across two of
|
|
59
|
+
the account's mailboxes. ``mailbox`` requires a server that accepts
|
|
60
|
+
the param — older servers reject it.
|
|
61
|
+
"""
|
|
62
|
+
query = {
|
|
63
|
+
k: v for k, v in {
|
|
64
|
+
"view": view, "body_format": body_format, "mailbox": mailbox,
|
|
65
|
+
}.items() if v is not None
|
|
66
|
+
}
|
|
49
67
|
return self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
|
|
50
68
|
|
|
51
69
|
def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
|
|
@@ -68,6 +86,20 @@ class SyncThreads:
|
|
|
68
86
|
body={},
|
|
69
87
|
)
|
|
70
88
|
|
|
89
|
+
def set_starred(self, mailbox_id: str, thread_id: str, *, starred: bool) -> "ThreadStarResponse":
|
|
90
|
+
"""Star or unstar a thread.
|
|
91
|
+
|
|
92
|
+
``starred=True`` marks the thread as starred; ``starred=False`` clears it.
|
|
93
|
+
Both ``mailbox_id`` and ``thread_id`` are URL-encoded (mirrors existing
|
|
94
|
+
``mark_read`` behaviour). Wraps
|
|
95
|
+
``PATCH /v1/mailboxes/:id/threads/:thread_id/star``.
|
|
96
|
+
"""
|
|
97
|
+
return self._http.request(
|
|
98
|
+
"PATCH",
|
|
99
|
+
f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/star",
|
|
100
|
+
body={"starred": starred},
|
|
101
|
+
)
|
|
102
|
+
|
|
71
103
|
|
|
72
104
|
class AsyncThreads:
|
|
73
105
|
def __init__(self, http: AsyncHttpClient) -> None:
|
|
@@ -104,8 +136,20 @@ class AsyncThreads:
|
|
|
104
136
|
|
|
105
137
|
return await fetch_page(None)
|
|
106
138
|
|
|
107
|
-
async def get(
|
|
108
|
-
|
|
139
|
+
async def get(
|
|
140
|
+
self,
|
|
141
|
+
id: str,
|
|
142
|
+
*,
|
|
143
|
+
view: str | None = None,
|
|
144
|
+
body_format: str | None = None,
|
|
145
|
+
mailbox: str | None = None,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Read a full thread (async). See SyncThreads.get for the full contract."""
|
|
148
|
+
query = {
|
|
149
|
+
k: v for k, v in {
|
|
150
|
+
"view": view, "body_format": body_format, "mailbox": mailbox,
|
|
151
|
+
}.items() if v is not None
|
|
152
|
+
}
|
|
109
153
|
return await self._http.request("GET", f"/v1/threads/{quote(id, safe='')}", query=query)
|
|
110
154
|
|
|
111
155
|
async def mark_read(self, mailbox_id: str, thread_id: str) -> dict[str, Any]:
|
|
@@ -115,3 +159,11 @@ class AsyncThreads:
|
|
|
115
159
|
f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/read",
|
|
116
160
|
body={},
|
|
117
161
|
)
|
|
162
|
+
|
|
163
|
+
async def set_starred(self, mailbox_id: str, thread_id: str, *, starred: bool) -> "ThreadStarResponse":
|
|
164
|
+
"""Star or unstar a thread (async). See SyncThreads.set_starred."""
|
|
165
|
+
return await self._http.request(
|
|
166
|
+
"PATCH",
|
|
167
|
+
f"/v1/mailboxes/{quote(mailbox_id, safe='')}/threads/{quote(thread_id, safe='')}/star",
|
|
168
|
+
body={"starred": starred},
|
|
169
|
+
)
|
|
@@ -602,6 +602,31 @@ class SendMessageResponse(TypedDict):
|
|
|
602
602
|
sends_remaining: int
|
|
603
603
|
|
|
604
604
|
|
|
605
|
+
class AsyncSendAck(TypedDict):
|
|
606
|
+
"""202 optimistic-ack from POST /v1/drafts/:id/send with Prefer: respond-async.
|
|
607
|
+
|
|
608
|
+
Exactly 4 fields — no scan/warning/hold_context (those only appear on the
|
|
609
|
+
synchronous 200 path). Poll messages.get(message_id) until state is terminal.
|
|
610
|
+
"""
|
|
611
|
+
message_id: str
|
|
612
|
+
status: Literal["queued_for_dispatch"]
|
|
613
|
+
daily_limit: int
|
|
614
|
+
sends_remaining: int
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class MessageStarResponse(TypedDict):
|
|
618
|
+
"""PATCH /v1/messages/:id/star response."""
|
|
619
|
+
message_id: str
|
|
620
|
+
starred: bool
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class ThreadStarResponse(TypedDict):
|
|
624
|
+
"""PATCH /v1/mailboxes/:id/threads/:thread_id/star response."""
|
|
625
|
+
thread_id: str
|
|
626
|
+
starred: bool
|
|
627
|
+
updated_count: int
|
|
628
|
+
|
|
629
|
+
|
|
605
630
|
class MessageSummary(TypedDict, total=False):
|
|
606
631
|
"""List-response item shape.
|
|
607
632
|
|
|
@@ -641,6 +666,8 @@ class MessageSummary(TypedDict, total=False):
|
|
|
641
666
|
review_trigger_source: NotRequired[ReviewQueueTriggerSource | None]
|
|
642
667
|
# Plaintext, whitespace-normalized, 200-character list excerpt.
|
|
643
668
|
body_preview: NotRequired[str | None]
|
|
669
|
+
# Customer inbox star/favorite marker. Optional — mirrors TS SDK MessageSummary.starred? (optional).
|
|
670
|
+
starred: NotRequired[bool]
|
|
644
671
|
# S7 NTH-003 — server-computed deep link into the web inbox shell, or None
|
|
645
672
|
# when PUBLIC_LINK_BASE_URL is unset on the server (fail-closed).
|
|
646
673
|
dashboard_url: NotRequired[str | None]
|
|
@@ -1187,6 +1214,8 @@ class ThreadSummary(TypedDict):
|
|
|
1187
1214
|
last_message_at: str
|
|
1188
1215
|
message_count: int
|
|
1189
1216
|
unread_count: int
|
|
1217
|
+
# Required — mirrors TS SDK ThreadSummary.starred (required, not optional).
|
|
1218
|
+
starred: bool
|
|
1190
1219
|
participants: list[str]
|
|
1191
1220
|
|
|
1192
1221
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Drift guard: _http._VERSION must match the installed package version.
|
|
2
|
+
|
|
3
|
+
If these diverge the User-Agent header sent to the API will report the wrong
|
|
4
|
+
SDK version. This test catches that before a release.
|
|
5
|
+
"""
|
|
6
|
+
import importlib.metadata
|
|
7
|
+
|
|
8
|
+
import replylayer._http as _http
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_user_agent_version_matches_package_version() -> None:
|
|
12
|
+
"""_VERSION in _http.py must equal the installed replylayer package version."""
|
|
13
|
+
package_version = importlib.metadata.version("replylayer")
|
|
14
|
+
assert _http._VERSION == package_version, (
|
|
15
|
+
f"_http._VERSION ({_http._VERSION!r}) does not match the installed "
|
|
16
|
+
f"package version ({package_version!r}). "
|
|
17
|
+
f"Update _VERSION in replylayer/_http.py to match pyproject.toml."
|
|
18
|
+
)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Tests for WS1 (star + filter parity) and WS6-SDK (async_dispatch) — 0.16.0."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
import respx
|
|
9
|
+
|
|
10
|
+
from replylayer import AsyncReplyLayer, ReplyLayer
|
|
11
|
+
|
|
12
|
+
BASE = "https://api.test.replylayer.ai"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sdk() -> ReplyLayer:
|
|
16
|
+
return ReplyLayer(api_key="rl_live_test", base_url=BASE, max_retries=0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def async_sdk() -> AsyncReplyLayer:
|
|
20
|
+
return AsyncReplyLayer(api_key="rl_live_test", base_url=BASE, max_retries=0)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── WS1: messages.set_starred ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@respx.mock
|
|
27
|
+
def test_messages_set_starred_true_issues_patch():
|
|
28
|
+
route = respx.patch(f"{BASE}/v1/messages/msg-1/star").mock(
|
|
29
|
+
return_value=httpx.Response(200, json={"message_id": "msg-1", "starred": True})
|
|
30
|
+
)
|
|
31
|
+
res = sdk().messages.set_starred("msg-1", starred=True)
|
|
32
|
+
assert route.called
|
|
33
|
+
assert res["message_id"] == "msg-1"
|
|
34
|
+
assert res["starred"] is True
|
|
35
|
+
payload = json.loads(route.calls[0].request.content)
|
|
36
|
+
assert payload == {"starred": True}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@respx.mock
|
|
40
|
+
def test_messages_set_starred_false_issues_patch():
|
|
41
|
+
route = respx.patch(f"{BASE}/v1/messages/msg-2/star").mock(
|
|
42
|
+
return_value=httpx.Response(200, json={"message_id": "msg-2", "starred": False})
|
|
43
|
+
)
|
|
44
|
+
res = sdk().messages.set_starred("msg-2", starred=False)
|
|
45
|
+
assert route.called
|
|
46
|
+
payload = json.loads(route.calls[0].request.content)
|
|
47
|
+
assert payload == {"starred": False}
|
|
48
|
+
assert res["starred"] is False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@respx.mock
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_async_messages_set_starred_issues_patch():
|
|
54
|
+
route = respx.patch(f"{BASE}/v1/messages/msg-3/star").mock(
|
|
55
|
+
return_value=httpx.Response(200, json={"message_id": "msg-3", "starred": True})
|
|
56
|
+
)
|
|
57
|
+
rl = async_sdk()
|
|
58
|
+
res = await rl.messages.set_starred("msg-3", starred=True)
|
|
59
|
+
assert route.called
|
|
60
|
+
payload = json.loads(route.calls[0].request.content)
|
|
61
|
+
assert payload == {"starred": True}
|
|
62
|
+
assert res["starred"] is True
|
|
63
|
+
await rl.aclose()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── WS1: messages.list() starred + has_attachment serialization ────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@respx.mock
|
|
70
|
+
def test_messages_list_starred_true_serializes_as_lowercase():
|
|
71
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
|
|
72
|
+
return_value=httpx.Response(200, json={"messages": []})
|
|
73
|
+
)
|
|
74
|
+
sdk().messages.list("mbx1", starred=True)
|
|
75
|
+
params = route.calls[0].request.url.params
|
|
76
|
+
assert params["starred"] == "true"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@respx.mock
|
|
80
|
+
def test_messages_list_starred_false_serializes_as_lowercase():
|
|
81
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
|
|
82
|
+
return_value=httpx.Response(200, json={"messages": []})
|
|
83
|
+
)
|
|
84
|
+
sdk().messages.list("mbx1", starred=False)
|
|
85
|
+
params = route.calls[0].request.url.params
|
|
86
|
+
assert params["starred"] == "false"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@respx.mock
|
|
90
|
+
def test_messages_list_has_attachment_true_serializes_as_lowercase():
|
|
91
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
|
|
92
|
+
return_value=httpx.Response(200, json={"messages": []})
|
|
93
|
+
)
|
|
94
|
+
sdk().messages.list("mbx1", has_attachment=True)
|
|
95
|
+
params = route.calls[0].request.url.params
|
|
96
|
+
assert params["has_attachment"] == "true"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@respx.mock
|
|
100
|
+
def test_messages_list_has_attachment_false_serializes_as_lowercase():
|
|
101
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
|
|
102
|
+
return_value=httpx.Response(200, json={"messages": []})
|
|
103
|
+
)
|
|
104
|
+
sdk().messages.list("mbx1", has_attachment=False)
|
|
105
|
+
params = route.calls[0].request.url.params
|
|
106
|
+
assert params["has_attachment"] == "false"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@respx.mock
|
|
110
|
+
def test_messages_list_starred_and_has_attachment_both_omitted_when_none():
|
|
111
|
+
route = respx.get(f"{BASE}/v1/mailboxes/mbx1/messages").mock(
|
|
112
|
+
return_value=httpx.Response(200, json={"messages": []})
|
|
113
|
+
)
|
|
114
|
+
sdk().messages.list("mbx1")
|
|
115
|
+
params = route.calls[0].request.url.params
|
|
116
|
+
assert "starred" not in params
|
|
117
|
+
assert "has_attachment" not in params
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── WS1: threads.set_starred ────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@respx.mock
|
|
124
|
+
def test_threads_set_starred_true_issues_patch():
|
|
125
|
+
route = respx.patch(f"{BASE}/v1/mailboxes/mbx-1/threads/t%40host.com/star").mock(
|
|
126
|
+
return_value=httpx.Response(
|
|
127
|
+
200,
|
|
128
|
+
json={"thread_id": "t@host.com", "starred": True, "updated_count": 3},
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
res = sdk().threads.set_starred("mbx-1", "t@host.com", starred=True)
|
|
132
|
+
assert route.called
|
|
133
|
+
payload = json.loads(route.calls[0].request.content)
|
|
134
|
+
assert payload == {"starred": True}
|
|
135
|
+
assert res["starred"] is True
|
|
136
|
+
assert res["updated_count"] == 3
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@respx.mock
|
|
140
|
+
def test_threads_set_starred_false_issues_patch():
|
|
141
|
+
route = respx.patch(f"{BASE}/v1/mailboxes/mbx-1/threads/t1/star").mock(
|
|
142
|
+
return_value=httpx.Response(
|
|
143
|
+
200,
|
|
144
|
+
json={"thread_id": "t1", "starred": False, "updated_count": 1},
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
res = sdk().threads.set_starred("mbx-1", "t1", starred=False)
|
|
148
|
+
assert route.called
|
|
149
|
+
payload = json.loads(route.calls[0].request.content)
|
|
150
|
+
assert payload == {"starred": False}
|
|
151
|
+
assert res["starred"] is False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@respx.mock
|
|
155
|
+
@pytest.mark.asyncio
|
|
156
|
+
async def test_async_threads_set_starred_issues_patch():
|
|
157
|
+
route = respx.patch(f"{BASE}/v1/mailboxes/mbx-1/threads/t2/star").mock(
|
|
158
|
+
return_value=httpx.Response(
|
|
159
|
+
200,
|
|
160
|
+
json={"thread_id": "t2", "starred": True, "updated_count": 2},
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
rl = async_sdk()
|
|
164
|
+
res = await rl.threads.set_starred("mbx-1", "t2", starred=True)
|
|
165
|
+
assert route.called
|
|
166
|
+
payload = json.loads(route.calls[0].request.content)
|
|
167
|
+
assert payload == {"starred": True}
|
|
168
|
+
assert res["starred"] is True
|
|
169
|
+
await rl.aclose()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── WS1: threads.get() mailbox param ────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@respx.mock
|
|
176
|
+
def test_threads_get_includes_mailbox_in_query_when_set():
|
|
177
|
+
route = respx.get(f"{BASE}/v1/threads/t-1").mock(
|
|
178
|
+
return_value=httpx.Response(
|
|
179
|
+
200,
|
|
180
|
+
json={"id": "t-1", "mailbox_id": "mbx-1", "subject": "Hi", "message_count": 1, "messages": []},
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
sdk().threads.get("t-1", mailbox="support")
|
|
184
|
+
params = route.calls[0].request.url.params
|
|
185
|
+
assert params["mailbox"] == "support"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@respx.mock
|
|
189
|
+
def test_threads_get_omits_mailbox_when_not_set():
|
|
190
|
+
route = respx.get(f"{BASE}/v1/threads/t-1").mock(
|
|
191
|
+
return_value=httpx.Response(
|
|
192
|
+
200,
|
|
193
|
+
json={"id": "t-1", "mailbox_id": "mbx-1", "subject": "Hi", "message_count": 1, "messages": []},
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
sdk().threads.get("t-1")
|
|
197
|
+
params = route.calls[0].request.url.params
|
|
198
|
+
assert "mailbox" not in params
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@respx.mock
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_async_threads_get_includes_mailbox_in_query_when_set():
|
|
204
|
+
route = respx.get(f"{BASE}/v1/threads/t-2").mock(
|
|
205
|
+
return_value=httpx.Response(
|
|
206
|
+
200,
|
|
207
|
+
json={"id": "t-2", "mailbox_id": "mbx-2", "subject": "Hey", "message_count": 1, "messages": []},
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
rl = async_sdk()
|
|
211
|
+
await rl.threads.get("t-2", mailbox="support")
|
|
212
|
+
params = route.calls[0].request.url.params
|
|
213
|
+
assert params["mailbox"] == "support"
|
|
214
|
+
await rl.aclose()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── WS1: list-row deserialization surfaces `starred` ────────────────────────
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_message_summary_starred_field_is_deserialized():
|
|
221
|
+
"""MessageSummary surfaces starred when present (NotRequired[bool])."""
|
|
222
|
+
row: dict = {
|
|
223
|
+
"id": "msg-s1",
|
|
224
|
+
"direction": "inbound",
|
|
225
|
+
"state": "available",
|
|
226
|
+
"sender": "a@b.com",
|
|
227
|
+
"recipient": "c@d.com",
|
|
228
|
+
"subject": "Hi",
|
|
229
|
+
"subaddress_instance_id": None,
|
|
230
|
+
"firewall_block": None,
|
|
231
|
+
"thread_id": None,
|
|
232
|
+
"created_at": "2026-06-07T00:00:00Z",
|
|
233
|
+
"read_at": None,
|
|
234
|
+
"starred": True,
|
|
235
|
+
}
|
|
236
|
+
# Validate the field is accessible (TypedDict is a dict at runtime).
|
|
237
|
+
assert row["starred"] is True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_thread_summary_starred_field_is_required():
|
|
241
|
+
"""ThreadSummary.starred is required — confirm the dict can be built."""
|
|
242
|
+
row: dict = {
|
|
243
|
+
"id": "t-s1",
|
|
244
|
+
"subject": "Thread",
|
|
245
|
+
"first_message_at": "2026-06-07T00:00:00Z",
|
|
246
|
+
"last_message_at": "2026-06-07T01:00:00Z",
|
|
247
|
+
"message_count": 1,
|
|
248
|
+
"unread_count": 0,
|
|
249
|
+
"starred": False,
|
|
250
|
+
"participants": ["a@b.com"],
|
|
251
|
+
}
|
|
252
|
+
assert row["starred"] is False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── WS6-SDK: drafts.send(async_dispatch=True) ───────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@respx.mock
|
|
259
|
+
def test_drafts_send_async_dispatch_true_sends_prefer_header():
|
|
260
|
+
route = respx.post(f"{BASE}/v1/drafts/d-1/send").mock(
|
|
261
|
+
return_value=httpx.Response(
|
|
262
|
+
202,
|
|
263
|
+
json={
|
|
264
|
+
"message_id": "msg-async-1",
|
|
265
|
+
"status": "queued_for_dispatch",
|
|
266
|
+
"daily_limit": 15,
|
|
267
|
+
"sends_remaining": 14,
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
res = sdk().drafts.send("d-1", async_dispatch=True)
|
|
272
|
+
assert route.called
|
|
273
|
+
req = route.calls[0].request
|
|
274
|
+
assert req.headers.get("prefer") == "respond-async"
|
|
275
|
+
assert res["status"] == "queued_for_dispatch"
|
|
276
|
+
assert res["message_id"] == "msg-async-1"
|
|
277
|
+
assert res["daily_limit"] == 15
|
|
278
|
+
assert res["sends_remaining"] == 14
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@respx.mock
|
|
282
|
+
def test_drafts_send_async_dispatch_false_does_not_send_prefer_header():
|
|
283
|
+
route = respx.post(f"{BASE}/v1/drafts/d-2/send").mock(
|
|
284
|
+
return_value=httpx.Response(
|
|
285
|
+
200,
|
|
286
|
+
json={
|
|
287
|
+
"message_id": "msg-sync-1",
|
|
288
|
+
"status": "sent",
|
|
289
|
+
"warning": None,
|
|
290
|
+
"daily_limit": 15,
|
|
291
|
+
"sends_remaining": 14,
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
res = sdk().drafts.send("d-2")
|
|
296
|
+
assert route.called
|
|
297
|
+
req = route.calls[0].request
|
|
298
|
+
assert "prefer" not in {k.lower() for k in req.headers.keys()}
|
|
299
|
+
assert res["status"] == "sent"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@respx.mock
|
|
303
|
+
def test_drafts_send_async_dispatch_default_does_not_send_prefer_header():
|
|
304
|
+
"""Default (async_dispatch not passed) must not include Prefer header."""
|
|
305
|
+
route = respx.post(f"{BASE}/v1/drafts/d-3/send").mock(
|
|
306
|
+
return_value=httpx.Response(
|
|
307
|
+
200,
|
|
308
|
+
json={"message_id": "msg-sync-2", "status": "sent", "warning": None, "daily_limit": 15, "sends_remaining": 14},
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
sdk().drafts.send("d-3")
|
|
312
|
+
req = route.calls[0].request
|
|
313
|
+
assert "prefer" not in {k.lower() for k in req.headers.keys()}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@respx.mock
|
|
317
|
+
@pytest.mark.asyncio
|
|
318
|
+
async def test_async_drafts_send_async_dispatch_true_sends_prefer_header():
|
|
319
|
+
route = respx.post(f"{BASE}/v1/drafts/d-4/send").mock(
|
|
320
|
+
return_value=httpx.Response(
|
|
321
|
+
202,
|
|
322
|
+
json={
|
|
323
|
+
"message_id": "msg-async-2",
|
|
324
|
+
"status": "queued_for_dispatch",
|
|
325
|
+
"daily_limit": 15,
|
|
326
|
+
"sends_remaining": 14,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
rl = async_sdk()
|
|
331
|
+
res = await rl.drafts.send("d-4", async_dispatch=True)
|
|
332
|
+
assert route.called
|
|
333
|
+
req = route.calls[0].request
|
|
334
|
+
assert req.headers.get("prefer") == "respond-async"
|
|
335
|
+
assert res["status"] == "queued_for_dispatch"
|
|
336
|
+
await rl.aclose()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@respx.mock
|
|
340
|
+
@pytest.mark.asyncio
|
|
341
|
+
async def test_async_drafts_send_default_does_not_send_prefer_header():
|
|
342
|
+
route = respx.post(f"{BASE}/v1/drafts/d-5/send").mock(
|
|
343
|
+
return_value=httpx.Response(
|
|
344
|
+
200,
|
|
345
|
+
json={"message_id": "msg-sync-3", "status": "sent", "warning": None, "daily_limit": 15, "sends_remaining": 14},
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
rl = async_sdk()
|
|
349
|
+
await rl.drafts.send("d-5")
|
|
350
|
+
req = route.calls[0].request
|
|
351
|
+
assert "prefer" not in {k.lower() for k in req.headers.keys()}
|
|
352
|
+
await rl.aclose()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ── WS6-SDK: AsyncSendAck type shape ────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_async_send_ack_type_has_four_fields():
|
|
359
|
+
"""AsyncSendAck TypedDict has exactly the 4 required fields."""
|
|
360
|
+
from replylayer import AsyncSendAck
|
|
361
|
+
ack: AsyncSendAck = {
|
|
362
|
+
"message_id": "msg-1",
|
|
363
|
+
"status": "queued_for_dispatch",
|
|
364
|
+
"daily_limit": 15,
|
|
365
|
+
"sends_remaining": 14,
|
|
366
|
+
}
|
|
367
|
+
assert ack["status"] == "queued_for_dispatch"
|
|
368
|
+
assert ack["daily_limit"] == 15
|
|
369
|
+
assert ack["sends_remaining"] == 14
|
|
@@ -171,13 +171,17 @@ wheels = [
|
|
|
171
171
|
|
|
172
172
|
[[package]]
|
|
173
173
|
name = "replylayer"
|
|
174
|
-
version = "0.
|
|
174
|
+
version = "0.16.0"
|
|
175
175
|
source = { editable = "." }
|
|
176
176
|
dependencies = [
|
|
177
177
|
{ name = "httpx" },
|
|
178
|
+
{ name = "typing-extensions" },
|
|
178
179
|
]
|
|
179
180
|
|
|
180
181
|
[package.optional-dependencies]
|
|
182
|
+
cli = [
|
|
183
|
+
{ name = "rly" },
|
|
184
|
+
]
|
|
181
185
|
dev = [
|
|
182
186
|
{ name = "pytest" },
|
|
183
187
|
{ name = "pytest-asyncio" },
|
|
@@ -190,8 +194,10 @@ requires-dist = [
|
|
|
190
194
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
|
191
195
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
|
|
192
196
|
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
|
|
197
|
+
{ name = "rly", marker = "extra == 'cli'", specifier = ">=0.6.3" },
|
|
198
|
+
{ name = "typing-extensions", specifier = ">=4.0" },
|
|
193
199
|
]
|
|
194
|
-
provides-extras = ["dev"]
|
|
200
|
+
provides-extras = ["cli", "dev"]
|
|
195
201
|
|
|
196
202
|
[[package]]
|
|
197
203
|
name = "respx"
|
|
@@ -205,6 +211,19 @@ wheels = [
|
|
|
205
211
|
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
|
|
206
212
|
]
|
|
207
213
|
|
|
214
|
+
[[package]]
|
|
215
|
+
name = "rly"
|
|
216
|
+
version = "0.6.3"
|
|
217
|
+
source = { registry = "https://pypi.org/simple" }
|
|
218
|
+
sdist = { url = "https://files.pythonhosted.org/packages/36/c4/82ff082990ce46c9d2d47f587bfc078cb33554aa076e2dad1f068fe010f0/rly-0.6.3.tar.gz", hash = "sha256:c5ab3dffe218f639e72298787c34e827e2c25ecb7c730553850136e8a6131ecd", size = 15405, upload-time = "2026-06-06T14:09:27.386Z" }
|
|
219
|
+
wheels = [
|
|
220
|
+
{ url = "https://files.pythonhosted.org/packages/40/9d/eab2b5b68f4cf187b882af768e2c7956a52c55715db4d016aebacf9204be/rly-0.6.3-py3-none-macosx_14_0_arm64.whl", hash = "sha256:b9837a5800b4dc9f2a709fdf637e891d11513d08a265e6d0a6f465e5def3c266", size = 34891158, upload-time = "2026-06-06T14:09:12.295Z" },
|
|
221
|
+
{ url = "https://files.pythonhosted.org/packages/c4/20/e209429517988e7de53db131f4f81e9942c497599448055f5c18fb12f678/rly-0.6.3-py3-none-macosx_14_0_x86_64.whl", hash = "sha256:33996146630e8b2151135854f86817f8b259a87022cfd7c36782074a400bb681", size = 36857258, upload-time = "2026-06-06T14:09:15.239Z" },
|
|
222
|
+
{ url = "https://files.pythonhosted.org/packages/19/28/8b9d4cc26a0396585939837e68dc621e4db796da63970a2eab070eddd52b/rly-0.6.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:26da04b1635338a923b528172af113a1945e97573549bd66935c983c82b26aa3", size = 39009513, upload-time = "2026-06-06T14:09:19.131Z" },
|
|
223
|
+
{ url = "https://files.pythonhosted.org/packages/d2/48/dbff54bfc88156373fcd133513b3aa4cf9a876934377ab7dc95a1959d731/rly-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:692fb50af3721278511b08b25183a687689454421efe2ce9dd970677b3675456", size = 39380097, upload-time = "2026-06-06T14:09:22.111Z" },
|
|
224
|
+
{ url = "https://files.pythonhosted.org/packages/92/20/6a7c55b3d70dc9448d793771f82e3a3aa76855bb8e3323a8861156f0385a/rly-0.6.3-py3-none-win_amd64.whl", hash = "sha256:451e201af43f031cbac2d1d221d2e6295374a9acd7f4e2386c6d9c4947199103", size = 31372056, upload-time = "2026-06-06T14:09:25.1Z" },
|
|
225
|
+
]
|
|
226
|
+
|
|
208
227
|
[[package]]
|
|
209
228
|
name = "tomli"
|
|
210
229
|
version = "2.4.1"
|
|
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
|