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.
- e2a-2.3.0/CHANGELOG.md +86 -0
- {e2a-2.2.0 → e2a-2.3.0}/PKG-INFO +4 -3
- {e2a-2.2.0 → e2a-2.3.0}/README.md +3 -2
- {e2a-2.2.0 → e2a-2.3.0}/codegen-requirements.txt +4 -4
- {e2a-2.2.0 → e2a-2.3.0}/pyproject.toml +1 -1
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/api.py +22 -1
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/async_client.py +28 -6
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/client.py +21 -4
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/generated/__init__.py +102 -1
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/generated/_internal.py +2 -0
- e2a-2.3.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +127 -0
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/handler.py +103 -3
- e2a-2.3.0/tests/test_idempotency.py +130 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_handler.py +88 -0
- e2a-2.2.0/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -79
- {e2a-2.2.0 → e2a-2.3.0}/.gitignore +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/__init__.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/__init__.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/generated/internal_agent.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/src/e2a/v1/websocket.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/__init__.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_contract.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_e2e.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_exports.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_generated_models.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_api.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_async_client.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_client.py +0 -0
- {e2a-2.2.0 → e2a-2.3.0}/tests/test_v1_websocket.py +0 -0
- {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.
|
{e2a-2.2.0 → e2a-2.3.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: e2a
|
|
3
|
-
Version: 2.
|
|
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
|
|
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
|
|
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.
|
|
15
|
+
pathspec==1.1.1
|
|
16
16
|
platformdirs==4.9.4
|
|
17
|
-
pydantic==2.13.
|
|
18
|
-
pydantic_core==2.46.
|
|
17
|
+
pydantic==2.13.4
|
|
18
|
+
pydantic_core==2.46.4
|
|
19
19
|
pytokens==0.4.1
|
|
20
|
-
typeguard==4.5.
|
|
20
|
+
typeguard==4.5.2
|
|
21
21
|
typing-inspection==0.4.2
|
|
22
22
|
typing_extensions==4.15.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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
{e2a-2.2.0 → e2a-2.3.0}/uv.lock
RENAMED
|
File without changes
|