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