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