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,824 @@
1
+ """AWS SES email provider adapter."""
2
+
3
+ import base64
4
+ import contextlib
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import logging
9
+ import re
10
+ import urllib.request
11
+ from datetime import UTC, datetime
12
+ from email import message_from_bytes
13
+ from email.utils import parsedate_to_datetime
14
+ from functools import lru_cache
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ import httpx
18
+ import markdown # type: ignore[import-untyped]
19
+ from cryptography import x509
20
+ from cryptography.hazmat.primitives import hashes
21
+ from cryptography.hazmat.primitives.asymmetric import padding
22
+
23
+ from nornweave.core.interfaces import (
24
+ EmailProvider,
25
+ InboundAttachment,
26
+ InboundMessage,
27
+ )
28
+ from nornweave.models.attachment import AttachmentDisposition
29
+
30
+ if TYPE_CHECKING:
31
+ from nornweave.models.attachment import SendAttachment
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # SES API service name for signing
36
+ SES_SERVICE = "ses"
37
+
38
+ # SNS message types
39
+ SNS_TYPE_NOTIFICATION = "Notification"
40
+ SNS_TYPE_SUBSCRIPTION_CONFIRMATION = "SubscriptionConfirmation"
41
+ SNS_TYPE_UNSUBSCRIBE_CONFIRMATION = "UnsubscribeConfirmation"
42
+
43
+ # SNS SigningCertURL validation pattern
44
+ SNS_CERT_URL_PATTERN = re.compile(r"^https://sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?/")
45
+
46
+
47
+ class SESWebhookError(Exception):
48
+ """Raised when webhook verification or parsing fails."""
49
+
50
+
51
+ class SESAdapter(EmailProvider):
52
+ """AWS SES implementation of EmailProvider.
53
+
54
+ Supports:
55
+ - Sending emails via SES v2 API with Content.Simple
56
+ - Parsing inbound webhooks via SNS notifications
57
+ - SNS signature verification using X.509 certificates
58
+ - Automatic SNS subscription confirmation
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ access_key_id: str,
64
+ secret_access_key: str,
65
+ region: str = "us-east-1",
66
+ configuration_set: str | None = None,
67
+ ) -> None:
68
+ """Initialize AWS SES adapter.
69
+
70
+ Args:
71
+ access_key_id: AWS access key ID
72
+ secret_access_key: AWS secret access key
73
+ region: AWS region (default: us-east-1)
74
+ configuration_set: Optional SES configuration set name for tracking
75
+ """
76
+ self._access_key_id = access_key_id
77
+ self._secret_access_key = secret_access_key
78
+ self._region = region
79
+ self._configuration_set = configuration_set
80
+ self._api_host = f"email.{region}.amazonaws.com"
81
+ self._api_url = f"https://{self._api_host}"
82
+
83
+ # -------------------------------------------------------------------------
84
+ # AWS Signature Version 4 Implementation
85
+ # -------------------------------------------------------------------------
86
+
87
+ def _sign(self, key: bytes, msg: str) -> bytes:
88
+ """HMAC-SHA256 sign a message."""
89
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
90
+
91
+ def _get_signature_key(self, date_stamp: str, region: str, service: str) -> bytes:
92
+ """Derive the signing key for AWS SigV4."""
93
+ k_date = self._sign(f"AWS4{self._secret_access_key}".encode(), date_stamp)
94
+ k_region = self._sign(k_date, region)
95
+ k_service = self._sign(k_region, service)
96
+ k_signing = self._sign(k_service, "aws4_request")
97
+ return k_signing
98
+
99
+ def _sign_request(
100
+ self,
101
+ method: str,
102
+ path: str,
103
+ headers: dict[str, str],
104
+ payload: bytes,
105
+ ) -> dict[str, str]:
106
+ """Sign an HTTP request using AWS Signature Version 4.
107
+
108
+ Args:
109
+ method: HTTP method (GET, POST, etc.)
110
+ path: Request path (e.g., /v2/email/outbound-emails)
111
+ headers: Request headers (will be modified with auth headers)
112
+ payload: Request body bytes
113
+
114
+ Returns:
115
+ Headers dict with Authorization and X-Amz-Date added
116
+ """
117
+ # Get current time
118
+ t = datetime.now(UTC)
119
+ amz_date = t.strftime("%Y%m%dT%H%M%SZ")
120
+ date_stamp = t.strftime("%Y%m%d")
121
+
122
+ # Add required headers
123
+ headers = dict(headers)
124
+ headers["Host"] = self._api_host
125
+ headers["X-Amz-Date"] = amz_date
126
+
127
+ # Create canonical request
128
+ canonical_uri = path
129
+ canonical_querystring = ""
130
+
131
+ # Create signed headers list
132
+ signed_headers_list = sorted(headers.keys(), key=str.lower)
133
+ signed_headers = ";".join(h.lower() for h in signed_headers_list)
134
+
135
+ # Create canonical headers
136
+ canonical_headers = ""
137
+ for header in signed_headers_list:
138
+ canonical_headers += f"{header.lower()}:{headers[header].strip()}\n"
139
+
140
+ # Hash the payload
141
+ payload_hash = hashlib.sha256(payload).hexdigest()
142
+
143
+ # Create canonical request
144
+ canonical_request = "\n".join(
145
+ [
146
+ method,
147
+ canonical_uri,
148
+ canonical_querystring,
149
+ canonical_headers,
150
+ signed_headers,
151
+ payload_hash,
152
+ ]
153
+ )
154
+
155
+ # Create string to sign
156
+ algorithm = "AWS4-HMAC-SHA256"
157
+ credential_scope = f"{date_stamp}/{self._region}/{SES_SERVICE}/aws4_request"
158
+ string_to_sign = "\n".join(
159
+ [
160
+ algorithm,
161
+ amz_date,
162
+ credential_scope,
163
+ hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
164
+ ]
165
+ )
166
+
167
+ # Calculate signature
168
+ signing_key = self._get_signature_key(date_stamp, self._region, SES_SERVICE)
169
+ signature = hmac.new(
170
+ signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
171
+ ).hexdigest()
172
+
173
+ # Create authorization header
174
+ authorization_header = (
175
+ f"{algorithm} "
176
+ f"Credential={self._access_key_id}/{credential_scope}, "
177
+ f"SignedHeaders={signed_headers}, "
178
+ f"Signature={signature}"
179
+ )
180
+
181
+ headers["Authorization"] = authorization_header
182
+ return headers
183
+
184
+ # -------------------------------------------------------------------------
185
+ # Send Email Implementation
186
+ # -------------------------------------------------------------------------
187
+
188
+ async def send_email(
189
+ self,
190
+ to: list[str],
191
+ subject: str,
192
+ body: str,
193
+ *,
194
+ from_address: str,
195
+ reply_to: str | None = None,
196
+ headers: dict[str, str] | None = None,
197
+ message_id: str | None = None,
198
+ in_reply_to: str | None = None,
199
+ references: list[str] | None = None,
200
+ cc: list[str] | None = None,
201
+ bcc: list[str] | None = None,
202
+ attachments: list[SendAttachment] | None = None,
203
+ html_body: str | None = None,
204
+ ) -> str:
205
+ """Send email via AWS SES v2 API.
206
+
207
+ Args:
208
+ to: List of recipient email addresses
209
+ subject: Email subject
210
+ body: Email body in Markdown/plain text format
211
+ from_address: Sender email address
212
+ reply_to: Optional reply-to address
213
+ headers: Optional custom headers
214
+ message_id: Optional custom Message-ID
215
+ in_reply_to: Optional In-Reply-To header for threading
216
+ references: Optional References header for threading
217
+ cc: Optional CC recipients
218
+ bcc: Optional BCC recipients
219
+ attachments: Optional list of attachments
220
+ html_body: Optional pre-rendered HTML body
221
+
222
+ Returns:
223
+ SES message ID from response
224
+ """
225
+ path = "/v2/email/outbound-emails"
226
+
227
+ # Convert Markdown body to HTML if html_body not provided
228
+ html_content = html_body or markdown.markdown(body)
229
+
230
+ # Build destination object
231
+ destination: dict[str, Any] = {
232
+ "ToAddresses": to,
233
+ }
234
+ if cc:
235
+ destination["CcAddresses"] = cc
236
+ if bcc:
237
+ destination["BccAddresses"] = bcc
238
+
239
+ # Build body object
240
+ body_obj: dict[str, Any] = {
241
+ "Text": {"Data": body, "Charset": "UTF-8"},
242
+ "Html": {"Data": html_content, "Charset": "UTF-8"},
243
+ }
244
+
245
+ # Build headers array for threading
246
+ headers_array: list[dict[str, str]] = []
247
+ if message_id:
248
+ headers_array.append({"Name": "Message-ID", "Value": message_id})
249
+ if in_reply_to:
250
+ headers_array.append({"Name": "In-Reply-To", "Value": in_reply_to})
251
+ if references:
252
+ headers_array.append({"Name": "References", "Value": " ".join(references)})
253
+
254
+ # Add custom headers
255
+ if headers:
256
+ for name, value in headers.items():
257
+ headers_array.append({"Name": name, "Value": value})
258
+
259
+ # Build simple content
260
+ simple_content: dict[str, Any] = {
261
+ "Subject": {"Data": subject, "Charset": "UTF-8"},
262
+ "Body": body_obj,
263
+ }
264
+
265
+ if headers_array:
266
+ simple_content["Headers"] = headers_array
267
+
268
+ # Build attachments array
269
+ if attachments:
270
+ attachments_array: list[dict[str, Any]] = []
271
+ for att in attachments:
272
+ # Get content bytes
273
+ content_bytes = att.get_content_bytes()
274
+ if content_bytes:
275
+ content_b64 = base64.b64encode(content_bytes).decode("utf-8")
276
+ elif att.content:
277
+ content_b64 = att.content # Already base64 string
278
+ else:
279
+ continue # Skip attachments without content
280
+
281
+ attachment_data: dict[str, Any] = {
282
+ "RawContent": content_b64,
283
+ "FileName": att.filename,
284
+ }
285
+
286
+ if att.content_type:
287
+ attachment_data["ContentType"] = att.content_type
288
+
289
+ # Handle disposition
290
+ if hasattr(att, "disposition") and att.disposition:
291
+ if att.disposition == AttachmentDisposition.INLINE:
292
+ attachment_data["ContentDisposition"] = "inline"
293
+ if hasattr(att, "content_id") and att.content_id:
294
+ attachment_data["ContentId"] = att.content_id
295
+ else:
296
+ attachment_data["ContentDisposition"] = "attachment"
297
+
298
+ attachments_array.append(attachment_data)
299
+
300
+ if attachments_array:
301
+ simple_content["Attachments"] = attachments_array
302
+
303
+ # Build request payload
304
+ data: dict[str, Any] = {
305
+ "FromEmailAddress": from_address,
306
+ "Destination": destination,
307
+ "Content": {"Simple": simple_content},
308
+ }
309
+
310
+ if reply_to:
311
+ data["ReplyToAddresses"] = [reply_to]
312
+
313
+ if self._configuration_set:
314
+ data["ConfigurationSetName"] = self._configuration_set
315
+
316
+ # Serialize payload
317
+ payload = json.dumps(data).encode("utf-8")
318
+
319
+ # Sign request
320
+ request_headers = {
321
+ "Content-Type": "application/json",
322
+ }
323
+ signed_headers = self._sign_request("POST", path, request_headers, payload)
324
+
325
+ logger.debug("Sending email via SES to %s", to)
326
+
327
+ async with httpx.AsyncClient() as client:
328
+ response = await client.post(
329
+ f"{self._api_url}{path}",
330
+ content=payload,
331
+ headers=signed_headers,
332
+ timeout=30.0,
333
+ )
334
+
335
+ if response.status_code not in (200, 201, 202):
336
+ logger.error(
337
+ "SES API error: %s - %s",
338
+ response.status_code,
339
+ response.text,
340
+ )
341
+ response.raise_for_status()
342
+
343
+ # Extract message ID from response
344
+ result = response.json()
345
+ ses_message_id: str = result.get("MessageId", "")
346
+ logger.info("Email sent via SES: %s", ses_message_id)
347
+ return ses_message_id
348
+
349
+ # -------------------------------------------------------------------------
350
+ # SNS Webhook Infrastructure
351
+ # -------------------------------------------------------------------------
352
+
353
+ @staticmethod
354
+ def get_sns_message_type(payload: dict[str, Any]) -> str | None:
355
+ """Get the SNS message type from payload.
356
+
357
+ Args:
358
+ payload: SNS notification payload
359
+
360
+ Returns:
361
+ Message type string or None if not an SNS message
362
+ """
363
+ return payload.get("Type")
364
+
365
+ @staticmethod
366
+ def is_inbound_event(payload: dict[str, Any]) -> bool:
367
+ """Check if webhook is an inbound email event.
368
+
369
+ Args:
370
+ payload: Webhook payload (SNS notification)
371
+
372
+ Returns:
373
+ True if this is an inbound email notification
374
+ """
375
+ # Must be a Notification type
376
+ if payload.get("Type") != SNS_TYPE_NOTIFICATION:
377
+ return False
378
+
379
+ # Parse the Message field to check notificationType
380
+ message_str = payload.get("Message", "")
381
+ if not message_str:
382
+ return False
383
+
384
+ try:
385
+ message = json.loads(message_str)
386
+ notification_type: str | None = message.get("notificationType")
387
+ return notification_type == "Received"
388
+ except json.JSONDecodeError:
389
+ return False
390
+
391
+ @staticmethod
392
+ def get_event_type(payload: dict[str, Any]) -> str:
393
+ """Get event type from SES notification.
394
+
395
+ Args:
396
+ payload: Webhook payload (SNS notification with SES data)
397
+
398
+ Returns:
399
+ Event type string ("inbound" for received emails)
400
+ """
401
+ # Parse the Message field
402
+ message_str = payload.get("Message", "")
403
+ if message_str:
404
+ try:
405
+ message = json.loads(message_str)
406
+ notification_type: str = message.get("notificationType", "")
407
+ if notification_type == "Received":
408
+ return "inbound"
409
+ return notification_type.lower() if notification_type else "unknown"
410
+ except json.JSONDecodeError:
411
+ pass
412
+ return "unknown"
413
+
414
+ async def handle_subscription_confirmation(self, payload: dict[str, Any]) -> bool:
415
+ """Handle SNS subscription confirmation by fetching SubscribeURL.
416
+
417
+ Args:
418
+ payload: SNS SubscriptionConfirmation payload
419
+
420
+ Returns:
421
+ True if subscription was confirmed successfully
422
+ """
423
+ subscribe_url = payload.get("SubscribeURL")
424
+ if not subscribe_url:
425
+ logger.warning("SubscriptionConfirmation missing SubscribeURL")
426
+ return False
427
+
428
+ logger.info("Confirming SNS subscription: %s", payload.get("TopicArn"))
429
+
430
+ async with httpx.AsyncClient() as client:
431
+ response = await client.get(subscribe_url, timeout=30.0)
432
+ if response.status_code == 200:
433
+ logger.info("SNS subscription confirmed successfully")
434
+ return True
435
+ else:
436
+ logger.error(
437
+ "Failed to confirm SNS subscription: %s",
438
+ response.status_code,
439
+ )
440
+ return False
441
+
442
+ # -------------------------------------------------------------------------
443
+ # SNS Signature Verification
444
+ # -------------------------------------------------------------------------
445
+
446
+ def _validate_signing_cert_url(self, url: str) -> bool:
447
+ """Validate that SigningCertURL is from AWS SNS.
448
+
449
+ Args:
450
+ url: The SigningCertURL from SNS message
451
+
452
+ Returns:
453
+ True if URL is valid AWS SNS certificate URL
454
+ """
455
+ match = SNS_CERT_URL_PATTERN.match(url)
456
+ return match is not None
457
+
458
+ @staticmethod
459
+ @lru_cache(maxsize=10)
460
+ def _fetch_certificate_sync(url: str) -> x509.Certificate:
461
+ """Fetch and cache SNS signing certificate (sync for caching).
462
+
463
+ Args:
464
+ url: Certificate URL
465
+
466
+ Returns:
467
+ X.509 certificate object
468
+
469
+ Raises:
470
+ SESWebhookError: If certificate cannot be fetched or parsed
471
+ """
472
+ try:
473
+ with urllib.request.urlopen(url, timeout=10) as response:
474
+ cert_pem = response.read()
475
+ return x509.load_pem_x509_certificate(cert_pem)
476
+ except Exception as e:
477
+ raise SESWebhookError(f"Failed to fetch signing certificate: {e}") from e
478
+
479
+ def _build_sns_string_to_sign(self, payload: dict[str, Any], message_type: str) -> str:
480
+ """Build the canonical string to sign for SNS message verification.
481
+
482
+ Args:
483
+ payload: SNS message payload
484
+ message_type: SNS message type
485
+
486
+ Returns:
487
+ Canonical string to sign
488
+ """
489
+ # Fields to include depend on message type
490
+ if message_type == SNS_TYPE_NOTIFICATION:
491
+ fields = ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"]
492
+ else:
493
+ # SubscriptionConfirmation and UnsubscribeConfirmation
494
+ fields = [
495
+ "Message",
496
+ "MessageId",
497
+ "SubscribeURL",
498
+ "Timestamp",
499
+ "Token",
500
+ "TopicArn",
501
+ "Type",
502
+ ]
503
+
504
+ # Build string to sign
505
+ parts = []
506
+ for field in fields:
507
+ if field in payload:
508
+ parts.append(field)
509
+ parts.append(str(payload[field]))
510
+
511
+ return "\n".join(parts) + "\n"
512
+
513
+ def verify_webhook_signature(self, payload: dict[str, Any]) -> None:
514
+ """Verify SNS message signature using X.509 certificate.
515
+
516
+ Args:
517
+ payload: SNS message payload with Signature and SigningCertURL
518
+
519
+ Raises:
520
+ SESWebhookError: If verification fails
521
+ """
522
+ # Get required fields
523
+ signature_b64 = payload.get("Signature")
524
+ signing_cert_url = payload.get("SigningCertURL")
525
+ message_type = payload.get("Type")
526
+
527
+ if not signature_b64:
528
+ raise SESWebhookError("Missing Signature field")
529
+ if not signing_cert_url:
530
+ raise SESWebhookError("Missing SigningCertURL field")
531
+ if not message_type:
532
+ raise SESWebhookError("Missing Type field")
533
+
534
+ # Validate SigningCertURL is from AWS
535
+ if not self._validate_signing_cert_url(signing_cert_url):
536
+ raise SESWebhookError("Invalid SigningCertURL: must be from sns.*.amazonaws.com")
537
+
538
+ # Fetch certificate (cached)
539
+ cert = self._fetch_certificate_sync(signing_cert_url)
540
+
541
+ # Build string to sign
542
+ string_to_sign = self._build_sns_string_to_sign(payload, message_type)
543
+
544
+ # Decode signature
545
+ try:
546
+ signature = base64.b64decode(signature_b64)
547
+ except ValueError as e:
548
+ raise SESWebhookError(f"Invalid signature encoding: {e}") from e
549
+
550
+ # Verify signature
551
+ try:
552
+ public_key = cert.public_key()
553
+ # SNS uses RSA with PKCS1v15 padding and SHA1
554
+ from cryptography.hazmat.primitives.asymmetric import rsa
555
+
556
+ if isinstance(public_key, rsa.RSAPublicKey):
557
+ public_key.verify(
558
+ signature,
559
+ string_to_sign.encode("utf-8"),
560
+ padding.PKCS1v15(),
561
+ hashes.SHA1(), # SNS uses SHA1
562
+ )
563
+ else:
564
+ raise SESWebhookError("Certificate does not contain RSA public key")
565
+ except SESWebhookError:
566
+ raise
567
+ except Exception as e:
568
+ raise SESWebhookError(f"Signature verification failed: {e}") from e
569
+
570
+ # -------------------------------------------------------------------------
571
+ # Inbound Email Parsing
572
+ # -------------------------------------------------------------------------
573
+
574
+ def _parse_mime_content(
575
+ self, content: str | bytes
576
+ ) -> tuple[str, str | None, list[InboundAttachment], dict[str, str]]:
577
+ """Parse MIME content to extract body and attachments.
578
+
579
+ Args:
580
+ content: Raw MIME email content
581
+
582
+ Returns:
583
+ Tuple of (body_plain, body_html, attachments, content_id_map)
584
+ """
585
+ # Parse MIME message
586
+ if isinstance(content, str):
587
+ # Check if it looks like base64 (no whitespace/newlines at start, all valid b64 chars)
588
+ # Real MIME content starts with headers like "Return-Path:" or "Content-Type:"
589
+ if content.startswith(
590
+ (
591
+ "Return-Path:",
592
+ "Content-Type:",
593
+ "MIME-Version:",
594
+ "From:",
595
+ "To:",
596
+ "Subject:",
597
+ "Date:",
598
+ "Message-ID:",
599
+ )
600
+ ):
601
+ # It's plain text MIME, not base64
602
+ content_bytes = content.encode("utf-8")
603
+ else:
604
+ # Try to decode if it might be base64
605
+ try:
606
+ content_bytes = base64.b64decode(content)
607
+ # Verify it looks like valid MIME after decode
608
+ try:
609
+ content_bytes.decode("utf-8")
610
+ except UnicodeDecodeError:
611
+ # If decoded content isn't valid UTF-8 and original was,
612
+ # the original wasn't base64
613
+ content_bytes = content.encode("utf-8")
614
+ except Exception:
615
+ content_bytes = content.encode("utf-8")
616
+ else:
617
+ content_bytes = content
618
+
619
+ msg = message_from_bytes(content_bytes)
620
+
621
+ body_plain = ""
622
+ body_html: str | None = None
623
+ attachments: list[InboundAttachment] = []
624
+ content_id_map: dict[str, str] = {}
625
+
626
+ if msg.is_multipart():
627
+ for part in msg.walk():
628
+ content_type = part.get_content_type()
629
+ content_disposition = str(part.get("Content-Disposition", ""))
630
+
631
+ # Skip multipart containers
632
+ if part.is_multipart():
633
+ continue
634
+
635
+ # Get content
636
+ try:
637
+ part_payload = part.get_payload(decode=True)
638
+ if part_payload is None or not isinstance(part_payload, bytes):
639
+ continue
640
+ part_content: bytes = part_payload
641
+ except Exception:
642
+ continue
643
+
644
+ # Handle text parts
645
+ if content_type == "text/plain" and "attachment" not in content_disposition:
646
+ charset = part.get_content_charset() or "utf-8"
647
+ try:
648
+ body_plain = part_content.decode(charset, errors="replace")
649
+ except (UnicodeDecodeError, LookupError):
650
+ body_plain = part_content.decode("utf-8", errors="replace")
651
+
652
+ elif content_type == "text/html" and "attachment" not in content_disposition:
653
+ charset = part.get_content_charset() or "utf-8"
654
+ try:
655
+ body_html = part_content.decode(charset, errors="replace")
656
+ except (UnicodeDecodeError, LookupError):
657
+ body_html = part_content.decode("utf-8", errors="replace")
658
+
659
+ # Handle attachments
660
+ elif "attachment" in content_disposition or "inline" in content_disposition:
661
+ filename = part.get_filename() or "unnamed"
662
+ content_id_header = part.get("Content-ID", "")
663
+ content_id = str(content_id_header).strip("<>") if content_id_header else ""
664
+
665
+ disposition = AttachmentDisposition.ATTACHMENT
666
+ if "inline" in content_disposition or content_id:
667
+ disposition = AttachmentDisposition.INLINE
668
+
669
+ attachment = InboundAttachment(
670
+ filename=filename,
671
+ content_type=content_type,
672
+ content=part_content,
673
+ size_bytes=len(part_content),
674
+ disposition=disposition,
675
+ content_id=content_id if content_id else None,
676
+ )
677
+ attachments.append(attachment)
678
+
679
+ if content_id:
680
+ content_id_map[content_id] = filename
681
+
682
+ else:
683
+ # Simple non-multipart message
684
+ content_type = msg.get_content_type()
685
+ try:
686
+ msg_payload = msg.get_payload(decode=True)
687
+ if msg_payload is not None and isinstance(msg_payload, bytes):
688
+ charset = msg.get_content_charset() or "utf-8"
689
+ try:
690
+ text = msg_payload.decode(charset, errors="replace")
691
+ except (UnicodeDecodeError, LookupError):
692
+ text = msg_payload.decode("utf-8", errors="replace")
693
+ if content_type == "text/html":
694
+ body_html = text
695
+ else:
696
+ body_plain = text
697
+ except Exception:
698
+ pass
699
+
700
+ return body_plain, body_html, attachments, content_id_map
701
+
702
+ def _extract_email_from_address(self, address: str | list[str]) -> str:
703
+ """Extract email address from various formats.
704
+
705
+ Args:
706
+ address: Email address (possibly with display name)
707
+
708
+ Returns:
709
+ Clean email address
710
+ """
711
+ if isinstance(address, list):
712
+ address = address[0] if address else ""
713
+
714
+ # Extract from "Name <email>" format
715
+ if "<" in address and ">" in address:
716
+ return address.split("<")[1].split(">")[0]
717
+ return address
718
+
719
+ def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
720
+ """Parse SNS notification containing SES email data.
721
+
722
+ Args:
723
+ payload: SNS notification payload
724
+
725
+ Returns:
726
+ InboundMessage with parsed email data
727
+ """
728
+ # Extract the SES notification from SNS Message field
729
+ message_str = payload.get("Message", "")
730
+ if not message_str:
731
+ raise SESWebhookError("Missing Message field in SNS notification")
732
+
733
+ try:
734
+ ses_notification = json.loads(message_str)
735
+ except json.JSONDecodeError as e:
736
+ raise SESWebhookError(f"Invalid JSON in Message field: {e}") from e
737
+
738
+ # Extract receipt data for verification results
739
+ receipt = ses_notification.get("receipt", {})
740
+ spf_result = receipt.get("spfVerdict", {}).get("status")
741
+ dkim_result = receipt.get("dkimVerdict", {}).get("status")
742
+ dmarc_result = receipt.get("dmarcVerdict", {}).get("status")
743
+
744
+ # Extract mail metadata
745
+ mail = ses_notification.get("mail", {})
746
+ common_headers = mail.get("commonHeaders", {})
747
+
748
+ # Parse sender
749
+ from_list = common_headers.get("from", [])
750
+ from_address = self._extract_email_from_address(
751
+ from_list[0] if from_list else mail.get("source", "")
752
+ )
753
+
754
+ # Parse recipient
755
+ to_list = common_headers.get("to", [])
756
+ to_address = to_list[0] if to_list else ""
757
+ if isinstance(to_address, str):
758
+ to_address = self._extract_email_from_address(to_address)
759
+
760
+ # Parse subject
761
+ subject = common_headers.get("subject", "")
762
+
763
+ # Parse message ID and threading headers
764
+ message_id = common_headers.get("messageId")
765
+ in_reply_to = common_headers.get("inReplyTo")
766
+ references_raw = common_headers.get("references", "")
767
+ if isinstance(references_raw, str):
768
+ references = [ref.strip() for ref in references_raw.split() if ref.strip()]
769
+ elif isinstance(references_raw, list):
770
+ references = references_raw
771
+ else:
772
+ references = []
773
+
774
+ # Parse headers into dictionary
775
+ headers_list = mail.get("headers", [])
776
+ headers: dict[str, str] = {}
777
+ for header in headers_list:
778
+ name = header.get("name", "")
779
+ value = header.get("value", "")
780
+ if name:
781
+ headers[name] = value
782
+
783
+ # Parse timestamp
784
+ timestamp = datetime.now(UTC)
785
+ date_str = common_headers.get("date")
786
+ if date_str:
787
+ with contextlib.suppress(ValueError, TypeError):
788
+ timestamp = parsedate_to_datetime(date_str)
789
+
790
+ # Parse CC addresses
791
+ cc_list = common_headers.get("cc", [])
792
+ cc_addresses = [self._extract_email_from_address(addr) for addr in cc_list]
793
+
794
+ # Parse raw MIME content for body and attachments
795
+ content = ses_notification.get("content", "")
796
+ body_plain = ""
797
+ body_html: str | None = None
798
+ attachments: list[InboundAttachment] = []
799
+ content_id_map: dict[str, str] = {}
800
+
801
+ if content:
802
+ body_plain, body_html, attachments, content_id_map = self._parse_mime_content(content)
803
+
804
+ return InboundMessage(
805
+ from_address=from_address,
806
+ to_address=to_address,
807
+ subject=subject,
808
+ body_plain=body_plain,
809
+ body_html=body_html,
810
+ stripped_text=None, # SES doesn't provide stripped versions
811
+ stripped_html=None,
812
+ message_id=message_id,
813
+ in_reply_to=in_reply_to,
814
+ references=references,
815
+ headers=headers,
816
+ timestamp=timestamp,
817
+ attachments=attachments,
818
+ content_id_map=content_id_map,
819
+ spf_result=spf_result,
820
+ dkim_result=dkim_result,
821
+ dmarc_result=dmarc_result,
822
+ cc_addresses=cc_addresses,
823
+ bcc_addresses=[], # BCC not visible in received emails
824
+ )