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.
- {e2a-2.0.0 → e2a-2.2.0}/PKG-INFO +41 -11
- {e2a-2.0.0 → e2a-2.2.0}/README.md +40 -10
- {e2a-2.0.0 → e2a-2.2.0}/pyproject.toml +1 -1
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/async_client.py +24 -3
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/client.py +33 -7
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/handler.py +43 -10
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_async_client.py +30 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_client.py +34 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_handler.py +45 -3
- {e2a-2.0.0 → e2a-2.2.0}/.gitignore +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/codegen-requirements.txt +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/__init__.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/__init__.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/api.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/__init__.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/_internal.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/generated/internal_agent.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/src/e2a/v1/websocket.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/__init__.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_contract.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_e2e.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_exports.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_generated_models.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_api.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/tests/test_v1_websocket.py +0 -0
- {e2a-2.0.0 → e2a-2.2.0}/uv.lock +0 -0
{e2a-2.0.0 → e2a-2.2.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e2a
|
|
3
|
-
Version: 2.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
407
|
-
- `client.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
373
|
-
- `client.
|
|
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
|
|
|
@@ -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`.
|
|
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.
|
|
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, …)
|
|
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):
|
|
@@ -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 ``
|
|
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.
|
|
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 ``
|
|
316
|
-
variable when omitted
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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="
|
|
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
|
|
466
|
-
monkeypatch.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{e2a-2.0.0 → e2a-2.2.0}/uv.lock
RENAMED
|
File without changes
|