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,344 @@
|
|
|
1
|
+
"""Resend webhook handler."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
10
|
+
|
|
11
|
+
from nornweave.adapters.resend import ResendAdapter, ResendWebhookError
|
|
12
|
+
from nornweave.core.config import Settings, get_settings
|
|
13
|
+
from nornweave.core.interfaces import (
|
|
14
|
+
StorageInterface, # noqa: TC001 - needed at runtime for FastAPI
|
|
15
|
+
)
|
|
16
|
+
from nornweave.models.message import Message, MessageDirection
|
|
17
|
+
from nornweave.models.thread import Thread
|
|
18
|
+
from nornweave.verdandi.parser import html_to_markdown
|
|
19
|
+
from nornweave.yggdrasil.dependencies import get_storage
|
|
20
|
+
|
|
21
|
+
router = APIRouter()
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Event types for email delivery tracking
|
|
26
|
+
DELIVERY_EVENT_TYPES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"email.sent",
|
|
29
|
+
"email.delivered",
|
|
30
|
+
"email.bounced",
|
|
31
|
+
"email.complained",
|
|
32
|
+
"email.failed",
|
|
33
|
+
"email.opened",
|
|
34
|
+
"email.clicked",
|
|
35
|
+
"email.delivery_delayed",
|
|
36
|
+
"email.scheduled",
|
|
37
|
+
"email.suppressed",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_resend_adapter(settings: Settings) -> ResendAdapter:
|
|
43
|
+
"""Create ResendAdapter with settings."""
|
|
44
|
+
return ResendAdapter(
|
|
45
|
+
api_key=settings.resend_api_key,
|
|
46
|
+
webhook_secret=settings.resend_webhook_secret,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/resend", status_code=status.HTTP_200_OK)
|
|
51
|
+
async def resend_webhook(
|
|
52
|
+
request: Request,
|
|
53
|
+
storage: StorageInterface = Depends(get_storage),
|
|
54
|
+
settings: Settings = Depends(get_settings),
|
|
55
|
+
) -> dict[str, str]:
|
|
56
|
+
"""Handle webhook from Resend.
|
|
57
|
+
|
|
58
|
+
Resend sends webhooks as JSON with Svix signature headers.
|
|
59
|
+
This handler:
|
|
60
|
+
1. Verifies the webhook signature (if secret is configured)
|
|
61
|
+
2. Routes to appropriate handler based on event type
|
|
62
|
+
3. For email.received: fetches full content, creates/resolves thread, stores message
|
|
63
|
+
4. For delivery events: logs the event for tracking
|
|
64
|
+
|
|
65
|
+
Supported event types:
|
|
66
|
+
- email.received: Inbound email (primary for NornWeave)
|
|
67
|
+
- email.sent: Outbound email accepted
|
|
68
|
+
- email.delivered: Successfully delivered
|
|
69
|
+
- email.bounced: Permanently rejected
|
|
70
|
+
- email.complained: Marked as spam
|
|
71
|
+
- email.failed: Failed to send
|
|
72
|
+
- email.opened: Recipient opened email
|
|
73
|
+
- email.clicked: Recipient clicked link
|
|
74
|
+
- email.delivery_delayed: Temporary delivery issue
|
|
75
|
+
- email.scheduled: Email scheduled
|
|
76
|
+
- email.suppressed: Suppressed by Resend
|
|
77
|
+
"""
|
|
78
|
+
# Get raw body for signature verification
|
|
79
|
+
raw_body = await request.body()
|
|
80
|
+
|
|
81
|
+
# Parse JSON payload
|
|
82
|
+
try:
|
|
83
|
+
payload: dict[str, Any] = await request.json()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error("Failed to parse JSON payload: %s", e)
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
88
|
+
detail="Invalid JSON payload",
|
|
89
|
+
) from e
|
|
90
|
+
|
|
91
|
+
# Create adapter
|
|
92
|
+
adapter = _get_resend_adapter(settings)
|
|
93
|
+
|
|
94
|
+
# Verify webhook signature if secret is configured
|
|
95
|
+
if settings.resend_webhook_secret:
|
|
96
|
+
try:
|
|
97
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
98
|
+
adapter.verify_webhook_signature(raw_body, headers)
|
|
99
|
+
logger.debug("Webhook signature verified successfully")
|
|
100
|
+
except ResendWebhookError as e:
|
|
101
|
+
logger.warning("Webhook signature verification failed: %s", e)
|
|
102
|
+
raise HTTPException(
|
|
103
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
104
|
+
detail=str(e),
|
|
105
|
+
) from e
|
|
106
|
+
else:
|
|
107
|
+
logger.warning("Webhook secret not configured, skipping signature verification")
|
|
108
|
+
|
|
109
|
+
# Get event type
|
|
110
|
+
event_type = ResendAdapter.get_event_type(payload)
|
|
111
|
+
logger.info("Received Resend webhook: %s", event_type)
|
|
112
|
+
|
|
113
|
+
# Route based on event type
|
|
114
|
+
if event_type == "email.received":
|
|
115
|
+
return await _handle_inbound_email(adapter, payload, storage)
|
|
116
|
+
elif event_type in DELIVERY_EVENT_TYPES:
|
|
117
|
+
return _handle_delivery_event(event_type, payload)
|
|
118
|
+
else:
|
|
119
|
+
logger.warning("Unknown event type: %s", event_type)
|
|
120
|
+
return {"status": "unknown_event", "event_type": event_type}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _handle_inbound_email(
|
|
124
|
+
adapter: ResendAdapter,
|
|
125
|
+
payload: dict[str, Any],
|
|
126
|
+
storage: StorageInterface,
|
|
127
|
+
) -> dict[str, str]:
|
|
128
|
+
"""Handle email.received event.
|
|
129
|
+
|
|
130
|
+
Fetches full email content from Resend API and stores the message.
|
|
131
|
+
"""
|
|
132
|
+
data = payload.get("data", {})
|
|
133
|
+
email_id = data.get("email_id", "unknown")
|
|
134
|
+
|
|
135
|
+
logger.info(
|
|
136
|
+
"Processing inbound email %s from %s to %s",
|
|
137
|
+
email_id,
|
|
138
|
+
data.get("from"),
|
|
139
|
+
data.get("to"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Parse webhook and fetch full content
|
|
143
|
+
content_fetch_failed = False
|
|
144
|
+
try:
|
|
145
|
+
inbound = await adapter.parse_inbound_webhook_with_content(
|
|
146
|
+
payload,
|
|
147
|
+
fetch_attachments=True,
|
|
148
|
+
)
|
|
149
|
+
except httpx.HTTPStatusError as e:
|
|
150
|
+
# If content fetch fails (e.g., API key lacks read permission),
|
|
151
|
+
# fall back to webhook metadata only and continue processing.
|
|
152
|
+
# This ensures the message is still created, but with limited content.
|
|
153
|
+
if e.response.status_code == 401:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Could not fetch full email content (API key restricted). "
|
|
156
|
+
"Processing with webhook metadata only. "
|
|
157
|
+
"To get full email content, use an API key with 'Full access' permission."
|
|
158
|
+
)
|
|
159
|
+
content_fetch_failed = True
|
|
160
|
+
inbound = adapter.parse_inbound_webhook(payload)
|
|
161
|
+
elif e.response.status_code == 404:
|
|
162
|
+
logger.warning(
|
|
163
|
+
"Email %s not found in Resend API. Processing with webhook metadata only.",
|
|
164
|
+
email_id,
|
|
165
|
+
)
|
|
166
|
+
content_fetch_failed = True
|
|
167
|
+
inbound = adapter.parse_inbound_webhook(payload)
|
|
168
|
+
else:
|
|
169
|
+
logger.error("Failed to fetch email content: %s", e)
|
|
170
|
+
raise HTTPException(
|
|
171
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
172
|
+
detail=f"Failed to process email: {e}",
|
|
173
|
+
) from e
|
|
174
|
+
except ValueError as e:
|
|
175
|
+
logger.error("Failed to parse webhook payload: %s", e)
|
|
176
|
+
raise HTTPException(
|
|
177
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
178
|
+
detail=f"Failed to parse webhook: {e}",
|
|
179
|
+
) from e
|
|
180
|
+
|
|
181
|
+
# Find inbox by recipient email address
|
|
182
|
+
inbox = await storage.get_inbox_by_email(inbound.to_address)
|
|
183
|
+
if inbox is None:
|
|
184
|
+
logger.warning("No inbox found for recipient: %s", inbound.to_address)
|
|
185
|
+
# Return 200 to prevent Resend from retrying, but log the issue
|
|
186
|
+
return {"status": "no_inbox", "recipient": inbound.to_address}
|
|
187
|
+
|
|
188
|
+
logger.info("Found inbox %s for recipient %s", inbox.id, inbound.to_address)
|
|
189
|
+
|
|
190
|
+
# Try to find existing thread by Message-ID references (for replies)
|
|
191
|
+
thread_id: str | None = None
|
|
192
|
+
|
|
193
|
+
# Check In-Reply-To header first
|
|
194
|
+
if inbound.in_reply_to:
|
|
195
|
+
existing_msg = await storage.get_message_by_provider_id(inbox.id, inbound.in_reply_to)
|
|
196
|
+
if existing_msg:
|
|
197
|
+
thread_id = existing_msg.thread_id
|
|
198
|
+
logger.debug("Found thread via In-Reply-To: %s", thread_id)
|
|
199
|
+
|
|
200
|
+
# Check References header
|
|
201
|
+
if not thread_id and inbound.references:
|
|
202
|
+
for ref in inbound.references:
|
|
203
|
+
existing_msg = await storage.get_message_by_provider_id(inbox.id, ref)
|
|
204
|
+
if existing_msg:
|
|
205
|
+
thread_id = existing_msg.thread_id
|
|
206
|
+
logger.debug("Found thread via References: %s", thread_id)
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
# If no thread found, create a new one
|
|
210
|
+
if thread_id:
|
|
211
|
+
thread = await storage.get_thread(thread_id)
|
|
212
|
+
else:
|
|
213
|
+
# Create new thread
|
|
214
|
+
new_thread = Thread(
|
|
215
|
+
thread_id=str(uuid.uuid4()),
|
|
216
|
+
inbox_id=inbox.id,
|
|
217
|
+
subject=inbound.subject,
|
|
218
|
+
timestamp=inbound.timestamp,
|
|
219
|
+
senders=[inbound.from_address],
|
|
220
|
+
recipients=[inbound.to_address],
|
|
221
|
+
)
|
|
222
|
+
thread = await storage.create_thread(new_thread)
|
|
223
|
+
thread_id = thread.id
|
|
224
|
+
logger.info("Created new thread %s for subject: %s", thread_id, inbound.subject)
|
|
225
|
+
|
|
226
|
+
# Convert HTML to Markdown for clean content
|
|
227
|
+
# Use stripped versions if available (Mailgun), fall back to full body (Resend)
|
|
228
|
+
content_clean = inbound.stripped_text or inbound.body_plain
|
|
229
|
+
extracted_html = inbound.stripped_html or inbound.body_html
|
|
230
|
+
if inbound.stripped_html:
|
|
231
|
+
content_clean = html_to_markdown(inbound.stripped_html)
|
|
232
|
+
elif inbound.body_html:
|
|
233
|
+
content_clean = html_to_markdown(inbound.body_html)
|
|
234
|
+
|
|
235
|
+
# Create the message
|
|
236
|
+
message = Message(
|
|
237
|
+
message_id=str(uuid.uuid4()),
|
|
238
|
+
thread_id=thread_id,
|
|
239
|
+
inbox_id=inbox.id,
|
|
240
|
+
provider_message_id=inbound.message_id,
|
|
241
|
+
direction=MessageDirection.INBOUND,
|
|
242
|
+
from_address=inbound.from_address,
|
|
243
|
+
to=[inbound.to_address, *inbound.cc_addresses],
|
|
244
|
+
subject=inbound.subject,
|
|
245
|
+
text=inbound.body_plain,
|
|
246
|
+
html=inbound.body_html,
|
|
247
|
+
extracted_text=content_clean,
|
|
248
|
+
extracted_html=extracted_html,
|
|
249
|
+
in_reply_to=inbound.in_reply_to,
|
|
250
|
+
references=inbound.references if inbound.references else None,
|
|
251
|
+
headers=inbound.headers,
|
|
252
|
+
timestamp=inbound.timestamp,
|
|
253
|
+
created_at=datetime.now(UTC),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
created_message = await storage.create_message(message)
|
|
257
|
+
logger.info("Created message %s in thread %s", created_message.id, thread_id)
|
|
258
|
+
|
|
259
|
+
# Store attachments if present
|
|
260
|
+
for att in inbound.attachments:
|
|
261
|
+
if att.content and att.size_bytes > 0:
|
|
262
|
+
try:
|
|
263
|
+
await storage.create_attachment(
|
|
264
|
+
message_id=created_message.id,
|
|
265
|
+
filename=att.filename,
|
|
266
|
+
content_type=att.content_type,
|
|
267
|
+
size_bytes=att.size_bytes,
|
|
268
|
+
disposition=att.disposition.value,
|
|
269
|
+
content_id=att.content_id,
|
|
270
|
+
)
|
|
271
|
+
logger.debug("Created attachment record for %s", att.filename)
|
|
272
|
+
except (ValueError, RuntimeError) as e:
|
|
273
|
+
logger.warning("Failed to create attachment record: %s", e)
|
|
274
|
+
|
|
275
|
+
# Update thread's last_message_at
|
|
276
|
+
if thread:
|
|
277
|
+
thread.last_message_at = created_message.created_at
|
|
278
|
+
thread.received_timestamp = created_message.created_at
|
|
279
|
+
await storage.update_thread(thread)
|
|
280
|
+
|
|
281
|
+
result = {
|
|
282
|
+
"status": "received",
|
|
283
|
+
"message_id": created_message.id,
|
|
284
|
+
"thread_id": thread_id or "",
|
|
285
|
+
"email_id": email_id,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if content_fetch_failed:
|
|
289
|
+
result["warning"] = (
|
|
290
|
+
"Email content could not be fetched from Resend API. "
|
|
291
|
+
"Message was created with metadata only (no body content). "
|
|
292
|
+
"Ensure your API key has 'Full access' permission."
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _handle_delivery_event(event_type: str, payload: dict[str, Any]) -> dict[str, str]:
|
|
299
|
+
"""Handle delivery-related events (sent, delivered, bounced, etc.).
|
|
300
|
+
|
|
301
|
+
These events are useful for tracking outbound email delivery status.
|
|
302
|
+
For now, we just log them. In the future, these could update message status.
|
|
303
|
+
"""
|
|
304
|
+
data = payload.get("data", {})
|
|
305
|
+
email_id = data.get("email_id", "unknown")
|
|
306
|
+
created_at = payload.get("created_at", "")
|
|
307
|
+
|
|
308
|
+
# Log event details
|
|
309
|
+
logger.info(
|
|
310
|
+
"Resend delivery event: %s for email %s at %s",
|
|
311
|
+
event_type,
|
|
312
|
+
email_id,
|
|
313
|
+
created_at,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# For bounced/complained events, log additional details
|
|
317
|
+
if event_type == "email.bounced":
|
|
318
|
+
bounce_info = data.get("bounce", {})
|
|
319
|
+
logger.warning(
|
|
320
|
+
"Email %s bounced: type=%s, subType=%s, message=%s",
|
|
321
|
+
email_id,
|
|
322
|
+
bounce_info.get("type"),
|
|
323
|
+
bounce_info.get("subType"),
|
|
324
|
+
bounce_info.get("message"),
|
|
325
|
+
)
|
|
326
|
+
elif event_type == "email.complained":
|
|
327
|
+
logger.warning(
|
|
328
|
+
"Email %s marked as spam by recipient: %s",
|
|
329
|
+
email_id,
|
|
330
|
+
data.get("to", []),
|
|
331
|
+
)
|
|
332
|
+
elif event_type == "email.failed":
|
|
333
|
+
logger.error(
|
|
334
|
+
"Email %s failed to send: subject=%s, to=%s",
|
|
335
|
+
email_id,
|
|
336
|
+
data.get("subject"),
|
|
337
|
+
data.get("to", []),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"status": "acknowledged",
|
|
342
|
+
"event_type": event_type,
|
|
343
|
+
"email_id": email_id,
|
|
344
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SendGrid webhook handler."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request, status
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.post("/sendgrid", status_code=status.HTTP_200_OK)
|
|
9
|
+
async def sendgrid_webhook(_request: Request) -> dict[str, str]:
|
|
10
|
+
"""Handle inbound email webhook from SendGrid.
|
|
11
|
+
|
|
12
|
+
TODO: Parse webhook payload, create/resolve thread, store message.
|
|
13
|
+
"""
|
|
14
|
+
# Placeholder - will be implemented with Verdandi (ingestion engine)
|
|
15
|
+
return {"status": "received"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""AWS SES webhook handler."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request, status
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.post("/ses", status_code=status.HTTP_200_OK)
|
|
9
|
+
async def ses_webhook(_request: Request) -> dict[str, str]:
|
|
10
|
+
"""Handle inbound email webhook from AWS SES.
|
|
11
|
+
|
|
12
|
+
TODO: Parse webhook payload (SNS notification), create/resolve thread, store message.
|
|
13
|
+
"""
|
|
14
|
+
# Placeholder - will be implemented with Verdandi (ingestion engine)
|
|
15
|
+
return {"status": "received"}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nornweave
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Open-source, self-hosted Inbox-as-a-Service API for AI Agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/DataCovey/nornweave
|
|
6
|
+
Project-URL: Documentation, https://nornweave.datacovey.com/docs/
|
|
7
|
+
Project-URL: Repository, https://github.com/DataCovey/nornweave
|
|
8
|
+
Project-URL: Issues, https://github.com/DataCovey/nornweave/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/DataCovey/nornweave/blob/main/CHANGELOG.md
|
|
10
|
+
Author: NornWeave Contributors
|
|
11
|
+
Maintainer: NornWeave Contributors
|
|
12
|
+
License: Apache-2.0
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: agents,ai,api,email,inbox,llm,mcp
|
|
15
|
+
Classifier: Development Status :: 3 - Alpha
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Communications :: Email
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.14
|
|
25
|
+
Requires-Dist: aiosqlite>=0.20.0
|
|
26
|
+
Requires-Dist: alembic>=1.14.0
|
|
27
|
+
Requires-Dist: click>=8.1.0
|
|
28
|
+
Requires-Dist: cryptography>=44.0.0
|
|
29
|
+
Requires-Dist: email-validator>=2.2.0
|
|
30
|
+
Requires-Dist: fastapi>=0.115.0
|
|
31
|
+
Requires-Dist: html2text>=2024.2.26
|
|
32
|
+
Requires-Dist: httpx>=0.28.0
|
|
33
|
+
Requires-Dist: markdown>=3.7
|
|
34
|
+
Requires-Dist: pydantic-settings>=2.6.0
|
|
35
|
+
Requires-Dist: pydantic>=2.10.0
|
|
36
|
+
Requires-Dist: python-multipart>=0.0.18
|
|
37
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.36
|
|
38
|
+
Requires-Dist: svix>=1.24.0
|
|
39
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
40
|
+
Provides-Extra: all
|
|
41
|
+
Requires-Dist: asyncpg>=0.30.0; extra == 'all'
|
|
42
|
+
Requires-Dist: fastmcp>=2.0.0; extra == 'all'
|
|
43
|
+
Requires-Dist: mcp>=1.0.0; extra == 'all'
|
|
44
|
+
Requires-Dist: openai>=1.57.0; extra == 'all'
|
|
45
|
+
Requires-Dist: pgvector>=0.3.6; extra == 'all'
|
|
46
|
+
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'all'
|
|
47
|
+
Requires-Dist: pypdf>=5.1.0; extra == 'all'
|
|
48
|
+
Requires-Dist: python-magic>=0.4.27; extra == 'all'
|
|
49
|
+
Requires-Dist: redis>=5.2.0; extra == 'all'
|
|
50
|
+
Provides-Extra: attachments
|
|
51
|
+
Requires-Dist: pypdf>=5.1.0; extra == 'attachments'
|
|
52
|
+
Requires-Dist: python-magic>=0.4.27; extra == 'attachments'
|
|
53
|
+
Provides-Extra: dev
|
|
54
|
+
Requires-Dist: aiosqlite>=0.20.0; extra == 'dev'
|
|
55
|
+
Requires-Dist: httpx>=0.28.0; extra == 'dev'
|
|
56
|
+
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
57
|
+
Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
|
|
58
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
59
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
60
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
61
|
+
Requires-Dist: ruff>=0.8.0; extra == 'dev'
|
|
62
|
+
Provides-Extra: mcp
|
|
63
|
+
Requires-Dist: fastmcp>=2.0.0; extra == 'mcp'
|
|
64
|
+
Requires-Dist: mcp>=1.0.0; extra == 'mcp'
|
|
65
|
+
Provides-Extra: postgres
|
|
66
|
+
Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
|
|
67
|
+
Requires-Dist: psycopg2-binary>=2.9.0; extra == 'postgres'
|
|
68
|
+
Provides-Extra: ratelimit
|
|
69
|
+
Requires-Dist: redis>=5.2.0; extra == 'ratelimit'
|
|
70
|
+
Provides-Extra: search
|
|
71
|
+
Requires-Dist: openai>=1.57.0; extra == 'search'
|
|
72
|
+
Requires-Dist: pgvector>=0.3.6; extra == 'search'
|
|
73
|
+
Description-Content-Type: text/markdown
|
|
74
|
+
|
|
75
|
+
<p align="center">
|
|
76
|
+
<img src="web/static/images/Nornorna_spinner.jpg" alt="The Norns weaving fate at Yggdrasil" width="400">
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
<h1 align="center">NornWeave</h1>
|
|
80
|
+
|
|
81
|
+
<p align="center">
|
|
82
|
+
<em>"Laws they made there, and life allotted / To the sons of men, and set their fates."</em><br>
|
|
83
|
+
- Voluspa (The Prophecy of the Seeress), Poetic Edda, Stanza 20
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
<p align="center">
|
|
87
|
+
<strong>Open-source, self-hosted Inbox-as-a-Service API for AI Agents</strong>
|
|
88
|
+
</p>
|
|
89
|
+
|
|
90
|
+
<p align="center">
|
|
91
|
+
<a href="https://github.com/DataCovey/nornweave/actions"><img src="https://github.com/DataCovey/nornweave/workflows/CI/badge.svg" alt="CI Status"></a>
|
|
92
|
+
<a href="https://github.com/DataCovey/nornweave/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
|
|
93
|
+
</p>
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## What is NornWeave?
|
|
98
|
+
|
|
99
|
+
Standard email APIs are stateless and built for transactional sending. **NornWeave** adds a **stateful layer** (Inboxes, Threads, History) and an **intelligent layer** (Markdown parsing, Semantic Search) to make email consumable by LLMs via REST or MCP.
|
|
100
|
+
|
|
101
|
+
In Norse mythology, the Norns (Urdr, Verdandi, and Skuld) dwell at the base of Yggdrasil, the World Tree. They weave the tapestry of fate for all beings. Similarly, NornWeave:
|
|
102
|
+
|
|
103
|
+
- Takes raw "water" (incoming email data streams)
|
|
104
|
+
- Weaves disconnected messages into coherent **Threads** (the Tapestry)
|
|
105
|
+
- Nourishes AI Agents with clean, structured context
|
|
106
|
+
|
|
107
|
+
## Features
|
|
108
|
+
|
|
109
|
+
### Foundation (The Mail Proxy)
|
|
110
|
+
- **Virtual Inboxes**: Create email addresses for your AI agents
|
|
111
|
+
- **Webhook Ingestion**: Receive emails from Mailgun, SES, SendGrid, Resend
|
|
112
|
+
- **Persistent Storage**: PostgreSQL with abstracted storage adapters
|
|
113
|
+
- **Email Sending**: Send replies through your configured provider
|
|
114
|
+
|
|
115
|
+
### Intelligence (The Agent Layer)
|
|
116
|
+
- **Content Parsing**: HTML to clean Markdown, cruft removal
|
|
117
|
+
- **Threading**: Automatic conversation grouping via email headers
|
|
118
|
+
- **MCP Server**: Connect directly to Claude, Cursor, and other MCP clients
|
|
119
|
+
- **Attachment Processing**: Extract text from PDFs and documents
|
|
120
|
+
|
|
121
|
+
### Enterprise (The Platform Layer)
|
|
122
|
+
- **Semantic Search**: Vector embeddings with pgvector
|
|
123
|
+
- **Real-time Webhooks**: Get notified of new messages
|
|
124
|
+
- **Rate Limiting**: Protect against runaway agents
|
|
125
|
+
- **Multi-Tenancy**: Organizations and projects
|
|
126
|
+
|
|
127
|
+
## Quick Start
|
|
128
|
+
|
|
129
|
+
### Install from PyPI
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Base installation (SQLite, all email providers)
|
|
133
|
+
pip install nornweave
|
|
134
|
+
|
|
135
|
+
# With PostgreSQL support
|
|
136
|
+
pip install nornweave[postgres]
|
|
137
|
+
|
|
138
|
+
# With MCP server for AI agents
|
|
139
|
+
pip install nornweave[mcp]
|
|
140
|
+
|
|
141
|
+
# Full installation
|
|
142
|
+
pip install nornweave[all]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Using Docker (Recommended for Production)
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Clone the repository
|
|
149
|
+
git clone https://github.com/DataCovey/nornweave.git
|
|
150
|
+
cd nornweave
|
|
151
|
+
|
|
152
|
+
# Copy environment configuration
|
|
153
|
+
cp .env.example .env
|
|
154
|
+
# Edit .env with your API keys
|
|
155
|
+
|
|
156
|
+
# Start the stack
|
|
157
|
+
docker compose up -d
|
|
158
|
+
|
|
159
|
+
# Run migrations
|
|
160
|
+
docker compose exec api alembic upgrade head
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Using uv (Development)
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# Clone the repository
|
|
167
|
+
git clone https://github.com/DataCovey/nornweave.git
|
|
168
|
+
cd nornweave
|
|
169
|
+
|
|
170
|
+
# Install dependencies
|
|
171
|
+
make install-dev
|
|
172
|
+
|
|
173
|
+
# Copy environment configuration
|
|
174
|
+
cp .env.example .env
|
|
175
|
+
|
|
176
|
+
# Start PostgreSQL (or use your own)
|
|
177
|
+
docker compose up -d postgres
|
|
178
|
+
|
|
179
|
+
# Run migrations
|
|
180
|
+
make migrate
|
|
181
|
+
|
|
182
|
+
# Start the development server
|
|
183
|
+
make dev
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Configure Your Email Provider
|
|
187
|
+
|
|
188
|
+
1. Set `EMAIL_PROVIDER` in `.env` (e.g., `mailgun`)
|
|
189
|
+
2. Add your provider's API key
|
|
190
|
+
3. Configure the webhook URL in your provider's dashboard:
|
|
191
|
+
```
|
|
192
|
+
https://your-server.com/webhooks/mailgun
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## API Overview
|
|
196
|
+
|
|
197
|
+
### Create an Inbox
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
curl -X POST http://localhost:8000/v1/inboxes \
|
|
201
|
+
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
202
|
+
-H "Content-Type: application/json" \
|
|
203
|
+
-d '{"name": "Support Agent", "email_username": "support"}'
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Read a Thread
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
curl http://localhost:8000/v1/threads/th_123 \
|
|
210
|
+
-H "Authorization: Bearer YOUR_API_KEY"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Response (LLM-optimized):
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"id": "th_123",
|
|
217
|
+
"subject": "Re: Pricing Question",
|
|
218
|
+
"messages": [
|
|
219
|
+
{ "role": "user", "author": "bob@gmail.com", "content": "How much is it?", "timestamp": "..." },
|
|
220
|
+
{ "role": "assistant", "author": "agent@myco.com", "content": "$20/mo", "timestamp": "..." }
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Send a Reply
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
curl -X POST http://localhost:8000/v1/messages \
|
|
229
|
+
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
230
|
+
-H "Content-Type: application/json" \
|
|
231
|
+
-d '{
|
|
232
|
+
"inbox_id": "ibx_555",
|
|
233
|
+
"reply_to_thread_id": "th_123",
|
|
234
|
+
"to": ["client@gmail.com"],
|
|
235
|
+
"subject": "Re: Pricing Question",
|
|
236
|
+
"body": "Thanks for your interest! Our pricing starts at $20/mo."
|
|
237
|
+
}'
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## MCP Integration
|
|
241
|
+
|
|
242
|
+
NornWeave exposes an MCP server for direct integration with Claude, Cursor, and other MCP-compatible clients.
|
|
243
|
+
|
|
244
|
+
### Available Tools
|
|
245
|
+
|
|
246
|
+
| Tool | Description |
|
|
247
|
+
|------|-------------|
|
|
248
|
+
| `create_inbox` | Provision a new email address |
|
|
249
|
+
| `send_email` | Send an email (auto-converts Markdown to HTML) |
|
|
250
|
+
| `search_email` | Find relevant messages in your inbox |
|
|
251
|
+
| `wait_for_reply` | Block until a reply arrives (experimental) |
|
|
252
|
+
|
|
253
|
+
### Configure in Cursor/Claude
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
pip install nornweave[mcp]
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"mcpServers": {
|
|
262
|
+
"nornweave": {
|
|
263
|
+
"command": "nornweave",
|
|
264
|
+
"args": ["mcp"],
|
|
265
|
+
"env": {
|
|
266
|
+
"NORNWEAVE_API_URL": "http://localhost:8000"
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Architecture
|
|
274
|
+
|
|
275
|
+
NornWeave uses a thematic architecture inspired by Norse mythology:
|
|
276
|
+
|
|
277
|
+
| Component | Name | Purpose |
|
|
278
|
+
|-----------|------|---------|
|
|
279
|
+
| Storage Layer | **Urdr** (The Well) | Database adapters (PostgreSQL, SQLite) |
|
|
280
|
+
| Ingestion Engine | **Verdandi** (The Loom) | Webhook processing, HTML to Markdown |
|
|
281
|
+
| API & Outbound | **Skuld** (The Prophecy) | REST API, email sending, rate limiting |
|
|
282
|
+
| Gateway | **Yggdrasil** | API router connecting all providers |
|
|
283
|
+
| MCP Tools | **Huginn & Muninn** | Read/write tools for AI agents |
|
|
284
|
+
|
|
285
|
+
## Supported Providers
|
|
286
|
+
|
|
287
|
+
| Provider | Sending | Receiving | Auto-Route Setup |
|
|
288
|
+
|----------|---------|-----------|------------------|
|
|
289
|
+
| Mailgun | yes | yes | yes |
|
|
290
|
+
| AWS SES | yes | yes | manual |
|
|
291
|
+
| SendGrid | yes | yes | yes |
|
|
292
|
+
| Resend | yes | yes | yes |
|
|
293
|
+
|
|
294
|
+
## Documentation
|
|
295
|
+
|
|
296
|
+
- [Getting Started Guide](https://nornweave.datacovey.com/docs/getting-started/)
|
|
297
|
+
- [API Reference](https://nornweave.datacovey.com/docs/api/)
|
|
298
|
+
- [Architecture Overview](https://nornweave.datacovey.com/docs/concepts/architecture/)
|
|
299
|
+
- [Provider Setup Guides](https://nornweave.datacovey.com/docs/guides/)
|
|
300
|
+
|
|
301
|
+
## Repository Structure
|
|
302
|
+
|
|
303
|
+
This is a monorepo:
|
|
304
|
+
|
|
305
|
+
- **`src/nornweave/`** – Main NornWeave server components
|
|
306
|
+
- **`clients/python/`** – Python client SDK (`nornweave-client`)
|
|
307
|
+
- **`packages/n8n-nodes-nornweave/`** – n8n community node (`@nornweave/n8n-nodes-nornweave`)
|
|
308
|
+
|
|
309
|
+
The root **`credentials`** symlink points to `packages/n8n-nodes-nornweave/credentials` so n8n’s package verification can find the credential file when checking the repo.
|
|
310
|
+
|
|
311
|
+
## Contributing
|
|
312
|
+
|
|
313
|
+
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
NornWeave is open-source software licensed under the [Apache 2.0 License](LICENSE).
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
<p align="center">
|
|
322
|
+
<sub>Image: "Nornorna spinner odet tradar vid Yggdrasil" by L. B. Hansen</sub><br>
|
|
323
|
+
<sub><a href="https://commons.wikimedia.org/w/index.php?curid=164065">Public Domain</a></sub>
|
|
324
|
+
</p>
|