nornweave 0.1.2__py3-none-any.whl
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.
- nornweave/__init__.py +3 -0
- nornweave/adapters/__init__.py +1 -0
- nornweave/adapters/base.py +5 -0
- nornweave/adapters/mailgun.py +196 -0
- nornweave/adapters/resend.py +510 -0
- nornweave/adapters/sendgrid.py +492 -0
- nornweave/adapters/ses.py +824 -0
- nornweave/cli.py +186 -0
- nornweave/core/__init__.py +26 -0
- nornweave/core/config.py +172 -0
- nornweave/core/exceptions.py +25 -0
- nornweave/core/interfaces.py +390 -0
- nornweave/core/storage.py +192 -0
- nornweave/core/utils.py +23 -0
- nornweave/huginn/__init__.py +10 -0
- nornweave/huginn/client.py +296 -0
- nornweave/huginn/config.py +52 -0
- nornweave/huginn/resources.py +165 -0
- nornweave/huginn/server.py +202 -0
- nornweave/models/__init__.py +113 -0
- nornweave/models/attachment.py +136 -0
- nornweave/models/event.py +275 -0
- nornweave/models/inbox.py +33 -0
- nornweave/models/message.py +284 -0
- nornweave/models/thread.py +172 -0
- nornweave/muninn/__init__.py +14 -0
- nornweave/muninn/tools.py +207 -0
- nornweave/search/__init__.py +1 -0
- nornweave/search/embeddings.py +1 -0
- nornweave/search/vector_store.py +1 -0
- nornweave/skuld/__init__.py +1 -0
- nornweave/skuld/rate_limiter.py +1 -0
- nornweave/skuld/scheduler.py +1 -0
- nornweave/skuld/sender.py +25 -0
- nornweave/skuld/webhooks.py +1 -0
- nornweave/storage/__init__.py +20 -0
- nornweave/storage/database.py +165 -0
- nornweave/storage/gcs.py +144 -0
- nornweave/storage/local.py +152 -0
- nornweave/storage/s3.py +164 -0
- nornweave/urdr/__init__.py +14 -0
- nornweave/urdr/adapters/__init__.py +16 -0
- nornweave/urdr/adapters/base.py +385 -0
- nornweave/urdr/adapters/postgres.py +50 -0
- nornweave/urdr/adapters/sqlite.py +51 -0
- nornweave/urdr/migrations/env.py +94 -0
- nornweave/urdr/migrations/script.py.mako +26 -0
- nornweave/urdr/migrations/versions/.gitkeep +0 -0
- nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
- nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
- nornweave/urdr/orm.py +641 -0
- nornweave/verdandi/__init__.py +45 -0
- nornweave/verdandi/attachments.py +471 -0
- nornweave/verdandi/content.py +420 -0
- nornweave/verdandi/headers.py +404 -0
- nornweave/verdandi/parser.py +25 -0
- nornweave/verdandi/sanitizer.py +9 -0
- nornweave/verdandi/threading.py +359 -0
- nornweave/yggdrasil/__init__.py +1 -0
- nornweave/yggdrasil/app.py +86 -0
- nornweave/yggdrasil/dependencies.py +190 -0
- nornweave/yggdrasil/middleware/__init__.py +1 -0
- nornweave/yggdrasil/middleware/auth.py +1 -0
- nornweave/yggdrasil/middleware/logging.py +1 -0
- nornweave/yggdrasil/routes/__init__.py +1 -0
- nornweave/yggdrasil/routes/v1/__init__.py +1 -0
- nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
- nornweave/yggdrasil/routes/v1/messages.py +200 -0
- nornweave/yggdrasil/routes/v1/search.py +84 -0
- nornweave/yggdrasil/routes/v1/threads.py +142 -0
- nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
- nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
- nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
- nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
- nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
- nornweave-0.1.2.dist-info/METADATA +324 -0
- nornweave-0.1.2.dist-info/RECORD +80 -0
- nornweave-0.1.2.dist-info/WHEEL +4 -0
- nornweave-0.1.2.dist-info/entry_points.txt +5 -0
- nornweave-0.1.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Resend email provider adapter."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import markdown # type: ignore[import-untyped]
|
|
9
|
+
from svix.webhooks import Webhook, WebhookVerificationError
|
|
10
|
+
|
|
11
|
+
from nornweave.core.interfaces import (
|
|
12
|
+
EmailProvider,
|
|
13
|
+
InboundAttachment,
|
|
14
|
+
InboundMessage,
|
|
15
|
+
)
|
|
16
|
+
from nornweave.models.attachment import AttachmentDisposition
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from nornweave.models.attachment import SendAttachment
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Resend API base URL
|
|
24
|
+
RESEND_API_URL = "https://api.resend.com"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResendWebhookError(Exception):
|
|
28
|
+
"""Raised when webhook verification or parsing fails."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResendAdapter(EmailProvider):
|
|
34
|
+
"""Resend implementation of EmailProvider.
|
|
35
|
+
|
|
36
|
+
Supports:
|
|
37
|
+
- Sending emails via Resend API
|
|
38
|
+
- Parsing inbound webhook payloads (email.received events)
|
|
39
|
+
- Fetching full email content from Resend API (webhooks only include metadata)
|
|
40
|
+
- Webhook signature verification using Svix
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, api_key: str, webhook_secret: str = "") -> None:
|
|
44
|
+
"""Initialize Resend adapter.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
api_key: Resend API key (starts with 're_')
|
|
48
|
+
webhook_secret: Webhook signing secret for signature verification
|
|
49
|
+
"""
|
|
50
|
+
self._api_key = api_key
|
|
51
|
+
self._webhook_secret = webhook_secret
|
|
52
|
+
self._api_url = RESEND_API_URL
|
|
53
|
+
|
|
54
|
+
async def send_email(
|
|
55
|
+
self,
|
|
56
|
+
to: list[str],
|
|
57
|
+
subject: str,
|
|
58
|
+
body: str,
|
|
59
|
+
*,
|
|
60
|
+
from_address: str,
|
|
61
|
+
reply_to: str | None = None,
|
|
62
|
+
headers: dict[str, str] | None = None,
|
|
63
|
+
message_id: str | None = None,
|
|
64
|
+
in_reply_to: str | None = None,
|
|
65
|
+
references: list[str] | None = None,
|
|
66
|
+
cc: list[str] | None = None,
|
|
67
|
+
bcc: list[str] | None = None,
|
|
68
|
+
attachments: list[SendAttachment] | None = None,
|
|
69
|
+
html_body: str | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Send email via Resend API.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
to: List of recipient email addresses
|
|
75
|
+
subject: Email subject
|
|
76
|
+
body: Email body in Markdown/plain text format
|
|
77
|
+
from_address: Sender email address
|
|
78
|
+
reply_to: Optional reply-to address
|
|
79
|
+
headers: Optional custom headers
|
|
80
|
+
message_id: Optional custom Message-ID
|
|
81
|
+
in_reply_to: Optional In-Reply-To header for threading
|
|
82
|
+
references: Optional References header for threading
|
|
83
|
+
cc: Optional CC recipients
|
|
84
|
+
bcc: Optional BCC recipients
|
|
85
|
+
attachments: Optional list of attachments
|
|
86
|
+
html_body: Optional pre-rendered HTML body
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Resend email ID
|
|
90
|
+
"""
|
|
91
|
+
url = f"{self._api_url}/emails"
|
|
92
|
+
|
|
93
|
+
# Convert Markdown body to HTML if html_body not provided
|
|
94
|
+
html_content = html_body or markdown.markdown(body)
|
|
95
|
+
|
|
96
|
+
# Build request payload
|
|
97
|
+
# See: https://resend.com/docs/api-reference/emails/send-email
|
|
98
|
+
data: dict[str, Any] = {
|
|
99
|
+
"from": from_address,
|
|
100
|
+
"to": to,
|
|
101
|
+
"subject": subject,
|
|
102
|
+
"text": body,
|
|
103
|
+
"html": html_content,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if reply_to:
|
|
107
|
+
data["reply_to"] = [reply_to]
|
|
108
|
+
|
|
109
|
+
if cc:
|
|
110
|
+
data["cc"] = cc
|
|
111
|
+
|
|
112
|
+
if bcc:
|
|
113
|
+
data["bcc"] = bcc
|
|
114
|
+
|
|
115
|
+
# Build custom headers
|
|
116
|
+
custom_headers: dict[str, str] = {}
|
|
117
|
+
if headers:
|
|
118
|
+
custom_headers.update(headers)
|
|
119
|
+
|
|
120
|
+
if message_id:
|
|
121
|
+
custom_headers["Message-ID"] = message_id
|
|
122
|
+
|
|
123
|
+
if in_reply_to:
|
|
124
|
+
custom_headers["In-Reply-To"] = in_reply_to
|
|
125
|
+
|
|
126
|
+
if references:
|
|
127
|
+
custom_headers["References"] = " ".join(references)
|
|
128
|
+
|
|
129
|
+
if custom_headers:
|
|
130
|
+
data["headers"] = custom_headers
|
|
131
|
+
|
|
132
|
+
# Process attachments
|
|
133
|
+
if attachments:
|
|
134
|
+
attachment_list: list[dict[str, Any]] = []
|
|
135
|
+
for att in attachments:
|
|
136
|
+
# SendAttachment.content is already base64-encoded
|
|
137
|
+
if att.content is None:
|
|
138
|
+
continue
|
|
139
|
+
attachment_data: dict[str, Any] = {
|
|
140
|
+
"filename": att.filename,
|
|
141
|
+
"content": att.content,
|
|
142
|
+
}
|
|
143
|
+
if att.content_type:
|
|
144
|
+
attachment_data["content_type"] = att.content_type
|
|
145
|
+
attachment_list.append(attachment_data)
|
|
146
|
+
data["attachments"] = attachment_list
|
|
147
|
+
|
|
148
|
+
logger.debug("Sending email via Resend to %s", to)
|
|
149
|
+
|
|
150
|
+
async with httpx.AsyncClient() as client:
|
|
151
|
+
response = await client.post(
|
|
152
|
+
url,
|
|
153
|
+
json=data,
|
|
154
|
+
headers={
|
|
155
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
},
|
|
158
|
+
timeout=30.0,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if response.status_code not in (200, 201):
|
|
162
|
+
logger.error(
|
|
163
|
+
"Resend API error: %s - %s",
|
|
164
|
+
response.status_code,
|
|
165
|
+
response.text,
|
|
166
|
+
)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
|
|
169
|
+
result = response.json()
|
|
170
|
+
email_id: str = result.get("id", "")
|
|
171
|
+
logger.info("Email sent via Resend: %s", email_id)
|
|
172
|
+
return email_id
|
|
173
|
+
|
|
174
|
+
def verify_webhook_signature(
|
|
175
|
+
self,
|
|
176
|
+
payload: str | bytes,
|
|
177
|
+
headers: dict[str, str],
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Verify Resend webhook signature using Svix.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
payload: Raw request body (must be the exact bytes/string received)
|
|
183
|
+
headers: Request headers containing svix-id, svix-timestamp, svix-signature
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Parsed and verified webhook payload
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ResendWebhookError: If verification fails or secret not configured
|
|
190
|
+
"""
|
|
191
|
+
if not self._webhook_secret:
|
|
192
|
+
raise ResendWebhookError("Webhook secret not configured")
|
|
193
|
+
|
|
194
|
+
# Normalize payload to string
|
|
195
|
+
if isinstance(payload, bytes):
|
|
196
|
+
payload = payload.decode("utf-8")
|
|
197
|
+
|
|
198
|
+
# Extract Svix headers (case-insensitive)
|
|
199
|
+
svix_headers: dict[str, str] = {}
|
|
200
|
+
for key, value in headers.items():
|
|
201
|
+
lower_key = key.lower()
|
|
202
|
+
if lower_key in ("svix-id", "svix-timestamp", "svix-signature"):
|
|
203
|
+
svix_headers[lower_key] = value
|
|
204
|
+
|
|
205
|
+
if not all(k in svix_headers for k in ("svix-id", "svix-timestamp", "svix-signature")):
|
|
206
|
+
raise ResendWebhookError("Missing required Svix headers")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
wh = Webhook(self._webhook_secret)
|
|
210
|
+
verified_payload: dict[str, Any] = wh.verify(payload, svix_headers)
|
|
211
|
+
return verified_payload
|
|
212
|
+
except WebhookVerificationError as e:
|
|
213
|
+
logger.warning("Webhook signature verification failed: %s", e)
|
|
214
|
+
raise ResendWebhookError(f"Signature verification failed: {e}") from e
|
|
215
|
+
|
|
216
|
+
async def fetch_email_content(self, email_id: str) -> dict[str, Any]:
|
|
217
|
+
"""Fetch full email content from Resend API.
|
|
218
|
+
|
|
219
|
+
Resend webhooks only include metadata (no body/attachments).
|
|
220
|
+
Use this method to fetch the complete email content.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
email_id: Resend email ID from webhook
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Full email data including html, text, headers, attachments
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
httpx.HTTPStatusError: If API request fails
|
|
230
|
+
"""
|
|
231
|
+
url = f"{self._api_url}/emails/receiving/{email_id}"
|
|
232
|
+
|
|
233
|
+
logger.debug("Fetching email content from Resend: %s", email_id)
|
|
234
|
+
|
|
235
|
+
async with httpx.AsyncClient() as client:
|
|
236
|
+
response = await client.get(
|
|
237
|
+
url,
|
|
238
|
+
headers={
|
|
239
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
240
|
+
},
|
|
241
|
+
timeout=30.0,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if response.status_code != 200:
|
|
245
|
+
logger.error(
|
|
246
|
+
"Resend API error fetching email %s: %s - %s",
|
|
247
|
+
email_id,
|
|
248
|
+
response.status_code,
|
|
249
|
+
response.text,
|
|
250
|
+
)
|
|
251
|
+
response.raise_for_status()
|
|
252
|
+
|
|
253
|
+
result: dict[str, Any] = response.json()
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
async def fetch_attachment_content(self, email_id: str, attachment_id: str) -> bytes:
|
|
257
|
+
"""Fetch attachment content from Resend API.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
email_id: Resend email ID
|
|
261
|
+
attachment_id: Attachment ID from webhook/email data
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Attachment binary content
|
|
265
|
+
"""
|
|
266
|
+
url = f"{self._api_url}/emails/receiving/{email_id}/attachments/{attachment_id}"
|
|
267
|
+
|
|
268
|
+
logger.debug("Fetching attachment %s from email %s", attachment_id, email_id)
|
|
269
|
+
|
|
270
|
+
async with httpx.AsyncClient() as client:
|
|
271
|
+
response = await client.get(
|
|
272
|
+
url,
|
|
273
|
+
headers={
|
|
274
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
275
|
+
},
|
|
276
|
+
timeout=60.0,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if response.status_code != 200:
|
|
280
|
+
logger.error(
|
|
281
|
+
"Resend API error fetching attachment: %s - %s",
|
|
282
|
+
response.status_code,
|
|
283
|
+
response.text,
|
|
284
|
+
)
|
|
285
|
+
response.raise_for_status()
|
|
286
|
+
|
|
287
|
+
return response.content
|
|
288
|
+
|
|
289
|
+
def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
|
|
290
|
+
"""Parse Resend inbound webhook payload into standardized InboundMessage.
|
|
291
|
+
|
|
292
|
+
Note: This parses the webhook metadata. For full email content (body, attachments),
|
|
293
|
+
use fetch_email_content() with the email_id from the webhook data.
|
|
294
|
+
|
|
295
|
+
Resend webhook payload structure for email.received:
|
|
296
|
+
{
|
|
297
|
+
"type": "email.received",
|
|
298
|
+
"created_at": "2024-02-22T23:41:12.126Z",
|
|
299
|
+
"data": {
|
|
300
|
+
"email_id": "...",
|
|
301
|
+
"created_at": "...",
|
|
302
|
+
"from": "Name <email@example.com>",
|
|
303
|
+
"to": ["recipient@example.com"],
|
|
304
|
+
"cc": [],
|
|
305
|
+
"bcc": [],
|
|
306
|
+
"message_id": "<...>",
|
|
307
|
+
"subject": "...",
|
|
308
|
+
"attachments": [{"id": "...", "filename": "...", "content_type": "..."}]
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
payload: Webhook payload (can be full webhook or just data object)
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
InboundMessage with parsed metadata (body fields will be empty)
|
|
317
|
+
"""
|
|
318
|
+
# Handle both full webhook payload and just the data object
|
|
319
|
+
if "type" in payload and "data" in payload:
|
|
320
|
+
data = payload["data"]
|
|
321
|
+
webhook_timestamp = payload.get("created_at")
|
|
322
|
+
else:
|
|
323
|
+
data = payload
|
|
324
|
+
webhook_timestamp = data.get("created_at")
|
|
325
|
+
|
|
326
|
+
# Parse sender - extract email from "Name <email>" format
|
|
327
|
+
from_field = data.get("from", "")
|
|
328
|
+
from_address = from_field
|
|
329
|
+
if "<" in from_field and ">" in from_field:
|
|
330
|
+
from_address = from_field.split("<")[1].split(">")[0]
|
|
331
|
+
|
|
332
|
+
# Parse recipients
|
|
333
|
+
to_list = data.get("to", [])
|
|
334
|
+
to_address = to_list[0] if to_list else ""
|
|
335
|
+
|
|
336
|
+
# Parse CC/BCC
|
|
337
|
+
cc_addresses = data.get("cc", []) or []
|
|
338
|
+
bcc_addresses = data.get("bcc", []) or []
|
|
339
|
+
|
|
340
|
+
# Parse timestamp
|
|
341
|
+
timestamp = datetime.now(UTC)
|
|
342
|
+
if webhook_timestamp:
|
|
343
|
+
try:
|
|
344
|
+
# Handle ISO 8601 format
|
|
345
|
+
timestamp_str = webhook_timestamp.replace("Z", "+00:00")
|
|
346
|
+
timestamp = datetime.fromisoformat(timestamp_str)
|
|
347
|
+
except (ValueError, AttributeError):
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
# Parse attachments metadata (content must be fetched separately)
|
|
351
|
+
attachments_meta: list[InboundAttachment] = []
|
|
352
|
+
for att in data.get("attachments", []) or []:
|
|
353
|
+
disposition = AttachmentDisposition.ATTACHMENT
|
|
354
|
+
if att.get("content_disposition") == "inline":
|
|
355
|
+
disposition = AttachmentDisposition.INLINE
|
|
356
|
+
|
|
357
|
+
attachments_meta.append(
|
|
358
|
+
InboundAttachment(
|
|
359
|
+
filename=att.get("filename", "unknown"),
|
|
360
|
+
content_type=att.get("content_type", "application/octet-stream"),
|
|
361
|
+
content=b"", # Content must be fetched via API
|
|
362
|
+
size_bytes=0, # Size not provided in webhook
|
|
363
|
+
disposition=disposition,
|
|
364
|
+
content_id=att.get("content_id"),
|
|
365
|
+
provider_id=att.get("id"),
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Build content_id_map for inline attachments
|
|
370
|
+
content_id_map: dict[str, str] = {}
|
|
371
|
+
for att in attachments_meta:
|
|
372
|
+
if att.content_id and att.provider_id:
|
|
373
|
+
content_id_map[att.content_id] = att.provider_id
|
|
374
|
+
|
|
375
|
+
return InboundMessage(
|
|
376
|
+
from_address=from_address,
|
|
377
|
+
to_address=to_address,
|
|
378
|
+
subject=data.get("subject", ""),
|
|
379
|
+
# Body content not included in webhook - must fetch via API
|
|
380
|
+
body_plain=data.get("text", ""),
|
|
381
|
+
body_html=data.get("html"),
|
|
382
|
+
stripped_text=None,
|
|
383
|
+
stripped_html=None,
|
|
384
|
+
# Threading headers
|
|
385
|
+
message_id=data.get("message_id"),
|
|
386
|
+
in_reply_to=None, # Not provided in webhook
|
|
387
|
+
references=[], # Not provided in webhook
|
|
388
|
+
# Metadata
|
|
389
|
+
headers=data.get("headers", {}),
|
|
390
|
+
timestamp=timestamp,
|
|
391
|
+
# Attachments (metadata only)
|
|
392
|
+
attachments=attachments_meta,
|
|
393
|
+
content_id_map=content_id_map,
|
|
394
|
+
# Verification (not provided by Resend)
|
|
395
|
+
spf_result=None,
|
|
396
|
+
dkim_result=None,
|
|
397
|
+
dmarc_result=None,
|
|
398
|
+
# CC/BCC
|
|
399
|
+
cc_addresses=cc_addresses,
|
|
400
|
+
bcc_addresses=bcc_addresses,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def parse_inbound_webhook_with_content(
|
|
404
|
+
self,
|
|
405
|
+
payload: dict[str, Any],
|
|
406
|
+
*,
|
|
407
|
+
fetch_attachments: bool = True,
|
|
408
|
+
) -> InboundMessage:
|
|
409
|
+
"""Parse webhook and fetch full email content from Resend API.
|
|
410
|
+
|
|
411
|
+
This is the recommended method for processing email.received webhooks
|
|
412
|
+
as it fetches the complete email body and attachment content.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
payload: Webhook payload
|
|
416
|
+
fetch_attachments: Whether to fetch attachment content (default True)
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
InboundMessage with full content
|
|
420
|
+
"""
|
|
421
|
+
# First parse the webhook metadata
|
|
422
|
+
inbound = self.parse_inbound_webhook(payload)
|
|
423
|
+
|
|
424
|
+
# Get email_id from payload
|
|
425
|
+
data = payload.get("data", payload)
|
|
426
|
+
email_id = data.get("email_id")
|
|
427
|
+
|
|
428
|
+
if not email_id:
|
|
429
|
+
logger.warning("No email_id in webhook, returning metadata only")
|
|
430
|
+
return inbound
|
|
431
|
+
|
|
432
|
+
# Fetch full email content
|
|
433
|
+
try:
|
|
434
|
+
full_email = await self.fetch_email_content(email_id)
|
|
435
|
+
|
|
436
|
+
# Update body content
|
|
437
|
+
inbound.body_plain = full_email.get("text") or ""
|
|
438
|
+
inbound.body_html = full_email.get("html")
|
|
439
|
+
|
|
440
|
+
# Update headers
|
|
441
|
+
if full_email.get("headers"):
|
|
442
|
+
inbound.headers = full_email["headers"]
|
|
443
|
+
|
|
444
|
+
# Parse In-Reply-To and References from headers
|
|
445
|
+
headers = full_email.get("headers", {})
|
|
446
|
+
if "in-reply-to" in headers:
|
|
447
|
+
inbound.in_reply_to = headers["in-reply-to"]
|
|
448
|
+
if "references" in headers:
|
|
449
|
+
inbound.references = inbound.parse_references_string(headers["references"])
|
|
450
|
+
|
|
451
|
+
# Fetch attachment content if requested
|
|
452
|
+
if fetch_attachments and inbound.attachments:
|
|
453
|
+
for att in inbound.attachments:
|
|
454
|
+
if att.provider_id:
|
|
455
|
+
try:
|
|
456
|
+
content = await self.fetch_attachment_content(email_id, att.provider_id)
|
|
457
|
+
att.content = content
|
|
458
|
+
att.size_bytes = len(content)
|
|
459
|
+
except httpx.HTTPStatusError as e:
|
|
460
|
+
logger.warning(
|
|
461
|
+
"Failed to fetch attachment %s: %s",
|
|
462
|
+
att.provider_id,
|
|
463
|
+
e,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
except httpx.HTTPStatusError as e:
|
|
467
|
+
# Log specific error details for common issues
|
|
468
|
+
if e.response.status_code == 401:
|
|
469
|
+
logger.error(
|
|
470
|
+
"Failed to fetch email content: API key lacks permission. "
|
|
471
|
+
"Ensure your Resend API key has 'Full access' permission, not 'Sending access' only. "
|
|
472
|
+
"Create a new key at https://resend.com/api-keys"
|
|
473
|
+
)
|
|
474
|
+
elif e.response.status_code == 404:
|
|
475
|
+
logger.error(
|
|
476
|
+
"Failed to fetch email content: Email %s not found in Resend. "
|
|
477
|
+
"The email may have been deleted or the ID is incorrect.",
|
|
478
|
+
email_id,
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
logger.error("Failed to fetch email content: %s", e)
|
|
482
|
+
# Re-raise the error so the webhook handler knows the fetch failed
|
|
483
|
+
raise
|
|
484
|
+
|
|
485
|
+
return inbound
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def get_event_type(payload: dict[str, Any]) -> str:
|
|
489
|
+
"""Extract event type from webhook payload.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
payload: Webhook payload
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Event type string (e.g., 'email.received', 'email.bounced')
|
|
496
|
+
"""
|
|
497
|
+
event_type: str = payload.get("type", "unknown")
|
|
498
|
+
return event_type
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def is_inbound_event(payload: dict[str, Any]) -> bool:
|
|
502
|
+
"""Check if webhook is an inbound email event.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
payload: Webhook payload
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
True if this is an email.received event
|
|
509
|
+
"""
|
|
510
|
+
return payload.get("type") == "email.received"
|