e2a 2.1.0__tar.gz → 2.3.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.3.0/CHANGELOG.md +86 -0
- {e2a-2.1.0 → e2a-2.3.0}/PKG-INFO +5 -4
- {e2a-2.1.0 → e2a-2.3.0}/README.md +4 -3
- {e2a-2.1.0 → e2a-2.3.0}/codegen-requirements.txt +4 -4
- {e2a-2.1.0 → e2a-2.3.0}/pyproject.toml +1 -1
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/api.py +22 -1
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/async_client.py +52 -9
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/client.py +52 -9
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/generated/__init__.py +102 -1
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/generated/_internal.py +2 -0
- e2a-2.3.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +127 -0
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/handler.py +110 -7
- e2a-2.3.0/tests/test_idempotency.py +130 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_async_client.py +30 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_client.py +34 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_handler.py +88 -0
- e2a-2.1.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -79
- {e2a-2.1.0 → e2a-2.3.0}/.gitignore +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/__init__.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/__init__.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/generated/internal_agent.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/websocket.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/__init__.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_contract.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_e2e.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_exports.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_generated_models.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_api.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_websocket.py +0 -0
- {e2a-2.1.0 → e2a-2.3.0}/uv.lock +0 -0
e2a-2.3.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.3.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `idempotency_key` parameter on `E2AClient.send()` / `.reply()` and their async
|
|
7
|
+
counterparts (and on the lower-level `E2AApi.send_email()` /
|
|
8
|
+
`reply_to_message()`). When supplied, it is sent as the `Idempotency-Key`
|
|
9
|
+
header so the server can deduplicate retries of the same send/reply. When
|
|
10
|
+
omitted, the SDK generates a fresh UUIDv4 per call — that gives
|
|
11
|
+
network-layer retry safety only; supply a stable key derived from the
|
|
12
|
+
triggering event (e.g. the inbound message id or a job id) to deduplicate
|
|
13
|
+
across an explicit retry loop.
|
|
14
|
+
- `InboundEmail.reply_to` and `AsyncInboundEmail.reply_to` (`list[str]`) — the
|
|
15
|
+
parsed `Reply-To:` header from the inbound message, surfaced as a first-class
|
|
16
|
+
field so consumers no longer need to re-parse `raw_message` with stdlib
|
|
17
|
+
`email.message_from_bytes()`. Empty list when the header is absent; the SDK
|
|
18
|
+
never silently falls back to `sender`. Use this when the sender is a no-reply
|
|
19
|
+
notifications mailbox (Granola, GitHub, CI bots) and you need the actual
|
|
20
|
+
correspondent.
|
|
21
|
+
- `MessageSummary.reply_to` (`list[str]`) on the REST polling path — the list
|
|
22
|
+
endpoint now mirrors the same field.
|
|
23
|
+
- `reply_to` added to `unverified_payload` for forensic inspection without
|
|
24
|
+
unlocking gated access.
|
|
25
|
+
|
|
26
|
+
### Reply-To trust path (decision)
|
|
27
|
+
`reply_to` is trusted on the same terms as `to`, `cc`, `recipient`,
|
|
28
|
+
`subject`, and the body fields: the e2a server parses it from
|
|
29
|
+
`raw_message`, places it in the JSON envelope, and TLS protects the wire
|
|
30
|
+
to your webhook URL. Treat the field as trustworthy once
|
|
31
|
+
`verify_signature()` succeeds **and** you're confident in your
|
|
32
|
+
relay-to-webhook connection (or via `client.get_message(...)`, which uses
|
|
33
|
+
the authenticated REST channel).
|
|
34
|
+
|
|
35
|
+
**What `verify_signature()` does not prove:** the HMAC binds a fixed set
|
|
36
|
+
of auth headers and `body_hash = SHA-256(raw_message)`. It does not sign
|
|
37
|
+
the JSON envelope itself, and the SDK reads `reply_to`, `to`, `cc`, etc.
|
|
38
|
+
from that envelope rather than re-parsing `raw_message`. So an attacker
|
|
39
|
+
who can modify the JSON wrapping after signing — but cannot modify
|
|
40
|
+
`raw_message` or the signed headers — can rewrite `reply_to` and the
|
|
41
|
+
HMAC will still verify. TLS to your webhook URL is the actual integrity
|
|
42
|
+
layer for the envelope fields; the HMAC is defense-in-depth for proven
|
|
43
|
+
origin and covers the body bytes. If you need byte-exact assurance for a
|
|
44
|
+
specific field, re-parse it from `raw_message` (whose integrity
|
|
45
|
+
`body_hash` *does* cover).
|
|
46
|
+
|
|
47
|
+
**Also not guaranteed:** upstream-DKIM coverage of `Reply-To:`. If the
|
|
48
|
+
original sender's DKIM signature did not sign `Reply-To` (whether
|
|
49
|
+
because they didn't sign it, or there was no DKIM at all), a MITM
|
|
50
|
+
between sender and e2a could have rewritten the header before it reached
|
|
51
|
+
the relay. e2a does not re-verify or surface per-header DKIM coverage
|
|
52
|
+
today — the `Authentication-Results` / SPF/DKIM surface is unchanged.
|
|
53
|
+
For routing decisions where attacker-controlled `Reply-To` would matter,
|
|
54
|
+
also confirm `email.is_verified` and that the sender's domain is one you
|
|
55
|
+
expect.
|
|
56
|
+
|
|
57
|
+
We chose to keep `reply_to` populated whenever it's present (rather than
|
|
58
|
+
masking it on partially-trusted messages or exposing a `reply_to_signed`
|
|
59
|
+
flag) so the field shape stays uniform with `to`/`cc` and consumers can
|
|
60
|
+
make their own policy decision. The trust model is documented on the
|
|
61
|
+
property docstring.
|
|
62
|
+
|
|
63
|
+
### Wire change
|
|
64
|
+
The webhook payload schema now includes an optional `reply_to: string[]`
|
|
65
|
+
field. Existing consumers that ignore unknown fields are unaffected; older
|
|
66
|
+
SDK versions parsing the same payload continue to work and simply do not
|
|
67
|
+
see the new key.
|
|
68
|
+
|
|
69
|
+
### Other generated-type additions
|
|
70
|
+
The high-level surface above is what most consumers will touch. For users
|
|
71
|
+
of `client.api.*` or `e2a.v1.generated.*` directly, the following backend
|
|
72
|
+
endpoints / fields also landed since 2.2.0 and are reflected in the
|
|
73
|
+
regenerated types:
|
|
74
|
+
|
|
75
|
+
- Per-record DNS verification — separate MX / SPF / DKIM diagnostic
|
|
76
|
+
responses on the domain-verification endpoints.
|
|
77
|
+
- Enriched `DashboardAgent` — `Inbound7d`, `Outbound7d`, `Pending`,
|
|
78
|
+
`LastDelivery`, `WebhookHealthy` fields on the dashboard list.
|
|
79
|
+
- OAuth 2.1 authorization-server endpoints (fosite-backed) used by the
|
|
80
|
+
MCP server flow.
|
|
81
|
+
- Per-domain DKIM key generation endpoint.
|
|
82
|
+
- One-time signing-secret reveal on creation.
|
|
83
|
+
- Pending-review polish — provenance, quoted-inbound, headers-preview,
|
|
84
|
+
draft-footer fields on the review payload.
|
|
85
|
+
|
|
86
|
+
These are additive and don't break existing 2.2.0 callers.
|
{e2a-2.1.0 → e2a-2.3.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e2a
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.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
|
|
|
@@ -431,7 +432,7 @@ All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `subject`, `t
|
|
|
431
432
|
High-level sync client. `api_key` falls back to `E2A_API_KEY` env var.
|
|
432
433
|
|
|
433
434
|
- `client.parse_webhook(body, secret=None)` → `InboundEmail` — parse + HMAC-verify (recommended for webhook handlers). Reads `E2A_WEBHOOK_SECRET` if no secret is passed; raises `PermissionError` on bad signature.
|
|
434
|
-
- `client.parse(body)` → `InboundEmail` —
|
|
435
|
+
- `client.parse(body)` → `InboundEmail` — *deprecated since 2.2, removed in 3.0.* Accepts bytes, str, dict, or `MessageDetail` and returns an unverified email. Use `parse_webhook` for webhook handlers, or `email.unverified_payload` for inspection without verification. Calling `parse` emits a `DeprecationWarning`.
|
|
435
436
|
- `client.get_message(message_id)` → `InboundEmail` — pre-verified (REST channel auth)
|
|
436
437
|
- `client.get_messages(status="unread", page_size=50)` → `MessageList`
|
|
437
438
|
- `client.reply(message_id, body, ...)` → `SendResult`
|
|
@@ -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
|
|
|
@@ -397,7 +398,7 @@ All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `subject`, `t
|
|
|
397
398
|
High-level sync client. `api_key` falls back to `E2A_API_KEY` env var.
|
|
398
399
|
|
|
399
400
|
- `client.parse_webhook(body, secret=None)` → `InboundEmail` — parse + HMAC-verify (recommended for webhook handlers). Reads `E2A_WEBHOOK_SECRET` if no secret is passed; raises `PermissionError` on bad signature.
|
|
400
|
-
- `client.parse(body)` → `InboundEmail` —
|
|
401
|
+
- `client.parse(body)` → `InboundEmail` — *deprecated since 2.2, removed in 3.0.* Accepts bytes, str, dict, or `MessageDetail` and returns an unverified email. Use `parse_webhook` for webhook handlers, or `email.unverified_payload` for inspection without verification. Calling `parse` emits a `DeprecationWarning`.
|
|
401
402
|
- `client.get_message(message_id)` → `InboundEmail` — pre-verified (REST channel auth)
|
|
402
403
|
- `client.get_messages(status="unread", page_size=50)` → `MessageList`
|
|
403
404
|
- `client.reply(message_id, body, ...)` → `SendResult`
|
|
@@ -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="")
|
|
@@ -200,18 +214,25 @@ class E2AApi:
|
|
|
200
214
|
agent_email: str,
|
|
201
215
|
message_id: str,
|
|
202
216
|
body: ReplyToMessageRequest,
|
|
217
|
+
idempotency_key: Optional[str] = None,
|
|
203
218
|
) -> SendEmailResponse:
|
|
204
219
|
resp = self._client.post(
|
|
205
220
|
f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
|
|
206
221
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
222
|
+
headers=_idempotency_header(idempotency_key),
|
|
207
223
|
)
|
|
208
224
|
_check_response(resp)
|
|
209
225
|
return SendEmailResponse.model_validate(resp.json())
|
|
210
226
|
|
|
211
|
-
def send_email(
|
|
227
|
+
def send_email(
|
|
228
|
+
self,
|
|
229
|
+
body: SendEmailRequest,
|
|
230
|
+
idempotency_key: Optional[str] = None,
|
|
231
|
+
) -> SendEmailResponse:
|
|
212
232
|
resp = self._client.post(
|
|
213
233
|
"/api/v1/send",
|
|
214
234
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
235
|
+
headers=_idempotency_header(idempotency_key),
|
|
215
236
|
)
|
|
216
237
|
_check_response(resp)
|
|
217
238
|
return SendEmailResponse.model_validate(resp.json())
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import base64
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
import warnings
|
|
13
14
|
from typing import TYPE_CHECKING, Any, AsyncIterator, Optional
|
|
14
15
|
from urllib.parse import quote
|
|
15
16
|
|
|
@@ -18,7 +19,7 @@ import httpx
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from e2a.v1.websocket import WSNotification
|
|
20
21
|
|
|
21
|
-
from e2a.v1.api import E2AApiError, _check_response
|
|
22
|
+
from e2a.v1.api import E2AApiError, _check_response, _idempotency_header
|
|
22
23
|
from e2a.v1.generated import (
|
|
23
24
|
Agent,
|
|
24
25
|
ApprovePendingMessageRequest,
|
|
@@ -186,18 +187,25 @@ class AsyncE2AApi:
|
|
|
186
187
|
agent_email: str,
|
|
187
188
|
message_id: str,
|
|
188
189
|
body: ReplyToMessageRequest,
|
|
190
|
+
idempotency_key: Optional[str] = None,
|
|
189
191
|
) -> SendEmailResponse:
|
|
190
192
|
resp = await self._client.post(
|
|
191
193
|
f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
|
|
192
194
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
195
|
+
headers=_idempotency_header(idempotency_key),
|
|
193
196
|
)
|
|
194
197
|
_check_response(resp)
|
|
195
198
|
return SendEmailResponse.model_validate(resp.json())
|
|
196
199
|
|
|
197
|
-
async def send_email(
|
|
200
|
+
async def send_email(
|
|
201
|
+
self,
|
|
202
|
+
body: SendEmailRequest,
|
|
203
|
+
idempotency_key: Optional[str] = None,
|
|
204
|
+
) -> SendEmailResponse:
|
|
198
205
|
resp = await self._client.post(
|
|
199
206
|
"/api/v1/send",
|
|
200
207
|
json=body.model_dump(by_alias=True, exclude_none=True),
|
|
208
|
+
headers=_idempotency_header(idempotency_key),
|
|
201
209
|
)
|
|
202
210
|
_check_response(resp)
|
|
203
211
|
return SendEmailResponse.model_validate(resp.json())
|
|
@@ -306,13 +314,33 @@ class AsyncE2AClient:
|
|
|
306
314
|
) -> AsyncInboundEmail:
|
|
307
315
|
"""Parse a webhook payload into an AsyncInboundEmail.
|
|
308
316
|
|
|
317
|
+
.. deprecated:: 2.2
|
|
318
|
+
Use :meth:`parse_webhook` for webhook handlers (parse + verify
|
|
319
|
+
in one call) or :attr:`AsyncInboundEmail.unverified_payload`
|
|
320
|
+
for inspection without verification. ``parse`` will be removed
|
|
321
|
+
in 3.0.
|
|
322
|
+
|
|
309
323
|
Synchronous (no I/O). The returned email's ``.reply()`` is async.
|
|
310
324
|
|
|
311
325
|
Returns an *unverified* AsyncInboundEmail — claim fields raise
|
|
312
326
|
:class:`UnverifiedEmailError` until you call
|
|
313
|
-
:meth:`AsyncInboundEmail.verify_signature`.
|
|
314
|
-
prefer :meth:`parse_webhook` which combines parse + verify.
|
|
327
|
+
:meth:`AsyncInboundEmail.verify_signature`.
|
|
315
328
|
"""
|
|
329
|
+
warnings.warn(
|
|
330
|
+
"AsyncE2AClient.parse() is deprecated and will be removed in 3.0. "
|
|
331
|
+
"For webhook handlers, use client.parse_webhook(body) — it "
|
|
332
|
+
"parses and HMAC-verifies in one call. For inspection without "
|
|
333
|
+
"verification, use email.unverified_payload after parse_webhook.",
|
|
334
|
+
DeprecationWarning,
|
|
335
|
+
stacklevel=2,
|
|
336
|
+
)
|
|
337
|
+
return self._parse_unverified(body)
|
|
338
|
+
|
|
339
|
+
def _parse_unverified(
|
|
340
|
+
self,
|
|
341
|
+
body: bytes | str | dict[str, Any] | MessageDetail,
|
|
342
|
+
) -> AsyncInboundEmail:
|
|
343
|
+
"""Internal parse without the deprecation warning."""
|
|
316
344
|
if isinstance(body, MessageDetail):
|
|
317
345
|
data = body.model_dump(by_alias=True)
|
|
318
346
|
elif isinstance(body, dict):
|
|
@@ -334,7 +362,7 @@ class AsyncE2AClient:
|
|
|
334
362
|
See :meth:`E2AClient.parse_webhook` — identical contract.
|
|
335
363
|
Synchronous despite living on the async client (no I/O).
|
|
336
364
|
"""
|
|
337
|
-
email = self.
|
|
365
|
+
email = self._parse_unverified(body)
|
|
338
366
|
if not email.verify_signature(secret):
|
|
339
367
|
raise PermissionError("HMAC signature verification failed")
|
|
340
368
|
return email
|
|
@@ -373,6 +401,7 @@ class AsyncE2AClient:
|
|
|
373
401
|
recipient=m.recipient or "",
|
|
374
402
|
to=list(m.to or []),
|
|
375
403
|
cc=list(m.cc or []),
|
|
404
|
+
reply_to=list(m.reply_to or []),
|
|
376
405
|
subject=m.subject or "",
|
|
377
406
|
status=m.status or "",
|
|
378
407
|
created_at=m.created_at or "",
|
|
@@ -394,8 +423,15 @@ class AsyncE2AClient:
|
|
|
394
423
|
conversation_id: Optional[str] = None,
|
|
395
424
|
attachments: Optional[list[Attachment]] = None,
|
|
396
425
|
agent_email: Optional[str] = None,
|
|
426
|
+
idempotency_key: Optional[str] = None,
|
|
397
427
|
) -> SendResult:
|
|
398
|
-
"""Reply to an inbound email.
|
|
428
|
+
"""Reply to an inbound email.
|
|
429
|
+
|
|
430
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
431
|
+
Supply a stable key derived from the triggering event (e.g. the
|
|
432
|
+
inbound message id) to make this reply safe to retry; omit to
|
|
433
|
+
let the SDK generate a fresh UUIDv4 per call.
|
|
434
|
+
"""
|
|
399
435
|
email = self._require_agent_email(agent_email)
|
|
400
436
|
req = ReplyToMessageRequest(
|
|
401
437
|
body=body,
|
|
@@ -406,7 +442,7 @@ class AsyncE2AClient:
|
|
|
406
442
|
conversation_id=conversation_id,
|
|
407
443
|
attachments=_serialize_attachments(attachments),
|
|
408
444
|
)
|
|
409
|
-
resp = await self.api.reply_to_message(email, message_id, req)
|
|
445
|
+
resp = await self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
|
|
410
446
|
return SendResult(
|
|
411
447
|
status=resp.status or "",
|
|
412
448
|
message_id=resp.message_id or "",
|
|
@@ -424,8 +460,15 @@ class AsyncE2AClient:
|
|
|
424
460
|
conversation_id: Optional[str] = None,
|
|
425
461
|
attachments: Optional[list[Attachment]] = None,
|
|
426
462
|
agent_email: Optional[str] = None,
|
|
463
|
+
idempotency_key: Optional[str] = None,
|
|
427
464
|
) -> SendResult:
|
|
428
|
-
"""Send a new email.
|
|
465
|
+
"""Send a new email.
|
|
466
|
+
|
|
467
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
468
|
+
Supply a stable key derived from the triggering event (e.g. a
|
|
469
|
+
job id) to make this send safe to retry; omit to let the SDK
|
|
470
|
+
generate a fresh UUIDv4 per call.
|
|
471
|
+
"""
|
|
429
472
|
email = self._require_agent_email(agent_email)
|
|
430
473
|
req = SendEmailRequest(
|
|
431
474
|
to=to,
|
|
@@ -438,7 +481,7 @@ class AsyncE2AClient:
|
|
|
438
481
|
conversation_id=conversation_id,
|
|
439
482
|
attachments=_serialize_attachments(attachments),
|
|
440
483
|
)
|
|
441
|
-
resp = await self.api.send_email(req)
|
|
484
|
+
resp = await self.api.send_email(req, idempotency_key=idempotency_key)
|
|
442
485
|
return SendResult(
|
|
443
486
|
status=resp.status or "",
|
|
444
487
|
message_id=resp.message_id or "",
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import base64
|
|
10
10
|
import json
|
|
11
11
|
import os
|
|
12
|
+
import warnings
|
|
12
13
|
from typing import Any, Optional
|
|
13
14
|
|
|
14
15
|
from e2a.v1.api import E2AApi
|
|
@@ -92,14 +93,39 @@ class E2AClient:
|
|
|
92
93
|
) -> InboundEmail:
|
|
93
94
|
"""Parse a webhook payload or MessageDetail into an InboundEmail.
|
|
94
95
|
|
|
96
|
+
.. deprecated:: 2.2
|
|
97
|
+
Use :meth:`parse_webhook` for webhook handlers (parse + verify
|
|
98
|
+
in one call) or :attr:`InboundEmail.unverified_payload` for
|
|
99
|
+
inspection without verification. ``parse`` will be removed in
|
|
100
|
+
3.0.
|
|
101
|
+
|
|
95
102
|
Accepts bytes, JSON string, dict, or a generated MessageDetail.
|
|
96
103
|
|
|
97
104
|
The returned InboundEmail starts in the *unverified* state —
|
|
98
|
-
property accesses (sender, subject, body, …)
|
|
99
|
-
:class:`UnverifiedEmailError` until
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
property accesses (sender, subject, body, …) raise
|
|
106
|
+
:class:`UnverifiedEmailError` until :meth:`InboundEmail.verify_signature`
|
|
107
|
+
succeeds. The combination of "looks usable" + "blows up on first
|
|
108
|
+
field access" is precisely the trap that motivated the deprecation;
|
|
109
|
+
``parse_webhook`` raises immediately on bad signatures and returns
|
|
110
|
+
a ready-to-use object on success.
|
|
102
111
|
"""
|
|
112
|
+
warnings.warn(
|
|
113
|
+
"E2AClient.parse() is deprecated and will be removed in 3.0. "
|
|
114
|
+
"For webhook handlers, use client.parse_webhook(body) — it "
|
|
115
|
+
"parses and HMAC-verifies in one call. For inspection without "
|
|
116
|
+
"verification, use email.unverified_payload after parse_webhook.",
|
|
117
|
+
DeprecationWarning,
|
|
118
|
+
stacklevel=2,
|
|
119
|
+
)
|
|
120
|
+
return self._parse_unverified(body)
|
|
121
|
+
|
|
122
|
+
def _parse_unverified(
|
|
123
|
+
self,
|
|
124
|
+
body: bytes | str | dict[str, Any] | MessageDetail,
|
|
125
|
+
) -> InboundEmail:
|
|
126
|
+
"""Internal parse without the deprecation warning. ``parse_webhook``
|
|
127
|
+
delegates here so the recommended path doesn't emit the warning
|
|
128
|
+
meant for direct ``parse`` callers."""
|
|
103
129
|
if isinstance(body, MessageDetail):
|
|
104
130
|
data = body.model_dump(by_alias=True)
|
|
105
131
|
elif isinstance(body, dict):
|
|
@@ -126,7 +152,7 @@ class E2AClient:
|
|
|
126
152
|
``secret`` defaults to the ``E2A_WEBHOOK_SECRET`` environment
|
|
127
153
|
variable (with ``E2A_HMAC_SECRET`` accepted as a deprecated alias).
|
|
128
154
|
"""
|
|
129
|
-
email = self.
|
|
155
|
+
email = self._parse_unverified(body)
|
|
130
156
|
if not email.verify_signature(secret):
|
|
131
157
|
raise PermissionError("HMAC signature verification failed")
|
|
132
158
|
return email
|
|
@@ -171,6 +197,7 @@ class E2AClient:
|
|
|
171
197
|
recipient=m.recipient or "",
|
|
172
198
|
to=list(m.to or []),
|
|
173
199
|
cc=list(m.cc or []),
|
|
200
|
+
reply_to=list(m.reply_to or []),
|
|
174
201
|
subject=m.subject or "",
|
|
175
202
|
status=m.status or "",
|
|
176
203
|
created_at=m.created_at or "",
|
|
@@ -192,8 +219,16 @@ class E2AClient:
|
|
|
192
219
|
conversation_id: Optional[str] = None,
|
|
193
220
|
attachments: Optional[list[Attachment]] = None,
|
|
194
221
|
agent_email: Optional[str] = None,
|
|
222
|
+
idempotency_key: Optional[str] = None,
|
|
195
223
|
) -> SendResult:
|
|
196
|
-
"""Reply to an inbound email.
|
|
224
|
+
"""Reply to an inbound email.
|
|
225
|
+
|
|
226
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
227
|
+
Supply a stable key derived from the triggering event (e.g. the
|
|
228
|
+
inbound message id) to make this reply safe to retry; omit to
|
|
229
|
+
let the SDK generate a fresh UUIDv4 per call (network-layer
|
|
230
|
+
retry safety only).
|
|
231
|
+
"""
|
|
197
232
|
email = self._require_agent_email(agent_email)
|
|
198
233
|
req = ReplyToMessageRequest(
|
|
199
234
|
body=body,
|
|
@@ -204,7 +239,7 @@ class E2AClient:
|
|
|
204
239
|
conversation_id=conversation_id,
|
|
205
240
|
attachments=_serialize_attachments(attachments),
|
|
206
241
|
)
|
|
207
|
-
resp = self.api.reply_to_message(email, message_id, req)
|
|
242
|
+
resp = self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
|
|
208
243
|
return SendResult(
|
|
209
244
|
status=resp.status or "",
|
|
210
245
|
message_id=resp.message_id or "",
|
|
@@ -222,8 +257,16 @@ class E2AClient:
|
|
|
222
257
|
conversation_id: Optional[str] = None,
|
|
223
258
|
attachments: Optional[list[Attachment]] = None,
|
|
224
259
|
agent_email: Optional[str] = None,
|
|
260
|
+
idempotency_key: Optional[str] = None,
|
|
225
261
|
) -> SendResult:
|
|
226
|
-
"""Send a new email.
|
|
262
|
+
"""Send a new email.
|
|
263
|
+
|
|
264
|
+
``idempotency_key`` is sent as the ``Idempotency-Key`` header.
|
|
265
|
+
Supply a stable key derived from the triggering event (e.g. a
|
|
266
|
+
job id) to make this send safe to retry; omit to let the SDK
|
|
267
|
+
generate a fresh UUIDv4 per call (network-layer retry safety
|
|
268
|
+
only — does not help across an explicit retry loop).
|
|
269
|
+
"""
|
|
227
270
|
email = self._require_agent_email(agent_email)
|
|
228
271
|
req = SendEmailRequest(
|
|
229
272
|
to=to,
|
|
@@ -236,7 +279,7 @@ class E2AClient:
|
|
|
236
279
|
conversation_id=conversation_id,
|
|
237
280
|
attachments=_serialize_attachments(attachments),
|
|
238
281
|
)
|
|
239
|
-
resp = self.api.send_email(req)
|
|
282
|
+
resp = self.api.send_email(req, idempotency_key=idempotency_key)
|
|
240
283
|
return SendResult(
|
|
241
284
|
status=resp.status or "",
|
|
242
285
|
message_id=resp.message_id or "",
|