e2a 2.0.0__tar.gz → 2.1.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.1.0}/PKG-INFO +41 -11
  2. {e2a-2.0.0 → e2a-2.1.0}/README.md +40 -10
  3. {e2a-2.0.0 → e2a-2.1.0}/pyproject.toml +1 -1
  4. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/client.py +2 -2
  5. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/handler.py +37 -7
  6. {e2a-2.0.0 → e2a-2.1.0}/tests/test_v1_handler.py +45 -3
  7. {e2a-2.0.0 → e2a-2.1.0}/.gitignore +0 -0
  8. {e2a-2.0.0 → e2a-2.1.0}/codegen-requirements.txt +0 -0
  9. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/__init__.py +0 -0
  10. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/__init__.py +0 -0
  11. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/api.py +0 -0
  12. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/async_client.py +0 -0
  13. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/generated/__init__.py +0 -0
  14. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/generated/_internal.py +0 -0
  15. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -0
  16. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/generated/internal_agent.py +0 -0
  17. {e2a-2.0.0 → e2a-2.1.0}/src/e2a/v1/websocket.py +0 -0
  18. {e2a-2.0.0 → e2a-2.1.0}/tests/__init__.py +0 -0
  19. {e2a-2.0.0 → e2a-2.1.0}/tests/test_contract.py +0 -0
  20. {e2a-2.0.0 → e2a-2.1.0}/tests/test_e2e.py +0 -0
  21. {e2a-2.0.0 → e2a-2.1.0}/tests/test_exports.py +0 -0
  22. {e2a-2.0.0 → e2a-2.1.0}/tests/test_generated_models.py +0 -0
  23. {e2a-2.0.0 → e2a-2.1.0}/tests/test_v1_api.py +0 -0
  24. {e2a-2.0.0 → e2a-2.1.0}/tests/test_v1_async_client.py +0 -0
  25. {e2a-2.0.0 → e2a-2.1.0}/tests/test_v1_client.py +0 -0
  26. {e2a-2.0.0 → e2a-2.1.0}/tests/test_v1_websocket.py +0 -0
  27. {e2a-2.0.0 → e2a-2.1.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.1.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` — accepts bytes, str, dict, or `MessageDetail`. Returns *unverified* — claim fields raise `UnverifiedEmailError` until `email.verify_signature()` succeeds.
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` — accepts bytes, str, dict, or `MessageDetail`. Returns *unverified* — claim fields raise `UnverifiedEmailError` until `email.verify_signature()` succeeds.
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.1.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"
@@ -123,8 +123,8 @@ class E2AClient:
123
123
  works. Raises :class:`PermissionError` on signature failure
124
124
  (so a webhook handler can let the exception bubble to a 401).
125
125
 
126
- ``secret`` defaults to the ``E2A_HMAC_SECRET`` environment
127
- variable.
126
+ ``secret`` defaults to the ``E2A_WEBHOOK_SECRET`` environment
127
+ variable (with ``E2A_HMAC_SECRET`` accepted as a deprecated alias).
128
128
  """
129
129
  email = self.parse(body)
130
130
  if not email.verify_signature(secret):
@@ -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,9 +342,9 @@ 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), so the standard webhook-handler pattern is::
318
348
 
319
349
  email = client.parse(body)
320
350
  if not email.verify_signature():
@@ -336,11 +366,11 @@ class InboundEmail:
336
366
  a verify attempt that always fails.
337
367
  """
338
368
  if secret is None:
339
- secret = os.environ.get("E2A_HMAC_SECRET", "")
369
+ secret = _resolve_webhook_secret()
340
370
  if not secret:
341
371
  raise ValueError(
342
372
  "verify_signature requires a secret. Pass it explicitly "
343
- "or set E2A_HMAC_SECRET in the environment."
373
+ "or set E2A_WEBHOOK_SECRET in the environment."
344
374
  )
345
375
  ok = _verify_auth_headers(self._auth, self._raw_message, secret)
346
376
  if ok:
@@ -672,11 +702,11 @@ class AsyncInboundEmail:
672
702
  def verify_signature(self, secret: Optional[str] = None) -> bool:
673
703
  """See :meth:`InboundEmail.verify_signature` — identical contract."""
674
704
  if secret is None:
675
- secret = os.environ.get("E2A_HMAC_SECRET", "")
705
+ secret = _resolve_webhook_secret()
676
706
  if not secret:
677
707
  raise ValueError(
678
708
  "verify_signature requires a secret. Pass it explicitly "
679
- "or set E2A_HMAC_SECRET in the environment."
709
+ "or set E2A_WEBHOOK_SECRET in the environment."
680
710
  )
681
711
  ok = _verify_auth_headers(self._auth, self._raw_message, secret)
682
712
  if ok:
@@ -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
File without changes
File without changes
File without changes