e2a 2.2.0__tar.gz → 2.4.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.
- e2a-2.4.0/CHANGELOG.md +119 -0
- {e2a-2.2.0 → e2a-2.4.0}/PKG-INFO +4 -3
- {e2a-2.2.0 → e2a-2.4.0}/README.md +3 -2
- {e2a-2.2.0 → e2a-2.4.0}/codegen-requirements.txt +4 -4
- {e2a-2.2.0 → e2a-2.4.0}/pyproject.toml +1 -1
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/api.py +68 -1
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/async_client.py +81 -9
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/client.py +58 -7
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/generated/__init__.py +111 -6
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/generated/_internal.py +2 -0
- e2a-2.4.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +127 -0
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/handler.py +103 -3
- e2a-2.4.0/tests/test_idempotency.py +183 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_api.py +2 -2
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_async_client.py +2 -2
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_client.py +2 -2
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_handler.py +88 -0
- e2a-2.2.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -79
- {e2a-2.2.0 → e2a-2.4.0}/.gitignore +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/__init__.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/__init__.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/generated/internal_agent.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/websocket.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/__init__.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_contract.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_e2e.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_exports.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_generated_models.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_websocket.py +0 -0
- {e2a-2.2.0 → e2a-2.4.0}/uv.lock +0 -0
e2a-2.4.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.4.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `idempotency_key` parameter on `E2AClient.approve_message()` and its
|
|
7
|
+
async counterpart (and on the lower-level `E2AApi.approve_message()`).
|
|
8
|
+
Approve fires a real SES send, so without a stable key a retry after
|
|
9
|
+
a transient failure could double-send. When supplied it's threaded
|
|
10
|
+
through as the `Idempotency-Key` header; when omitted the SDK mints
|
|
11
|
+
a fresh UUIDv4 per call — that gives network-layer retry safety only.
|
|
12
|
+
Supply a stable key derived from the review event (typically the
|
|
13
|
+
pending `message_id`) to dedupe across an explicit retry loop.
|
|
14
|
+
- `sort`, `from_`, `subject_contains`, `conversation_id`, `since`,
|
|
15
|
+
`until` kwargs on `E2AApi.list_messages()` and the high-level
|
|
16
|
+
`E2AClient.get_messages()` (sync + async). `sort` defaults
|
|
17
|
+
server-side to newest-first; pass `"asc"` for FIFO polling. The
|
|
18
|
+
substring filters are case-insensitive and capped at 200 chars
|
|
19
|
+
server-side. `since` / `until` accept RFC3339 timestamps and
|
|
20
|
+
bracket `created_at`. Filter values are encoded into `next_token`,
|
|
21
|
+
so continuation requests must keep the same filter values.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Default sort flipped to newest-first** on `GET /messages`. Prior
|
|
25
|
+
releases silently returned oldest-first for `direction=inbound` (the
|
|
26
|
+
SDK default) and newest-first for `direction=all`. A polling agent
|
|
27
|
+
that relied on FIFO drain order should now pass `sort="asc"` to
|
|
28
|
+
preserve the old behavior.
|
|
29
|
+
- `agent_mode` is now a required field on `RegisterAgentRequest`. The
|
|
30
|
+
server previously silently defaulted to `"cloud"` and then 400'd
|
|
31
|
+
with a cryptic "webhook_url is required" message; it now explicitly
|
|
32
|
+
rejects requests missing `agent_mode` with a clear error. Pydantic
|
|
33
|
+
v2 will raise a validation error if you instantiate the request
|
|
34
|
+
without it. Set `agent_mode="local"` or `"cloud"` explicitly.
|
|
35
|
+
|
|
36
|
+
## 2.3.0
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- `idempotency_key` parameter on `E2AClient.send()` / `.reply()` and their async
|
|
40
|
+
counterparts (and on the lower-level `E2AApi.send_email()` /
|
|
41
|
+
`reply_to_message()`). When supplied, it is sent as the `Idempotency-Key`
|
|
42
|
+
header so the server can deduplicate retries of the same send/reply. When
|
|
43
|
+
omitted, the SDK generates a fresh UUIDv4 per call — that gives
|
|
44
|
+
network-layer retry safety only; supply a stable key derived from the
|
|
45
|
+
triggering event (e.g. the inbound message id or a job id) to deduplicate
|
|
46
|
+
across an explicit retry loop.
|
|
47
|
+
- `InboundEmail.reply_to` and `AsyncInboundEmail.reply_to` (`list[str]`) — the
|
|
48
|
+
parsed `Reply-To:` header from the inbound message, surfaced as a first-class
|
|
49
|
+
field so consumers no longer need to re-parse `raw_message` with stdlib
|
|
50
|
+
`email.message_from_bytes()`. Empty list when the header is absent; the SDK
|
|
51
|
+
never silently falls back to `sender`. Use this when the sender is a no-reply
|
|
52
|
+
notifications mailbox (Granola, GitHub, CI bots) and you need the actual
|
|
53
|
+
correspondent.
|
|
54
|
+
- `MessageSummary.reply_to` (`list[str]`) on the REST polling path — the list
|
|
55
|
+
endpoint now mirrors the same field.
|
|
56
|
+
- `reply_to` added to `unverified_payload` for forensic inspection without
|
|
57
|
+
unlocking gated access.
|
|
58
|
+
|
|
59
|
+
### Reply-To trust path (decision)
|
|
60
|
+
`reply_to` is trusted on the same terms as `to`, `cc`, `recipient`,
|
|
61
|
+
`subject`, and the body fields: the e2a server parses it from
|
|
62
|
+
`raw_message`, places it in the JSON envelope, and TLS protects the wire
|
|
63
|
+
to your webhook URL. Treat the field as trustworthy once
|
|
64
|
+
`verify_signature()` succeeds **and** you're confident in your
|
|
65
|
+
relay-to-webhook connection (or via `client.get_message(...)`, which uses
|
|
66
|
+
the authenticated REST channel).
|
|
67
|
+
|
|
68
|
+
**What `verify_signature()` does not prove:** the HMAC binds a fixed set
|
|
69
|
+
of auth headers and `body_hash = SHA-256(raw_message)`. It does not sign
|
|
70
|
+
the JSON envelope itself, and the SDK reads `reply_to`, `to`, `cc`, etc.
|
|
71
|
+
from that envelope rather than re-parsing `raw_message`. So an attacker
|
|
72
|
+
who can modify the JSON wrapping after signing — but cannot modify
|
|
73
|
+
`raw_message` or the signed headers — can rewrite `reply_to` and the
|
|
74
|
+
HMAC will still verify. TLS to your webhook URL is the actual integrity
|
|
75
|
+
layer for the envelope fields; the HMAC is defense-in-depth for proven
|
|
76
|
+
origin and covers the body bytes. If you need byte-exact assurance for a
|
|
77
|
+
specific field, re-parse it from `raw_message` (whose integrity
|
|
78
|
+
`body_hash` *does* cover).
|
|
79
|
+
|
|
80
|
+
**Also not guaranteed:** upstream-DKIM coverage of `Reply-To:`. If the
|
|
81
|
+
original sender's DKIM signature did not sign `Reply-To` (whether
|
|
82
|
+
because they didn't sign it, or there was no DKIM at all), a MITM
|
|
83
|
+
between sender and e2a could have rewritten the header before it reached
|
|
84
|
+
the relay. e2a does not re-verify or surface per-header DKIM coverage
|
|
85
|
+
today — the `Authentication-Results` / SPF/DKIM surface is unchanged.
|
|
86
|
+
For routing decisions where attacker-controlled `Reply-To` would matter,
|
|
87
|
+
also confirm `email.is_verified` and that the sender's domain is one you
|
|
88
|
+
expect.
|
|
89
|
+
|
|
90
|
+
We chose to keep `reply_to` populated whenever it's present (rather than
|
|
91
|
+
masking it on partially-trusted messages or exposing a `reply_to_signed`
|
|
92
|
+
flag) so the field shape stays uniform with `to`/`cc` and consumers can
|
|
93
|
+
make their own policy decision. The trust model is documented on the
|
|
94
|
+
property docstring.
|
|
95
|
+
|
|
96
|
+
### Wire change
|
|
97
|
+
The webhook payload schema now includes an optional `reply_to: string[]`
|
|
98
|
+
field. Existing consumers that ignore unknown fields are unaffected; older
|
|
99
|
+
SDK versions parsing the same payload continue to work and simply do not
|
|
100
|
+
see the new key.
|
|
101
|
+
|
|
102
|
+
### Other generated-type additions
|
|
103
|
+
The high-level surface above is what most consumers will touch. For users
|
|
104
|
+
of `client.api.*` or `e2a.v1.generated.*` directly, the following backend
|
|
105
|
+
endpoints / fields also landed since 2.2.0 and are reflected in the
|
|
106
|
+
regenerated types:
|
|
107
|
+
|
|
108
|
+
- Per-record DNS verification — separate MX / SPF / DKIM diagnostic
|
|
109
|
+
responses on the domain-verification endpoints.
|
|
110
|
+
- Enriched `DashboardAgent` — `Inbound7d`, `Outbound7d`, `Pending`,
|
|
111
|
+
`LastDelivery`, `WebhookHealthy` fields on the dashboard list.
|
|
112
|
+
- OAuth 2.1 authorization-server endpoints (fosite-backed) used by the
|
|
113
|
+
MCP server flow.
|
|
114
|
+
- Per-domain DKIM key generation endpoint.
|
|
115
|
+
- One-time signing-secret reveal on creation.
|
|
116
|
+
- Pending-review polish — provenance, quoted-inbound, headers-preview,
|
|
117
|
+
draft-footer fields on the review payload.
|
|
118
|
+
|
|
119
|
+
These are additive and don't break existing 2.2.0 callers.
|
{e2a-2.2.0 → e2a-2.4.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e2a
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Python SDK for the e2a protocol — email-to-agent authentication
|
|
5
5
|
Project-URL: Homepage, https://e2a.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/Mnexa-AI/e2a
|
|
@@ -120,7 +120,7 @@ def webhook():
|
|
|
120
120
|
return {"ok": True}
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Get a signing secret from the dashboard's
|
|
123
|
+
Get a signing secret from the dashboard's **Webhook secrets** page (or `POST /api/v1/users/me/signing-secrets`). Set it as `E2A_WEBHOOK_SECRET` so `parse_webhook` picks it up automatically, or pass it explicitly: `client.parse_webhook(body, secret="whsec_...")`.
|
|
124
124
|
|
|
125
125
|
## Raw vs high-level API
|
|
126
126
|
|
|
@@ -407,6 +407,7 @@ print(result.status, result.message_id)
|
|
|
407
407
|
| `recipient` | `str` | Per-delivery target — your agent's address |
|
|
408
408
|
| `to` | `list[str]` | Parsed `To:` header — every address from the original message |
|
|
409
409
|
| `cc` | `list[str]` | Parsed `Cc:` header (empty when no CCs) |
|
|
410
|
+
| `reply_to` | `list[str]` | Parsed `Reply-To:` header — empty when absent (never falls back to `sender`). Useful when `sender` is a no-reply notifications address (Granola, GitHub, etc.) and the real correspondent is in Reply-To. |
|
|
410
411
|
| `subject` | `str` | Email subject line |
|
|
411
412
|
| `text_body` | `str` | Plain-text email body |
|
|
412
413
|
| `html_body` | `str \| None` | HTML email body, if present |
|
|
@@ -416,7 +417,7 @@ print(result.status, result.message_id)
|
|
|
416
417
|
| `auth` | `AuthHeaders` | Full authentication details |
|
|
417
418
|
| `raw_message` | `bytes` | Raw RFC 2822 email bytes |
|
|
418
419
|
|
|
419
|
-
All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
|
|
420
|
+
All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `reply_to`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
|
|
420
421
|
|
|
421
422
|
**Methods:**
|
|
422
423
|
|
|
@@ -86,7 +86,7 @@ def webhook():
|
|
|
86
86
|
return {"ok": True}
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
Get a signing secret from the dashboard's
|
|
89
|
+
Get a signing secret from the dashboard's **Webhook secrets** page (or `POST /api/v1/users/me/signing-secrets`). Set it as `E2A_WEBHOOK_SECRET` so `parse_webhook` picks it up automatically, or pass it explicitly: `client.parse_webhook(body, secret="whsec_...")`.
|
|
90
90
|
|
|
91
91
|
## Raw vs high-level API
|
|
92
92
|
|
|
@@ -373,6 +373,7 @@ print(result.status, result.message_id)
|
|
|
373
373
|
| `recipient` | `str` | Per-delivery target — your agent's address |
|
|
374
374
|
| `to` | `list[str]` | Parsed `To:` header — every address from the original message |
|
|
375
375
|
| `cc` | `list[str]` | Parsed `Cc:` header (empty when no CCs) |
|
|
376
|
+
| `reply_to` | `list[str]` | Parsed `Reply-To:` header — empty when absent (never falls back to `sender`). Useful when `sender` is a no-reply notifications address (Granola, GitHub, etc.) and the real correspondent is in Reply-To. |
|
|
376
377
|
| `subject` | `str` | Email subject line |
|
|
377
378
|
| `text_body` | `str` | Plain-text email body |
|
|
378
379
|
| `html_body` | `str \| None` | HTML email body, if present |
|
|
@@ -382,7 +383,7 @@ print(result.status, result.message_id)
|
|
|
382
383
|
| `auth` | `AuthHeaders` | Full authentication details |
|
|
383
384
|
| `raw_message` | `bytes` | Raw RFC 2822 email bytes |
|
|
384
385
|
|
|
385
|
-
All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
|
|
386
|
+
All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `reply_to`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
|
|
386
387
|
|
|
387
388
|
**Methods:**
|
|
388
389
|
|
|
@@ -12,11 +12,11 @@ isort==8.0.1
|
|
|
12
12
|
more-itertools==10.8.0
|
|
13
13
|
mypy_extensions==1.1.0
|
|
14
14
|
packaging==26.2
|
|
15
|
-
pathspec==1.1.
|
|
15
|
+
pathspec==1.1.1
|
|
16
16
|
platformdirs==4.9.4
|
|
17
|
-
pydantic==2.13.
|
|
18
|
-
pydantic_core==2.46.
|
|
17
|
+
pydantic==2.13.4
|
|
18
|
+
pydantic_core==2.46.4
|
|
19
19
|
pytokens==0.4.1
|
|
20
|
-
typeguard==4.5.
|
|
20
|
+
typeguard==4.5.2
|
|
21
21
|
typing-inspection==0.4.2
|
|
22
22
|
typing_extensions==4.15.0
|
|
@@ -11,6 +11,7 @@ see :class:`e2a.v1.client.E2AClient`.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import os
|
|
14
|
+
import uuid
|
|
14
15
|
from typing import Optional
|
|
15
16
|
from urllib.parse import quote
|
|
16
17
|
|
|
@@ -59,6 +60,19 @@ def _check_response(resp: httpx.Response) -> None:
|
|
|
59
60
|
raise E2AApiError(resp.status_code, message)
|
|
60
61
|
|
|
61
62
|
|
|
63
|
+
def _idempotency_header(idempotency_key: Optional[str]) -> dict:
|
|
64
|
+
"""Build the ``Idempotency-Key`` header for a side-effectful send.
|
|
65
|
+
|
|
66
|
+
A caller-supplied key is passed through verbatim. When ``None``, a
|
|
67
|
+
fresh UUIDv4 is generated so callers get retry-safe transport
|
|
68
|
+
behavior by default. To benefit across an explicit retry loop the
|
|
69
|
+
caller must supply a stable key (the per-call default does not
|
|
70
|
+
survive retries — each call would mint a new UUID).
|
|
71
|
+
"""
|
|
72
|
+
key = idempotency_key if idempotency_key is not None else uuid.uuid4().hex
|
|
73
|
+
return {"Idempotency-Key": key}
|
|
74
|
+
|
|
75
|
+
|
|
62
76
|
def _encode_email(email: str) -> str:
|
|
63
77
|
"""URL-encode an email for use in path segments."""
|
|
64
78
|
return quote(email, safe="")
|
|
@@ -177,8 +191,44 @@ class E2AApi:
|
|
|
177
191
|
status: str = "unread",
|
|
178
192
|
page_size: int = 50,
|
|
179
193
|
token: Optional[str] = None,
|
|
194
|
+
sort: Optional[str] = None,
|
|
195
|
+
from_: Optional[str] = None,
|
|
196
|
+
subject_contains: Optional[str] = None,
|
|
197
|
+
conversation_id: Optional[str] = None,
|
|
198
|
+
since: Optional[str] = None,
|
|
199
|
+
until: Optional[str] = None,
|
|
180
200
|
) -> ListMessagesResponse:
|
|
201
|
+
"""List messages for an agent.
|
|
202
|
+
|
|
203
|
+
``sort`` defaults server-side to ``"desc"`` (newest first).
|
|
204
|
+
Pass ``"asc"`` for FIFO polling — drain the inbox in arrival
|
|
205
|
+
order. The choice is encoded in ``next_token`` so subsequent
|
|
206
|
+
pages keep the same order; switching mid-pagination returns
|
|
207
|
+
400.
|
|
208
|
+
|
|
209
|
+
``from_``, ``subject_contains``: case-insensitive substring
|
|
210
|
+
match (Postgres ILIKE). Capped server-side at 200 chars.
|
|
211
|
+
|
|
212
|
+
``conversation_id``: exact match — narrow to one thread.
|
|
213
|
+
|
|
214
|
+
``since`` / ``until``: RFC3339 timestamps (``datetime.isoformat()``
|
|
215
|
+
produces a valid value as long as it ends in ``Z`` or has a
|
|
216
|
+
timezone offset). Bracket on ``created_at`` (``>= since`` and
|
|
217
|
+
``< until``).
|
|
218
|
+
"""
|
|
181
219
|
params: dict[str, str] = {"status": status, "page_size": str(page_size)}
|
|
220
|
+
if sort:
|
|
221
|
+
params["sort"] = sort
|
|
222
|
+
if from_:
|
|
223
|
+
params["from"] = from_
|
|
224
|
+
if subject_contains:
|
|
225
|
+
params["subject_contains"] = subject_contains
|
|
226
|
+
if conversation_id:
|
|
227
|
+
params["conversation_id"] = conversation_id
|
|
228
|
+
if since:
|
|
229
|
+
params["since"] = since
|
|
230
|
+
if until:
|
|
231
|
+
params["until"] = until
|
|
182
232
|
if token:
|
|
183
233
|
params["token"] = token
|
|
184
234
|
resp = self._client.get(
|
|
@@ -200,18 +250,25 @@ class E2AApi:
|
|
|
200
250
|
agent_email: str,
|
|
201
251
|
message_id: str,
|
|
202
252
|
body: ReplyToMessageRequest,
|
|
253
|
+
idempotency_key: Optional[str] = None,
|
|
203
254
|
) -> SendEmailResponse:
|
|
204
255
|
resp = self._client.post(
|
|
205
256
|
f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
|
|
206
257
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
258
|
+
headers=_idempotency_header(idempotency_key),
|
|
207
259
|
)
|
|
208
260
|
_check_response(resp)
|
|
209
261
|
return SendEmailResponse.model_validate(resp.json())
|
|
210
262
|
|
|
211
|
-
def send_email(
|
|
263
|
+
def send_email(
|
|
264
|
+
self,
|
|
265
|
+
body: SendEmailRequest,
|
|
266
|
+
idempotency_key: Optional[str] = None,
|
|
267
|
+
) -> SendEmailResponse:
|
|
212
268
|
resp = self._client.post(
|
|
213
269
|
"/api/v1/send",
|
|
214
270
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
271
|
+
headers=_idempotency_header(idempotency_key),
|
|
215
272
|
)
|
|
216
273
|
_check_response(resp)
|
|
217
274
|
return SendEmailResponse.model_validate(resp.json())
|
|
@@ -243,17 +300,27 @@ class E2AApi:
|
|
|
243
300
|
self,
|
|
244
301
|
message_id: str,
|
|
245
302
|
overrides: Optional[ApprovePendingMessageRequest] = None,
|
|
303
|
+
idempotency_key: Optional[str] = None,
|
|
246
304
|
) -> ApprovePendingMessageResponse:
|
|
247
305
|
"""Approve a held outbound message.
|
|
248
306
|
|
|
249
307
|
Pass ``overrides`` to approve with edits (any subset of
|
|
250
308
|
subject / body_text / body_html / to / cc / bcc / attachments).
|
|
251
309
|
Pass ``None`` (the default) to approve the draft as-is.
|
|
310
|
+
|
|
311
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
312
|
+
Approve fires a real SES send, so supplying a stable key
|
|
313
|
+
derived from the review event makes retries safe (the server
|
|
314
|
+
replays the original response instead of double-sending).
|
|
315
|
+
When omitted the SDK mints a fresh UUIDv4 per call — that
|
|
316
|
+
gives network-layer retry safety only; the per-call default
|
|
317
|
+
does not survive an explicit retry loop.
|
|
252
318
|
"""
|
|
253
319
|
payload = overrides.model_dump(by_alias=True, exclude_none=True) if overrides else {}
|
|
254
320
|
resp = self._client.post(
|
|
255
321
|
f"/api/v1/messages/{quote(message_id, safe='')}/approve",
|
|
256
322
|
json=payload,
|
|
323
|
+
headers=_idempotency_header(idempotency_key),
|
|
257
324
|
)
|
|
258
325
|
_check_response(resp)
|
|
259
326
|
return ApprovePendingMessageResponse.model_validate(resp.json())
|
|
@@ -19,7 +19,7 @@ import httpx
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from e2a.v1.websocket import WSNotification
|
|
21
21
|
|
|
22
|
-
from e2a.v1.api import E2AApiError, _check_response
|
|
22
|
+
from e2a.v1.api import E2AApiError, _check_response, _idempotency_header
|
|
23
23
|
from e2a.v1.generated import (
|
|
24
24
|
Agent,
|
|
25
25
|
ApprovePendingMessageRequest,
|
|
@@ -164,8 +164,28 @@ class AsyncE2AApi:
|
|
|
164
164
|
status: str = "unread",
|
|
165
165
|
page_size: int = 50,
|
|
166
166
|
token: Optional[str] = None,
|
|
167
|
+
sort: Optional[str] = None,
|
|
168
|
+
from_: Optional[str] = None,
|
|
169
|
+
subject_contains: Optional[str] = None,
|
|
170
|
+
conversation_id: Optional[str] = None,
|
|
171
|
+
since: Optional[str] = None,
|
|
172
|
+
until: Optional[str] = None,
|
|
167
173
|
) -> ListMessagesResponse:
|
|
174
|
+
"""Async variant of :meth:`E2AApi.list_messages`. See that
|
|
175
|
+
method for the full filter / sort docs."""
|
|
168
176
|
params: dict[str, str] = {"status": status, "page_size": str(page_size)}
|
|
177
|
+
if sort:
|
|
178
|
+
params["sort"] = sort
|
|
179
|
+
if from_:
|
|
180
|
+
params["from"] = from_
|
|
181
|
+
if subject_contains:
|
|
182
|
+
params["subject_contains"] = subject_contains
|
|
183
|
+
if conversation_id:
|
|
184
|
+
params["conversation_id"] = conversation_id
|
|
185
|
+
if since:
|
|
186
|
+
params["since"] = since
|
|
187
|
+
if until:
|
|
188
|
+
params["until"] = until
|
|
169
189
|
if token:
|
|
170
190
|
params["token"] = token
|
|
171
191
|
resp = await self._client.get(
|
|
@@ -187,18 +207,25 @@ class AsyncE2AApi:
|
|
|
187
207
|
agent_email: str,
|
|
188
208
|
message_id: str,
|
|
189
209
|
body: ReplyToMessageRequest,
|
|
210
|
+
idempotency_key: Optional[str] = None,
|
|
190
211
|
) -> SendEmailResponse:
|
|
191
212
|
resp = await self._client.post(
|
|
192
213
|
f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
|
|
193
214
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
215
|
+
headers=_idempotency_header(idempotency_key),
|
|
194
216
|
)
|
|
195
217
|
_check_response(resp)
|
|
196
218
|
return SendEmailResponse.model_validate(resp.json())
|
|
197
219
|
|
|
198
|
-
async def send_email(
|
|
220
|
+
async def send_email(
|
|
221
|
+
self,
|
|
222
|
+
body: SendEmailRequest,
|
|
223
|
+
idempotency_key: Optional[str] = None,
|
|
224
|
+
) -> SendEmailResponse:
|
|
199
225
|
resp = await self._client.post(
|
|
200
226
|
"/api/v1/send",
|
|
201
227
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
228
|
+
headers=_idempotency_header(idempotency_key),
|
|
202
229
|
)
|
|
203
230
|
_check_response(resp)
|
|
204
231
|
return SendEmailResponse.model_validate(resp.json())
|
|
@@ -224,11 +251,15 @@ class AsyncE2AApi:
|
|
|
224
251
|
self,
|
|
225
252
|
message_id: str,
|
|
226
253
|
overrides: Optional[ApprovePendingMessageRequest] = None,
|
|
254
|
+
idempotency_key: Optional[str] = None,
|
|
227
255
|
) -> ApprovePendingMessageResponse:
|
|
256
|
+
"""Async variant of :meth:`E2AApi.approve_message`. ``idempotency_key``
|
|
257
|
+
closes the SES double-send window — see that method for details."""
|
|
228
258
|
payload = overrides.model_dump(by_alias=True, exclude_none=True) if overrides else {}
|
|
229
259
|
resp = await self._client.post(
|
|
230
260
|
f"/api/v1/messages/{quote(message_id, safe='')}/approve",
|
|
231
261
|
json=payload,
|
|
262
|
+
headers=_idempotency_header(idempotency_key),
|
|
232
263
|
)
|
|
233
264
|
_check_response(resp)
|
|
234
265
|
return ApprovePendingMessageResponse.model_validate(resp.json())
|
|
@@ -382,10 +413,35 @@ class AsyncE2AClient:
|
|
|
382
413
|
page_size: int = 50,
|
|
383
414
|
token: Optional[str] = None,
|
|
384
415
|
agent_email: Optional[str] = None,
|
|
416
|
+
sort: Optional[str] = None,
|
|
417
|
+
from_: Optional[str] = None,
|
|
418
|
+
subject_contains: Optional[str] = None,
|
|
419
|
+
conversation_id: Optional[str] = None,
|
|
420
|
+
since: Optional[str] = None,
|
|
421
|
+
until: Optional[str] = None,
|
|
385
422
|
) -> MessageList:
|
|
386
|
-
"""Fetch message summaries with ergonomic field names.
|
|
423
|
+
"""Fetch message summaries with ergonomic field names.
|
|
424
|
+
|
|
425
|
+
``sort`` defaults server-side to ``"desc"`` (newest first). Pass
|
|
426
|
+
``"asc"`` to drain the inbox in arrival order — FIFO polling.
|
|
427
|
+
|
|
428
|
+
Search filters (``from_``, ``subject_contains``, ``conversation_id``,
|
|
429
|
+
``since``, ``until``) match the sync client — see
|
|
430
|
+
:meth:`E2AApi.list_messages` for the full reference.
|
|
431
|
+
"""
|
|
387
432
|
email = self._require_agent_email(agent_email)
|
|
388
|
-
resp = await self.api.list_messages(
|
|
433
|
+
resp = await self.api.list_messages(
|
|
434
|
+
email,
|
|
435
|
+
status=status,
|
|
436
|
+
page_size=page_size,
|
|
437
|
+
token=token,
|
|
438
|
+
sort=sort,
|
|
439
|
+
from_=from_,
|
|
440
|
+
subject_contains=subject_contains,
|
|
441
|
+
conversation_id=conversation_id,
|
|
442
|
+
since=since,
|
|
443
|
+
until=until,
|
|
444
|
+
)
|
|
389
445
|
messages = [
|
|
390
446
|
MessageSummary(
|
|
391
447
|
message_id=m.message_id or "",
|
|
@@ -394,6 +450,7 @@ class AsyncE2AClient:
|
|
|
394
450
|
recipient=m.recipient or "",
|
|
395
451
|
to=list(m.to or []),
|
|
396
452
|
cc=list(m.cc or []),
|
|
453
|
+
reply_to=list(m.reply_to or []),
|
|
397
454
|
subject=m.subject or "",
|
|
398
455
|
status=m.status or "",
|
|
399
456
|
created_at=m.created_at or "",
|
|
@@ -415,8 +472,15 @@ class AsyncE2AClient:
|
|
|
415
472
|
conversation_id: Optional[str] = None,
|
|
416
473
|
attachments: Optional[list[Attachment]] = None,
|
|
417
474
|
agent_email: Optional[str] = None,
|
|
475
|
+
idempotency_key: Optional[str] = None,
|
|
418
476
|
) -> SendResult:
|
|
419
|
-
"""Reply to an inbound email.
|
|
477
|
+
"""Reply to an inbound email.
|
|
478
|
+
|
|
479
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
480
|
+
Supply a stable key derived from the triggering event (e.g. the
|
|
481
|
+
inbound message id) to make this reply safe to retry; omit to
|
|
482
|
+
let the SDK generate a fresh UUIDv4 per call.
|
|
483
|
+
"""
|
|
420
484
|
email = self._require_agent_email(agent_email)
|
|
421
485
|
req = ReplyToMessageRequest(
|
|
422
486
|
body=body,
|
|
@@ -427,7 +491,7 @@ class AsyncE2AClient:
|
|
|
427
491
|
conversation_id=conversation_id,
|
|
428
492
|
attachments=_serialize_attachments(attachments),
|
|
429
493
|
)
|
|
430
|
-
resp = await self.api.reply_to_message(email, message_id, req)
|
|
494
|
+
resp = await self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
|
|
431
495
|
return SendResult(
|
|
432
496
|
status=resp.status or "",
|
|
433
497
|
message_id=resp.message_id or "",
|
|
@@ -445,8 +509,15 @@ class AsyncE2AClient:
|
|
|
445
509
|
conversation_id: Optional[str] = None,
|
|
446
510
|
attachments: Optional[list[Attachment]] = None,
|
|
447
511
|
agent_email: Optional[str] = None,
|
|
512
|
+
idempotency_key: Optional[str] = None,
|
|
448
513
|
) -> SendResult:
|
|
449
|
-
"""Send a new email.
|
|
514
|
+
"""Send a new email.
|
|
515
|
+
|
|
516
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
517
|
+
Supply a stable key derived from the triggering event (e.g. a
|
|
518
|
+
job id) to make this send safe to retry; omit to let the SDK
|
|
519
|
+
generate a fresh UUIDv4 per call.
|
|
520
|
+
"""
|
|
450
521
|
email = self._require_agent_email(agent_email)
|
|
451
522
|
req = SendEmailRequest(
|
|
452
523
|
to=to,
|
|
@@ -459,7 +530,7 @@ class AsyncE2AClient:
|
|
|
459
530
|
conversation_id=conversation_id,
|
|
460
531
|
attachments=_serialize_attachments(attachments),
|
|
461
532
|
)
|
|
462
|
-
resp = await self.api.send_email(req)
|
|
533
|
+
resp = await self.api.send_email(req, idempotency_key=idempotency_key)
|
|
463
534
|
return SendResult(
|
|
464
535
|
status=resp.status or "",
|
|
465
536
|
message_id=resp.message_id or "",
|
|
@@ -542,6 +613,7 @@ class AsyncE2AClient:
|
|
|
542
613
|
to: Optional[list[str]] = None,
|
|
543
614
|
cc: Optional[list[str]] = None,
|
|
544
615
|
bcc: Optional[list[str]] = None,
|
|
616
|
+
idempotency_key: Optional[str] = None,
|
|
545
617
|
):
|
|
546
618
|
any_override = any(
|
|
547
619
|
v is not None for v in (subject, body_text, body_html, to, cc, bcc)
|
|
@@ -558,7 +630,7 @@ class AsyncE2AClient:
|
|
|
558
630
|
if any_override
|
|
559
631
|
else None
|
|
560
632
|
)
|
|
561
|
-
return await self.api.approve_message(message_id, overrides)
|
|
633
|
+
return await self.api.approve_message(message_id, overrides, idempotency_key=idempotency_key)
|
|
562
634
|
|
|
563
635
|
async def reject_message(self, message_id: str, reason: str = ""):
|
|
564
636
|
return await self.api.reject_message(message_id, reason)
|
|
@@ -185,10 +185,36 @@ class E2AClient:
|
|
|
185
185
|
page_size: int = 50,
|
|
186
186
|
token: Optional[str] = None,
|
|
187
187
|
agent_email: Optional[str] = None,
|
|
188
|
+
sort: Optional[str] = None,
|
|
189
|
+
from_: Optional[str] = None,
|
|
190
|
+
subject_contains: Optional[str] = None,
|
|
191
|
+
conversation_id: Optional[str] = None,
|
|
192
|
+
since: Optional[str] = None,
|
|
193
|
+
until: Optional[str] = None,
|
|
188
194
|
) -> MessageList:
|
|
189
|
-
"""Fetch message summaries with ergonomic field names.
|
|
195
|
+
"""Fetch message summaries with ergonomic field names.
|
|
196
|
+
|
|
197
|
+
``sort`` defaults server-side to ``"desc"`` (newest first). Pass
|
|
198
|
+
``"asc"`` to drain the inbox in arrival order — FIFO polling.
|
|
199
|
+
|
|
200
|
+
``from_`` / ``subject_contains`` are case-insensitive substring
|
|
201
|
+
filters (capped at 200 chars server-side). ``conversation_id``
|
|
202
|
+
exact-matches a thread. ``since`` / ``until`` are RFC3339
|
|
203
|
+
timestamps bounding ``created_at``.
|
|
204
|
+
"""
|
|
190
205
|
email = self._require_agent_email(agent_email)
|
|
191
|
-
resp = self.api.list_messages(
|
|
206
|
+
resp = self.api.list_messages(
|
|
207
|
+
email,
|
|
208
|
+
status=status,
|
|
209
|
+
page_size=page_size,
|
|
210
|
+
token=token,
|
|
211
|
+
sort=sort,
|
|
212
|
+
from_=from_,
|
|
213
|
+
subject_contains=subject_contains,
|
|
214
|
+
conversation_id=conversation_id,
|
|
215
|
+
since=since,
|
|
216
|
+
until=until,
|
|
217
|
+
)
|
|
192
218
|
messages = [
|
|
193
219
|
MessageSummary(
|
|
194
220
|
message_id=m.message_id or "",
|
|
@@ -197,6 +223,7 @@ class E2AClient:
|
|
|
197
223
|
recipient=m.recipient or "",
|
|
198
224
|
to=list(m.to or []),
|
|
199
225
|
cc=list(m.cc or []),
|
|
226
|
+
reply_to=list(m.reply_to or []),
|
|
200
227
|
subject=m.subject or "",
|
|
201
228
|
status=m.status or "",
|
|
202
229
|
created_at=m.created_at or "",
|
|
@@ -218,8 +245,16 @@ class E2AClient:
|
|
|
218
245
|
conversation_id: Optional[str] = None,
|
|
219
246
|
attachments: Optional[list[Attachment]] = None,
|
|
220
247
|
agent_email: Optional[str] = None,
|
|
248
|
+
idempotency_key: Optional[str] = None,
|
|
221
249
|
) -> SendResult:
|
|
222
|
-
"""Reply to an inbound email.
|
|
250
|
+
"""Reply to an inbound email.
|
|
251
|
+
|
|
252
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
253
|
+
Supply a stable key derived from the triggering event (e.g. the
|
|
254
|
+
inbound message id) to make this reply safe to retry; omit to
|
|
255
|
+
let the SDK generate a fresh UUIDv4 per call (network-layer
|
|
256
|
+
retry safety only).
|
|
257
|
+
"""
|
|
223
258
|
email = self._require_agent_email(agent_email)
|
|
224
259
|
req = ReplyToMessageRequest(
|
|
225
260
|
body=body,
|
|
@@ -230,7 +265,7 @@ class E2AClient:
|
|
|
230
265
|
conversation_id=conversation_id,
|
|
231
266
|
attachments=_serialize_attachments(attachments),
|
|
232
267
|
)
|
|
233
|
-
resp = self.api.reply_to_message(email, message_id, req)
|
|
268
|
+
resp = self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
|
|
234
269
|
return SendResult(
|
|
235
270
|
status=resp.status or "",
|
|
236
271
|
message_id=resp.message_id or "",
|
|
@@ -248,8 +283,16 @@ class E2AClient:
|
|
|
248
283
|
conversation_id: Optional[str] = None,
|
|
249
284
|
attachments: Optional[list[Attachment]] = None,
|
|
250
285
|
agent_email: Optional[str] = None,
|
|
286
|
+
idempotency_key: Optional[str] = None,
|
|
251
287
|
) -> SendResult:
|
|
252
|
-
"""Send a new email.
|
|
288
|
+
"""Send a new email.
|
|
289
|
+
|
|
290
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
291
|
+
Supply a stable key derived from the triggering event (e.g. a
|
|
292
|
+
job id) to make this send safe to retry; omit to let the SDK
|
|
293
|
+
generate a fresh UUIDv4 per call (network-layer retry safety
|
|
294
|
+
only — does not help across an explicit retry loop).
|
|
295
|
+
"""
|
|
253
296
|
email = self._require_agent_email(agent_email)
|
|
254
297
|
req = SendEmailRequest(
|
|
255
298
|
to=to,
|
|
@@ -262,7 +305,7 @@ class E2AClient:
|
|
|
262
305
|
conversation_id=conversation_id,
|
|
263
306
|
attachments=_serialize_attachments(attachments),
|
|
264
307
|
)
|
|
265
|
-
resp = self.api.send_email(req)
|
|
308
|
+
resp = self.api.send_email(req, idempotency_key=idempotency_key)
|
|
266
309
|
return SendResult(
|
|
267
310
|
status=resp.status or "",
|
|
268
311
|
message_id=resp.message_id or "",
|
|
@@ -352,11 +395,19 @@ class E2AClient:
|
|
|
352
395
|
to: Optional[list[str]] = None,
|
|
353
396
|
cc: Optional[list[str]] = None,
|
|
354
397
|
bcc: Optional[list[str]] = None,
|
|
398
|
+
idempotency_key: Optional[str] = None,
|
|
355
399
|
):
|
|
356
400
|
"""Approve a held outbound message.
|
|
357
401
|
|
|
358
402
|
Pass any subset of overrides to approve with edits; pass none
|
|
359
403
|
to approve as-is.
|
|
404
|
+
|
|
405
|
+
``idempotency_key`` makes retries safe across the SES double-
|
|
406
|
+
send window. Supply a stable key derived from the review event
|
|
407
|
+
(e.g. the dashboard click id or the pending ``message_id``) to
|
|
408
|
+
make retries dedupe. When omitted the SDK mints a fresh UUIDv4
|
|
409
|
+
per call — that gives network-layer retry safety only; the
|
|
410
|
+
per-call default does not survive an explicit retry loop.
|
|
360
411
|
"""
|
|
361
412
|
any_override = any(
|
|
362
413
|
v is not None for v in (subject, body_text, body_html, to, cc, bcc)
|
|
@@ -373,7 +424,7 @@ class E2AClient:
|
|
|
373
424
|
if any_override
|
|
374
425
|
else None
|
|
375
426
|
)
|
|
376
|
-
return self.api.approve_message(message_id, overrides)
|
|
427
|
+
return self.api.approve_message(message_id, overrides, idempotency_key=idempotency_key)
|
|
377
428
|
|
|
378
429
|
def reject_message(self, message_id: str, reason: str = ""):
|
|
379
430
|
"""Reject a held outbound message. The optional reason is
|