e2a 2.2.0__tar.gz → 2.4.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.4.0/CHANGELOG.md +119 -0
  2. {e2a-2.2.0 → e2a-2.4.0}/PKG-INFO +4 -3
  3. {e2a-2.2.0 → e2a-2.4.0}/README.md +3 -2
  4. {e2a-2.2.0 → e2a-2.4.0}/codegen-requirements.txt +4 -4
  5. {e2a-2.2.0 → e2a-2.4.0}/pyproject.toml +1 -1
  6. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/api.py +68 -1
  7. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/async_client.py +81 -9
  8. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/client.py +58 -7
  9. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/generated/__init__.py +111 -6
  10. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/generated/_internal.py +2 -0
  11. e2a-2.4.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +127 -0
  12. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/handler.py +103 -3
  13. e2a-2.4.0/tests/test_idempotency.py +183 -0
  14. {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_api.py +2 -2
  15. {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_async_client.py +2 -2
  16. {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_client.py +2 -2
  17. {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_handler.py +88 -0
  18. e2a-2.2.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -79
  19. {e2a-2.2.0 → e2a-2.4.0}/.gitignore +0 -0
  20. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/__init__.py +0 -0
  21. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/__init__.py +0 -0
  22. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/generated/internal_agent.py +0 -0
  23. {e2a-2.2.0 → e2a-2.4.0}/src/e2a/v1/websocket.py +0 -0
  24. {e2a-2.2.0 → e2a-2.4.0}/tests/__init__.py +0 -0
  25. {e2a-2.2.0 → e2a-2.4.0}/tests/test_contract.py +0 -0
  26. {e2a-2.2.0 → e2a-2.4.0}/tests/test_e2e.py +0 -0
  27. {e2a-2.2.0 → e2a-2.4.0}/tests/test_exports.py +0 -0
  28. {e2a-2.2.0 → e2a-2.4.0}/tests/test_generated_models.py +0 -0
  29. {e2a-2.2.0 → e2a-2.4.0}/tests/test_v1_websocket.py +0 -0
  30. {e2a-2.2.0 → e2a-2.4.0}/uv.lock +0 -0
e2a-2.4.0/CHANGELOG.md ADDED
@@ -0,0 +1,119 @@
1
+ # Changelog
2
+
3
+ ## 2.4.0
4
+
5
+ ### Added
6
+ - `idempotency_key` parameter on `E2AClient.approve_message()` and its
7
+ async counterpart (and on the lower-level `E2AApi.approve_message()`).
8
+ Approve fires a real SES send, so without a stable key a retry after
9
+ a transient failure could double-send. When supplied it's threaded
10
+ through as the `Idempotency-Key` header; when omitted the SDK mints
11
+ a fresh UUIDv4 per call — that gives network-layer retry safety only.
12
+ Supply a stable key derived from the review event (typically the
13
+ pending `message_id`) to dedupe across an explicit retry loop.
14
+ - `sort`, `from_`, `subject_contains`, `conversation_id`, `since`,
15
+ `until` kwargs on `E2AApi.list_messages()` and the high-level
16
+ `E2AClient.get_messages()` (sync + async). `sort` defaults
17
+ server-side to newest-first; pass `"asc"` for FIFO polling. The
18
+ substring filters are case-insensitive and capped at 200 chars
19
+ server-side. `since` / `until` accept RFC3339 timestamps and
20
+ bracket `created_at`. Filter values are encoded into `next_token`,
21
+ so continuation requests must keep the same filter values.
22
+
23
+ ### Changed
24
+ - **Default sort flipped to newest-first** on `GET /messages`. Prior
25
+ releases silently returned oldest-first for `direction=inbound` (the
26
+ SDK default) and newest-first for `direction=all`. A polling agent
27
+ that relied on FIFO drain order should now pass `sort="asc"` to
28
+ preserve the old behavior.
29
+ - `agent_mode` is now a required field on `RegisterAgentRequest`. The
30
+ server previously silently defaulted to `"cloud"` and then 400'd
31
+ with a cryptic "webhook_url is required" message; it now explicitly
32
+ rejects requests missing `agent_mode` with a clear error. Pydantic
33
+ v2 will raise a validation error if you instantiate the request
34
+ without it. Set `agent_mode="local"` or `"cloud"` explicitly.
35
+
36
+ ## 2.3.0
37
+
38
+ ### Added
39
+ - `idempotency_key` parameter on `E2AClient.send()` / `.reply()` and their async
40
+ counterparts (and on the lower-level `E2AApi.send_email()` /
41
+ `reply_to_message()`). When supplied, it is sent as the `Idempotency-Key`
42
+ header so the server can deduplicate retries of the same send/reply. When
43
+ omitted, the SDK generates a fresh UUIDv4 per call — that gives
44
+ network-layer retry safety only; supply a stable key derived from the
45
+ triggering event (e.g. the inbound message id or a job id) to deduplicate
46
+ across an explicit retry loop.
47
+ - `InboundEmail.reply_to` and `AsyncInboundEmail.reply_to` (`list[str]`) — the
48
+ parsed `Reply-To:` header from the inbound message, surfaced as a first-class
49
+ field so consumers no longer need to re-parse `raw_message` with stdlib
50
+ `email.message_from_bytes()`. Empty list when the header is absent; the SDK
51
+ never silently falls back to `sender`. Use this when the sender is a no-reply
52
+ notifications mailbox (Granola, GitHub, CI bots) and you need the actual
53
+ correspondent.
54
+ - `MessageSummary.reply_to` (`list[str]`) on the REST polling path — the list
55
+ endpoint now mirrors the same field.
56
+ - `reply_to` added to `unverified_payload` for forensic inspection without
57
+ unlocking gated access.
58
+
59
+ ### Reply-To trust path (decision)
60
+ `reply_to` is trusted on the same terms as `to`, `cc`, `recipient`,
61
+ `subject`, and the body fields: the e2a server parses it from
62
+ `raw_message`, places it in the JSON envelope, and TLS protects the wire
63
+ to your webhook URL. Treat the field as trustworthy once
64
+ `verify_signature()` succeeds **and** you're confident in your
65
+ relay-to-webhook connection (or via `client.get_message(...)`, which uses
66
+ the authenticated REST channel).
67
+
68
+ **What `verify_signature()` does not prove:** the HMAC binds a fixed set
69
+ of auth headers and `body_hash = SHA-256(raw_message)`. It does not sign
70
+ the JSON envelope itself, and the SDK reads `reply_to`, `to`, `cc`, etc.
71
+ from that envelope rather than re-parsing `raw_message`. So an attacker
72
+ who can modify the JSON wrapping after signing — but cannot modify
73
+ `raw_message` or the signed headers — can rewrite `reply_to` and the
74
+ HMAC will still verify. TLS to your webhook URL is the actual integrity
75
+ layer for the envelope fields; the HMAC is defense-in-depth for proven
76
+ origin and covers the body bytes. If you need byte-exact assurance for a
77
+ specific field, re-parse it from `raw_message` (whose integrity
78
+ `body_hash` *does* cover).
79
+
80
+ **Also not guaranteed:** upstream-DKIM coverage of `Reply-To:`. If the
81
+ original sender's DKIM signature did not sign `Reply-To` (whether
82
+ because they didn't sign it, or there was no DKIM at all), a MITM
83
+ between sender and e2a could have rewritten the header before it reached
84
+ the relay. e2a does not re-verify or surface per-header DKIM coverage
85
+ today — the `Authentication-Results` / SPF/DKIM surface is unchanged.
86
+ For routing decisions where attacker-controlled `Reply-To` would matter,
87
+ also confirm `email.is_verified` and that the sender's domain is one you
88
+ expect.
89
+
90
+ We chose to keep `reply_to` populated whenever it's present (rather than
91
+ masking it on partially-trusted messages or exposing a `reply_to_signed`
92
+ flag) so the field shape stays uniform with `to`/`cc` and consumers can
93
+ make their own policy decision. The trust model is documented on the
94
+ property docstring.
95
+
96
+ ### Wire change
97
+ The webhook payload schema now includes an optional `reply_to: string[]`
98
+ field. Existing consumers that ignore unknown fields are unaffected; older
99
+ SDK versions parsing the same payload continue to work and simply do not
100
+ see the new key.
101
+
102
+ ### Other generated-type additions
103
+ The high-level surface above is what most consumers will touch. For users
104
+ of `client.api.*` or `e2a.v1.generated.*` directly, the following backend
105
+ endpoints / fields also landed since 2.2.0 and are reflected in the
106
+ regenerated types:
107
+
108
+ - Per-record DNS verification — separate MX / SPF / DKIM diagnostic
109
+ responses on the domain-verification endpoints.
110
+ - Enriched `DashboardAgent` — `Inbound7d`, `Outbound7d`, `Pending`,
111
+ `LastDelivery`, `WebhookHealthy` fields on the dashboard list.
112
+ - OAuth 2.1 authorization-server endpoints (fosite-backed) used by the
113
+ MCP server flow.
114
+ - Per-domain DKIM key generation endpoint.
115
+ - One-time signing-secret reveal on creation.
116
+ - Pending-review polish — provenance, quoted-inbound, headers-preview,
117
+ draft-footer fields on the review payload.
118
+
119
+ 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.4.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.4.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="")
@@ -177,8 +191,44 @@ class E2AApi:
177
191
  status: str = "unread",
178
192
  page_size: int = 50,
179
193
  token: Optional[str] = None,
194
+ sort: Optional[str] = None,
195
+ from_: Optional[str] = None,
196
+ subject_contains: Optional[str] = None,
197
+ conversation_id: Optional[str] = None,
198
+ since: Optional[str] = None,
199
+ until: Optional[str] = None,
180
200
  ) -> ListMessagesResponse:
201
+ """List messages for an agent.
202
+
203
+ ``sort`` defaults server-side to ``"desc"`` (newest first).
204
+ Pass ``"asc"`` for FIFO polling — drain the inbox in arrival
205
+ order. The choice is encoded in ``next_token`` so subsequent
206
+ pages keep the same order; switching mid-pagination returns
207
+ 400.
208
+
209
+ ``from_``, ``subject_contains``: case-insensitive substring
210
+ match (Postgres ILIKE). Capped server-side at 200 chars.
211
+
212
+ ``conversation_id``: exact match — narrow to one thread.
213
+
214
+ ``since`` / ``until``: RFC3339 timestamps (``datetime.isoformat()``
215
+ produces a valid value as long as it ends in ``Z`` or has a
216
+ timezone offset). Bracket on ``created_at`` (``>= since`` and
217
+ ``< until``).
218
+ """
181
219
  params: dict[str, str] = {"status": status, "page_size": str(page_size)}
220
+ if sort:
221
+ params["sort"] = sort
222
+ if from_:
223
+ params["from"] = from_
224
+ if subject_contains:
225
+ params["subject_contains"] = subject_contains
226
+ if conversation_id:
227
+ params["conversation_id"] = conversation_id
228
+ if since:
229
+ params["since"] = since
230
+ if until:
231
+ params["until"] = until
182
232
  if token:
183
233
  params["token"] = token
184
234
  resp = self._client.get(
@@ -200,18 +250,25 @@ class E2AApi:
200
250
  agent_email: str,
201
251
  message_id: str,
202
252
  body: ReplyToMessageRequest,
253
+ idempotency_key: Optional[str] = None,
203
254
  ) -> SendEmailResponse:
204
255
  resp = self._client.post(
205
256
  f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
206
257
  json=body.model_dump(by_alias=True, exclude_none=True),
258
+ headers=_idempotency_header(idempotency_key),
207
259
  )
208
260
  _check_response(resp)
209
261
  return SendEmailResponse.model_validate(resp.json())
210
262
 
211
- def send_email(self, body: SendEmailRequest) -> SendEmailResponse:
263
+ def send_email(
264
+ self,
265
+ body: SendEmailRequest,
266
+ idempotency_key: Optional[str] = None,
267
+ ) -> SendEmailResponse:
212
268
  resp = self._client.post(
213
269
  "/api/v1/send",
214
270
  json=body.model_dump(by_alias=True, exclude_none=True),
271
+ headers=_idempotency_header(idempotency_key),
215
272
  )
216
273
  _check_response(resp)
217
274
  return SendEmailResponse.model_validate(resp.json())
@@ -243,17 +300,27 @@ class E2AApi:
243
300
  self,
244
301
  message_id: str,
245
302
  overrides: Optional[ApprovePendingMessageRequest] = None,
303
+ idempotency_key: Optional[str] = None,
246
304
  ) -> ApprovePendingMessageResponse:
247
305
  """Approve a held outbound message.
248
306
 
249
307
  Pass ``overrides`` to approve with edits (any subset of
250
308
  subject / body_text / body_html / to / cc / bcc / attachments).
251
309
  Pass ``None`` (the default) to approve the draft as-is.
310
+
311
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
312
+ Approve fires a real SES send, so supplying a stable key
313
+ derived from the review event makes retries safe (the server
314
+ replays the original response instead of double-sending).
315
+ When omitted the SDK mints a fresh UUIDv4 per call — that
316
+ gives network-layer retry safety only; the per-call default
317
+ does not survive an explicit retry loop.
252
318
  """
253
319
  payload = overrides.model_dump(by_alias=True, exclude_none=True) if overrides else {}
254
320
  resp = self._client.post(
255
321
  f"/api/v1/messages/{quote(message_id, safe='')}/approve",
256
322
  json=payload,
323
+ headers=_idempotency_header(idempotency_key),
257
324
  )
258
325
  _check_response(resp)
259
326
  return ApprovePendingMessageResponse.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,
@@ -164,8 +164,28 @@ class AsyncE2AApi:
164
164
  status: str = "unread",
165
165
  page_size: int = 50,
166
166
  token: Optional[str] = None,
167
+ sort: Optional[str] = None,
168
+ from_: Optional[str] = None,
169
+ subject_contains: Optional[str] = None,
170
+ conversation_id: Optional[str] = None,
171
+ since: Optional[str] = None,
172
+ until: Optional[str] = None,
167
173
  ) -> ListMessagesResponse:
174
+ """Async variant of :meth:`E2AApi.list_messages`. See that
175
+ method for the full filter / sort docs."""
168
176
  params: dict[str, str] = {"status": status, "page_size": str(page_size)}
177
+ if sort:
178
+ params["sort"] = sort
179
+ if from_:
180
+ params["from"] = from_
181
+ if subject_contains:
182
+ params["subject_contains"] = subject_contains
183
+ if conversation_id:
184
+ params["conversation_id"] = conversation_id
185
+ if since:
186
+ params["since"] = since
187
+ if until:
188
+ params["until"] = until
169
189
  if token:
170
190
  params["token"] = token
171
191
  resp = await self._client.get(
@@ -187,18 +207,25 @@ class AsyncE2AApi:
187
207
  agent_email: str,
188
208
  message_id: str,
189
209
  body: ReplyToMessageRequest,
210
+ idempotency_key: Optional[str] = None,
190
211
  ) -> SendEmailResponse:
191
212
  resp = await self._client.post(
192
213
  f"/api/v1/agents/{_encode_email(agent_email)}/messages/{message_id}/reply",
193
214
  json=body.model_dump(by_alias=True, exclude_none=True),
215
+ headers=_idempotency_header(idempotency_key),
194
216
  )
195
217
  _check_response(resp)
196
218
  return SendEmailResponse.model_validate(resp.json())
197
219
 
198
- async def send_email(self, body: SendEmailRequest) -> SendEmailResponse:
220
+ async def send_email(
221
+ self,
222
+ body: SendEmailRequest,
223
+ idempotency_key: Optional[str] = None,
224
+ ) -> SendEmailResponse:
199
225
  resp = await self._client.post(
200
226
  "/api/v1/send",
201
227
  json=body.model_dump(by_alias=True, exclude_none=True),
228
+ headers=_idempotency_header(idempotency_key),
202
229
  )
203
230
  _check_response(resp)
204
231
  return SendEmailResponse.model_validate(resp.json())
@@ -224,11 +251,15 @@ class AsyncE2AApi:
224
251
  self,
225
252
  message_id: str,
226
253
  overrides: Optional[ApprovePendingMessageRequest] = None,
254
+ idempotency_key: Optional[str] = None,
227
255
  ) -> ApprovePendingMessageResponse:
256
+ """Async variant of :meth:`E2AApi.approve_message`. ``idempotency_key``
257
+ closes the SES double-send window — see that method for details."""
228
258
  payload = overrides.model_dump(by_alias=True, exclude_none=True) if overrides else {}
229
259
  resp = await self._client.post(
230
260
  f"/api/v1/messages/{quote(message_id, safe='')}/approve",
231
261
  json=payload,
262
+ headers=_idempotency_header(idempotency_key),
232
263
  )
233
264
  _check_response(resp)
234
265
  return ApprovePendingMessageResponse.model_validate(resp.json())
@@ -382,10 +413,35 @@ class AsyncE2AClient:
382
413
  page_size: int = 50,
383
414
  token: Optional[str] = None,
384
415
  agent_email: Optional[str] = None,
416
+ sort: Optional[str] = None,
417
+ from_: Optional[str] = None,
418
+ subject_contains: Optional[str] = None,
419
+ conversation_id: Optional[str] = None,
420
+ since: Optional[str] = None,
421
+ until: Optional[str] = None,
385
422
  ) -> MessageList:
386
- """Fetch message summaries with ergonomic field names."""
423
+ """Fetch message summaries with ergonomic field names.
424
+
425
+ ``sort`` defaults server-side to ``"desc"`` (newest first). Pass
426
+ ``"asc"`` to drain the inbox in arrival order — FIFO polling.
427
+
428
+ Search filters (``from_``, ``subject_contains``, ``conversation_id``,
429
+ ``since``, ``until``) match the sync client — see
430
+ :meth:`E2AApi.list_messages` for the full reference.
431
+ """
387
432
  email = self._require_agent_email(agent_email)
388
- resp = await self.api.list_messages(email, status=status, page_size=page_size, token=token)
433
+ resp = await self.api.list_messages(
434
+ email,
435
+ status=status,
436
+ page_size=page_size,
437
+ token=token,
438
+ sort=sort,
439
+ from_=from_,
440
+ subject_contains=subject_contains,
441
+ conversation_id=conversation_id,
442
+ since=since,
443
+ until=until,
444
+ )
389
445
  messages = [
390
446
  MessageSummary(
391
447
  message_id=m.message_id or "",
@@ -394,6 +450,7 @@ class AsyncE2AClient:
394
450
  recipient=m.recipient or "",
395
451
  to=list(m.to or []),
396
452
  cc=list(m.cc or []),
453
+ reply_to=list(m.reply_to or []),
397
454
  subject=m.subject or "",
398
455
  status=m.status or "",
399
456
  created_at=m.created_at or "",
@@ -415,8 +472,15 @@ class AsyncE2AClient:
415
472
  conversation_id: Optional[str] = None,
416
473
  attachments: Optional[list[Attachment]] = None,
417
474
  agent_email: Optional[str] = None,
475
+ idempotency_key: Optional[str] = None,
418
476
  ) -> SendResult:
419
- """Reply to an inbound email."""
477
+ """Reply to an inbound email.
478
+
479
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
480
+ Supply a stable key derived from the triggering event (e.g. the
481
+ inbound message id) to make this reply safe to retry; omit to
482
+ let the SDK generate a fresh UUIDv4 per call.
483
+ """
420
484
  email = self._require_agent_email(agent_email)
421
485
  req = ReplyToMessageRequest(
422
486
  body=body,
@@ -427,7 +491,7 @@ class AsyncE2AClient:
427
491
  conversation_id=conversation_id,
428
492
  attachments=_serialize_attachments(attachments),
429
493
  )
430
- resp = await self.api.reply_to_message(email, message_id, req)
494
+ resp = await self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
431
495
  return SendResult(
432
496
  status=resp.status or "",
433
497
  message_id=resp.message_id or "",
@@ -445,8 +509,15 @@ class AsyncE2AClient:
445
509
  conversation_id: Optional[str] = None,
446
510
  attachments: Optional[list[Attachment]] = None,
447
511
  agent_email: Optional[str] = None,
512
+ idempotency_key: Optional[str] = None,
448
513
  ) -> SendResult:
449
- """Send a new email."""
514
+ """Send a new email.
515
+
516
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
517
+ Supply a stable key derived from the triggering event (e.g. a
518
+ job id) to make this send safe to retry; omit to let the SDK
519
+ generate a fresh UUIDv4 per call.
520
+ """
450
521
  email = self._require_agent_email(agent_email)
451
522
  req = SendEmailRequest(
452
523
  to=to,
@@ -459,7 +530,7 @@ class AsyncE2AClient:
459
530
  conversation_id=conversation_id,
460
531
  attachments=_serialize_attachments(attachments),
461
532
  )
462
- resp = await self.api.send_email(req)
533
+ resp = await self.api.send_email(req, idempotency_key=idempotency_key)
463
534
  return SendResult(
464
535
  status=resp.status or "",
465
536
  message_id=resp.message_id or "",
@@ -542,6 +613,7 @@ class AsyncE2AClient:
542
613
  to: Optional[list[str]] = None,
543
614
  cc: Optional[list[str]] = None,
544
615
  bcc: Optional[list[str]] = None,
616
+ idempotency_key: Optional[str] = None,
545
617
  ):
546
618
  any_override = any(
547
619
  v is not None for v in (subject, body_text, body_html, to, cc, bcc)
@@ -558,7 +630,7 @@ class AsyncE2AClient:
558
630
  if any_override
559
631
  else None
560
632
  )
561
- return await self.api.approve_message(message_id, overrides)
633
+ return await self.api.approve_message(message_id, overrides, idempotency_key=idempotency_key)
562
634
 
563
635
  async def reject_message(self, message_id: str, reason: str = ""):
564
636
  return await self.api.reject_message(message_id, reason)
@@ -185,10 +185,36 @@ class E2AClient:
185
185
  page_size: int = 50,
186
186
  token: Optional[str] = None,
187
187
  agent_email: Optional[str] = None,
188
+ sort: Optional[str] = None,
189
+ from_: Optional[str] = None,
190
+ subject_contains: Optional[str] = None,
191
+ conversation_id: Optional[str] = None,
192
+ since: Optional[str] = None,
193
+ until: Optional[str] = None,
188
194
  ) -> MessageList:
189
- """Fetch message summaries with ergonomic field names."""
195
+ """Fetch message summaries with ergonomic field names.
196
+
197
+ ``sort`` defaults server-side to ``"desc"`` (newest first). Pass
198
+ ``"asc"`` to drain the inbox in arrival order — FIFO polling.
199
+
200
+ ``from_`` / ``subject_contains`` are case-insensitive substring
201
+ filters (capped at 200 chars server-side). ``conversation_id``
202
+ exact-matches a thread. ``since`` / ``until`` are RFC3339
203
+ timestamps bounding ``created_at``.
204
+ """
190
205
  email = self._require_agent_email(agent_email)
191
- resp = self.api.list_messages(email, status=status, page_size=page_size, token=token)
206
+ resp = self.api.list_messages(
207
+ email,
208
+ status=status,
209
+ page_size=page_size,
210
+ token=token,
211
+ sort=sort,
212
+ from_=from_,
213
+ subject_contains=subject_contains,
214
+ conversation_id=conversation_id,
215
+ since=since,
216
+ until=until,
217
+ )
192
218
  messages = [
193
219
  MessageSummary(
194
220
  message_id=m.message_id or "",
@@ -197,6 +223,7 @@ class E2AClient:
197
223
  recipient=m.recipient or "",
198
224
  to=list(m.to or []),
199
225
  cc=list(m.cc or []),
226
+ reply_to=list(m.reply_to or []),
200
227
  subject=m.subject or "",
201
228
  status=m.status or "",
202
229
  created_at=m.created_at or "",
@@ -218,8 +245,16 @@ class E2AClient:
218
245
  conversation_id: Optional[str] = None,
219
246
  attachments: Optional[list[Attachment]] = None,
220
247
  agent_email: Optional[str] = None,
248
+ idempotency_key: Optional[str] = None,
221
249
  ) -> SendResult:
222
- """Reply to an inbound email."""
250
+ """Reply to an inbound email.
251
+
252
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
253
+ Supply a stable key derived from the triggering event (e.g. the
254
+ inbound message id) to make this reply safe to retry; omit to
255
+ let the SDK generate a fresh UUIDv4 per call (network-layer
256
+ retry safety only).
257
+ """
223
258
  email = self._require_agent_email(agent_email)
224
259
  req = ReplyToMessageRequest(
225
260
  body=body,
@@ -230,7 +265,7 @@ class E2AClient:
230
265
  conversation_id=conversation_id,
231
266
  attachments=_serialize_attachments(attachments),
232
267
  )
233
- resp = self.api.reply_to_message(email, message_id, req)
268
+ resp = self.api.reply_to_message(email, message_id, req, idempotency_key=idempotency_key)
234
269
  return SendResult(
235
270
  status=resp.status or "",
236
271
  message_id=resp.message_id or "",
@@ -248,8 +283,16 @@ class E2AClient:
248
283
  conversation_id: Optional[str] = None,
249
284
  attachments: Optional[list[Attachment]] = None,
250
285
  agent_email: Optional[str] = None,
286
+ idempotency_key: Optional[str] = None,
251
287
  ) -> SendResult:
252
- """Send a new email."""
288
+ """Send a new email.
289
+
290
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
291
+ Supply a stable key derived from the triggering event (e.g. a
292
+ job id) to make this send safe to retry; omit to let the SDK
293
+ generate a fresh UUIDv4 per call (network-layer retry safety
294
+ only — does not help across an explicit retry loop).
295
+ """
253
296
  email = self._require_agent_email(agent_email)
254
297
  req = SendEmailRequest(
255
298
  to=to,
@@ -262,7 +305,7 @@ class E2AClient:
262
305
  conversation_id=conversation_id,
263
306
  attachments=_serialize_attachments(attachments),
264
307
  )
265
- resp = self.api.send_email(req)
308
+ resp = self.api.send_email(req, idempotency_key=idempotency_key)
266
309
  return SendResult(
267
310
  status=resp.status or "",
268
311
  message_id=resp.message_id or "",
@@ -352,11 +395,19 @@ class E2AClient:
352
395
  to: Optional[list[str]] = None,
353
396
  cc: Optional[list[str]] = None,
354
397
  bcc: Optional[list[str]] = None,
398
+ idempotency_key: Optional[str] = None,
355
399
  ):
356
400
  """Approve a held outbound message.
357
401
 
358
402
  Pass any subset of overrides to approve with edits; pass none
359
403
  to approve as-is.
404
+
405
+ ``idempotency_key`` makes retries safe across the SES double-
406
+ send window. Supply a stable key derived from the review event
407
+ (e.g. the dashboard click id or the pending ``message_id``) to
408
+ make retries dedupe. When omitted the SDK mints a fresh UUIDv4
409
+ per call — that gives network-layer retry safety only; the
410
+ per-call default does not survive an explicit retry loop.
360
411
  """
361
412
  any_override = any(
362
413
  v is not None for v in (subject, body_text, body_html, to, cc, bcc)
@@ -373,7 +424,7 @@ class E2AClient:
373
424
  if any_override
374
425
  else None
375
426
  )
376
- return self.api.approve_message(message_id, overrides)
427
+ return self.api.approve_message(message_id, overrides, idempotency_key=idempotency_key)
377
428
 
378
429
  def reject_message(self, message_id: str, reason: str = ""):
379
430
  """Reject a held outbound message. The optional reason is