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.
Files changed (30) hide show
  1. e2a-2.3.0/CHANGELOG.md +86 -0
  2. {e2a-2.1.0 → e2a-2.3.0}/PKG-INFO +5 -4
  3. {e2a-2.1.0 → e2a-2.3.0}/README.md +4 -3
  4. {e2a-2.1.0 → e2a-2.3.0}/codegen-requirements.txt +4 -4
  5. {e2a-2.1.0 → e2a-2.3.0}/pyproject.toml +1 -1
  6. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/api.py +22 -1
  7. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/async_client.py +52 -9
  8. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/client.py +52 -9
  9. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/generated/__init__.py +102 -1
  10. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/generated/_internal.py +2 -0
  11. e2a-2.3.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +127 -0
  12. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/handler.py +110 -7
  13. e2a-2.3.0/tests/test_idempotency.py +130 -0
  14. {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_async_client.py +30 -0
  15. {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_client.py +34 -0
  16. {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_handler.py +88 -0
  17. e2a-2.1.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -79
  18. {e2a-2.1.0 → e2a-2.3.0}/.gitignore +0 -0
  19. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/__init__.py +0 -0
  20. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/__init__.py +0 -0
  21. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/generated/internal_agent.py +0 -0
  22. {e2a-2.1.0 → e2a-2.3.0}/src/e2a/v1/websocket.py +0 -0
  23. {e2a-2.1.0 → e2a-2.3.0}/tests/__init__.py +0 -0
  24. {e2a-2.1.0 → e2a-2.3.0}/tests/test_contract.py +0 -0
  25. {e2a-2.1.0 → e2a-2.3.0}/tests/test_e2e.py +0 -0
  26. {e2a-2.1.0 → e2a-2.3.0}/tests/test_exports.py +0 -0
  27. {e2a-2.1.0 → e2a-2.3.0}/tests/test_generated_models.py +0 -0
  28. {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_api.py +0 -0
  29. {e2a-2.1.0 → e2a-2.3.0}/tests/test_v1_websocket.py +0 -0
  30. {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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e2a
3
- Version: 2.1.0
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 Settings → Webhook signing secrets (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_...")`.
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` — accepts bytes, str, dict, or `MessageDetail`. Returns *unverified* claim fields raise `UnverifiedEmailError` until `email.verify_signature()` succeeds.
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 Settings → Webhook signing secrets (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_...")`.
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` — accepts bytes, str, dict, or `MessageDetail`. Returns *unverified* claim fields raise `UnverifiedEmailError` until `email.verify_signature()` succeeds.
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.0
15
+ pathspec==1.1.1
16
16
  platformdirs==4.9.4
17
- pydantic==2.13.3
18
- pydantic_core==2.46.3
17
+ pydantic==2.13.4
18
+ pydantic_core==2.46.4
19
19
  pytokens==0.4.1
20
- typeguard==4.5.1
20
+ typeguard==4.5.2
21
21
  typing-inspection==0.4.2
22
22
  typing_extensions==4.15.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "e2a"
7
- version = "2.1.0"
7
+ version = "2.3.0"
8
8
  description = "Python SDK for the e2a protocol — email-to-agent authentication"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.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(self, body: SendEmailRequest) -> SendEmailResponse:
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(self, body: SendEmailRequest) -> SendEmailResponse:
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`. For webhook handlers,
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.parse(body)
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, …) will raise
99
- :class:`UnverifiedEmailError` until you call
100
- :meth:`InboundEmail.verify_signature`. For webhook handlers,
101
- prefer :meth:`parse_webhook` which combines parse + verify.
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.parse(body)
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 "",