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,492 @@
1
+ """SendGrid email provider adapter."""
2
+
3
+ import base64
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ import time
8
+ from datetime import UTC, datetime
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import httpx
12
+ import markdown # type: ignore[import-untyped]
13
+ from cryptography.exceptions import InvalidSignature
14
+ from cryptography.hazmat.primitives import hashes
15
+ from cryptography.hazmat.primitives.asymmetric import ec
16
+
17
+ from nornweave.core.interfaces import (
18
+ EmailProvider,
19
+ InboundAttachment,
20
+ InboundMessage,
21
+ )
22
+ from nornweave.models.attachment import AttachmentDisposition
23
+
24
+ if TYPE_CHECKING:
25
+ from nornweave.models.attachment import SendAttachment
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # SendGrid API base URL
30
+ SENDGRID_API_URL = "https://api.sendgrid.com"
31
+
32
+ # Webhook signature headers
33
+ SIGNATURE_HEADER = "X-Twilio-Email-Event-Webhook-Signature"
34
+ TIMESTAMP_HEADER = "X-Twilio-Email-Event-Webhook-Timestamp"
35
+
36
+ # Timestamp tolerance for webhook verification (5 minutes)
37
+ TIMESTAMP_TOLERANCE_SECONDS = 300
38
+
39
+
40
+ class SendGridWebhookError(Exception):
41
+ """Raised when webhook verification or parsing fails."""
42
+
43
+ pass
44
+
45
+
46
+ class SendGridAdapter(EmailProvider):
47
+ """SendGrid implementation of EmailProvider.
48
+
49
+ Supports:
50
+ - Sending emails via SendGrid v3 Mail Send API
51
+ - Parsing inbound webhook payloads (Inbound Parse)
52
+ - Webhook signature verification using ECDSA
53
+ """
54
+
55
+ def __init__(self, api_key: str, webhook_public_key: str = "") -> None:
56
+ """Initialize SendGrid adapter.
57
+
58
+ Args:
59
+ api_key: SendGrid API key (starts with 'SG.')
60
+ webhook_public_key: ECDSA public key for webhook signature verification
61
+ (base64-encoded, from SendGrid security policy)
62
+ """
63
+ self._api_key = api_key
64
+ self._webhook_public_key = webhook_public_key
65
+ self._api_url = SENDGRID_API_URL
66
+
67
+ async def send_email(
68
+ self,
69
+ to: list[str],
70
+ subject: str,
71
+ body: str,
72
+ *,
73
+ from_address: str,
74
+ reply_to: str | None = None,
75
+ headers: dict[str, str] | None = None,
76
+ message_id: str | None = None,
77
+ in_reply_to: str | None = None,
78
+ references: list[str] | None = None,
79
+ cc: list[str] | None = None,
80
+ bcc: list[str] | None = None,
81
+ attachments: list[SendAttachment] | None = None,
82
+ html_body: str | None = None,
83
+ ) -> str:
84
+ """Send email via SendGrid v3 Mail Send API.
85
+
86
+ Args:
87
+ to: List of recipient email addresses
88
+ subject: Email subject
89
+ body: Email body in Markdown/plain text format
90
+ from_address: Sender email address
91
+ reply_to: Optional reply-to address
92
+ headers: Optional custom headers
93
+ message_id: Optional custom Message-ID
94
+ in_reply_to: Optional In-Reply-To header for threading
95
+ references: Optional References header for threading
96
+ cc: Optional CC recipients
97
+ bcc: Optional BCC recipients
98
+ attachments: Optional list of attachments
99
+ html_body: Optional pre-rendered HTML body
100
+
101
+ Returns:
102
+ SendGrid message ID from X-Message-Id header
103
+ """
104
+ url = f"{self._api_url}/v3/mail/send"
105
+
106
+ # Convert Markdown body to HTML if html_body not provided
107
+ html_content = html_body or markdown.markdown(body)
108
+
109
+ # Build personalizations array with recipients
110
+ personalizations: dict[str, Any] = {
111
+ "to": [{"email": email} for email in to],
112
+ }
113
+
114
+ if cc:
115
+ personalizations["cc"] = [{"email": email} for email in cc]
116
+
117
+ if bcc:
118
+ personalizations["bcc"] = [{"email": email} for email in bcc]
119
+
120
+ # Build content array
121
+ content = [
122
+ {"type": "text/plain", "value": body},
123
+ {"type": "text/html", "value": html_content},
124
+ ]
125
+
126
+ # Build request payload
127
+ data: dict[str, Any] = {
128
+ "personalizations": [personalizations],
129
+ "from": {"email": from_address},
130
+ "subject": subject,
131
+ "content": content,
132
+ }
133
+
134
+ # Add reply_to
135
+ if reply_to:
136
+ data["reply_to"] = {"email": reply_to}
137
+
138
+ # Build custom headers
139
+ custom_headers: dict[str, str] = {}
140
+ if headers:
141
+ custom_headers.update(headers)
142
+
143
+ if message_id:
144
+ custom_headers["Message-ID"] = message_id
145
+
146
+ if in_reply_to:
147
+ custom_headers["In-Reply-To"] = in_reply_to
148
+
149
+ if references:
150
+ custom_headers["References"] = " ".join(references)
151
+
152
+ if custom_headers:
153
+ data["headers"] = custom_headers
154
+
155
+ # Process attachments
156
+ if attachments:
157
+ attachment_list: list[dict[str, Any]] = []
158
+ for att in attachments:
159
+ # SendAttachment.content is already base64-encoded string
160
+ # If raw bytes are provided via get_content_bytes(), encode them
161
+ content_bytes = att.get_content_bytes()
162
+ if content_bytes:
163
+ content_b64 = base64.b64encode(content_bytes).decode("utf-8")
164
+ elif att.content:
165
+ content_b64 = att.content # Already base64 string
166
+ else:
167
+ continue # Skip attachments without content
168
+
169
+ attachment_data: dict[str, Any] = {
170
+ "content": content_b64,
171
+ "filename": att.filename,
172
+ }
173
+ if att.content_type:
174
+ attachment_data["type"] = att.content_type
175
+
176
+ # Handle disposition (inline vs attachment)
177
+ if hasattr(att, "disposition") and att.disposition:
178
+ attachment_data["disposition"] = att.disposition.value
179
+ if att.disposition == AttachmentDisposition.INLINE and hasattr(
180
+ att, "content_id"
181
+ ):
182
+ attachment_data["content_id"] = att.content_id
183
+
184
+ attachment_list.append(attachment_data)
185
+ data["attachments"] = attachment_list
186
+
187
+ logger.debug("Sending email via SendGrid to %s", to)
188
+
189
+ async with httpx.AsyncClient() as client:
190
+ response = await client.post(
191
+ url,
192
+ json=data,
193
+ headers={
194
+ "Authorization": f"Bearer {self._api_key}",
195
+ "Content-Type": "application/json",
196
+ },
197
+ timeout=30.0,
198
+ )
199
+
200
+ if response.status_code not in (200, 201, 202):
201
+ logger.error(
202
+ "SendGrid API error: %s - %s",
203
+ response.status_code,
204
+ response.text,
205
+ )
206
+ response.raise_for_status()
207
+
208
+ # Extract message ID from X-Message-Id header
209
+ message_id_header: str = response.headers.get("X-Message-Id", "")
210
+ logger.info("Email sent via SendGrid: %s", message_id_header)
211
+ return message_id_header
212
+
213
+ def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
214
+ """Parse SendGrid Inbound Parse webhook payload into standardized InboundMessage.
215
+
216
+ SendGrid Inbound Parse sends data as multipart/form-data with fields:
217
+ - from, to, subject, text, html
218
+ - headers (newline-separated string)
219
+ - envelope (JSON string)
220
+ - charsets (JSON string)
221
+ - SPF, dkim, spam_score
222
+ - attachments (count)
223
+ - attachment-info (JSON with attachment metadata)
224
+ - content-ids (JSON mapping Content-ID to attachment field name)
225
+
226
+ Args:
227
+ payload: Parsed form data from webhook (dict of field values)
228
+
229
+ Returns:
230
+ InboundMessage with parsed email data
231
+ """
232
+ # Parse sender - extract email from "Name <email>" format
233
+ from_field = payload.get("from", "")
234
+ from_address = from_field
235
+ if "<" in from_field and ">" in from_field:
236
+ from_address = from_field.split("<")[1].split(">")[0]
237
+
238
+ # Parse recipient
239
+ to_address = payload.get("to", "")
240
+
241
+ # Parse headers string into dictionary
242
+ headers: dict[str, str] = {}
243
+ headers_raw = payload.get("headers", "")
244
+ if headers_raw:
245
+ headers = self._parse_headers_string(headers_raw)
246
+
247
+ # Extract threading headers from parsed headers
248
+ message_id = headers.get("Message-ID") or headers.get("Message-Id")
249
+ in_reply_to = headers.get("In-Reply-To")
250
+ references_str = headers.get("References", "")
251
+ references = [ref.strip() for ref in references_str.split() if ref.strip()]
252
+
253
+ # Parse timestamp from headers or use current time
254
+ timestamp = datetime.now(UTC)
255
+ date_header = headers.get("Date")
256
+ if date_header:
257
+ try:
258
+ from email.utils import parsedate_to_datetime
259
+
260
+ timestamp = parsedate_to_datetime(date_header)
261
+ except (ValueError, TypeError):
262
+ pass
263
+
264
+ # Parse attachments from attachment-info JSON
265
+ attachments_meta: list[InboundAttachment] = []
266
+ content_id_map: dict[str, str] = {}
267
+
268
+ attachment_info_raw = payload.get("attachment-info", "")
269
+ content_ids_raw = payload.get("content-ids", "")
270
+
271
+ # Parse content-ids mapping (Content-ID -> attachment field name)
272
+ content_ids_mapping: dict[str, str] = {}
273
+ if content_ids_raw:
274
+ with contextlib.suppress(json.JSONDecodeError):
275
+ content_ids_mapping = json.loads(content_ids_raw)
276
+
277
+ # Reverse mapping: attachment field name -> Content-ID
278
+ field_to_content_id = {v: k for k, v in content_ids_mapping.items()}
279
+
280
+ if attachment_info_raw:
281
+ try:
282
+ attachment_info = json.loads(attachment_info_raw)
283
+ for field_name, att_data in attachment_info.items():
284
+ # Determine disposition
285
+ content_id = field_to_content_id.get(field_name)
286
+ disposition = AttachmentDisposition.ATTACHMENT
287
+ if content_id:
288
+ disposition = AttachmentDisposition.INLINE
289
+
290
+ attachments_meta.append(
291
+ InboundAttachment(
292
+ filename=att_data.get("filename") or att_data.get("name", "unknown"),
293
+ content_type=att_data.get("type", "application/octet-stream"),
294
+ content=b"", # Content comes as separate form fields
295
+ size_bytes=0,
296
+ disposition=disposition,
297
+ content_id=content_id,
298
+ provider_id=field_name,
299
+ )
300
+ )
301
+
302
+ # Build content_id_map for inline images
303
+ if content_id:
304
+ content_id_map[content_id] = field_name
305
+
306
+ except json.JSONDecodeError:
307
+ logger.warning("Failed to parse attachment-info JSON")
308
+
309
+ # Parse CC from headers
310
+ cc_header = headers.get("Cc", "")
311
+ cc_addresses = (
312
+ [addr.strip() for addr in cc_header.split(",") if addr.strip()] if cc_header else []
313
+ )
314
+
315
+ # Parse SPF and DKIM results
316
+ spf_result = payload.get("SPF")
317
+ dkim_result = payload.get("dkim")
318
+
319
+ return InboundMessage(
320
+ from_address=from_address,
321
+ to_address=to_address,
322
+ subject=payload.get("subject", ""),
323
+ body_plain=payload.get("text", ""),
324
+ body_html=payload.get("html"),
325
+ stripped_text=payload.get("stripped-text"),
326
+ stripped_html=payload.get("stripped-html"),
327
+ message_id=message_id,
328
+ in_reply_to=in_reply_to,
329
+ references=references,
330
+ headers=headers,
331
+ timestamp=timestamp,
332
+ attachments=attachments_meta,
333
+ content_id_map=content_id_map,
334
+ spf_result=spf_result,
335
+ dkim_result=dkim_result,
336
+ dmarc_result=None, # Not provided by SendGrid Inbound Parse
337
+ cc_addresses=cc_addresses,
338
+ bcc_addresses=[], # BCC not visible in received emails
339
+ )
340
+
341
+ def _parse_headers_string(self, headers_raw: str) -> dict[str, str]:
342
+ """Parse newline-separated headers string into dictionary.
343
+
344
+ Args:
345
+ headers_raw: Raw headers string from SendGrid webhook
346
+
347
+ Returns:
348
+ Dictionary of header name -> value
349
+ """
350
+ headers: dict[str, str] = {}
351
+ current_header = ""
352
+ current_value = ""
353
+
354
+ for line in headers_raw.split("\n"):
355
+ if not line:
356
+ continue
357
+
358
+ # Check if this is a continuation line (starts with whitespace)
359
+ if line[0] in (" ", "\t"):
360
+ current_value += " " + line.strip()
361
+ else:
362
+ # Save previous header if exists
363
+ if current_header:
364
+ headers[current_header] = current_value
365
+
366
+ # Parse new header
367
+ if ":" in line:
368
+ parts = line.split(":", 1)
369
+ current_header = parts[0].strip()
370
+ current_value = parts[1].strip() if len(parts) > 1 else ""
371
+ else:
372
+ current_header = ""
373
+ current_value = ""
374
+
375
+ # Save last header
376
+ if current_header:
377
+ headers[current_header] = current_value
378
+
379
+ return headers
380
+
381
+ def verify_webhook_signature(
382
+ self,
383
+ payload: bytes,
384
+ headers: dict[str, str],
385
+ ) -> None:
386
+ """Verify SendGrid Inbound Parse webhook signature using ECDSA.
387
+
388
+ Args:
389
+ payload: Raw request body (must be exact bytes received)
390
+ headers: Request headers containing signature and timestamp
391
+
392
+ Raises:
393
+ SendGridWebhookError: If verification fails or public key not configured
394
+ """
395
+ if not self._webhook_public_key:
396
+ raise SendGridWebhookError("Webhook public key not configured")
397
+
398
+ # Extract signature headers (case-insensitive)
399
+ signature = None
400
+ timestamp = None
401
+ for key, value in headers.items():
402
+ lower_key = key.lower()
403
+ if lower_key == SIGNATURE_HEADER.lower():
404
+ signature = value
405
+ elif lower_key == TIMESTAMP_HEADER.lower():
406
+ timestamp = value
407
+
408
+ if not signature:
409
+ raise SendGridWebhookError(f"Missing required header: {SIGNATURE_HEADER}")
410
+
411
+ if not timestamp:
412
+ raise SendGridWebhookError(f"Missing required header: {TIMESTAMP_HEADER}")
413
+
414
+ # Validate timestamp is within tolerance
415
+ try:
416
+ timestamp_int = int(timestamp)
417
+ current_time = int(time.time())
418
+ if abs(current_time - timestamp_int) > TIMESTAMP_TOLERANCE_SECONDS:
419
+ raise SendGridWebhookError(
420
+ f"Timestamp validation failed: timestamp {timestamp} is outside "
421
+ f"acceptable tolerance of {TIMESTAMP_TOLERANCE_SECONDS} seconds"
422
+ )
423
+ except ValueError:
424
+ raise SendGridWebhookError(f"Invalid timestamp format: {timestamp}")
425
+
426
+ # Build signed payload: timestamp + payload
427
+ signed_payload = timestamp.encode("utf-8") + payload
428
+
429
+ # Decode signature from base64
430
+ try:
431
+ signature_bytes = base64.b64decode(signature)
432
+ except Exception as e:
433
+ raise SendGridWebhookError(f"Invalid signature encoding: {e}")
434
+
435
+ # Load public key
436
+ try:
437
+ # The public key from SendGrid is base64-encoded DER format
438
+ public_key_bytes = base64.b64decode(self._webhook_public_key)
439
+
440
+ # Try loading as raw public key bytes (EC point)
441
+ # SendGrid provides the key in SubjectPublicKeyInfo DER format
442
+ from cryptography.hazmat.primitives.serialization import load_der_public_key
443
+
444
+ public_key = load_der_public_key(public_key_bytes)
445
+ except Exception as e:
446
+ raise SendGridWebhookError(f"Invalid public key: {e}")
447
+
448
+ # Verify signature
449
+ try:
450
+ if not isinstance(public_key, ec.EllipticCurvePublicKey):
451
+ raise SendGridWebhookError("Public key is not an ECDSA key")
452
+
453
+ public_key.verify(
454
+ signature_bytes,
455
+ signed_payload,
456
+ ec.ECDSA(hashes.SHA256()),
457
+ )
458
+ except InvalidSignature:
459
+ raise SendGridWebhookError("Signature verification failed")
460
+ except Exception as e:
461
+ raise SendGridWebhookError(f"Signature verification error: {e}")
462
+
463
+ @staticmethod
464
+ def get_event_type(payload: dict[str, Any]) -> str: # noqa: ARG004
465
+ """Get event type for Inbound Parse webhook.
466
+
467
+ SendGrid Inbound Parse doesn't have event types like Event Webhooks.
468
+ All Inbound Parse webhooks are inbound email events.
469
+
470
+ Args:
471
+ payload: Webhook payload
472
+
473
+ Returns:
474
+ Event type string ("inbound")
475
+ """
476
+ return "inbound"
477
+
478
+ @staticmethod
479
+ def is_inbound_event(payload: dict[str, Any]) -> bool:
480
+ """Check if webhook is an inbound email event.
481
+
482
+ For SendGrid Inbound Parse, this always returns True as all
483
+ webhooks to the Inbound Parse endpoint are inbound emails.
484
+
485
+ Args:
486
+ payload: Webhook payload
487
+
488
+ Returns:
489
+ True (Inbound Parse webhooks are always inbound events)
490
+ """
491
+ # Check for typical Inbound Parse fields to verify it's a valid payload
492
+ return "from" in payload or "to" in payload or "subject" in payload