e2a 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
e2a-0.1.0/.gitignore ADDED
@@ -0,0 +1,20 @@
1
+ bin/
2
+ /e2a
3
+ *.exe
4
+ .env
5
+ .DS_Store
6
+ config.local.yaml
7
+ client_secret_*.json
8
+
9
+ # Web app
10
+ web/node_modules/
11
+ web/.next/
12
+ web/out/
13
+ web/.env.local
14
+
15
+ # Python SDK
16
+ sdks/python/.venv/
17
+ sdks/python/__pycache__/
18
+ sdks/python/src/*.egg-info/
19
+ sdks/python/.pytest_cache/
20
+ *.pyc
e2a-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: e2a
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the e2a protocol — email-to-agent authentication
5
+ Project-URL: Homepage, https://e2a.dev
6
+ Project-URL: Repository, https://github.com/Mnexa-AI/e2a
7
+ Project-URL: Documentation, https://e2a.dev
8
+ Author-email: Mnexa AI <josh@mnexa.ai>
9
+ License-Expression: MIT
10
+ Keywords: agent,authentication,e2a,email,webhook
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Communications :: Email
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.24
23
+ Provides-Extra: dev
24
+ Requires-Dist: build; extra == 'dev'
25
+ Requires-Dist: pytest-httpx; extra == 'dev'
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Requires-Dist: twine; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # e2a Python SDK
31
+
32
+ Python SDK for the [e2a protocol](https://e2a.dev) — email-to-agent authentication.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install e2a
38
+ ```
39
+
40
+ ## Quick start
41
+
42
+ ### Verify and parse incoming webhooks
43
+
44
+ ```python
45
+ from e2a import E2AClient, parse_payload
46
+
47
+ client = E2AClient(
48
+ api_key="e2a_your_api_key",
49
+ signing_key="e2a_your_signing_key",
50
+ )
51
+
52
+ # In your webhook handler (Flask, FastAPI, etc.)
53
+ def handle_webhook(request):
54
+ body = request.get_data()
55
+ signature = request.headers["X-E2A-Webhook-Signature"]
56
+
57
+ if not client.verify_webhook(body, signature):
58
+ return "invalid signature", 401
59
+
60
+ payload = parse_payload(request.json, dict(request.headers))
61
+
62
+ print(f"From: {payload.sender}")
63
+ print(f"To: {payload.recipient}")
64
+ print(f"Verified: {payload.auth.verified}")
65
+ print(f"Message ID: {payload.message_id}")
66
+ ```
67
+
68
+ ### Reply to an email
69
+
70
+ ```python
71
+ # Use the message_id from the webhook payload
72
+ result = client.reply(
73
+ message_id=payload.message_id,
74
+ body="Thanks for your email!",
75
+ html_body="<p>Thanks for your email!</p>", # optional
76
+ )
77
+ print(f"Sent via {result.method}, ID: {result.message_id}")
78
+ ```
79
+
80
+ ### Send a new email
81
+
82
+ ```python
83
+ result = client.send(
84
+ to="alice@example.com",
85
+ subject="Hello from my agent",
86
+ body="This is a message from an AI agent.",
87
+ )
88
+ ```
89
+
90
+ ## Conversation threading
91
+
92
+ e2a supports an opaque `conversation_id` that lets your agent track multi-turn
93
+ email threads. This is useful when your agent system has its own concept of
94
+ conversations and needs to route follow-up emails to the right one.
95
+
96
+ **How it works:**
97
+
98
+ 1. When your agent replies or sends an email, pass a `conversation_id`.
99
+ e2a stores a mapping from the outgoing email's Message-ID to your conversation ID.
100
+
101
+ 2. When a human replies to that email, e2a matches the `In-Reply-To` header
102
+ against stored Message-IDs and includes `conversation_id` in the webhook payload.
103
+
104
+ 3. For first-contact emails (no prior thread), `conversation_id` is `None`.
105
+
106
+ ```python
107
+ @app.post("/webhook")
108
+ async def webhook(request: Request):
109
+ body = await request.body()
110
+ signature = request.headers.get("X-E2A-Webhook-Signature", "")
111
+
112
+ if not e2a.verify_webhook(body, signature):
113
+ raise HTTPException(401, "invalid signature")
114
+
115
+ payload = parse_payload(await request.json(), dict(request.headers))
116
+
117
+ if payload.conversation_id:
118
+ # This is a follow-up — route to the existing conversation
119
+ conversation = get_conversation(payload.conversation_id)
120
+ else:
121
+ # First contact — create a new conversation
122
+ conversation = create_conversation(sender=payload.sender)
123
+
124
+ response = conversation.generate_reply(payload)
125
+
126
+ # Tag the reply with your conversation ID so future emails are linked
127
+ e2a.reply(
128
+ payload.message_id,
129
+ body=response.text,
130
+ html_body=response.html,
131
+ conversation_id=conversation.id,
132
+ )
133
+
134
+ return {"ok": True}
135
+ ```
136
+
137
+ The same works for `client.send()` — pass `conversation_id` when initiating an
138
+ outbound email, and any reply to it will arrive with that ID in the webhook.
139
+
140
+ ```python
141
+ result = client.send(
142
+ to="alice@example.com",
143
+ subject="Following up",
144
+ body="Hi Alice, just checking in.",
145
+ conversation_id="conv_abc123",
146
+ )
147
+ # When Alice replies, the webhook will include conversation_id="conv_abc123"
148
+ ```
149
+
150
+ ### FastAPI example
151
+
152
+ ```python
153
+ from fastapi import FastAPI, Request, HTTPException
154
+ from e2a import E2AClient, parse_payload
155
+
156
+ app = FastAPI()
157
+ e2a = E2AClient(api_key="e2a_...", signing_key="e2a_...")
158
+
159
+ @app.post("/webhook")
160
+ async def webhook(request: Request):
161
+ body = await request.body()
162
+ signature = request.headers.get("X-E2A-Webhook-Signature", "")
163
+
164
+ if not e2a.verify_webhook(body, signature):
165
+ raise HTTPException(401, "invalid signature")
166
+
167
+ data = await request.json()
168
+ payload = parse_payload(data, dict(request.headers))
169
+
170
+ # Process the email...
171
+ response = process_email(payload)
172
+
173
+ # Reply
174
+ if payload.message_id:
175
+ e2a.reply(payload.message_id, body=response)
176
+
177
+ return {"ok": True}
178
+ ```
179
+
180
+ ## API Reference
181
+
182
+ ### `E2AClient(api_key, signing_key, base_url="https://e2a.dev")`
183
+
184
+ - `client.reply(message_id, body, html_body=None, conversation_id=None)` → `SendResult`
185
+ - `client.send(to, subject, body, content_type=None, conversation_id=None)` → `SendResult`
186
+ - `client.verify_webhook(body, signature)` → `bool`
187
+
188
+ ### `verify_signature(body, signature, signing_key)` → `bool`
189
+
190
+ ### `parse_payload(data, headers)` → `WebhookPayload`
191
+
192
+ ### Models
193
+
194
+ - `WebhookPayload` — `message_id`, `conversation_id`, `sender`, `recipient`, `raw_message`, `auth`, `received_at`
195
+ - `AuthHeaders` — `verified`, `sender`, `entity_type`, `domain_check`, `agent_id`, `human_id`
196
+ - `SendResult` — `status`, `message_id`, `method`
197
+ - `E2AError` — raised on API errors, has `status_code` and `message`
e2a-0.1.0/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # e2a Python SDK
2
+
3
+ Python SDK for the [e2a protocol](https://e2a.dev) — email-to-agent authentication.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install e2a
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ### Verify and parse incoming webhooks
14
+
15
+ ```python
16
+ from e2a import E2AClient, parse_payload
17
+
18
+ client = E2AClient(
19
+ api_key="e2a_your_api_key",
20
+ signing_key="e2a_your_signing_key",
21
+ )
22
+
23
+ # In your webhook handler (Flask, FastAPI, etc.)
24
+ def handle_webhook(request):
25
+ body = request.get_data()
26
+ signature = request.headers["X-E2A-Webhook-Signature"]
27
+
28
+ if not client.verify_webhook(body, signature):
29
+ return "invalid signature", 401
30
+
31
+ payload = parse_payload(request.json, dict(request.headers))
32
+
33
+ print(f"From: {payload.sender}")
34
+ print(f"To: {payload.recipient}")
35
+ print(f"Verified: {payload.auth.verified}")
36
+ print(f"Message ID: {payload.message_id}")
37
+ ```
38
+
39
+ ### Reply to an email
40
+
41
+ ```python
42
+ # Use the message_id from the webhook payload
43
+ result = client.reply(
44
+ message_id=payload.message_id,
45
+ body="Thanks for your email!",
46
+ html_body="<p>Thanks for your email!</p>", # optional
47
+ )
48
+ print(f"Sent via {result.method}, ID: {result.message_id}")
49
+ ```
50
+
51
+ ### Send a new email
52
+
53
+ ```python
54
+ result = client.send(
55
+ to="alice@example.com",
56
+ subject="Hello from my agent",
57
+ body="This is a message from an AI agent.",
58
+ )
59
+ ```
60
+
61
+ ## Conversation threading
62
+
63
+ e2a supports an opaque `conversation_id` that lets your agent track multi-turn
64
+ email threads. This is useful when your agent system has its own concept of
65
+ conversations and needs to route follow-up emails to the right one.
66
+
67
+ **How it works:**
68
+
69
+ 1. When your agent replies or sends an email, pass a `conversation_id`.
70
+ e2a stores a mapping from the outgoing email's Message-ID to your conversation ID.
71
+
72
+ 2. When a human replies to that email, e2a matches the `In-Reply-To` header
73
+ against stored Message-IDs and includes `conversation_id` in the webhook payload.
74
+
75
+ 3. For first-contact emails (no prior thread), `conversation_id` is `None`.
76
+
77
+ ```python
78
+ @app.post("/webhook")
79
+ async def webhook(request: Request):
80
+ body = await request.body()
81
+ signature = request.headers.get("X-E2A-Webhook-Signature", "")
82
+
83
+ if not e2a.verify_webhook(body, signature):
84
+ raise HTTPException(401, "invalid signature")
85
+
86
+ payload = parse_payload(await request.json(), dict(request.headers))
87
+
88
+ if payload.conversation_id:
89
+ # This is a follow-up — route to the existing conversation
90
+ conversation = get_conversation(payload.conversation_id)
91
+ else:
92
+ # First contact — create a new conversation
93
+ conversation = create_conversation(sender=payload.sender)
94
+
95
+ response = conversation.generate_reply(payload)
96
+
97
+ # Tag the reply with your conversation ID so future emails are linked
98
+ e2a.reply(
99
+ payload.message_id,
100
+ body=response.text,
101
+ html_body=response.html,
102
+ conversation_id=conversation.id,
103
+ )
104
+
105
+ return {"ok": True}
106
+ ```
107
+
108
+ The same works for `client.send()` — pass `conversation_id` when initiating an
109
+ outbound email, and any reply to it will arrive with that ID in the webhook.
110
+
111
+ ```python
112
+ result = client.send(
113
+ to="alice@example.com",
114
+ subject="Following up",
115
+ body="Hi Alice, just checking in.",
116
+ conversation_id="conv_abc123",
117
+ )
118
+ # When Alice replies, the webhook will include conversation_id="conv_abc123"
119
+ ```
120
+
121
+ ### FastAPI example
122
+
123
+ ```python
124
+ from fastapi import FastAPI, Request, HTTPException
125
+ from e2a import E2AClient, parse_payload
126
+
127
+ app = FastAPI()
128
+ e2a = E2AClient(api_key="e2a_...", signing_key="e2a_...")
129
+
130
+ @app.post("/webhook")
131
+ async def webhook(request: Request):
132
+ body = await request.body()
133
+ signature = request.headers.get("X-E2A-Webhook-Signature", "")
134
+
135
+ if not e2a.verify_webhook(body, signature):
136
+ raise HTTPException(401, "invalid signature")
137
+
138
+ data = await request.json()
139
+ payload = parse_payload(data, dict(request.headers))
140
+
141
+ # Process the email...
142
+ response = process_email(payload)
143
+
144
+ # Reply
145
+ if payload.message_id:
146
+ e2a.reply(payload.message_id, body=response)
147
+
148
+ return {"ok": True}
149
+ ```
150
+
151
+ ## API Reference
152
+
153
+ ### `E2AClient(api_key, signing_key, base_url="https://e2a.dev")`
154
+
155
+ - `client.reply(message_id, body, html_body=None, conversation_id=None)` → `SendResult`
156
+ - `client.send(to, subject, body, content_type=None, conversation_id=None)` → `SendResult`
157
+ - `client.verify_webhook(body, signature)` → `bool`
158
+
159
+ ### `verify_signature(body, signature, signing_key)` → `bool`
160
+
161
+ ### `parse_payload(data, headers)` → `WebhookPayload`
162
+
163
+ ### Models
164
+
165
+ - `WebhookPayload` — `message_id`, `conversation_id`, `sender`, `recipient`, `raw_message`, `auth`, `received_at`
166
+ - `AuthHeaders` — `verified`, `sender`, `entity_type`, `domain_check`, `agent_id`, `human_id`
167
+ - `SendResult` — `status`, `message_id`, `method`
168
+ - `E2AError` — raised on API errors, has `status_code` and `message`
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "e2a"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the e2a protocol — email-to-agent authentication"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Mnexa AI", email = "josh@mnexa.ai" }]
13
+ keywords = ["email", "agent", "authentication", "e2a", "webhook"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Communications :: Email",
25
+ ]
26
+ dependencies = ["httpx>=0.24"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://e2a.dev"
30
+ Repository = "https://github.com/Mnexa-AI/e2a"
31
+ Documentation = "https://e2a.dev"
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=7", "pytest-httpx", "build", "twine"]
@@ -0,0 +1,12 @@
1
+ from e2a.client import E2AClient
2
+ from e2a.models import WebhookPayload, AuthHeaders, SendResult
3
+ from e2a.webhook import verify_signature, parse_payload
4
+
5
+ __all__ = [
6
+ "E2AClient",
7
+ "WebhookPayload",
8
+ "AuthHeaders",
9
+ "SendResult",
10
+ "verify_signature",
11
+ "parse_payload",
12
+ ]
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import httpx
6
+
7
+ from e2a.models import SendResult
8
+
9
+
10
+ class E2AClient:
11
+ """Client for the e2a API.
12
+
13
+ Args:
14
+ api_key: Your agent's API key (starts with ``e2a_``).
15
+ signing_key: Your agent's signing key for verifying webhooks.
16
+ base_url: e2a API base URL. Defaults to ``https://e2a.dev``.
17
+ timeout: Request timeout in seconds. Defaults to 30.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: str,
23
+ signing_key: str,
24
+ base_url: str = "https://e2a.dev",
25
+ timeout: float = 30,
26
+ ) -> None:
27
+ self.api_key = api_key
28
+ self.signing_key = signing_key
29
+ self.base_url = base_url.rstrip("/")
30
+ self._client = httpx.Client(
31
+ base_url=self.base_url,
32
+ headers={"Authorization": f"Bearer {api_key}"},
33
+ timeout=timeout,
34
+ )
35
+
36
+ def reply(
37
+ self,
38
+ message_id: str,
39
+ body: str,
40
+ html_body: Optional[str] = None,
41
+ conversation_id: Optional[str] = None,
42
+ ) -> SendResult:
43
+ """Reply to an inbound email.
44
+
45
+ Args:
46
+ message_id: The ``message_id`` from the webhook payload.
47
+ body: Plain-text reply body.
48
+ html_body: Optional HTML body for rich replies.
49
+ conversation_id: Optional opaque ID for your conversation/thread.
50
+ When provided, e2a stores a mapping so that follow-up emails
51
+ in the same thread will include this ID in the webhook payload.
52
+
53
+ Returns:
54
+ A SendResult with status, message_id, and delivery method.
55
+
56
+ Raises:
57
+ E2AError: If the API returns an error.
58
+ """
59
+ payload: dict = {"body": body}
60
+ if html_body:
61
+ payload["html_body"] = html_body
62
+ if conversation_id:
63
+ payload["conversation_id"] = conversation_id
64
+
65
+ resp = self._client.post(f"/api/messages/{message_id}/reply", json=payload)
66
+ _check_response(resp)
67
+ data = resp.json()
68
+ return SendResult(
69
+ status=data["status"],
70
+ message_id=data["message_id"],
71
+ method=data["method"],
72
+ )
73
+
74
+ def send(
75
+ self,
76
+ to: str,
77
+ subject: str,
78
+ body: str,
79
+ content_type: Optional[str] = None,
80
+ conversation_id: Optional[str] = None,
81
+ ) -> SendResult:
82
+ """Send a new email.
83
+
84
+ Args:
85
+ to: Recipient email address.
86
+ subject: Email subject.
87
+ body: Email body.
88
+ content_type: MIME content type (defaults to text/plain).
89
+ conversation_id: Optional opaque ID for your conversation/thread.
90
+ When provided, e2a stores a mapping so that replies to this
91
+ email will include this ID in the webhook payload.
92
+
93
+ Returns:
94
+ A SendResult with status, message_id, and delivery method.
95
+
96
+ Raises:
97
+ E2AError: If the API returns an error.
98
+ """
99
+ payload: dict = {"to": to, "subject": subject, "body": body}
100
+ if content_type:
101
+ payload["content_type"] = content_type
102
+ if conversation_id:
103
+ payload["conversation_id"] = conversation_id
104
+
105
+ resp = self._client.post("/api/send", json=payload)
106
+ _check_response(resp)
107
+ data = resp.json()
108
+ return SendResult(
109
+ status=data["status"],
110
+ message_id=data["message_id"],
111
+ method=data["method"],
112
+ )
113
+
114
+ def verify_webhook(self, body: bytes, signature: str) -> bool:
115
+ """Verify a webhook signature using this client's signing key.
116
+
117
+ Args:
118
+ body: Raw request body bytes.
119
+ signature: Value of the X-E2A-Webhook-Signature header.
120
+
121
+ Returns:
122
+ True if the signature is valid.
123
+ """
124
+ from e2a.webhook import verify_signature
125
+
126
+ return verify_signature(body, signature, self.signing_key)
127
+
128
+ def close(self) -> None:
129
+ """Close the underlying HTTP client."""
130
+ self._client.close()
131
+
132
+ def __enter__(self) -> E2AClient:
133
+ return self
134
+
135
+ def __exit__(self, *args: object) -> None:
136
+ self.close()
137
+
138
+
139
+ class E2AError(Exception):
140
+ """Raised when the e2a API returns an error."""
141
+
142
+ def __init__(self, status_code: int, message: str) -> None:
143
+ self.status_code = status_code
144
+ self.message = message
145
+ super().__init__(f"e2a API error ({status_code}): {message}")
146
+
147
+
148
+ def _check_response(resp: httpx.Response) -> None:
149
+ if resp.status_code >= 400:
150
+ try:
151
+ message = resp.text.strip()
152
+ except Exception:
153
+ message = f"HTTP {resp.status_code}"
154
+ raise E2AError(resp.status_code, message)
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class AuthHeaders:
10
+ """Parsed e2a authentication headers from a webhook request."""
11
+
12
+ verified: bool
13
+ sender: str
14
+ entity_type: str # "human" or "agent"
15
+ domain_check: str
16
+ agent_id: str
17
+ human_id: str
18
+
19
+ @classmethod
20
+ def from_headers(cls, headers: dict[str, str]) -> AuthHeaders:
21
+ return cls(
22
+ verified=headers.get("X-E2A-Auth-Verified", "").lower() == "true",
23
+ sender=headers.get("X-E2A-Auth-Sender", ""),
24
+ entity_type=headers.get("X-E2A-Auth-Entity-Type", ""),
25
+ domain_check=headers.get("X-E2A-Auth-Domain-Check", ""),
26
+ agent_id=headers.get("X-E2A-Auth-Agent-Id", ""),
27
+ human_id=headers.get("X-E2A-Auth-Human-Id", ""),
28
+ )
29
+
30
+
31
+ @dataclass
32
+ class WebhookPayload:
33
+ """Parsed webhook payload from e2a.
34
+
35
+ Attributes:
36
+ message_id: Unique e2a message identifier (use this to reply).
37
+ sender: Sender email address.
38
+ recipient: Recipient email address (your agent's address).
39
+ raw_message: Raw RFC 2822 email bytes.
40
+ auth: Parsed authentication headers (verified status, sender identity, etc.).
41
+ received_at: When e2a received the email.
42
+ conversation_id: Your opaque conversation/thread ID, if a prior reply in this
43
+ thread included one. ``None`` for first-contact emails with no thread history.
44
+ """
45
+
46
+ message_id: str
47
+ sender: str
48
+ recipient: str
49
+ raw_message: bytes
50
+ auth: AuthHeaders
51
+ received_at: Optional[datetime] = None
52
+ conversation_id: Optional[str] = None
53
+
54
+
55
+ @dataclass
56
+ class SendResult:
57
+ """Result from sending an email or reply."""
58
+
59
+ status: str
60
+ message_id: str
61
+ method: str # "smtp" or "webhook"
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from e2a.models import AuthHeaders, WebhookPayload
10
+
11
+
12
+ def verify_signature(body: bytes, signature: str, signing_key: str) -> bool:
13
+ """Verify the HMAC-SHA256 signature on a webhook request body.
14
+
15
+ Args:
16
+ body: Raw request body bytes.
17
+ signature: Value of the X-E2A-Webhook-Signature header.
18
+ signing_key: Your agent's signing key.
19
+
20
+ Returns:
21
+ True if the signature is valid.
22
+ """
23
+ expected = hmac.new(signing_key.encode(), body, hashlib.sha256).hexdigest()
24
+ return hmac.compare_digest(expected, signature)
25
+
26
+
27
+ def parse_payload(data: dict[str, Any], headers: dict[str, str]) -> WebhookPayload:
28
+ """Parse a webhook JSON body and request headers into a WebhookPayload.
29
+
30
+ Args:
31
+ data: Parsed JSON body from the webhook request.
32
+ headers: HTTP request headers (used for auth headers).
33
+
34
+ Returns:
35
+ A WebhookPayload with parsed fields.
36
+ """
37
+ raw_message = b""
38
+ if data.get("raw_message"):
39
+ try:
40
+ raw_message = base64.b64decode(data["raw_message"])
41
+ except Exception:
42
+ raw_message = data["raw_message"].encode() if isinstance(data["raw_message"], str) else b""
43
+
44
+ received_at = None
45
+ if data.get("received_at"):
46
+ try:
47
+ received_at = datetime.fromisoformat(data["received_at"].replace("Z", "+00:00"))
48
+ except (ValueError, AttributeError):
49
+ pass
50
+
51
+ return WebhookPayload(
52
+ message_id=data.get("message_id", ""),
53
+ sender=data.get("from", ""),
54
+ recipient=data.get("to", ""),
55
+ raw_message=raw_message,
56
+ auth=AuthHeaders.from_headers(headers),
57
+ received_at=received_at,
58
+ conversation_id=data.get("conversation_id"),
59
+ )
File without changes
@@ -0,0 +1,143 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from e2a.client import E2AClient, E2AError
7
+
8
+
9
+ def test_reply_success(httpx_mock):
10
+ httpx_mock.add_response(
11
+ url="https://e2a.dev/api/messages/msg_123/reply",
12
+ method="POST",
13
+ json={"status": "sent", "message_id": "reply_456", "method": "smtp"},
14
+ )
15
+
16
+ with E2AClient(api_key="e2a_test", signing_key="sign_test") as client:
17
+ result = client.reply("msg_123", "Thanks!")
18
+
19
+ assert result.status == "sent"
20
+ assert result.message_id == "reply_456"
21
+ assert result.method == "smtp"
22
+
23
+ request = httpx_mock.get_request()
24
+ assert request.headers["Authorization"] == "Bearer e2a_test"
25
+ body = json.loads(request.content)
26
+ assert body == {"body": "Thanks!"}
27
+
28
+
29
+ def test_reply_with_html(httpx_mock):
30
+ httpx_mock.add_response(
31
+ url="https://e2a.dev/api/messages/msg_123/reply",
32
+ method="POST",
33
+ json={"status": "sent", "message_id": "reply_789", "method": "smtp"},
34
+ )
35
+
36
+ with E2AClient(api_key="e2a_test", signing_key="sign_test") as client:
37
+ result = client.reply("msg_123", "Thanks!", html_body="<p>Thanks!</p>")
38
+
39
+ body = json.loads(httpx_mock.get_request().content)
40
+ assert body == {"body": "Thanks!", "html_body": "<p>Thanks!</p>"}
41
+
42
+
43
+ def test_reply_not_found(httpx_mock):
44
+ httpx_mock.add_response(
45
+ url="https://e2a.dev/api/messages/msg_bad/reply",
46
+ method="POST",
47
+ status_code=404,
48
+ text="message not found",
49
+ )
50
+
51
+ with E2AClient(api_key="e2a_test", signing_key="sign_test") as client:
52
+ with pytest.raises(E2AError) as exc_info:
53
+ client.reply("msg_bad", "Hello")
54
+
55
+ assert exc_info.value.status_code == 404
56
+ assert "message not found" in exc_info.value.message
57
+
58
+
59
+ def test_send_success(httpx_mock):
60
+ httpx_mock.add_response(
61
+ url="https://e2a.dev/api/send",
62
+ method="POST",
63
+ json={"status": "sent", "message_id": "send_abc", "method": "webhook"},
64
+ )
65
+
66
+ with E2AClient(api_key="e2a_test", signing_key="sign_test") as client:
67
+ result = client.send("alice@example.com", "Hello", "Hi Alice")
68
+
69
+ assert result.status == "sent"
70
+ assert result.method == "webhook"
71
+
72
+ body = json.loads(httpx_mock.get_request().content)
73
+ assert body == {"to": "alice@example.com", "subject": "Hello", "body": "Hi Alice"}
74
+
75
+
76
+ def test_send_unauthorized(httpx_mock):
77
+ httpx_mock.add_response(
78
+ url="https://e2a.dev/api/send",
79
+ method="POST",
80
+ status_code=401,
81
+ text="unauthorized",
82
+ )
83
+
84
+ with E2AClient(api_key="bad_key", signing_key="sign_test") as client:
85
+ with pytest.raises(E2AError) as exc_info:
86
+ client.send("alice@example.com", "Hello", "Hi")
87
+
88
+ assert exc_info.value.status_code == 401
89
+
90
+
91
+ def test_verify_webhook():
92
+ import hashlib
93
+ import hmac
94
+
95
+ body = b'{"from":"alice@example.com"}'
96
+ key = "sign_test"
97
+ sig = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
98
+
99
+ client = E2AClient(api_key="e2a_test", signing_key=key)
100
+ assert client.verify_webhook(body, sig) is True
101
+ assert client.verify_webhook(body, "wrong") is False
102
+ client.close()
103
+
104
+
105
+ def test_reply_with_conversation_id(httpx_mock):
106
+ httpx_mock.add_response(
107
+ url="https://e2a.dev/api/messages/msg_123/reply",
108
+ method="POST",
109
+ json={"status": "sent", "message_id": "reply_cid", "method": "smtp"},
110
+ )
111
+
112
+ with E2AClient(api_key="e2a_test", signing_key="sign_test") as client:
113
+ result = client.reply("msg_123", "Thanks!", conversation_id="conv_abc")
114
+
115
+ body = json.loads(httpx_mock.get_request().content)
116
+ assert body == {"body": "Thanks!", "conversation_id": "conv_abc"}
117
+
118
+
119
+ def test_send_with_conversation_id(httpx_mock):
120
+ httpx_mock.add_response(
121
+ url="https://e2a.dev/api/send",
122
+ method="POST",
123
+ json={"status": "sent", "message_id": "send_cid", "method": "smtp"},
124
+ )
125
+
126
+ with E2AClient(api_key="e2a_test", signing_key="sign_test") as client:
127
+ result = client.send("alice@example.com", "Hello", "Hi", conversation_id="conv_xyz")
128
+
129
+ body = json.loads(httpx_mock.get_request().content)
130
+ assert body["conversation_id"] == "conv_xyz"
131
+
132
+
133
+ def test_custom_base_url(httpx_mock):
134
+ httpx_mock.add_response(
135
+ url="http://localhost:8080/api/send",
136
+ method="POST",
137
+ json={"status": "sent", "message_id": "local_123", "method": "smtp"},
138
+ )
139
+
140
+ with E2AClient(api_key="e2a_test", signing_key="s", base_url="http://localhost:8080") as client:
141
+ result = client.send("alice@example.com", "Test", "Body")
142
+
143
+ assert result.message_id == "local_123"
@@ -0,0 +1,100 @@
1
+ import hashlib
2
+ import hmac
3
+
4
+ from e2a.webhook import verify_signature, parse_payload
5
+
6
+
7
+ def test_verify_signature_valid():
8
+ body = b'{"from":"alice@example.com"}'
9
+ key = "test-signing-key"
10
+ sig = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
11
+
12
+ assert verify_signature(body, sig, key) is True
13
+
14
+
15
+ def test_verify_signature_invalid():
16
+ body = b'{"from":"alice@example.com"}'
17
+ assert verify_signature(body, "bad-signature", "test-key") is False
18
+
19
+
20
+ def test_verify_signature_tampered_body():
21
+ body = b'{"from":"alice@example.com"}'
22
+ key = "test-key"
23
+ sig = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
24
+
25
+ tampered = b'{"from":"evil@example.com"}'
26
+ assert verify_signature(tampered, sig, key) is False
27
+
28
+
29
+ def test_parse_payload_full():
30
+ data = {
31
+ "message_id": "msg_abc123",
32
+ "from": "alice@gmail.com",
33
+ "to": "bot@agent.example.com",
34
+ "raw_message": "SGVsbG8=", # base64("Hello")
35
+ "received_at": "2026-03-22T10:30:00Z",
36
+ }
37
+ headers = {
38
+ "X-E2A-Auth-Verified": "true",
39
+ "X-E2A-Auth-Sender": "alice@gmail.com",
40
+ "X-E2A-Auth-Entity-Type": "human",
41
+ "X-E2A-Auth-Domain-Check": "spf=pass dkim=pass",
42
+ "X-E2A-Auth-Agent-Id": "agent_123",
43
+ "X-E2A-Auth-Human-Id": "human_456",
44
+ }
45
+
46
+ payload = parse_payload(data, headers)
47
+
48
+ assert payload.message_id == "msg_abc123"
49
+ assert payload.sender == "alice@gmail.com"
50
+ assert payload.recipient == "bot@agent.example.com"
51
+ assert payload.raw_message == b"Hello"
52
+ assert payload.received_at is not None
53
+ assert payload.auth.verified is True
54
+ assert payload.auth.sender == "alice@gmail.com"
55
+ assert payload.auth.entity_type == "human"
56
+ assert payload.auth.agent_id == "agent_123"
57
+ assert payload.auth.human_id == "human_456"
58
+
59
+
60
+ def test_parse_payload_minimal():
61
+ data = {"from": "alice@gmail.com", "to": "bot@example.com"}
62
+ headers = {}
63
+
64
+ payload = parse_payload(data, headers)
65
+
66
+ assert payload.message_id == ""
67
+ assert payload.sender == "alice@gmail.com"
68
+ assert payload.raw_message == b""
69
+ assert payload.auth.verified is False
70
+
71
+
72
+ def test_parse_payload_missing_message_id():
73
+ data = {"from": "alice@gmail.com", "to": "bot@example.com", "raw_message": "SGVsbG8="}
74
+ headers = {"X-E2A-Auth-Verified": "false"}
75
+
76
+ payload = parse_payload(data, headers)
77
+
78
+ assert payload.message_id == ""
79
+ assert payload.auth.verified is False
80
+
81
+
82
+ def test_parse_payload_with_conversation_id():
83
+ data = {
84
+ "message_id": "msg_abc",
85
+ "conversation_id": "conv_123",
86
+ "from": "alice@gmail.com",
87
+ "to": "bot@example.com",
88
+ }
89
+
90
+ payload = parse_payload(data, {})
91
+
92
+ assert payload.conversation_id == "conv_123"
93
+
94
+
95
+ def test_parse_payload_without_conversation_id():
96
+ data = {"from": "alice@gmail.com", "to": "bot@example.com"}
97
+
98
+ payload = parse_payload(data, {})
99
+
100
+ assert payload.conversation_id is None