e2a 2.0.0__tar.gz → 2.2.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 (27) hide show
  1. {e2a-2.0.0 → e2a-2.2.0}/PKG-INFO +41 -11
  2. {e2a-2.0.0 → e2a-2.2.0}/README.md +40 -10
  3. {e2a-2.0.0 → e2a-2.2.0}/pyproject.toml +1 -1
  4. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/async_client.py +24 -3
  5. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/client.py +33 -7
  6. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/handler.py +43 -10
  7. {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_async_client.py +30 -0
  8. {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_client.py +34 -0
  9. {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_handler.py +45 -3
  10. {e2a-2.0.0 → e2a-2.2.0}/.gitignore +0 -0
  11. {e2a-2.0.0 → e2a-2.2.0}/codegen-requirements.txt +0 -0
  12. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/__init__.py +0 -0
  13. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/__init__.py +0 -0
  14. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/api.py +0 -0
  15. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/__init__.py +0 -0
  16. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/_internal.py +0 -0
  17. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -0
  18. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/internal_agent.py +0 -0
  19. {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/websocket.py +0 -0
  20. {e2a-2.0.0 → e2a-2.2.0}/tests/__init__.py +0 -0
  21. {e2a-2.0.0 → e2a-2.2.0}/tests/test_contract.py +0 -0
  22. {e2a-2.0.0 → e2a-2.2.0}/tests/test_e2e.py +0 -0
  23. {e2a-2.0.0 → e2a-2.2.0}/tests/test_exports.py +0 -0
  24. {e2a-2.0.0 → e2a-2.2.0}/tests/test_generated_models.py +0 -0
  25. {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_api.py +0 -0
  26. {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_websocket.py +0 -0
  27. {e2a-2.0.0 → e2a-2.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e2a
3
- Version: 2.0.0
3
+ Version: 2.2.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
@@ -48,6 +48,17 @@ For WebSocket real-time delivery:
48
48
  pip install e2a[ws]
49
49
  ```
50
50
 
51
+ ## Upgrading from 1.x to 2.0
52
+
53
+ Webhook-parsed emails now refuse to expose claim fields (`sender`, `subject`, `text_body`, …) until the HMAC signature is verified — `email.sender` raises `UnverifiedEmailError` instead of silently returning attacker-controllable data. The one-line fix is to switch `client.parse(body)` → `client.parse_webhook(body)`:
54
+
55
+ ```diff
56
+ - email = client.parse(await request.body())
57
+ + email = client.parse_webhook(await request.body())
58
+ ```
59
+
60
+ `parse_webhook` reads the secret from `E2A_WEBHOOK_SECRET`; set it before upgrading. If you must inspect the payload before verifying, use `email.unverified_payload`. REST-fetched emails (`client.get_message`) are unaffected — they're pre-verified via the bearer token. Full background in the [PR](https://github.com/Mnexa-AI/e2a/pull/57).
61
+
51
62
  ## Import paths
52
63
 
53
64
  The stable, pinned API surface lives under `e2a.v1`:
@@ -72,15 +83,22 @@ client = E2AClient()
72
83
 
73
84
  Mount the webhook in your web framework:
74
85
 
86
+ Webhook payloads are HMAC-signed. The SDK gates field access behind verification — accessing `email.sender`, `email.subject`, etc. on an unverified payload raises `UnverifiedEmailError`. Use `client.parse_webhook(...)` to parse + verify in one call:
87
+
75
88
  **FastAPI:**
76
89
  ```python
77
- from fastapi import FastAPI, Request
90
+ from fastapi import FastAPI, Request, HTTPException
78
91
 
79
92
  app = FastAPI()
80
93
 
81
94
  @app.post("/webhook")
82
95
  async def webhook(request: Request):
83
- email = client.parse(await request.body())
96
+ try:
97
+ email = client.parse_webhook(await request.body()) # reads E2A_WEBHOOK_SECRET
98
+ except PermissionError:
99
+ raise HTTPException(401, "bad signature")
100
+ # ValueError is raised if no secret is configured — let it 500 so a misconfig
101
+ # surfaces loudly, or catch it here to return a clearer message.
84
102
  print(f"From: {email.sender}, Subject: {email.subject}")
85
103
  email.reply("Thanks for reaching out!")
86
104
  return {"ok": True}
@@ -88,17 +106,22 @@ async def webhook(request: Request):
88
106
 
89
107
  **Flask:**
90
108
  ```python
91
- from flask import Flask, request
109
+ from flask import Flask, request, abort
92
110
 
93
111
  app = Flask(__name__)
94
112
 
95
113
  @app.post("/webhook")
96
114
  def webhook():
97
- email = client.parse(request.get_data())
115
+ try:
116
+ email = client.parse_webhook(request.get_data())
117
+ except PermissionError:
118
+ abort(401)
98
119
  email.reply("Thanks for reaching out!")
99
120
  return {"ok": True}
100
121
  ```
101
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_...")`.
124
+
102
125
  ## Raw vs high-level API
103
126
 
104
127
  The SDK has two layers:
@@ -132,7 +155,7 @@ whether the other side is a human replying from Gmail or another e2a agent.
132
155
  ```python
133
156
  @app.post("/webhook")
134
157
  async def webhook(request: Request):
135
- email = client.parse(await request.body())
158
+ email = client.parse_webhook(await request.body())
136
159
 
137
160
  if email.conversation_id:
138
161
  # Follow-up — route to the existing conversation
@@ -220,7 +243,7 @@ Inbound email attachments are automatically parsed and available on
220
243
  `email.attachments`:
221
244
 
222
245
  ```python
223
- email = client.parse(body)
246
+ email = client.parse_webhook(body)
224
247
  for att in email.attachments:
225
248
  print(f"{att.filename} ({att.content_type}, {att.size} bytes)")
226
249
  save_file(att.filename, att.data)
@@ -273,7 +296,7 @@ client = AsyncE2AClient() # reads E2A_API_KEY from env
273
296
 
274
297
  @app.post("/webhook")
275
298
  async def webhook(request: Request):
276
- email = client.parse(await request.body())
299
+ email = await client.parse_webhook(await request.body())
277
300
  await email.reply("Thanks!", conversation_id="conv_123")
278
301
  return {"ok": True}
279
302
  ```
@@ -393,9 +416,13 @@ print(result.status, result.message_id)
393
416
  | `auth` | `AuthHeaders` | Full authentication details |
394
417
  | `raw_message` | `bytes` | Raw RFC 2822 email bytes |
395
418
 
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
+
396
421
  **Methods:**
397
422
 
423
+ - `email.verify_signature(secret=None)` → `bool` — verifies the HMAC; falls back to `E2A_WEBHOOK_SECRET`. Sets the verified flag on success so claim fields become accessible.
398
424
  - `email.reply(body, html_body=None, conversation_id=None, attachments=None)` → `SendResult`
425
+ - `email.unverified_payload` — escape hatch for inspection (debugging, logging) without verifying. Treat as untrusted.
399
426
 
400
427
  ## API Reference
401
428
 
@@ -403,8 +430,9 @@ print(result.status, result.message_id)
403
430
 
404
431
  High-level sync client. `api_key` falls back to `E2A_API_KEY` env var.
405
432
 
406
- - `client.parse(body)` → `InboundEmail` — accepts bytes, str, dict, or `MessageDetail`
407
- - `client.get_message(message_id)` → `InboundEmail`
433
+ - `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` — *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
+ - `client.get_message(message_id)` → `InboundEmail` — pre-verified (REST channel auth)
408
436
  - `client.get_messages(status="unread", page_size=50)` → `MessageList`
409
437
  - `client.reply(message_id, body, ...)` → `SendResult`
410
438
  - `client.send(to, subject, body, ...)` → `SendResult`
@@ -422,11 +450,13 @@ Same as `E2AClient` — all I/O methods are `async`. `parse()` is sync (no I/O n
422
450
  - `InboundEmail` / `AsyncInboundEmail` — parsed email with `.reply()`
423
451
  - `Attachment` — `filename`, `content_type`, `data` (bytes), `size`
424
452
  - `SendResult` — `status`, `message_id`, `method`
425
- - `AuthHeaders` — `verified`, `sender`, `entity_type`, `domain_check`, `delegation`, `signature`, `timestamp`
453
+ - `AuthHeaders` — `verified`, `sender`, `entity_type`, `domain_check`, `delegation`, `signature`, `timestamp`, `message_id`, `body_hash`
426
454
 
427
455
  ### Exceptions
428
456
 
429
457
  - `E2AApiError` — API error (has `status_code` and `message`)
458
+ - `UnverifiedEmailError` — raised on `InboundEmail` claim-field access before `verify_signature()` has succeeded
459
+ - `PermissionError` — raised by `parse_webhook` on bad signature
430
460
 
431
461
  ## License
432
462
 
@@ -14,6 +14,17 @@ For WebSocket real-time delivery:
14
14
  pip install e2a[ws]
15
15
  ```
16
16
 
17
+ ## Upgrading from 1.x to 2.0
18
+
19
+ Webhook-parsed emails now refuse to expose claim fields (`sender`, `subject`, `text_body`, …) until the HMAC signature is verified — `email.sender` raises `UnverifiedEmailError` instead of silently returning attacker-controllable data. The one-line fix is to switch `client.parse(body)` → `client.parse_webhook(body)`:
20
+
21
+ ```diff
22
+ - email = client.parse(await request.body())
23
+ + email = client.parse_webhook(await request.body())
24
+ ```
25
+
26
+ `parse_webhook` reads the secret from `E2A_WEBHOOK_SECRET`; set it before upgrading. If you must inspect the payload before verifying, use `email.unverified_payload`. REST-fetched emails (`client.get_message`) are unaffected — they're pre-verified via the bearer token. Full background in the [PR](https://github.com/Mnexa-AI/e2a/pull/57).
27
+
17
28
  ## Import paths
18
29
 
19
30
  The stable, pinned API surface lives under `e2a.v1`:
@@ -38,15 +49,22 @@ client = E2AClient()
38
49
 
39
50
  Mount the webhook in your web framework:
40
51
 
52
+ Webhook payloads are HMAC-signed. The SDK gates field access behind verification — accessing `email.sender`, `email.subject`, etc. on an unverified payload raises `UnverifiedEmailError`. Use `client.parse_webhook(...)` to parse + verify in one call:
53
+
41
54
  **FastAPI:**
42
55
  ```python
43
- from fastapi import FastAPI, Request
56
+ from fastapi import FastAPI, Request, HTTPException
44
57
 
45
58
  app = FastAPI()
46
59
 
47
60
  @app.post("/webhook")
48
61
  async def webhook(request: Request):
49
- email = client.parse(await request.body())
62
+ try:
63
+ email = client.parse_webhook(await request.body()) # reads E2A_WEBHOOK_SECRET
64
+ except PermissionError:
65
+ raise HTTPException(401, "bad signature")
66
+ # ValueError is raised if no secret is configured — let it 500 so a misconfig
67
+ # surfaces loudly, or catch it here to return a clearer message.
50
68
  print(f"From: {email.sender}, Subject: {email.subject}")
51
69
  email.reply("Thanks for reaching out!")
52
70
  return {"ok": True}
@@ -54,17 +72,22 @@ async def webhook(request: Request):
54
72
 
55
73
  **Flask:**
56
74
  ```python
57
- from flask import Flask, request
75
+ from flask import Flask, request, abort
58
76
 
59
77
  app = Flask(__name__)
60
78
 
61
79
  @app.post("/webhook")
62
80
  def webhook():
63
- email = client.parse(request.get_data())
81
+ try:
82
+ email = client.parse_webhook(request.get_data())
83
+ except PermissionError:
84
+ abort(401)
64
85
  email.reply("Thanks for reaching out!")
65
86
  return {"ok": True}
66
87
  ```
67
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_...")`.
90
+
68
91
  ## Raw vs high-level API
69
92
 
70
93
  The SDK has two layers:
@@ -98,7 +121,7 @@ whether the other side is a human replying from Gmail or another e2a agent.
98
121
  ```python
99
122
  @app.post("/webhook")
100
123
  async def webhook(request: Request):
101
- email = client.parse(await request.body())
124
+ email = client.parse_webhook(await request.body())
102
125
 
103
126
  if email.conversation_id:
104
127
  # Follow-up — route to the existing conversation
@@ -186,7 +209,7 @@ Inbound email attachments are automatically parsed and available on
186
209
  `email.attachments`:
187
210
 
188
211
  ```python
189
- email = client.parse(body)
212
+ email = client.parse_webhook(body)
190
213
  for att in email.attachments:
191
214
  print(f"{att.filename} ({att.content_type}, {att.size} bytes)")
192
215
  save_file(att.filename, att.data)
@@ -239,7 +262,7 @@ client = AsyncE2AClient() # reads E2A_API_KEY from env
239
262
 
240
263
  @app.post("/webhook")
241
264
  async def webhook(request: Request):
242
- email = client.parse(await request.body())
265
+ email = await client.parse_webhook(await request.body())
243
266
  await email.reply("Thanks!", conversation_id="conv_123")
244
267
  return {"ok": True}
245
268
  ```
@@ -359,9 +382,13 @@ print(result.status, result.message_id)
359
382
  | `auth` | `AuthHeaders` | Full authentication details |
360
383
  | `raw_message` | `bytes` | Raw RFC 2822 email bytes |
361
384
 
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
+
362
387
  **Methods:**
363
388
 
389
+ - `email.verify_signature(secret=None)` → `bool` — verifies the HMAC; falls back to `E2A_WEBHOOK_SECRET`. Sets the verified flag on success so claim fields become accessible.
364
390
  - `email.reply(body, html_body=None, conversation_id=None, attachments=None)` → `SendResult`
391
+ - `email.unverified_payload` — escape hatch for inspection (debugging, logging) without verifying. Treat as untrusted.
365
392
 
366
393
  ## API Reference
367
394
 
@@ -369,8 +396,9 @@ print(result.status, result.message_id)
369
396
 
370
397
  High-level sync client. `api_key` falls back to `E2A_API_KEY` env var.
371
398
 
372
- - `client.parse(body)` → `InboundEmail` — accepts bytes, str, dict, or `MessageDetail`
373
- - `client.get_message(message_id)` → `InboundEmail`
399
+ - `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` — *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
+ - `client.get_message(message_id)` → `InboundEmail` — pre-verified (REST channel auth)
374
402
  - `client.get_messages(status="unread", page_size=50)` → `MessageList`
375
403
  - `client.reply(message_id, body, ...)` → `SendResult`
376
404
  - `client.send(to, subject, body, ...)` → `SendResult`
@@ -388,11 +416,13 @@ Same as `E2AClient` — all I/O methods are `async`. `parse()` is sync (no I/O n
388
416
  - `InboundEmail` / `AsyncInboundEmail` — parsed email with `.reply()`
389
417
  - `Attachment` — `filename`, `content_type`, `data` (bytes), `size`
390
418
  - `SendResult` — `status`, `message_id`, `method`
391
- - `AuthHeaders` — `verified`, `sender`, `entity_type`, `domain_check`, `delegation`, `signature`, `timestamp`
419
+ - `AuthHeaders` — `verified`, `sender`, `entity_type`, `domain_check`, `delegation`, `signature`, `timestamp`, `message_id`, `body_hash`
392
420
 
393
421
  ### Exceptions
394
422
 
395
423
  - `E2AApiError` — API error (has `status_code` and `message`)
424
+ - `UnverifiedEmailError` — raised on `InboundEmail` claim-field access before `verify_signature()` has succeeded
425
+ - `PermissionError` — raised by `parse_webhook` on bad signature
396
426
 
397
427
  ## License
398
428
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "e2a"
7
- version = "2.0.0"
7
+ version = "2.2.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"
@@ -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
 
@@ -306,13 +307,33 @@ class AsyncE2AClient:
306
307
  ) -> AsyncInboundEmail:
307
308
  """Parse a webhook payload into an AsyncInboundEmail.
308
309
 
310
+ .. deprecated:: 2.2
311
+ Use :meth:`parse_webhook` for webhook handlers (parse + verify
312
+ in one call) or :attr:`AsyncInboundEmail.unverified_payload`
313
+ for inspection without verification. ``parse`` will be removed
314
+ in 3.0.
315
+
309
316
  Synchronous (no I/O). The returned email's ``.reply()`` is async.
310
317
 
311
318
  Returns an *unverified* AsyncInboundEmail — claim fields raise
312
319
  :class:`UnverifiedEmailError` until you call
313
- :meth:`AsyncInboundEmail.verify_signature`. For webhook handlers,
314
- prefer :meth:`parse_webhook` which combines parse + verify.
320
+ :meth:`AsyncInboundEmail.verify_signature`.
315
321
  """
322
+ warnings.warn(
323
+ "AsyncE2AClient.parse() is deprecated and will be removed in 3.0. "
324
+ "For webhook handlers, use client.parse_webhook(body) — it "
325
+ "parses and HMAC-verifies in one call. For inspection without "
326
+ "verification, use email.unverified_payload after parse_webhook.",
327
+ DeprecationWarning,
328
+ stacklevel=2,
329
+ )
330
+ return self._parse_unverified(body)
331
+
332
+ def _parse_unverified(
333
+ self,
334
+ body: bytes | str | dict[str, Any] | MessageDetail,
335
+ ) -> AsyncInboundEmail:
336
+ """Internal parse without the deprecation warning."""
316
337
  if isinstance(body, MessageDetail):
317
338
  data = body.model_dump(by_alias=True)
318
339
  elif isinstance(body, dict):
@@ -334,7 +355,7 @@ class AsyncE2AClient:
334
355
  See :meth:`E2AClient.parse_webhook` — identical contract.
335
356
  Synchronous despite living on the async client (no I/O).
336
357
  """
337
- email = self.parse(body)
358
+ email = self._parse_unverified(body)
338
359
  if not email.verify_signature(secret):
339
360
  raise PermissionError("HMAC signature verification failed")
340
361
  return email
@@ -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):
@@ -123,10 +149,10 @@ class E2AClient:
123
149
  works. Raises :class:`PermissionError` on signature failure
124
150
  (so a webhook handler can let the exception bubble to a 401).
125
151
 
126
- ``secret`` defaults to the ``E2A_HMAC_SECRET`` environment
127
- variable.
152
+ ``secret`` defaults to the ``E2A_WEBHOOK_SECRET`` environment
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
@@ -131,6 +131,36 @@ def _verify_auth_headers(
131
131
  return hmac.compare_digest(h.signature, expected)
132
132
 
133
133
 
134
+ _warned_legacy_env = False
135
+
136
+
137
+ def _resolve_webhook_secret() -> str:
138
+ """Read the webhook signing secret from env, preferring the new name.
139
+
140
+ Order: ``E2A_WEBHOOK_SECRET`` (canonical) → ``E2A_HMAC_SECRET`` (legacy
141
+ alias, kept for backward compatibility with SDK 2.0). Emits a one-time
142
+ DeprecationWarning when only the legacy name is set so users notice
143
+ before the alias is removed in a future major release.
144
+ """
145
+ val = os.environ.get("E2A_WEBHOOK_SECRET", "")
146
+ if val:
147
+ return val
148
+ legacy = os.environ.get("E2A_HMAC_SECRET", "")
149
+ if legacy:
150
+ global _warned_legacy_env
151
+ if not _warned_legacy_env:
152
+ import warnings
153
+ warnings.warn(
154
+ "E2A_HMAC_SECRET is deprecated; rename it to E2A_WEBHOOK_SECRET. "
155
+ "The legacy name will be removed in a future major release.",
156
+ DeprecationWarning,
157
+ stacklevel=3,
158
+ )
159
+ _warned_legacy_env = True
160
+ return legacy
161
+ return ""
162
+
163
+
134
164
  @dataclass
135
165
  class Attachment:
136
166
  """An email attachment."""
@@ -312,13 +342,16 @@ class InboundEmail:
312
342
  On success, transitions this instance to the "verified" state
313
343
  so subsequent property reads (sender, subject, body, …) work.
314
344
 
315
- ``secret`` defaults to the ``E2A_HMAC_SECRET`` environment
316
- variable when omitted, so the standard webhook-handler pattern
317
- is just::
345
+ ``secret`` defaults to the ``E2A_WEBHOOK_SECRET`` environment
346
+ variable when omitted (with ``E2A_HMAC_SECRET`` accepted as a
347
+ deprecated alias).
318
348
 
319
- email = client.parse(body)
320
- if not email.verify_signature():
321
- return 401
349
+ Most webhook handlers should use :meth:`E2AClient.parse_webhook`
350
+ instead it calls ``verify_signature`` internally and raises
351
+ ``PermissionError`` on failure, so the handler reads as one
352
+ concise call. Use ``verify_signature`` directly only when you
353
+ need to inspect ``unverified_payload`` first or have some other
354
+ reason to keep the unverified object around.
322
355
 
323
356
  Checks (in order):
324
357
 
@@ -336,11 +369,11 @@ class InboundEmail:
336
369
  a verify attempt that always fails.
337
370
  """
338
371
  if secret is None:
339
- secret = os.environ.get("E2A_HMAC_SECRET", "")
372
+ secret = _resolve_webhook_secret()
340
373
  if not secret:
341
374
  raise ValueError(
342
375
  "verify_signature requires a secret. Pass it explicitly "
343
- "or set E2A_HMAC_SECRET in the environment."
376
+ "or set E2A_WEBHOOK_SECRET in the environment."
344
377
  )
345
378
  ok = _verify_auth_headers(self._auth, self._raw_message, secret)
346
379
  if ok:
@@ -672,11 +705,11 @@ class AsyncInboundEmail:
672
705
  def verify_signature(self, secret: Optional[str] = None) -> bool:
673
706
  """See :meth:`InboundEmail.verify_signature` — identical contract."""
674
707
  if secret is None:
675
- secret = os.environ.get("E2A_HMAC_SECRET", "")
708
+ secret = _resolve_webhook_secret()
676
709
  if not secret:
677
710
  raise ValueError(
678
711
  "verify_signature requires a secret. Pass it explicitly "
679
- "or set E2A_HMAC_SECRET in the environment."
712
+ "or set E2A_WEBHOOK_SECRET in the environment."
680
713
  )
681
714
  ok = _verify_auth_headers(self._auth, self._raw_message, secret)
682
715
  if ok:
@@ -121,6 +121,36 @@ async def test_parse_unsupported_type():
121
121
  client.parse(12345)
122
122
 
123
123
 
124
+ @pytest.mark.anyio
125
+ async def test_parse_emits_deprecation_warning():
126
+ """Mirror of the sync test — async client's `parse` must also emit
127
+ the deprecation warning so async webhook handlers see the same
128
+ migration signal."""
129
+ webhook = _make_message_detail_json()
130
+ async with AsyncE2AClient(api_key="k", agent_email="bot@agents.e2a.dev") as client:
131
+ with pytest.warns(DeprecationWarning, match="parse_webhook"):
132
+ client.parse(webhook)
133
+
134
+
135
+ @pytest.mark.anyio
136
+ async def test_parse_webhook_does_not_emit_deprecation_warning(monkeypatch):
137
+ """Same regression guard as the sync test — `parse_webhook` must not
138
+ surface the deprecation warning meant for direct `parse` callers."""
139
+ import warnings
140
+
141
+ monkeypatch.setenv("E2A_WEBHOOK_SECRET", "secret-xyz")
142
+ webhook = _make_message_detail_json()
143
+ async with AsyncE2AClient(api_key="k", agent_email="bot@agents.e2a.dev") as client:
144
+ with warnings.catch_warnings():
145
+ warnings.simplefilter("error")
146
+ try:
147
+ client.parse_webhook(webhook)
148
+ except DeprecationWarning:
149
+ pytest.fail("parse_webhook should not emit DeprecationWarning")
150
+ except PermissionError:
151
+ pass
152
+
153
+
124
154
  # ── get_message() ────────────────────────────────────────────────
125
155
 
126
156
 
@@ -116,6 +116,40 @@ def test_parse_unsupported_type():
116
116
  client.parse(12345)
117
117
 
118
118
 
119
+ def test_parse_emits_deprecation_warning():
120
+ """Calling the legacy `parse` method on the sync client emits a
121
+ DeprecationWarning that points users at parse_webhook. Pin the
122
+ deprecation contract so it isn't silently dropped before 3.0."""
123
+ webhook = _make_message_detail_json()
124
+ with E2AClient(api_key="k", agent_email="bot@agents.e2a.dev") as client:
125
+ with pytest.warns(DeprecationWarning, match="parse_webhook"):
126
+ client.parse(webhook)
127
+
128
+
129
+ def test_parse_webhook_does_not_emit_deprecation_warning(monkeypatch):
130
+ """The recommended path must not emit the deprecation warning even
131
+ though it shares the underlying parse logic. Regression: an early
132
+ refactor had parse_webhook delegate to parse(), which made every
133
+ correct caller see the deprecation message."""
134
+ import warnings
135
+
136
+ monkeypatch.setenv("E2A_WEBHOOK_SECRET", "secret-xyz")
137
+ webhook = _make_message_detail_json()
138
+ with E2AClient(api_key="k", agent_email="bot@agents.e2a.dev") as client:
139
+ with warnings.catch_warnings():
140
+ warnings.simplefilter("error")
141
+ try:
142
+ client.parse_webhook(webhook)
143
+ except DeprecationWarning:
144
+ pytest.fail("parse_webhook should not emit DeprecationWarning")
145
+ except PermissionError:
146
+ # Expected — fixture body has no real signature, so
147
+ # verify_signature returns False and parse_webhook raises.
148
+ # The point of this test is the absence of DeprecationWarning,
149
+ # which we'd have hit before the PermissionError if present.
150
+ pass
151
+
152
+
119
153
  # ── get_message() ────────────────────────────────────────────────
120
154
 
121
155
 
@@ -452,18 +452,20 @@ def test_unverified_email_allows_verify_inputs():
452
452
 
453
453
  def test_verify_signature_with_no_secret_and_no_env_raises(monkeypatch):
454
454
  """No secret + no env → ValueError (better than silent False)."""
455
+ monkeypatch.delenv("E2A_WEBHOOK_SECRET", raising=False)
455
456
  monkeypatch.delenv("E2A_HMAC_SECRET", raising=False)
456
457
  raw = _make_raw_email()
457
458
  data = _make_webhook_data(raw)
458
459
  email = build_inbound_email(data, _mock_client())
459
460
 
460
- with pytest.raises(ValueError, match="E2A_HMAC_SECRET"):
461
+ with pytest.raises(ValueError, match="E2A_WEBHOOK_SECRET"):
461
462
  email.verify_signature()
462
463
 
463
464
 
464
465
  def test_verify_signature_reads_env_when_no_arg(monkeypatch):
465
- """Default secret comes from E2A_HMAC_SECRET when not passed."""
466
- monkeypatch.setenv("E2A_HMAC_SECRET", "wrong-secret-but-not-empty")
466
+ """Default secret comes from E2A_WEBHOOK_SECRET when not passed."""
467
+ monkeypatch.delenv("E2A_HMAC_SECRET", raising=False)
468
+ monkeypatch.setenv("E2A_WEBHOOK_SECRET", "wrong-secret-but-not-empty")
467
469
  raw = _make_raw_email()
468
470
  data = _make_webhook_data(raw)
469
471
  email = build_inbound_email(data, _mock_client())
@@ -474,6 +476,46 @@ def test_verify_signature_reads_env_when_no_arg(monkeypatch):
474
476
  assert email.verify_signature() is False
475
477
 
476
478
 
479
+ def test_verify_signature_falls_back_to_legacy_env(monkeypatch, recwarn):
480
+ """E2A_HMAC_SECRET still works for SDK 2.0 users, with a deprecation warning."""
481
+ # Reset the module-level "warned once" flag so this test sees the warning
482
+ # regardless of test ordering.
483
+ from e2a.v1 import handler as _handler
484
+ _handler._warned_legacy_env = False
485
+
486
+ monkeypatch.delenv("E2A_WEBHOOK_SECRET", raising=False)
487
+ monkeypatch.setenv("E2A_HMAC_SECRET", "wrong-secret-but-not-empty")
488
+ raw = _make_raw_email()
489
+ data = _make_webhook_data(raw)
490
+ email = build_inbound_email(data, _mock_client())
491
+
492
+ assert email.verify_signature() is False # called the verify path
493
+ deprecations = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)]
494
+ assert any("E2A_HMAC_SECRET" in str(w.message) for w in deprecations), (
495
+ "expected DeprecationWarning mentioning E2A_HMAC_SECRET"
496
+ )
497
+
498
+
499
+ def test_verify_signature_prefers_canonical_env_over_legacy(monkeypatch):
500
+ """When both names are set, E2A_WEBHOOK_SECRET wins (no warning)."""
501
+ from e2a.v1 import handler as _handler
502
+ _handler._warned_legacy_env = False
503
+
504
+ monkeypatch.setenv("E2A_WEBHOOK_SECRET", "canonical-wrong")
505
+ monkeypatch.setenv("E2A_HMAC_SECRET", "legacy-wrong")
506
+ raw = _make_raw_email()
507
+ data = _make_webhook_data(raw)
508
+ email = build_inbound_email(data, _mock_client())
509
+
510
+ import warnings
511
+ with warnings.catch_warnings(record=True) as caught:
512
+ warnings.simplefilter("always")
513
+ assert email.verify_signature() is False
514
+ assert not any(issubclass(w.category, DeprecationWarning) for w in caught), (
515
+ "should not warn when canonical env var is set"
516
+ )
517
+
518
+
477
519
  def test_verify_signature_unlocks_field_access_on_success():
478
520
  """A successful verify_signature() flips _verified and unlocks fields."""
479
521
  raw = _make_raw_email()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes