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 +20 -0
- e2a-0.1.0/PKG-INFO +197 -0
- e2a-0.1.0/README.md +168 -0
- e2a-0.1.0/pyproject.toml +34 -0
- e2a-0.1.0/src/e2a/__init__.py +12 -0
- e2a-0.1.0/src/e2a/client.py +154 -0
- e2a-0.1.0/src/e2a/models.py +61 -0
- e2a-0.1.0/src/e2a/webhook.py +59 -0
- e2a-0.1.0/tests/__init__.py +0 -0
- e2a-0.1.0/tests/test_client.py +143 -0
- e2a-0.1.0/tests/test_webhook.py +100 -0
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`
|
e2a-0.1.0/pyproject.toml
ADDED
|
@@ -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
|