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,202 @@
1
+ """MCP server setup (Huginn & Muninn).
2
+
3
+ This module provides the FastMCP server that exposes NornWeave email capabilities
4
+ to AI agents via the Model Context Protocol.
5
+
6
+ Supports three transports:
7
+ - stdio: For Claude Desktop, Cursor, and local CLI usage (default)
8
+ - sse: For web-based MCP clients and browser integrations
9
+ - http: For remote/cloud deployments and LangChain integration
10
+ """
11
+
12
+ from typing import Any, Literal
13
+
14
+ from fastmcp import FastMCP
15
+
16
+ from nornweave.huginn.client import NornWeaveClient
17
+ from nornweave.huginn.resources import get_recent_threads, get_thread_content
18
+ from nornweave.muninn.tools import create_inbox, search_email, send_email, wait_for_reply
19
+
20
+ # Create the FastMCP server
21
+ mcp = FastMCP(
22
+ name="nornweave",
23
+ version="0.1.2",
24
+ instructions="Email capabilities for AI agents - create inboxes, send emails, search messages",
25
+ )
26
+
27
+ # Global client instance (created on first use)
28
+ _client: NornWeaveClient | None = None
29
+
30
+
31
+ def _get_client() -> NornWeaveClient:
32
+ """Get or create the NornWeave API client."""
33
+ global _client
34
+ if _client is None:
35
+ _client = NornWeaveClient()
36
+ return _client
37
+
38
+
39
+ # -----------------------------------------------------------------------------
40
+ # Resources (Read-only data access)
41
+ # -----------------------------------------------------------------------------
42
+
43
+
44
+ @mcp.resource("email://inbox/{inbox_id}/recent")
45
+ async def resource_recent_threads(inbox_id: str) -> str:
46
+ """Get recent threads for an inbox.
47
+
48
+ Returns the 10 most recent thread summaries with id, subject,
49
+ last_message_at, message_count, and participants.
50
+ """
51
+ client = _get_client()
52
+ return await get_recent_threads(client, inbox_id)
53
+
54
+
55
+ @mcp.resource("email://thread/{thread_id}")
56
+ async def resource_thread_content(thread_id: str) -> str:
57
+ """Get thread content in Markdown format.
58
+
59
+ Returns the full thread conversation formatted as Markdown,
60
+ optimized for LLM context windows.
61
+ """
62
+ client = _get_client()
63
+ return await get_thread_content(client, thread_id)
64
+
65
+
66
+ @mcp.tool()
67
+ async def tool_create_inbox(name: str, username: str) -> dict[str, Any]:
68
+ """Create a new inbox.
69
+
70
+ Provision a new email address for the agent.
71
+
72
+ Args:
73
+ name: Display name for the inbox (e.g., "Support Bot")
74
+ username: Local part of email address (e.g., "support" becomes support@yourdomain.com)
75
+
76
+ Returns:
77
+ Created inbox with id, email_address, and name.
78
+ """
79
+ client = _get_client()
80
+ return await create_inbox(client, name=name, username=username)
81
+
82
+
83
+ @mcp.tool()
84
+ async def tool_send_email(
85
+ inbox_id: str,
86
+ recipient: str,
87
+ subject: str,
88
+ body: str,
89
+ thread_id: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ """Send an email.
92
+
93
+ Send an email from an inbox. The body content should be in Markdown format
94
+ and will be automatically converted to HTML.
95
+
96
+ Args:
97
+ inbox_id: The inbox ID to send from
98
+ recipient: Email address to send to
99
+ subject: Email subject line
100
+ body: Email body in Markdown format
101
+ thread_id: Optional thread ID if this is a reply to an existing thread
102
+
103
+ Returns:
104
+ Send response with message_id, thread_id, and status.
105
+ """
106
+ client = _get_client()
107
+ return await send_email(
108
+ client,
109
+ inbox_id=inbox_id,
110
+ recipient=recipient,
111
+ subject=subject,
112
+ body=body,
113
+ thread_id=thread_id,
114
+ )
115
+
116
+
117
+ @mcp.tool()
118
+ async def tool_search_email(query: str, inbox_id: str, limit: int = 10) -> dict[str, Any]:
119
+ """Search for emails.
120
+
121
+ Find relevant messages in an inbox by query text.
122
+
123
+ Args:
124
+ query: Search query (e.g., "invoice", "meeting request")
125
+ inbox_id: Inbox to search in
126
+ limit: Maximum number of results (default: 10, max: 100)
127
+
128
+ Returns:
129
+ Search results with matching messages containing id, thread_id, content, created_at.
130
+ """
131
+ client = _get_client()
132
+ return await search_email(client, query=query, inbox_id=inbox_id, limit=min(limit, 100))
133
+
134
+
135
+ @mcp.tool()
136
+ async def tool_wait_for_reply(thread_id: str, timeout_seconds: int = 300) -> dict[str, Any]:
137
+ """Wait for a reply in a thread (experimental).
138
+
139
+ Block execution until a new email arrives in the specified thread.
140
+ Useful for synchronous agent workflows that need to wait for responses.
141
+
142
+ Args:
143
+ thread_id: Thread ID to wait on
144
+ timeout_seconds: Maximum wait time in seconds (default: 300 / 5 minutes)
145
+
146
+ Returns:
147
+ If reply received: {"received": true, "message": {...}}
148
+ If timeout: {"received": false, "timeout": true, "waited_seconds": N}
149
+
150
+ Note:
151
+ This is an experimental feature that uses polling. It may not be suitable
152
+ for all use cases and could incur additional API calls.
153
+ """
154
+ client = _get_client()
155
+ return await wait_for_reply(client, thread_id=thread_id, timeout_seconds=timeout_seconds)
156
+
157
+
158
+ # -----------------------------------------------------------------------------
159
+ # Server Entry Points
160
+ # -----------------------------------------------------------------------------
161
+
162
+
163
+ def serve(
164
+ transport: Literal["stdio", "sse", "http"] = "stdio",
165
+ host: str = "0.0.0.0",
166
+ port: int = 3000,
167
+ ) -> None:
168
+ """Run the MCP server.
169
+
170
+ Args:
171
+ transport: Transport type - "stdio" (default), "sse", or "http"
172
+ host: Host to bind for SSE/HTTP transports (default: 0.0.0.0)
173
+ port: Port for SSE/HTTP transports (default: 3000)
174
+ """
175
+ if transport == "stdio":
176
+ mcp.run()
177
+ elif transport == "sse":
178
+ mcp.run(transport="sse", host=host, port=port)
179
+ elif transport == "http":
180
+ mcp.run(transport="streamable-http", host=host, port=port)
181
+ else:
182
+ raise ValueError(f"Unknown transport: {transport}")
183
+
184
+
185
+ def serve_stdio() -> None:
186
+ """Run MCP server with stdio transport (for MCP entry point)."""
187
+ serve(transport="stdio")
188
+
189
+
190
+ def serve_sse(host: str = "0.0.0.0", port: int = 3000) -> None:
191
+ """Run MCP server with SSE transport."""
192
+ serve(transport="sse", host=host, port=port)
193
+
194
+
195
+ def serve_http(host: str = "0.0.0.0", port: int = 3000) -> None:
196
+ """Run MCP server with HTTP transport."""
197
+ serve(transport="http", host=host, port=port)
198
+
199
+
200
+ # For MCP entry point discovery
201
+ if __name__ == "__main__":
202
+ serve_stdio()
@@ -0,0 +1,113 @@
1
+ """Domain models: Inbox, Thread, Message, Event, Attachment."""
2
+
3
+ from nornweave.models.attachment import (
4
+ Attachment,
5
+ AttachmentCreate,
6
+ AttachmentDisposition,
7
+ AttachmentMeta,
8
+ AttachmentResponse,
9
+ AttachmentUpload,
10
+ SendAttachment,
11
+ )
12
+ from nornweave.models.event import (
13
+ BounceEvent,
14
+ ComplaintEvent,
15
+ DeliveryEvent,
16
+ Event,
17
+ EventCreate,
18
+ EventType,
19
+ ListEventsResponse,
20
+ MessageBouncedEvent,
21
+ MessageComplainedEvent,
22
+ MessageDeliveredEvent,
23
+ MessageReceivedEvent,
24
+ MessageRejectedEvent,
25
+ MessageSentEvent,
26
+ Recipient,
27
+ RejectEvent,
28
+ SendEvent,
29
+ WebhookEvent,
30
+ )
31
+ from nornweave.models.inbox import Inbox, InboxBase, InboxCreate
32
+ from nornweave.models.message import (
33
+ ListMessagesResponse,
34
+ Message,
35
+ MessageBase,
36
+ MessageCreate,
37
+ MessageDirection,
38
+ MessageInCreate,
39
+ MessageItem,
40
+ ReplyToMessageRequest,
41
+ SendMessageRequest,
42
+ SendMessageResponse,
43
+ UpdateMessageRequest,
44
+ )
45
+ from nornweave.models.thread import (
46
+ ListThreadsResponse,
47
+ Thread,
48
+ ThreadBase,
49
+ ThreadCreate,
50
+ ThreadItem,
51
+ ThreadSummary,
52
+ UpdateThreadRequest,
53
+ )
54
+
55
+ # Rebuild models with forward references now that all models are imported.
56
+ # This is necessary because some models have circular references (e.g., Thread->Message,
57
+ # Message->Attachment, MessageReceivedEvent->Message) which cannot be resolved at module load time.
58
+ Message.model_rebuild()
59
+ Thread.model_rebuild()
60
+ MessageReceivedEvent.model_rebuild()
61
+
62
+ __all__ = [
63
+ # Attachment models
64
+ "Attachment",
65
+ "AttachmentCreate",
66
+ "AttachmentDisposition",
67
+ "AttachmentMeta",
68
+ "AttachmentResponse",
69
+ "AttachmentUpload",
70
+ # Event models
71
+ "BounceEvent",
72
+ "ComplaintEvent",
73
+ "DeliveryEvent",
74
+ "Event",
75
+ "EventCreate",
76
+ "EventType",
77
+ # Inbox models
78
+ "Inbox",
79
+ "InboxBase",
80
+ "InboxCreate",
81
+ "ListEventsResponse",
82
+ # Message models
83
+ "ListMessagesResponse",
84
+ # Thread models
85
+ "ListThreadsResponse",
86
+ "Message",
87
+ "MessageBase",
88
+ "MessageBouncedEvent",
89
+ "MessageComplainedEvent",
90
+ "MessageCreate",
91
+ "MessageDeliveredEvent",
92
+ "MessageDirection",
93
+ "MessageInCreate",
94
+ "MessageItem",
95
+ "MessageReceivedEvent",
96
+ "MessageRejectedEvent",
97
+ "MessageSentEvent",
98
+ "Recipient",
99
+ "RejectEvent",
100
+ "ReplyToMessageRequest",
101
+ "SendAttachment",
102
+ "SendEvent",
103
+ "SendMessageRequest",
104
+ "SendMessageResponse",
105
+ "Thread",
106
+ "ThreadBase",
107
+ "ThreadCreate",
108
+ "ThreadItem",
109
+ "ThreadSummary",
110
+ "UpdateMessageRequest",
111
+ "UpdateThreadRequest",
112
+ "WebhookEvent",
113
+ ]
@@ -0,0 +1,136 @@
1
+ """Attachment models for email attachments.
2
+
3
+ Attachment models for file handling in messages.
4
+ """
5
+
6
+ import base64
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ if TYPE_CHECKING:
13
+ from datetime import datetime
14
+
15
+
16
+ class AttachmentDisposition(str, Enum):
17
+ """Content disposition of attachment."""
18
+
19
+ INLINE = "inline"
20
+ ATTACHMENT = "attachment"
21
+
22
+
23
+ class AttachmentBase(BaseModel):
24
+ """Base attachment fields shared across models."""
25
+
26
+ filename: str | None = Field(None, description="Filename of attachment")
27
+ content_type: str | None = Field(None, description="MIME content type")
28
+ content_disposition: AttachmentDisposition | None = Field(
29
+ None, description="Content disposition (inline or attachment)"
30
+ )
31
+ content_id: str | None = Field(
32
+ None, description="Content-ID for inline attachments (cid: references)"
33
+ )
34
+
35
+
36
+ class Attachment(AttachmentBase):
37
+ """
38
+ Full attachment model for storage and API responses.
39
+ """
40
+
41
+ attachment_id: str = Field(..., description="Unique ID of attachment")
42
+ size: int = Field(..., description="Size of attachment in bytes")
43
+
44
+ model_config = {"extra": "forbid"}
45
+
46
+
47
+ class AttachmentResponse(AttachmentBase):
48
+ """
49
+ Attachment response with download URL.
50
+
51
+ API response model with download URL.
52
+ """
53
+
54
+ attachment_id: str
55
+ size: int
56
+ download_url: str = Field(..., description="URL to download the attachment")
57
+ expires_at: datetime = Field(..., description="Time at which the download URL expires")
58
+
59
+ model_config = {"extra": "forbid"}
60
+
61
+
62
+ class SendAttachment(BaseModel):
63
+ """
64
+ Attachment for outbound emails.
65
+
66
+ Attachment payload for outbound messages.
67
+ """
68
+
69
+ filename: str | None = None
70
+ content_type: str | None = None
71
+ content_disposition: AttachmentDisposition | None = None
72
+ content_id: str | None = Field(None, description="For inline attachments")
73
+ content: str | None = Field(None, description="Base64 encoded content of attachment")
74
+ url: str | None = Field(None, description="URL to the attachment (alternative to content)")
75
+
76
+ model_config = {"extra": "forbid"}
77
+
78
+ def get_content_bytes(self) -> bytes | None:
79
+ """Decode base64 content to bytes."""
80
+ if self.content:
81
+ return base64.b64decode(self.content)
82
+ return None
83
+
84
+
85
+ class AttachmentUpload(BaseModel):
86
+ """Attachment upload for outbound emails via API."""
87
+
88
+ filename: str = Field(..., min_length=1, max_length=255)
89
+ content_type: str = Field(..., min_length=1)
90
+ content_base64: str = Field(..., description="Base64-encoded file content")
91
+ disposition: AttachmentDisposition = Field(default=AttachmentDisposition.ATTACHMENT)
92
+ content_id: str | None = Field(None, description="For inline attachments")
93
+
94
+ model_config = {"extra": "forbid"}
95
+
96
+ def get_content_bytes(self) -> bytes:
97
+ """Decode base64 content to bytes."""
98
+ return base64.b64decode(self.content_base64)
99
+
100
+ @property
101
+ def size_bytes(self) -> int:
102
+ """Calculate size from base64 content."""
103
+ return len(self.get_content_bytes())
104
+
105
+
106
+ class AttachmentCreate(BaseModel):
107
+ """Internal model for creating attachments in storage."""
108
+
109
+ message_id: str
110
+ filename: str
111
+ content_type: str
112
+ size_bytes: int
113
+ disposition: AttachmentDisposition = AttachmentDisposition.ATTACHMENT
114
+ content_id: str | None = None
115
+ content: bytes | None = None
116
+ storage_path: str | None = None
117
+ storage_backend: str | None = None
118
+
119
+ model_config = {"extra": "forbid", "arbitrary_types_allowed": True}
120
+
121
+
122
+ class AttachmentMeta(BaseModel):
123
+ """
124
+ Lightweight attachment metadata for listing/display without content.
125
+
126
+ Used in thread summaries and message listings.
127
+ """
128
+
129
+ attachment_id: str
130
+ filename: str | None
131
+ content_type: str | None
132
+ size: int
133
+ disposition: AttachmentDisposition = AttachmentDisposition.ATTACHMENT
134
+ content_id: str | None = None
135
+
136
+ model_config = {"extra": "forbid"}