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,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
|
+
)
|