e2a 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. e2a-2.3.0/CHANGELOG.md +86 -0
  2. {e2a-2.2.0 → e2a-2.3.0}/PKG-INFO +4 -3
  3. {e2a-2.2.0 → e2a-2.3.0}/README.md +3 -2
  4. {e2a-2.2.0 → e2a-2.3.0}/codegen-requirements.txt +4 -4
  5. {e2a-2.2.0 → e2a-2.3.0}/pyproject.toml +1 -1
  6. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/api.py +22 -1
  7. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/async_client.py +28 -6
  8. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/client.py +21 -4
  9. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/generated/__init__.py +102 -1
  10. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/generated/_internal.py +2 -0
  11. e2a-2.3.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +127 -0
  12. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/handler.py +103 -3
  13. e2a-2.3.0/tests/test_idempotency.py +130 -0
  14. {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_handler.py +88 -0
  15. e2a-2.2.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -79
  16. {e2a-2.2.0 → e2a-2.3.0}/.gitignore +0 -0
  17. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/__init__.py +0 -0
  18. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/__init__.py +0 -0
  19. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/generated/internal_agent.py +0 -0
  20. {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/websocket.py +0 -0
  21. {e2a-2.2.0 → e2a-2.3.0}/tests/__init__.py +0 -0
  22. {e2a-2.2.0 → e2a-2.3.0}/tests/test_contract.py +0 -0
  23. {e2a-2.2.0 → e2a-2.3.0}/tests/test_e2e.py +0 -0
  24. {e2a-2.2.0 → e2a-2.3.0}/tests/test_exports.py +0 -0
  25. {e2a-2.2.0 → e2a-2.3.0}/tests/test_generated_models.py +0 -0
  26. {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_api.py +0 -0
  27. {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_async_client.py +0 -0
  28. {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_client.py +0 -0
  29. {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_websocket.py +0 -0
  30. {e2a-2.2.0 → e2a-2.3.0}/uv.lock +0 -0
e2a-2.3.0/CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ ## 2.3.0
4
+
5
+ ### Added
6
+ - `idempotency_key` parameter on `E2AClient.send()` / `.reply()` and their async
7
+ counterparts (and on the lower-level `E2AApi.send_email()` /
8
+ `reply_to_message()`). When supplied, it is sent as the `Idempotency-Key`
9
+ header so the server can deduplicate retries of the same send/reply. When
10
+ omitted, the SDK generates a fresh UUIDv4 per call — that gives
11
+ network-layer retry safety only; supply a stable key derived from the
12
+ triggering event (e.g. the inbound message id or a job id) to deduplicate
13
+ across an explicit retry loop.
14
+ - `InboundEmail.reply_to` and `AsyncInboundEmail.reply_to` (`list[str]`) — the
15
+ parsed `Reply-To:` header from the inbound message, surfaced as a first-class
16
+ field so consumers no longer need to re-parse `raw_message` with stdlib
17
+ `email.message_from_bytes()`. Empty list when the header is absent; the SDK
18
+ never silently falls back to `sender`. Use this when the sender is a no-reply
19
+ notifications mailbox (Granola, GitHub, CI bots) and you need the actual
20
+ correspondent.
21
+ - `MessageSummary.reply_to` (`list[str]`) on the REST polling path — the list
22
+ endpoint now mirrors the same field.
23
+ - `reply_to` added to `unverified_payload` for forensic inspection without
24
+ unlocking gated access.
25
+
26
+ ### Reply-To trust path (decision)
27
+ `reply_to` is trusted on the same terms as `to`, `cc`, `recipient`,
28
+ `subject`, and the body fields: the e2a server parses it from
29
+ `raw_message`, places it in the JSON envelope, and TLS protects the wire
30
+ to your webhook URL. Treat the field as trustworthy once
31
+ `verify_signature()` succeeds **and** you're confident in your
32
+ relay-to-webhook connection (or via `client.get_message(...)`, which uses
33
+ the authenticated REST channel).
34
+
35
+ **What `verify_signature()` does not prove:** the HMAC binds a fixed set
36
+ of auth headers and `body_hash = SHA-256(raw_message)`. It does not sign
37
+ the JSON envelope itself, and the SDK reads `reply_to`, `to`, `cc`, etc.
38
+ from that envelope rather than re-parsing `raw_message`. So an attacker
39
+ who can modify the JSON wrapping after signing — but cannot modify
40
+ `raw_message` or the signed headers — can rewrite `reply_to` and the
41
+ HMAC will still verify. TLS to your webhook URL is the actual integrity
42
+ layer for the envelope fields; the HMAC is defense-in-depth for proven
43
+ origin and covers the body bytes. If you need byte-exact assurance for a
44
+ specific field, re-parse it from `raw_message` (whose integrity
45
+ `body_hash` *does* cover).
46
+
47
+ **Also not guaranteed:** upstream-DKIM coverage of `Reply-To:`. If the
48
+ original sender's DKIM signature did not sign `Reply-To` (whether
49
+ because they didn't sign it, or there was no DKIM at all), a MITM
50
+ between sender and e2a could have rewritten the header before it reached
51
+ the relay. e2a does not re-verify or surface per-header DKIM coverage
52
+ today — the `Authentication-Results` / SPF/DKIM surface is unchanged.
53
+ For routing decisions where attacker-controlled `Reply-To` would matter,
54
+ also confirm `email.is_verified` and that the sender's domain is one you
55
+ expect.
56
+
57
+ We chose to keep `reply_to` populated whenever it's present (rather than
58
+ masking it on partially-trusted messages or exposing a `reply_to_signed`
59
+ flag) so the field shape stays uniform with `to`/`cc` and consumers can
60
+ make their own policy decision. The trust model is documented on the
61
+ property docstring.
62
+
63
+ ### Wire change
64
+ The webhook payload schema now includes an optional `reply_to: string[]`
65
+ field. Existing consumers that ignore unknown fields are unaffected; older
66
+ SDK versions parsing the same payload continue to work and simply do not
67
+ see the new key.
68
+
69
+ ### Other generated-type additions
70
+ The high-level surface above is what most consumers will touch. For users
71
+ of `client.api.*` or `e2a.v1.generated.*` directly, the following backend
72
+ endpoints / fields also landed since 2.2.0 and are reflected in the
73
+ regenerated types:
74
+
75
+ - Per-record DNS verification — separate MX / SPF / DKIM diagnostic
76
+ responses on the domain-verification endpoints.
77
+ - Enriched `DashboardAgent` — `Inbound7d`, `Outbound7d`, `Pending`,
78
+ `LastDelivery`, `WebhookHealthy` fields on the dashboard list.
79
+ - OAuth 2.1 authorization-server endpoints (fosite-backed) used by the
80
+ MCP server flow.
81
+ - Per-domain DKIM key generation endpoint.
82
+ - One-time signing-secret reveal on creation.
83
+ - Pending-review polish — provenance, quoted-inbound, headers-preview,
84
+ draft-footer fields on the review payload.
85
+
86
+ These are additive and don't break existing 2.2.0 callers.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e2a
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Python SDK for the e2a protocol — email-to-agent authentication
5
5
  Project-URL: Homepage, https://e2a.dev
6
6
  Project-URL: Repository, https://github.com/Mnexa-AI/e2a
@@ -120,7 +120,7 @@ def webhook():
120
120
  return {"ok": True}
121
121
  ```
122
122
 
123
- Get a signing secret from the dashboard's Settings → Webhook signing secrets (or `POST /api/v1/users/me/signing-secrets`). Set it as `E2A_WEBHOOK_SECRET` so `parse_webhook` picks it up automatically, or pass it explicitly: `client.parse_webhook(body, secret="whsec_...")`.
123
+ Get a signing secret from the dashboard's **Webhook secrets** page (or `POST /api/v1/users/me/signing-secrets`). Set it as `E2A_WEBHOOK_SECRET` so `parse_webhook` picks it up automatically, or pass it explicitly: `client.parse_webhook(body, secret="whsec_...")`.
124
124
 
125
125
  ## Raw vs high-level API
126
126
 
@@ -407,6 +407,7 @@ print(result.status, result.message_id)
407
407
  | `recipient` | `str` | Per-delivery target — your agent's address |
408
408
  | `to` | `list[str]` | Parsed `To:` header — every address from the original message |
409
409
  | `cc` | `list[str]` | Parsed `Cc:` header (empty when no CCs) |
410
+ | `reply_to` | `list[str]` | Parsed `Reply-To:` header — empty when absent (never falls back to `sender`). Useful when `sender` is a no-reply notifications address (Granola, GitHub, etc.) and the real correspondent is in Reply-To. |
410
411
  | `subject` | `str` | Email subject line |
411
412
  | `text_body` | `str` | Plain-text email body |
412
413
  | `html_body` | `str \| None` | HTML email body, if present |
@@ -416,7 +417,7 @@ print(result.status, result.message_id)
416
417
  | `auth` | `AuthHeaders` | Full authentication details |
417
418
  | `raw_message` | `bytes` | Raw RFC 2822 email bytes |
418
419
 
419
- All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
420
+ All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `reply_to`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
420
421
 
421
422
  **Methods:**
422
423
 
@@ -86,7 +86,7 @@ def webhook():
86
86
  return {"ok": True}
87
87
  ```
88
88
 
89
- Get a signing secret from the dashboard's Settings → Webhook signing secrets (or `POST /api/v1/users/me/signing-secrets`). Set it as `E2A_WEBHOOK_SECRET` so `parse_webhook` picks it up automatically, or pass it explicitly: `client.parse_webhook(body, secret="whsec_...")`.
89
+ Get a signing secret from the dashboard's **Webhook secrets** page (or `POST /api/v1/users/me/signing-secrets`). Set it as `E2A_WEBHOOK_SECRET` so `parse_webhook` picks it up automatically, or pass it explicitly: `client.parse_webhook(body, secret="whsec_...")`.
90
90
 
91
91
  ## Raw vs high-level API
92
92
 
@@ -373,6 +373,7 @@ print(result.status, result.message_id)
373
373
  | `recipient` | `str` | Per-delivery target — your agent's address |
374
374
  | `to` | `list[str]` | Parsed `To:` header — every address from the original message |
375
375
  | `cc` | `list[str]` | Parsed `Cc:` header (empty when no CCs) |
376
+ | `reply_to` | `list[str]` | Parsed `Reply-To:` header — empty when absent (never falls back to `sender`). Useful when `sender` is a no-reply notifications address (Granola, GitHub, etc.) and the real correspondent is in Reply-To. |
376
377
  | `subject` | `str` | Email subject line |
377
378
  | `text_body` | `str` | Plain-text email body |
378
379
  | `html_body` | `str \| None` | HTML email body, if present |
@@ -382,7 +383,7 @@ print(result.status, result.message_id)
382
383
  | `auth` | `AuthHeaders` | Full authentication details |
383
384
  | `raw_message` | `bytes` | Raw RFC 2822 email bytes |
384
385
 
385
- All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
386
+ All claim fields (`message_id`, `sender`, `recipient`, `to`, `cc`, `reply_to`, `subject`, `text_body`, `html_body`, `attachments`, `conversation_id`, `received_at`) are gated — accessing them on an unverified webhook payload raises `UnverifiedEmailError`. Always-available regardless of verification: `auth`, `raw_message`, `is_verified`, `verified`, `unverified_payload`. Emails returned by `client.get_message(...)` are pre-verified (the bearer token already authenticated the channel). `client.get_messages(...)` returns lightweight `MessageSummary` items, not `InboundEmail`, so the gate doesn't apply.
386
387
 
387
388
  **Methods:**
388
389
 
@@ -12,11 +12,11 @@ isort==8.0.1
12
12
  more-itertools==10.8.0
13
13
  mypy_extensions==1.1.0
14
14
  packaging==26.2
15
- pathspec==1.1.0
15
+ pathspec==1.1.1
16
16
  platformdirs==4.9.4
17
- pydantic==2.13.3
18
- pydantic_core==2.46.3
17
+ pydantic==2.13.4
18
+ pydantic_core==2.46.4
19
19
  pytokens==0.4.1
20
- typeguard==4.5.1
20
+ typeguard==4.5.2
21
21
  typing-inspection==0.4.2
22
22
  typing_extensions==4.15.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "e2a"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Python SDK for the e2a protocol — email-to-agent authentication"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -11,6 +11,7 @@ see :class:`e2a.v1.client.E2AClient`.
11
11
  from __future__ import annotations
12
12
 
13
13
  import os
14
+ import uuid
14
15
  from typing import Optional
15
16
  from urllib.parse import quote
16
17
 
@@ -59,6 +60,19 @@ def _check_response(resp: httpx.Response) -> None:
59
60
  raise E2AApiError(resp.status_code, message)
60
61
 
61
62
 
63
+ def _idempotency_header(idempotency_key: Optional[str]) -> dict:
64
+ """Build the ``Idempotency-Key`` header for a side-effectful send.
65
+
66
+ A caller-supplied key is passed through verbatim. When ``None``, a
67
+ fresh UUIDv4 is generated so callers get retry-safe transport
68
+ behavior by default. To benefit across an explicit retry loop the
69
+ caller must supply a stable key (the per-call default does not
70
+ survive retries — each call would mint a new UUID).
71
+ """
72
+ key = idempotency_key if idempotency_key is not None else uuid.uuid4().hex
73
+ return {"Idempotency-Key": key}
74
+
75
+
62
76
  def _encode_email(email: str) -> str:
63
77
  """URL-encode an email for use in path segments."""
64
78
  return quote(email, safe="")
@@ -200,18 +214,25 @@ class E2AApi:
200
214
  agent_email: str,
201
215
  message_id: str,
202
216
  body: ReplyToMessageRequest,
217
+ idempotency_key: Optional[str] = None,
203
218
  ) -> SendEmailResponse:
204
219
  resp = self._client.post(
205
220
  f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
206
221
  json=body.model_dump(by_alias=True, exclude_none=True),
222
+ headers=_idempotency_header(idempotency_key),
207
223
  )
208
224
  _check_response(resp)
209
225
  return SendEmailResponse.model_validate(resp.json())
210
226
 
211
- def send_email(self, body: SendEmailRequest) -> SendEmailResponse:
227
+ def send_email(
228
+ self,
229
+ body: SendEmailRequest,
230
+ idempotency_key: Optional[str] = None,
231
+ ) -> SendEmailResponse:
212
232
  resp = self._client.post(
213
233
  "/api/v1/send",
214
234
  json=body.model_dump(by_alias=True, exclude_none=True),
235
+ headers=_idempotency_header(idempotency_key),
215
236
  )
216
237
  _check_response(resp)
217
238
  return SendEmailResponse.model_validate(resp.json())
@@ -19,7 +19,7 @@ import httpx
19
19
  if TYPE_CHECKING:
20
20
  from e2a.v1.websocket import WSNotification
21
21
 
22
- from e2a.v1.api import E2AApiError, _check_response
22
+ from e2a.v1.api import E2AApiError, _check_response, _idempotency_header
23
23
  from e2a.v1.generated import (
24
24
  Agent,
25
25
  ApprovePendingMessageRequest,
@@ -187,18 +187,25 @@ class AsyncE2AApi:
187
187
  agent_email: str,
188
188
  message_id: str,
189
189
  body: ReplyToMessageRequest,
190
+ idempotency_key: Optional[str] = None,
190
191
  ) -> SendEmailResponse:
191
192
  resp = await self._client.post(
192
193
  f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
193
194
  json=body.model_dump(by_alias=True, exclude_none=True),
195
+ headers=_idempotency_header(idempotency_key),
194
196
  )
195
197
  _check_response(resp)
196
198
  return SendEmailResponse.model_validate(resp.json())
197
199
 
198
- async def send_email(self, body: SendEmailRequest) -> SendEmailResponse:
200
+ async def send_email(
201
+ self,
202
+ body: SendEmailRequest,
203
+ idempotency_key: Optional[str] = None,
204
+ ) -> SendEmailResponse:
199
205
  resp = await self._client.post(
200
206
  "/api/v1/send",
201
207
  json=body.model_dump(by_alias=True, exclude_none=True),
208
+ headers=_idempotency_header(idempotency_key),
202
209
  )
203
210
  _check_response(resp)
204
211
  return SendEmailResponse.model_validate(resp.json())
@@ -394,6 +401,7 @@ class AsyncE2AClient:
394
401
  recipient=m.recipient or "",
395
402
  to=list(m.to or []),
396
403
  cc=list(m.cc or []),
404
+ reply_to=list(m.reply_to or []),
397
405
  subject=m.subject or "",
398
406
  status=m.status or "",
399
407
  created_at=m.created_at or "",
@@ -415,8 +423,15 @@ class AsyncE2AClient:
415
423
  conversation_id: Optional[str] = None,
416
424
  attachments: Optional[list[Attachment]] = None,
417
425
  agent_email: Optional[str] = None,
426
+ idempotency_key: Optional[str] = None,
418
427
  ) -> SendResult:
419
- """Reply to an inbound email."""
428
+ """Reply to an inbound email.
429
+
430
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
431
+ Supply a stable key derived from the triggering event (e.g. the
432
+ inbound message id) to make this reply safe to retry; omit to
433
+ let the SDK generate a fresh UUIDv4 per call.
434
+ """
420
435
  email = self._require_agent_email(agent_email)
421
436
  req = ReplyToMessageRequest(
422
437
  body=body,
@@ -427,7 +442,7 @@ class AsyncE2AClient:
427
442
  conversation_id=conversation_id,
428
443
  attachments=_serialize_attachments(attachments),
429
444
  )
430
- resp = await self.api.reply_to_message(email, message_id, req)
445
+ resp = await self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
431
446
  return SendResult(
432
447
  status=resp.status or "",
433
448
  message_id=resp.message_id or "",
@@ -445,8 +460,15 @@ class AsyncE2AClient:
445
460
  conversation_id: Optional[str] = None,
446
461
  attachments: Optional[list[Attachment]] = None,
447
462
  agent_email: Optional[str] = None,
463
+ idempotency_key: Optional[str] = None,
448
464
  ) -> SendResult:
449
- """Send a new email."""
465
+ """Send a new email.
466
+
467
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
468
+ Supply a stable key derived from the triggering event (e.g. a
469
+ job id) to make this send safe to retry; omit to let the SDK
470
+ generate a fresh UUIDv4 per call.
471
+ """
450
472
  email = self._require_agent_email(agent_email)
451
473
  req = SendEmailRequest(
452
474
  to=to,
@@ -459,7 +481,7 @@ class AsyncE2AClient:
459
481
  conversation_id=conversation_id,
460
482
  attachments=_serialize_attachments(attachments),
461
483
  )
462
- resp = await self.api.send_email(req)
484
+ resp = await self.api.send_email(req, idempotency_key=idempotency_key)
463
485
  return SendResult(
464
486
  status=resp.status or "",
465
487
  message_id=resp.message_id or "",
@@ -197,6 +197,7 @@ class E2AClient:
197
197
  recipient=m.recipient or "",
198
198
  to=list(m.to or []),
199
199
  cc=list(m.cc or []),
200
+ reply_to=list(m.reply_to or []),
200
201
  subject=m.subject or "",
201
202
  status=m.status or "",
202
203
  created_at=m.created_at or "",
@@ -218,8 +219,16 @@ class E2AClient:
218
219
  conversation_id: Optional[str] = None,
219
220
  attachments: Optional[list[Attachment]] = None,
220
221
  agent_email: Optional[str] = None,
222
+ idempotency_key: Optional[str] = None,
221
223
  ) -> SendResult:
222
- """Reply to an inbound email."""
224
+ """Reply to an inbound email.
225
+
226
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
227
+ Supply a stable key derived from the triggering event (e.g. the
228
+ inbound message id) to make this reply safe to retry; omit to
229
+ let the SDK generate a fresh UUIDv4 per call (network-layer
230
+ retry safety only).
231
+ """
223
232
  email = self._require_agent_email(agent_email)
224
233
  req = ReplyToMessageRequest(
225
234
  body=body,
@@ -230,7 +239,7 @@ class E2AClient:
230
239
  conversation_id=conversation_id,
231
240
  attachments=_serialize_attachments(attachments),
232
241
  )
233
- resp = self.api.reply_to_message(email, message_id, req)
242
+ resp = self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
234
243
  return SendResult(
235
244
  status=resp.status or "",
236
245
  message_id=resp.message_id or "",
@@ -248,8 +257,16 @@ class E2AClient:
248
257
  conversation_id: Optional[str] = None,
249
258
  attachments: Optional[list[Attachment]] = None,
250
259
  agent_email: Optional[str] = None,
260
+ idempotency_key: Optional[str] = None,
251
261
  ) -> SendResult:
252
- """Send a new email."""
262
+ """Send a new email.
263
+
264
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
265
+ Supply a stable key derived from the triggering event (e.g. a
266
+ job id) to make this send safe to retry; omit to let the SDK
267
+ generate a fresh UUIDv4 per call (network-layer retry safety
268
+ only — does not help across an explicit retry loop).
269
+ """
253
270
  email = self._require_agent_email(agent_email)
254
271
  req = SendEmailRequest(
255
272
  to=to,
@@ -262,7 +279,7 @@ class E2AClient:
262
279
  conversation_id=conversation_id,
263
280
  attachments=_serialize_attachments(attachments),
264
281
  )
265
- resp = self.api.send_email(req)
282
+ resp = self.api.send_email(req, idempotency_key=idempotency_key)
266
283
  return SendResult(
267
284
  status=resp.status or "",
268
285
  message_id=resp.message_id or "",
@@ -76,6 +76,7 @@ class DNSRecords(BaseModel):
76
76
  model_config = ConfigDict(
77
77
  populate_by_name=True,
78
78
  )
79
+ dkim: DNSRecord | None = None
79
80
  mx: DNSRecord | None = None
80
81
  txt: DNSRecord | None = None
81
82
 
@@ -88,6 +89,9 @@ class DeleteUserDataResult(BaseModel):
88
89
  api_keys_deleted: int | None = None
89
90
  domains_deleted: int | None = None
90
91
  messages_deleted: int | None = None
92
+ oauth_access_tokens_deleted: int | None = None
93
+ oauth_auth_codes_deleted: int | None = None
94
+ oauth_refresh_tokens_deleted: int | None = None
91
95
  sessions_deleted: int | None = None
92
96
  usage_events_deleted: int | None = None
93
97
  usage_summaries_deleted: int | None = None
@@ -119,9 +123,21 @@ class Domain(BaseModel):
119
123
  model_config = ConfigDict(
120
124
  populate_by_name=True,
121
125
  )
126
+ agent_count: int | None = Field(
127
+ None,
128
+ description='AgentCount is populated by list endpoints. Single-domain endpoints\n(register, verify) leave it zero — the count would require an\nextra query that callers can derive from the agents list anyway.',
129
+ )
122
130
  created_at: str | None = None
123
131
  dns_records: DNSRecords | None = None
124
132
  domain: str | None = Field(None, examples=['yourdomain.com'])
133
+ is_primary: bool | None = Field(
134
+ None,
135
+ description='IsPrimary marks the user\'s default domain — at most one per\nuser, enforced server-side via SetDomainPrimary. The redesign\'s\nDomains list renders this as a "Primary" chip.',
136
+ )
137
+ last_checked_at: str | None = Field(
138
+ None,
139
+ description='LastCheckedAt is the timestamp of the most recent\n/api/v1/domains/{domain}/verify probe (success or failure).\nDistinct from VerifiedAt, which only updates on success.',
140
+ )
125
141
  verification_token: str | None = Field(None, examples=['e2a-verify=abc123'])
126
142
  verified: bool | None = None
127
143
  verified_at: str | None = None
@@ -153,6 +169,7 @@ class MessageDetail(BaseModel):
153
169
  message_id: str | None = Field(None, examples=['msg_abc123'])
154
170
  raw_message: str | None = None
155
171
  recipient: str | None = Field(None, examples=['my-bot@example.com'])
172
+ reply_to: list[str] | None = None
156
173
  status: str | None = Field(None, examples=['read'])
157
174
  subject: str | None = Field(None, examples=['Hello'])
158
175
  to: list[str] | None = Field(None, examples=[['my-bot@example.com']])
@@ -165,12 +182,58 @@ class MessageSummary(BaseModel):
165
182
  cc: list[str] | None = None
166
183
  conversation_id: str | None = None
167
184
  created_at: str | None = Field(None, examples=['2025-01-15T10:30:00Z'])
185
+ direction: Literal['inbound', 'outbound'] | None = Field(None, examples=['inbound'])
168
186
  from_: str | None = Field(None, alias='from', examples=['alice@example.com'])
187
+ hitl_status: (
188
+ Literal[
189
+ 'pending_approval',
190
+ 'sent',
191
+ 'rejected',
192
+ 'expired_approved',
193
+ 'expired_rejected',
194
+ ]
195
+ | None
196
+ ) = Field(None, examples=['sent'])
169
197
  message_id: str | None = Field(None, examples=['msg_abc123'])
170
198
  recipient: str | None = Field(None, examples=['my-bot@example.com'])
171
- status: Literal['unread', 'read'] | None = Field(None, examples=['unread'])
199
+ reply_to: list[str] | None = None
200
+ size_bytes: int | None = Field(None, examples=[4231])
201
+ status: str | None = Field(
202
+ None,
203
+ description='Status carries the inbound inbox_status value (`unread` | `read`).\nEmpty string for outbound rows — clients filtering on Status must\ngate on `Direction == "inbound"` first. The enum was removed from\nthe swag annotation deliberately so SDK generators don\'t emit a\n`Literal["unread", "read"]` that breaks at runtime.',
204
+ )
172
205
  subject: str | None = Field(None, examples=['Hello'])
173
206
  to: list[str] | None = Field(None, examples=[['my-bot@example.com']])
207
+ webhook_error: str | None = None
208
+ webhook_status: Literal['pending', 'delivered', 'failed'] | None = Field(
209
+ None, examples=['delivered']
210
+ )
211
+
212
+
213
+ class OAuthConnectionEntry(BaseModel):
214
+ model_config = ConfigDict(
215
+ populate_by_name=True,
216
+ )
217
+ agent_email: str | None = None
218
+ client_id: str | None = None
219
+ client_name: str | None = None
220
+ expires_at: str | None = None
221
+ issued_at: str | None = None
222
+ revoked_at: str | None = None
223
+ scope: str | None = None
224
+
225
+
226
+ class PendingMessageInboundContext(BaseModel):
227
+ model_config = ConfigDict(
228
+ populate_by_name=True,
229
+ )
230
+ auth_headers: dict[str, str] | None = Field(
231
+ None,
232
+ description='AuthHeaders carries the SPF/DKIM/DMARC validation results captured\nat inbound time. Keys are conventionally "spf", "dkim", "dmarc"\neach with values "pass" | "fail" | "neutral" | etc. The dashboard\nrenders these as found/missing chips on the provenance pane.',
233
+ )
234
+ created_at: str | None = Field(None, examples=['2025-01-15T10:25:00Z'])
235
+ sender: str | None = Field(None, examples=['alice@gmail.com'])
236
+ subject: str | None = Field(None, examples=['contract details'])
174
237
 
175
238
 
176
239
  class PendingMessageSummary(BaseModel):
@@ -261,6 +324,7 @@ class SigningSecretSummary(BaseModel):
261
324
  id: str | None = None
262
325
  last_signed_at: str | None = None
263
326
  name: str | None = None
327
+ secret: str | None = None
264
328
  secret_prefix: str | None = None
265
329
 
266
330
 
@@ -275,6 +339,13 @@ class UpdateAgentRequest(BaseModel):
275
339
  webhook_url: str | None = None
276
340
 
277
341
 
342
+ class UpdateDomainRequest(BaseModel):
343
+ model_config = ConfigDict(
344
+ populate_by_name=True,
345
+ )
346
+ is_primary: bool | None = None
347
+
348
+
278
349
  class UsageEventEntry(BaseModel):
279
350
  model_config = ConfigDict(
280
351
  populate_by_name=True,
@@ -301,7 +372,22 @@ class VerifyDomainResponse(BaseModel):
301
372
  model_config = ConfigDict(
302
373
  populate_by_name=True,
303
374
  )
375
+ dkim: Literal['found', 'missing', 'deferred'] | None = Field(
376
+ None,
377
+ description='DKIM status: "found" iff the published TXT record at\n"{selector}._domainkey.{domain}" matches the per-domain public\nkey stored at registration time. "missing" iff a keypair is\nstored but the TXT record isn\'t published yet. "deferred" iff\nno keypair is stored — pre-migration rows that haven\'t been\nre-claimed since #5 shipped. A fresh-claimed domain always has\na keypair, so "deferred" only appears on legacy data.',
378
+ examples=['found'],
379
+ )
304
380
  domain: str | None = Field(None, examples=['yourdomain.com'])
381
+ mx: Literal['found', 'missing'] | None = Field(
382
+ None,
383
+ description='MX status: "found" iff at least one MX record points at the\ndeployment\'s smtp domain. "missing" otherwise.',
384
+ examples=['found'],
385
+ )
386
+ spf: Literal['found', 'missing'] | None = Field(
387
+ None,
388
+ description='SPF status: "found" iff a v=spf1 TXT record includes the\ndeployment\'s send domain. "missing" otherwise.',
389
+ examples=['found'],
390
+ )
305
391
  verified: bool | None = None
306
392
  verified_at: str | None = None
307
393
 
@@ -358,10 +444,24 @@ class PendingMessageDetail(BaseModel):
358
444
  edited: bool | None = None
359
445
  email_message_id: str | None = Field(None, examples=['<orig@gmail.com>'])
360
446
  id: str | None = Field(None, examples=['msg_abc123'])
447
+ inbound: PendingMessageInboundContext | None = Field(
448
+ None,
449
+ description='InboundContext is attached when this is a reply — provides the\nSPF/DKIM/DMARC provenance + sender/subject of the inbound message\nbeing replied to so the review panel can render the context pane.',
450
+ )
361
451
  method: str | None = Field(None, examples=['smtp'])
362
452
  provider_message_id: str | None = None
363
453
  rejection_reason: str | None = None
364
454
  reviewed_at: str | None = Field(None, examples=['2025-01-15T10:35:00Z'])
455
+ reviewed_by_name: str | None = Field(
456
+ None,
457
+ description="ReviewedByName is the JOIN'd display name from the reviewer's\nusers row. NULL when reviewed_by_user_id is null (worker) or when\nthe reviewer's user account has since been deleted (the FK has\nON DELETE SET NULL specifically so this doesn't poison the audit\ntrail).",
458
+ examples=['Jamie'],
459
+ )
460
+ reviewed_by_user_id: str | None = Field(
461
+ None,
462
+ description='ReviewedByUserID identifies the human reviewer for approved or\nrejected messages. NULL on TTL-expired transitions (worker\nauto-approve / auto-reject) where no human reviewed the message.',
463
+ examples=['usr_abc123'],
464
+ )
365
465
  status: (
366
466
  Literal[
367
467
  'sent',
@@ -416,6 +516,7 @@ class UserExport(BaseModel):
416
516
  domains: list[github_com_Mnexa_AI_e2a_internal_identity.Domain] | None = None
417
517
  generated_at: str | None = None
418
518
  messages: list[github_com_Mnexa_AI_e2a_internal_identity.Message] | None = None
519
+ oauth_connections: list[OAuthConnectionEntry] | None = None
419
520
  schema_version: str | None = None
420
521
  usage_events: list[UsageEventEntry] | None = None
421
522
  user: UserExportUser | None = None
@@ -126,6 +126,7 @@ class MessageDetail(BaseModel):
126
126
  from_: str | None = Field(None, alias='from', examples=['alice@example.com'])
127
127
  message_id: str | None = Field(None, examples=['msg_abc123'])
128
128
  raw_message: str | None = None
129
+ reply_to: list[str] | None = None
129
130
  status: str | None = Field(None, examples=['read'])
130
131
  subject: str | None = Field(None, examples=['Hello'])
131
132
  to: str | None = Field(None, examples=['my-bot@example.com'])
@@ -139,6 +140,7 @@ class MessageSummary(BaseModel):
139
140
  created_at: str | None = Field(None, examples=['2025-01-15T10:30:00Z'])
140
141
  from_: str | None = Field(None, alias='from', examples=['alice@example.com'])
141
142
  message_id: str | None = Field(None, examples=['msg_abc123'])
143
+ reply_to: list[str] | None = None
142
144
  status: Literal['unread', 'read'] | None = Field(None, examples=['unread'])
143
145
  subject: str | None = Field(None, examples=['Hello'])
144
146
  to: str | None = Field(None, examples=['my-bot@example.com'])
@@ -0,0 +1,127 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: e2a-openapi3.yaml
3
+
4
+ from __future__ import annotations
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ class AgentIdentity(BaseModel):
10
+ model_config = ConfigDict(
11
+ populate_by_name=True,
12
+ )
13
+ agent_mode: str | None = None
14
+ created_at: str | None = None
15
+ domain: str | None = None
16
+ domain_verified: bool | None = None
17
+ email: str | None = None
18
+ hitl_enabled: bool | None = None
19
+ hitl_expiration_action: str | None = None
20
+ hitl_ttl_seconds: int | None = None
21
+ id: str | None = None
22
+ inbound_7d: int | None = Field(
23
+ None,
24
+ description='Dashboard enrichment fields. Computed at read\ntime by ListAgentsByUser via correlated subqueries — other load\npaths (GetAgentByID / GetAgentByEmail) leave them at zero values,\nsame pattern as Domain.AgentCount. Switch to denormalized columns\nif the read cost ever bites.',
25
+ )
26
+ last_delivery_at: str | None = None
27
+ name: str | None = None
28
+ outbound_7d: int | None = None
29
+ pending_count: int | None = None
30
+ public: bool | None = None
31
+ user_id: str | None = None
32
+ webhook_healthy: bool | None = Field(
33
+ None,
34
+ description="WebhookHealthy is false iff there's been a failed webhook delivery\nin the last 24h. Defaults to true for agents with no deliveries\nyet — avoids painting fresh agents red. Meaningless for\nagent_mode='local'; the frontend hides the badge in that case.",
35
+ )
36
+ webhook_url: str | None = None
37
+
38
+
39
+ class Domain(BaseModel):
40
+ model_config = ConfigDict(
41
+ populate_by_name=True,
42
+ )
43
+ agent_count: int | None = Field(
44
+ None,
45
+ description='AgentCount is computed at read time by ListDomainsByUser and is\nnot a persisted column. Single-domain LookupDomain leaves it at\nthe zero value — callers that need the count call the list path\n(this column-versus-aggregate split avoids changing every store\nsignature to thread an agent-counter through).',
46
+ )
47
+ created_at: str | None = None
48
+ dkim_public_key: str | None = None
49
+ dkim_selector: str | None = Field(
50
+ None,
51
+ description="DKIM keypair fields. The selector + public key\nare user-facing — the dashboard shows them so users can copy the\nDNS TXT record. The private key is intentionally NOT in the JSON\nshape; it's only read by the outbound signer via\nGetDKIMKey(domain). Domains created before migration 014 ran\nkeep all three NULL until the next ClaimOrCreate or backfill.",
52
+ )
53
+ domain: str | None = None
54
+ is_primary: bool | None = Field(
55
+ None,
56
+ description="IsPrimary marks the user's default domain. At most one TRUE per\nuser (enforced by a partial unique index in migration 013).",
57
+ )
58
+ last_checked_at: str | None = Field(
59
+ None,
60
+ description='LastCheckedAt is updated whenever the verification probe runs,\nsuccessful or not. NULL until the first probe — distinct from\n"probed and failed" which is captured by `verified=false` + a\nnon-null LastCheckedAt.',
61
+ )
62
+ user_id: str | None = None
63
+ verification_token: str | None = None
64
+ verified: bool | None = None
65
+ verified_at: str | None = None
66
+
67
+
68
+ class Message(BaseModel):
69
+ model_config = ConfigDict(
70
+ populate_by_name=True,
71
+ )
72
+ agent_id: str | None = None
73
+ approval_expires_at: str | None = None
74
+ attachments: list[int] | None = None
75
+ auth_headers: dict[str, str] | None = None
76
+ bcc: list[str] | None = None
77
+ body_html: str | None = None
78
+ body_text: str | None = None
79
+ cc: list[str] | None = None
80
+ conversation_id: str | None = None
81
+ created_at: str | None = None
82
+ delivery_status: str | None = None
83
+ direction: str | None = None
84
+ edited: bool | None = None
85
+ email_message_id: str | None = None
86
+ expires_at: str | None = None
87
+ id: str | None = None
88
+ inbox_status: str | None = Field(
89
+ None,
90
+ description="InboxStatus mirrors messages.inbox_status ('unread' | 'read') for\ninbound rows. Kept separate from DeliveryStatus (which currently\ncarries the same value under a confusing JSON key — see line 161)\nso the dashboard's inbox can read it under a non-overloaded key.\nEmpty on outbound rows. Populated by GetMessagesByAgent.",
91
+ )
92
+ method: str | None = None
93
+ provider_message_id: str | None = None
94
+ raw_message: list[int] | None = None
95
+ recipient: str | None = None
96
+ rejection_reason: str | None = None
97
+ reply_to: list[str] | None = Field(
98
+ None,
99
+ description='ReplyTo is the parsed Reply-To: header on inbound messages — empty\nwhen the header was absent. Distinct from Sender so consumers can\nrecover the original From: of forwarded / notification mail whose\nReply-To points at a different mailbox. Outbound-irrelevant.',
100
+ )
101
+ reviewed_at: str | None = None
102
+ reviewed_by_name: str | None = Field(
103
+ None,
104
+ description="ReviewedByName is the JOIN'd display name from the reviewer's\nusers row, populated only by GetOutboundMessageForUser. List\nendpoints leave this empty to avoid a join-per-row cost — the\npending-detail page is where reviewer attribution matters.",
105
+ )
106
+ reviewed_by_user_id: str | None = Field(
107
+ None,
108
+ description='ReviewedByUserID identifies the human reviewer who approved or\nrejected this message. NULL on worker-triggered transitions\n(TTL auto-approve / auto-reject) — operator-visible signal "no\nhuman looked at this." Set by ApproveAndSend and RejectPending,\nleft null by ExpireApproveAndSend / ExpireReject.',
109
+ )
110
+ sender: str | None = None
111
+ size_bytes: int | None = Field(
112
+ None,
113
+ description='SizeBytes is the byte length of raw_message. Populated by load paths\nthat compute it (e.g. GetMessagesByAgent for the dashboard inbox).\nZero on load paths that don\'t — the inbox renders "—" in that case.',
114
+ )
115
+ status: str | None = Field(
116
+ None,
117
+ description="HITL approval fields. Status defaults to 'sent'; body and attachments\nare populated only while a message is in 'pending_approval', and are\nscrubbed on any terminal transition.",
118
+ )
119
+ subject: str | None = None
120
+ to_recipients: list[str] | None = Field(
121
+ None,
122
+ description='Multi-recipient fields. For outbound, these are the addressed\nTo/Cc/Bcc recipients of the send. For inbound, ToRecipients and CC\nare the parsed To: and Cc: headers of the original message (the\nper-delivery target for this row is in Recipient). BCC is\noutbound-only.',
123
+ )
124
+ type: str | None = None
125
+ webhook_attempts: int | None = None
126
+ webhook_error: str | None = None
127
+ webhook_status: str | None = None
@@ -12,7 +12,7 @@ import email as email_lib
12
12
  import hashlib
13
13
  import hmac
14
14
  import os
15
- from dataclasses import dataclass
15
+ from dataclasses import dataclass, field
16
16
  from datetime import datetime, timedelta, timezone
17
17
  from email.policy import default as default_policy
18
18
  from typing import TYPE_CHECKING, Any, Optional
@@ -193,6 +193,10 @@ class MessageSummary:
193
193
  subject: str
194
194
  status: str # "unread" or "read"
195
195
  created_at: str
196
+ # Added after the initial release; defaulted to [] so existing
197
+ # positional constructors (test fixtures, custom adapters) keep
198
+ # working without shifting the trailing args.
199
+ reply_to: list[str] = field(default_factory=list)
196
200
 
197
201
 
198
202
  @dataclass
@@ -242,8 +246,8 @@ class InboundEmail:
242
246
  - :attr:`verified` — has verify_signature succeeded yet?
243
247
 
244
248
  Gated (require verify_signature first):
245
- message_id, conversation_id, sender, recipient, to, cc, subject,
246
- text_body, html_body, attachments, received_at, reply().
249
+ message_id, conversation_id, sender, recipient, to, cc, reply_to,
250
+ subject, text_body, html_body, attachments, received_at, reply().
247
251
  """
248
252
 
249
253
  def __init__(
@@ -263,6 +267,10 @@ class InboundEmail:
263
267
  received_at: Optional[str],
264
268
  raw_message: bytes,
265
269
  client: E2AClient,
270
+ # Added after the initial release; defaulted to None so existing
271
+ # constructors keep working. Normalized to [] below to match the
272
+ # SDK contract that reply_to is always a list, never None.
273
+ reply_to: Optional[list[str]] = None,
266
274
  ) -> None:
267
275
  # Stored as private fields. Public access flows through @property
268
276
  # gates that check self._verified. The constructor takes the
@@ -273,6 +281,7 @@ class InboundEmail:
273
281
  self._recipient = recipient
274
282
  self._to = to
275
283
  self._cc = cc
284
+ self._reply_to = reply_to if reply_to is not None else []
276
285
  self._subject = subject
277
286
  self._text_body = text_body
278
287
  self._html_body = html_body
@@ -329,6 +338,7 @@ class InboundEmail:
329
338
  "recipient": self._recipient,
330
339
  "to": list(self._to),
331
340
  "cc": list(self._cc),
341
+ "reply_to": list(self._reply_to),
332
342
  "subject": self._subject,
333
343
  "text_body": self._text_body,
334
344
  "html_body": self._html_body,
@@ -419,6 +429,47 @@ class InboundEmail:
419
429
  self._require_verified()
420
430
  return self._cc
421
431
 
432
+ @property
433
+ def reply_to(self) -> list[str]:
434
+ """Parsed Reply-To: header — addresses the sender wants replies sent to.
435
+
436
+ Empty list when the header was absent (the SDK never silently falls
437
+ back to :attr:`sender`; that decision belongs to the caller).
438
+ Distinct from :attr:`sender` so consumers can recover the original
439
+ From: address of forwarded / notification mail whose Reply-To
440
+ points at a different mailbox.
441
+
442
+ .. warning::
443
+ Trust path is weaker than you might assume. The HMAC binds a
444
+ fixed set of auth headers (sender, timestamp, message_id,
445
+ body_hash, …) and ``body_hash = SHA-256(raw_message)``. It
446
+ does **not** sign the JSON envelope, and the SDK reads this
447
+ field from the JSON envelope — not from ``raw_message``. So
448
+ ``reply_to`` here is trusted on the same terms as :attr:`to`,
449
+ :attr:`cc`, :attr:`recipient`, :attr:`subject`, and the body
450
+ fields: the server placed it in the JSON, TLS protected the
451
+ wire to your webhook URL, and you trust your relay-to-webhook
452
+ connection. The HMAC alone does not prevent an attacker who
453
+ can modify the JSON envelope after signing from rewriting
454
+ ``reply_to`` while ``verify_signature`` still returns
455
+ ``True``.
456
+
457
+ If you need byte-exact integrity (e.g. routing decisions
458
+ where an attacker who can break TLS would matter), re-parse
459
+ the ``Reply-To:`` header from :attr:`raw_message` yourself —
460
+ that bytes-level integrity *is* covered by ``body_hash``.
461
+
462
+ Separately, this is **not** an upstream-DKIM coverage check.
463
+ If the original sender's DKIM signature did not cover
464
+ Reply-To (whether because they did not sign it, or there was
465
+ no DKIM at all), a MITM between sender and e2a could have
466
+ rewritten the header before it reached the relay. For
467
+ high-stakes routing, also confirm :attr:`is_verified` and
468
+ that the sender's domain is one you expect.
469
+ """
470
+ self._require_verified()
471
+ return self._reply_to
472
+
422
473
  @property
423
474
  def subject(self) -> str:
424
475
  self._require_verified()
@@ -593,6 +644,9 @@ def _parse_payload(data: dict[str, Any]) -> dict[str, Any]:
593
644
  "recipient": data.get("recipient", ""),
594
645
  "to": list(data.get("to") or []),
595
646
  "cc": list(data.get("cc") or []),
647
+ # reply_to comes from the server's parse of the Reply-To: header.
648
+ # Empty list when absent — the server never falls back to From:.
649
+ "reply_to": list(data.get("reply_to") or []),
596
650
  "subject": subject or data.get("subject", ""),
597
651
  "text_body": text_body,
598
652
  "html_body": html_body,
@@ -649,6 +703,9 @@ class AsyncInboundEmail:
649
703
  received_at: Optional[str],
650
704
  raw_message: bytes,
651
705
  client: AsyncE2AClient,
706
+ # See InboundEmail.__init__ for the rationale on positioning
707
+ # reply_to last with a None default.
708
+ reply_to: Optional[list[str]] = None,
652
709
  ) -> None:
653
710
  self._message_id = message_id
654
711
  self._conversation_id = conversation_id
@@ -656,6 +713,7 @@ class AsyncInboundEmail:
656
713
  self._recipient = recipient
657
714
  self._to = to
658
715
  self._cc = cc
716
+ self._reply_to = reply_to if reply_to is not None else []
659
717
  self._subject = subject
660
718
  self._text_body = text_body
661
719
  self._html_body = html_body
@@ -695,6 +753,7 @@ class AsyncInboundEmail:
695
753
  "recipient": self._recipient,
696
754
  "to": list(self._to),
697
755
  "cc": list(self._cc),
756
+ "reply_to": list(self._reply_to),
698
757
  "subject": self._subject,
699
758
  "text_body": self._text_body,
700
759
  "html_body": self._html_body,
@@ -755,6 +814,47 @@ class AsyncInboundEmail:
755
814
  self._require_verified()
756
815
  return self._cc
757
816
 
817
+ @property
818
+ def reply_to(self) -> list[str]:
819
+ """Parsed Reply-To: header — addresses the sender wants replies sent to.
820
+
821
+ Empty list when the header was absent (the SDK never silently falls
822
+ back to :attr:`sender`; that decision belongs to the caller).
823
+ Distinct from :attr:`sender` so consumers can recover the original
824
+ From: address of forwarded / notification mail whose Reply-To
825
+ points at a different mailbox.
826
+
827
+ .. warning::
828
+ Trust path is weaker than you might assume. The HMAC binds a
829
+ fixed set of auth headers (sender, timestamp, message_id,
830
+ body_hash, …) and ``body_hash = SHA-256(raw_message)``. It
831
+ does **not** sign the JSON envelope, and the SDK reads this
832
+ field from the JSON envelope — not from ``raw_message``. So
833
+ ``reply_to`` here is trusted on the same terms as :attr:`to`,
834
+ :attr:`cc`, :attr:`recipient`, :attr:`subject`, and the body
835
+ fields: the server placed it in the JSON, TLS protected the
836
+ wire to your webhook URL, and you trust your relay-to-webhook
837
+ connection. The HMAC alone does not prevent an attacker who
838
+ can modify the JSON envelope after signing from rewriting
839
+ ``reply_to`` while ``verify_signature`` still returns
840
+ ``True``.
841
+
842
+ If you need byte-exact integrity (e.g. routing decisions
843
+ where an attacker who can break TLS would matter), re-parse
844
+ the ``Reply-To:`` header from :attr:`raw_message` yourself —
845
+ that bytes-level integrity *is* covered by ``body_hash``.
846
+
847
+ Separately, this is **not** an upstream-DKIM coverage check.
848
+ If the original sender's DKIM signature did not cover
849
+ Reply-To (whether because they did not sign it, or there was
850
+ no DKIM at all), a MITM between sender and e2a could have
851
+ rewritten the header before it reached the relay. For
852
+ high-stakes routing, also confirm :attr:`is_verified` and
853
+ that the sender's domain is one you expect.
854
+ """
855
+ self._require_verified()
856
+ return self._reply_to
857
+
758
858
  @property
759
859
  def subject(self) -> str:
760
860
  self._require_verified()
@@ -0,0 +1,130 @@
1
+ """Tests for the Idempotency-Key transport behavior in the Python SDK."""
2
+
3
+ import re
4
+
5
+ import pytest
6
+
7
+ from e2a.v1.api import E2AApi
8
+ from e2a.v1.client import E2AClient
9
+ from e2a.v1.generated import ReplyToMessageRequest, SendEmailRequest
10
+
11
+
12
+ BASE = "https://e2a.dev"
13
+
14
+ UUIDV4_RE = re.compile(
15
+ r"^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
16
+ re.IGNORECASE,
17
+ )
18
+
19
+
20
+ def test_send_email_auto_generates_idempotency_key(httpx_mock):
21
+ httpx_mock.add_response(
22
+ url=f"{BASE}/api/v1/send",
23
+ method="POST",
24
+ json={"status": "sent", "message_id": "msg_abc", "method": "smtp"},
25
+ )
26
+
27
+ with E2AApi(api_key="e2a_test") as api:
28
+ api.send_email(
29
+ SendEmailRequest(to=["alice@example.com"], subject="x", body="y")
30
+ )
31
+
32
+ req = httpx_mock.get_request()
33
+ key = req.headers["Idempotency-Key"]
34
+ assert key, "Idempotency-Key header not set"
35
+ assert UUIDV4_RE.match(key), f"key {key!r} is not a UUIDv4 hex/canonical shape"
36
+
37
+
38
+ def test_send_email_honors_caller_supplied_key(httpx_mock):
39
+ httpx_mock.add_response(
40
+ url=f"{BASE}/api/v1/send",
41
+ method="POST",
42
+ json={"status": "sent", "message_id": "msg_abc", "method": "smtp"},
43
+ )
44
+
45
+ with E2AApi(api_key="e2a_test") as api:
46
+ api.send_email(
47
+ SendEmailRequest(to=["alice@example.com"], subject="x", body="y"),
48
+ idempotency_key="user-supplied-key-42",
49
+ )
50
+
51
+ req = httpx_mock.get_request()
52
+ assert req.headers["Idempotency-Key"] == "user-supplied-key-42"
53
+
54
+
55
+ def test_reply_to_message_carries_idempotency_key(httpx_mock):
56
+ httpx_mock.add_response(
57
+ url=f"{BASE}/api/v1/agents/bot%40test.dev/messages/msg_in_xyz/reply",
58
+ method="POST",
59
+ json={"status": "sent", "message_id": "msg_out_xyz", "method": "smtp"},
60
+ )
61
+
62
+ with E2AApi(api_key="e2a_test") as api:
63
+ api.reply_to_message(
64
+ "bot@test.dev",
65
+ "msg_in_xyz",
66
+ ReplyToMessageRequest(body="hi"),
67
+ idempotency_key="reply-key-1",
68
+ )
69
+
70
+ req = httpx_mock.get_request()
71
+ assert req.headers["Idempotency-Key"] == "reply-key-1"
72
+
73
+
74
+ def test_send_email_generates_different_key_each_call(httpx_mock):
75
+ httpx_mock.add_response(
76
+ url=f"{BASE}/api/v1/send",
77
+ method="POST",
78
+ json={"status": "sent", "message_id": "msg_a", "method": "smtp"},
79
+ )
80
+ httpx_mock.add_response(
81
+ url=f"{BASE}/api/v1/send",
82
+ method="POST",
83
+ json={"status": "sent", "message_id": "msg_b", "method": "smtp"},
84
+ )
85
+
86
+ with E2AApi(api_key="e2a_test") as api:
87
+ api.send_email(SendEmailRequest(to=["a@b.com"], subject="x", body="y"))
88
+ api.send_email(SendEmailRequest(to=["a@b.com"], subject="x", body="y"))
89
+
90
+ reqs = httpx_mock.get_requests()
91
+ keys = [r.headers["Idempotency-Key"] for r in reqs]
92
+ assert len(keys) == 2
93
+ assert keys[0] != keys[1], "auto-generated keys should differ per call"
94
+
95
+
96
+ def test_high_level_client_send_threads_idempotency_key(httpx_mock):
97
+ httpx_mock.add_response(
98
+ url=f"{BASE}/api/v1/send",
99
+ method="POST",
100
+ json={"status": "sent", "message_id": "msg_xyz", "method": "smtp"},
101
+ )
102
+
103
+ with E2AClient(
104
+ api_key="e2a_test", agent_email="bot@test.dev"
105
+ ) as client:
106
+ client.send(
107
+ ["alice@example.com"],
108
+ "x",
109
+ "y",
110
+ idempotency_key="client-key-99",
111
+ )
112
+
113
+ req = httpx_mock.get_request()
114
+ assert req.headers["Idempotency-Key"] == "client-key-99"
115
+
116
+
117
+ def test_high_level_client_reply_threads_idempotency_key(httpx_mock):
118
+ httpx_mock.add_response(
119
+ url=f"{BASE}/api/v1/agents/bot%40test.dev/messages/msg_in_abc/reply",
120
+ method="POST",
121
+ json={"status": "sent", "message_id": "msg_out_abc", "method": "smtp"},
122
+ )
123
+
124
+ with E2AClient(
125
+ api_key="e2a_test", agent_email="bot@test.dev"
126
+ ) as client:
127
+ client.reply("msg_in_abc", "hi", idempotency_key="client-reply-key")
128
+
129
+ req = httpx_mock.get_request()
130
+ assert req.headers["Idempotency-Key"] == "client-reply-key"
@@ -198,6 +198,94 @@ def test_build_inbound_email_uses_structured_to_cc():
198
198
  assert email.recipient == "bot@agent.example.com"
199
199
 
200
200
 
201
+ # ── reply_to (server-parsed Reply-To: header) ─────────────────────
202
+
203
+
204
+ def test_reply_to_absent_is_empty_list():
205
+ """No Reply-To header → reply_to is [], never falls back to sender.
206
+
207
+ Callers decide whether to address replies to sender or somewhere else.
208
+ """
209
+ raw = _make_raw_email()
210
+ data = _make_webhook_data(raw)
211
+ # _make_webhook_data does not set reply_to; the server would omit it too.
212
+ email = build_inbound_email(data, _mock_client(), trusted=True)
213
+ assert email.reply_to == []
214
+
215
+
216
+ def test_reply_to_single_address():
217
+ """Most common case: notifications@foo with Reply-To: user@bar."""
218
+ raw = _make_raw_email()
219
+ data = _make_webhook_data(raw)
220
+ data["reply_to"] = ["user@bar.com"]
221
+ email = build_inbound_email(data, _mock_client(), trusted=True)
222
+ assert email.reply_to == ["user@bar.com"]
223
+
224
+
225
+ def test_reply_to_multi_address():
226
+ """RFC 5322 § 3.6.2 permits multiple addresses in Reply-To."""
227
+ raw = _make_raw_email()
228
+ data = _make_webhook_data(raw)
229
+ data["reply_to"] = ["support@company.com", "ceo@company.com"]
230
+ email = build_inbound_email(data, _mock_client(), trusted=True)
231
+ assert email.reply_to == ["support@company.com", "ceo@company.com"]
232
+
233
+
234
+ def test_reply_to_display_names_stripped_by_server():
235
+ """The SDK mirrors `to`/`cc`: it trusts the server's normalized list
236
+ (bare addresses, display names already stripped). This test pins the
237
+ contract — if the server sends bare addresses, the SDK surfaces them
238
+ unchanged."""
239
+ raw = _make_raw_email()
240
+ data = _make_webhook_data(raw)
241
+ # Server would have sent ParseAddressList("Foo Bar" <foo@bar.com>) →
242
+ # ["foo@bar.com"]; the SDK does not re-parse.
243
+ data["reply_to"] = ["foo@bar.com"]
244
+ email = build_inbound_email(data, _mock_client(), trusted=True)
245
+ assert email.reply_to == ["foo@bar.com"]
246
+
247
+
248
+ def test_reply_to_gated_until_verified():
249
+ """reply_to obeys the same UnverifiedEmailError gate as `to`/`cc` —
250
+ a webhook delivery is not safe to read until verify_signature() has
251
+ confirmed the HMAC."""
252
+ from e2a.v1 import UnverifiedEmailError
253
+ raw = _make_raw_email()
254
+ data = _make_webhook_data(raw)
255
+ data["reply_to"] = ["user@bar.com"]
256
+ email = build_inbound_email(data, _mock_client()) # NOT trusted=True
257
+ with pytest.raises(UnverifiedEmailError):
258
+ _ = email.reply_to
259
+
260
+
261
+ def test_reply_to_trusted_through_body_hash():
262
+ """The trust path for reply_to: e2a's HMAC binds SHA-256(raw_message),
263
+ and Reply-To is in raw_message bytes. Tampering with the structured
264
+ reply_to alone doesn't change body_hash, so the SDK trusts what the
265
+ server parses — same model as `to`/`cc`. This test pins the property:
266
+ a successfully-verified message exposes reply_to without re-parsing.
267
+ """
268
+ secret = "x" * 32
269
+ raw = b"From: notifications@example.com\r\nReply-To: real-user@example.com\r\nSubject: Hi\r\n\r\nbody"
270
+ email, _ = _signed_email(secret=secret, body=raw)
271
+ # The verified path: HMAC over body_hash succeeds → field access unlocks.
272
+ assert email.verify_signature(secret) is True
273
+ # _signed_email's fixture doesn't set reply_to in the JSON; this also
274
+ # confirms the safe default (empty) when the server didn't ship it.
275
+ assert email.reply_to == []
276
+
277
+
278
+ def test_reply_to_in_unverified_payload():
279
+ """The escape hatch for inspecting unverified payloads also includes
280
+ reply_to so forensics can see what arrived without unlocking gates."""
281
+ raw = _make_raw_email()
282
+ data = _make_webhook_data(raw)
283
+ data["reply_to"] = ["user@bar.com"]
284
+ email = build_inbound_email(data, _mock_client()) # unverified
285
+ payload = email.unverified_payload
286
+ assert payload["reply_to"] == ["user@bar.com"]
287
+
288
+
201
289
  # ── build_inbound_email ──────────────────────────────────────────
202
290
 
203
291
 
@@ -1,79 +0,0 @@
1
- # generated by datamodel-codegen:
2
- # filename: e2a-openapi3.yaml
3
-
4
- from __future__ import annotations
5
-
6
- from pydantic import BaseModel, ConfigDict, Field
7
-
8
-
9
- class AgentIdentity(BaseModel):
10
- model_config = ConfigDict(
11
- populate_by_name=True,
12
- )
13
- agent_mode: str | None = None
14
- created_at: str | None = None
15
- domain: str | None = None
16
- domain_verified: bool | None = None
17
- email: str | None = None
18
- hitl_enabled: bool | None = None
19
- hitl_expiration_action: str | None = None
20
- hitl_ttl_seconds: int | None = None
21
- id: str | None = None
22
- name: str | None = None
23
- public: bool | None = None
24
- user_id: str | None = None
25
- webhook_url: str | None = None
26
-
27
-
28
- class Domain(BaseModel):
29
- model_config = ConfigDict(
30
- populate_by_name=True,
31
- )
32
- created_at: str | None = None
33
- domain: str | None = None
34
- user_id: str | None = None
35
- verification_token: str | None = None
36
- verified: bool | None = None
37
- verified_at: str | None = None
38
-
39
-
40
- class Message(BaseModel):
41
- model_config = ConfigDict(
42
- populate_by_name=True,
43
- )
44
- agent_id: str | None = None
45
- approval_expires_at: str | None = None
46
- attachments: list[int] | None = None
47
- auth_headers: dict[str, str] | None = None
48
- bcc: list[str] | None = None
49
- body_html: str | None = None
50
- body_text: str | None = None
51
- cc: list[str] | None = None
52
- conversation_id: str | None = None
53
- created_at: str | None = None
54
- delivery_status: str | None = None
55
- direction: str | None = None
56
- edited: bool | None = None
57
- email_message_id: str | None = None
58
- expires_at: str | None = None
59
- id: str | None = None
60
- method: str | None = None
61
- provider_message_id: str | None = None
62
- raw_message: list[int] | None = None
63
- recipient: str | None = None
64
- rejection_reason: str | None = None
65
- reviewed_at: str | None = None
66
- sender: str | None = None
67
- status: str | None = Field(
68
- None,
69
- description="HITL approval fields. Status defaults to 'sent'; body and attachments\nare populated only while a message is in 'pending_approval', and are\nscrubbed on any terminal transition.",
70
- )
71
- subject: str | None = None
72
- to_recipients: list[str] | None = Field(
73
- None,
74
- description='Multi-recipient fields. For outbound, these are the addressed\nTo/Cc/Bcc recipients of the send. For inbound, ToRecipients and CC\nare the parsed To: and Cc: headers of the original message (the\nper-delivery target for this row is in Recipient). BCC is\noutbound-only.',
75
- )
76
- type: str | None = None
77
- webhook_attempts: int | None = None
78
- webhook_error: str | None = None
79
- webhook_status: str | None = None
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