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.
Files changed (80) hide show
  1. nornweave/__init__.py +3 -0
  2. nornweave/adapters/__init__.py +1 -0
  3. nornweave/adapters/base.py +5 -0
  4. nornweave/adapters/mailgun.py +196 -0
  5. nornweave/adapters/resend.py +510 -0
  6. nornweave/adapters/sendgrid.py +492 -0
  7. nornweave/adapters/ses.py +824 -0
  8. nornweave/cli.py +186 -0
  9. nornweave/core/__init__.py +26 -0
  10. nornweave/core/config.py +172 -0
  11. nornweave/core/exceptions.py +25 -0
  12. nornweave/core/interfaces.py +390 -0
  13. nornweave/core/storage.py +192 -0
  14. nornweave/core/utils.py +23 -0
  15. nornweave/huginn/__init__.py +10 -0
  16. nornweave/huginn/client.py +296 -0
  17. nornweave/huginn/config.py +52 -0
  18. nornweave/huginn/resources.py +165 -0
  19. nornweave/huginn/server.py +202 -0
  20. nornweave/models/__init__.py +113 -0
  21. nornweave/models/attachment.py +136 -0
  22. nornweave/models/event.py +275 -0
  23. nornweave/models/inbox.py +33 -0
  24. nornweave/models/message.py +284 -0
  25. nornweave/models/thread.py +172 -0
  26. nornweave/muninn/__init__.py +14 -0
  27. nornweave/muninn/tools.py +207 -0
  28. nornweave/search/__init__.py +1 -0
  29. nornweave/search/embeddings.py +1 -0
  30. nornweave/search/vector_store.py +1 -0
  31. nornweave/skuld/__init__.py +1 -0
  32. nornweave/skuld/rate_limiter.py +1 -0
  33. nornweave/skuld/scheduler.py +1 -0
  34. nornweave/skuld/sender.py +25 -0
  35. nornweave/skuld/webhooks.py +1 -0
  36. nornweave/storage/__init__.py +20 -0
  37. nornweave/storage/database.py +165 -0
  38. nornweave/storage/gcs.py +144 -0
  39. nornweave/storage/local.py +152 -0
  40. nornweave/storage/s3.py +164 -0
  41. nornweave/urdr/__init__.py +14 -0
  42. nornweave/urdr/adapters/__init__.py +16 -0
  43. nornweave/urdr/adapters/base.py +385 -0
  44. nornweave/urdr/adapters/postgres.py +50 -0
  45. nornweave/urdr/adapters/sqlite.py +51 -0
  46. nornweave/urdr/migrations/env.py +94 -0
  47. nornweave/urdr/migrations/script.py.mako +26 -0
  48. nornweave/urdr/migrations/versions/.gitkeep +0 -0
  49. nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
  50. nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
  51. nornweave/urdr/orm.py +641 -0
  52. nornweave/verdandi/__init__.py +45 -0
  53. nornweave/verdandi/attachments.py +471 -0
  54. nornweave/verdandi/content.py +420 -0
  55. nornweave/verdandi/headers.py +404 -0
  56. nornweave/verdandi/parser.py +25 -0
  57. nornweave/verdandi/sanitizer.py +9 -0
  58. nornweave/verdandi/threading.py +359 -0
  59. nornweave/yggdrasil/__init__.py +1 -0
  60. nornweave/yggdrasil/app.py +86 -0
  61. nornweave/yggdrasil/dependencies.py +190 -0
  62. nornweave/yggdrasil/middleware/__init__.py +1 -0
  63. nornweave/yggdrasil/middleware/auth.py +1 -0
  64. nornweave/yggdrasil/middleware/logging.py +1 -0
  65. nornweave/yggdrasil/routes/__init__.py +1 -0
  66. nornweave/yggdrasil/routes/v1/__init__.py +1 -0
  67. nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
  68. nornweave/yggdrasil/routes/v1/messages.py +200 -0
  69. nornweave/yggdrasil/routes/v1/search.py +84 -0
  70. nornweave/yggdrasil/routes/v1/threads.py +142 -0
  71. nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
  72. nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
  73. nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
  74. nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
  75. nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
  76. nornweave-0.1.2.dist-info/METADATA +324 -0
  77. nornweave-0.1.2.dist-info/RECORD +80 -0
  78. nornweave-0.1.2.dist-info/WHEEL +4 -0
  79. nornweave-0.1.2.dist-info/entry_points.txt +5 -0
  80. 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"