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