nornweave 0.1.3__py3-none-any.whl → 0.1.4__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/adapters/resend.py +57 -7
- nornweave/core/config.py +50 -1
- nornweave/core/interfaces.py +63 -1
- nornweave/huginn/client.py +137 -8
- nornweave/huginn/resources.py +9 -0
- nornweave/huginn/server.py +147 -8
- nornweave/models/thread.py +2 -0
- nornweave/muninn/tools.py +267 -19
- nornweave/storage/database.py +1 -1
- nornweave/urdr/adapters/base.py +173 -3
- nornweave/urdr/migrations/versions/20260205_0003_llm_thread_summary.py +54 -0
- nornweave/urdr/orm.py +27 -1
- nornweave/verdandi/llm/__init__.py +52 -0
- nornweave/verdandi/llm/anthropic.py +63 -0
- nornweave/verdandi/llm/base.py +35 -0
- nornweave/verdandi/llm/gemini.py +78 -0
- nornweave/verdandi/llm/openai.py +60 -0
- nornweave/verdandi/parser.py +2 -1
- nornweave/verdandi/summarize.py +231 -0
- nornweave/yggdrasil/app.py +2 -1
- nornweave/yggdrasil/routes/v1/attachments.py +317 -0
- nornweave/yggdrasil/routes/v1/messages.py +180 -20
- nornweave/yggdrasil/routes/v1/threads.py +4 -0
- nornweave/yggdrasil/routes/webhooks/mailgun.py +4 -0
- nornweave/yggdrasil/routes/webhooks/resend.py +76 -16
- {nornweave-0.1.3.dist-info → nornweave-0.1.4.dist-info}/METADATA +19 -1
- {nornweave-0.1.3.dist-info → nornweave-0.1.4.dist-info}/RECORD +30 -22
- {nornweave-0.1.3.dist-info → nornweave-0.1.4.dist-info}/WHEEL +0 -0
- {nornweave-0.1.3.dist-info → nornweave-0.1.4.dist-info}/entry_points.txt +0 -0
- {nornweave-0.1.3.dist-info → nornweave-0.1.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Attachment endpoints."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime # noqa: TC003 - needed at runtime for Pydantic
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from nornweave.core.config import Settings, get_settings
|
|
15
|
+
from nornweave.core.interfaces import StorageInterface # noqa: TC001 - needed at runtime
|
|
16
|
+
from nornweave.core.storage import create_attachment_storage
|
|
17
|
+
from nornweave.yggdrasil.dependencies import get_storage
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# -----------------------------------------------------------------------------
|
|
23
|
+
# Response Models
|
|
24
|
+
# -----------------------------------------------------------------------------
|
|
25
|
+
class AttachmentMeta(BaseModel):
|
|
26
|
+
"""Lightweight attachment metadata for listings."""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
message_id: str
|
|
30
|
+
filename: str
|
|
31
|
+
content_type: str
|
|
32
|
+
size: int
|
|
33
|
+
created_at: datetime | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AttachmentDetail(BaseModel):
|
|
37
|
+
"""Full attachment metadata."""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
message_id: str
|
|
41
|
+
filename: str
|
|
42
|
+
content_type: str
|
|
43
|
+
size: int
|
|
44
|
+
disposition: str | None = None
|
|
45
|
+
content_id: str | None = None
|
|
46
|
+
storage_backend: str | None = None
|
|
47
|
+
content_hash: str | None = None
|
|
48
|
+
created_at: datetime | None = None
|
|
49
|
+
download_url: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AttachmentListResponse(BaseModel):
|
|
53
|
+
"""Response model for attachment list."""
|
|
54
|
+
|
|
55
|
+
items: list[AttachmentMeta]
|
|
56
|
+
count: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AttachmentBase64Response(BaseModel):
|
|
60
|
+
"""Response model for base64 attachment content."""
|
|
61
|
+
|
|
62
|
+
content: str
|
|
63
|
+
content_type: str
|
|
64
|
+
filename: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ContentFormat(str, Enum):
|
|
68
|
+
"""Content format options."""
|
|
69
|
+
|
|
70
|
+
BINARY = "binary"
|
|
71
|
+
BASE64 = "base64"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------------------------------------------------------------
|
|
75
|
+
# URL Signing Utilities
|
|
76
|
+
# -----------------------------------------------------------------------------
|
|
77
|
+
def _get_signing_secret(settings: Settings) -> str:
|
|
78
|
+
"""Get the signing secret for URL verification."""
|
|
79
|
+
return settings.webhook_secret or "default-signing-secret"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sign_url(attachment_id: str, expiry: int, secret: str) -> str:
|
|
83
|
+
"""Create HMAC signature for URL."""
|
|
84
|
+
message = f"{attachment_id}:{expiry}"
|
|
85
|
+
signature = hmac.new(
|
|
86
|
+
secret.encode(),
|
|
87
|
+
message.encode(),
|
|
88
|
+
hashlib.sha256,
|
|
89
|
+
).hexdigest()[:32]
|
|
90
|
+
return signature
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _verify_signed_url(
|
|
94
|
+
attachment_id: str,
|
|
95
|
+
token: str | None,
|
|
96
|
+
expires: int | None,
|
|
97
|
+
secret: str,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""Verify a signed download URL."""
|
|
100
|
+
if token is None or expires is None:
|
|
101
|
+
return True # No signature required (direct access)
|
|
102
|
+
|
|
103
|
+
# Check expiry
|
|
104
|
+
if expires < time.time():
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Verify signature
|
|
108
|
+
expected = _sign_url(attachment_id, expires, secret)
|
|
109
|
+
return hmac.compare_digest(token, expected)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# -----------------------------------------------------------------------------
|
|
113
|
+
# Endpoints
|
|
114
|
+
# -----------------------------------------------------------------------------
|
|
115
|
+
@router.get("/attachments", response_model=AttachmentListResponse)
|
|
116
|
+
async def list_attachments(
|
|
117
|
+
message_id: str | None = Query(None, description="Filter by message ID"),
|
|
118
|
+
thread_id: str | None = Query(None, description="Filter by thread ID"),
|
|
119
|
+
inbox_id: str | None = Query(None, description="Filter by inbox ID"),
|
|
120
|
+
limit: int = Query(100, ge=1, le=500),
|
|
121
|
+
offset: int = Query(0, ge=0),
|
|
122
|
+
storage: StorageInterface = Depends(get_storage),
|
|
123
|
+
) -> AttachmentListResponse:
|
|
124
|
+
"""List attachments with exactly one filter (message_id, thread_id, or inbox_id)."""
|
|
125
|
+
# Validate exactly one filter is provided
|
|
126
|
+
filters = [f for f in [message_id, thread_id, inbox_id] if f is not None]
|
|
127
|
+
if len(filters) == 0:
|
|
128
|
+
raise HTTPException(
|
|
129
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
130
|
+
detail="Exactly one filter required: message_id, thread_id, or inbox_id",
|
|
131
|
+
)
|
|
132
|
+
if len(filters) > 1:
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
135
|
+
detail="Only one filter allowed: message_id, thread_id, or inbox_id",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Get attachments based on filter
|
|
139
|
+
if message_id:
|
|
140
|
+
# Verify message exists
|
|
141
|
+
message = await storage.get_message(message_id)
|
|
142
|
+
if message is None:
|
|
143
|
+
raise HTTPException(
|
|
144
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
145
|
+
detail=f"Message {message_id} not found",
|
|
146
|
+
)
|
|
147
|
+
attachments = await storage.list_attachments_for_message(message_id)
|
|
148
|
+
elif thread_id:
|
|
149
|
+
# Verify thread exists
|
|
150
|
+
thread = await storage.get_thread(thread_id)
|
|
151
|
+
if thread is None:
|
|
152
|
+
raise HTTPException(
|
|
153
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
154
|
+
detail=f"Thread {thread_id} not found",
|
|
155
|
+
)
|
|
156
|
+
attachments = await storage.list_attachments_for_thread(
|
|
157
|
+
thread_id, limit=limit, offset=offset
|
|
158
|
+
)
|
|
159
|
+
else: # inbox_id
|
|
160
|
+
# Verify inbox exists
|
|
161
|
+
assert inbox_id is not None # Guaranteed by filter logic above
|
|
162
|
+
inbox = await storage.get_inbox(inbox_id)
|
|
163
|
+
if inbox is None:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
166
|
+
detail=f"Inbox {inbox_id} not found",
|
|
167
|
+
)
|
|
168
|
+
attachments = await storage.list_attachments_for_inbox(
|
|
169
|
+
inbox_id,
|
|
170
|
+
limit=limit,
|
|
171
|
+
offset=offset,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return AttachmentListResponse(
|
|
175
|
+
items=[
|
|
176
|
+
AttachmentMeta(
|
|
177
|
+
id=a["id"],
|
|
178
|
+
message_id=a["message_id"],
|
|
179
|
+
filename=a["filename"],
|
|
180
|
+
content_type=a["content_type"],
|
|
181
|
+
size=a["size_bytes"],
|
|
182
|
+
created_at=a.get("created_at"),
|
|
183
|
+
)
|
|
184
|
+
for a in attachments
|
|
185
|
+
],
|
|
186
|
+
count=len(attachments),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@router.get("/attachments/{attachment_id}", response_model=AttachmentDetail)
|
|
191
|
+
async def get_attachment(
|
|
192
|
+
attachment_id: str,
|
|
193
|
+
storage: StorageInterface = Depends(get_storage),
|
|
194
|
+
settings: Settings = Depends(get_settings),
|
|
195
|
+
) -> AttachmentDetail:
|
|
196
|
+
"""Get attachment metadata by ID."""
|
|
197
|
+
attachment = await storage.get_attachment(attachment_id)
|
|
198
|
+
if attachment is None:
|
|
199
|
+
raise HTTPException(
|
|
200
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
201
|
+
detail=f"Attachment {attachment_id} not found",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Generate download URL
|
|
205
|
+
download_url: str | None = None
|
|
206
|
+
storage_backend_name = attachment.get("storage_backend")
|
|
207
|
+
|
|
208
|
+
if storage_backend_name in ("s3", "gcs") and attachment.get("storage_path"):
|
|
209
|
+
# Use cloud provider's presigned URL
|
|
210
|
+
try:
|
|
211
|
+
storage_backend = create_attachment_storage(settings)
|
|
212
|
+
download_url = await storage_backend.get_download_url(
|
|
213
|
+
attachment["storage_path"],
|
|
214
|
+
filename=attachment["filename"],
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass # Fall back to no download URL
|
|
218
|
+
elif attachment.get("storage_path") or storage_backend_name == "database":
|
|
219
|
+
# Generate signed URL for local/database storage
|
|
220
|
+
expiry = int(time.time() + 3600) # 1 hour
|
|
221
|
+
secret = _get_signing_secret(settings)
|
|
222
|
+
token = _sign_url(attachment_id, expiry, secret)
|
|
223
|
+
download_url = f"/v1/attachments/{attachment_id}/content?token={token}&expires={expiry}"
|
|
224
|
+
|
|
225
|
+
return AttachmentDetail(
|
|
226
|
+
id=attachment["id"],
|
|
227
|
+
message_id=attachment["message_id"],
|
|
228
|
+
filename=attachment["filename"],
|
|
229
|
+
content_type=attachment["content_type"],
|
|
230
|
+
size=attachment["size_bytes"],
|
|
231
|
+
disposition=attachment.get("disposition"),
|
|
232
|
+
content_id=attachment.get("content_id"),
|
|
233
|
+
storage_backend=storage_backend_name,
|
|
234
|
+
content_hash=attachment.get("content_hash"),
|
|
235
|
+
created_at=attachment.get("created_at"),
|
|
236
|
+
download_url=download_url,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@router.get("/attachments/{attachment_id}/content", response_model=None)
|
|
241
|
+
async def get_attachment_content(
|
|
242
|
+
attachment_id: str,
|
|
243
|
+
response_format: Literal["binary", "base64"] = Query(
|
|
244
|
+
"binary", alias="format", description="Response format"
|
|
245
|
+
),
|
|
246
|
+
token: str | None = Query(None, description="Signed URL token"),
|
|
247
|
+
expires: int | None = Query(None, description="Signed URL expiry timestamp"),
|
|
248
|
+
storage: StorageInterface = Depends(get_storage),
|
|
249
|
+
settings: Settings = Depends(get_settings),
|
|
250
|
+
) -> Response | AttachmentBase64Response:
|
|
251
|
+
"""Download attachment content.
|
|
252
|
+
|
|
253
|
+
For local/database storage, requires valid signed URL (token + expires).
|
|
254
|
+
For cloud storage (S3/GCS), use the presigned URL from attachment metadata.
|
|
255
|
+
"""
|
|
256
|
+
# Get attachment metadata
|
|
257
|
+
attachment = await storage.get_attachment(attachment_id)
|
|
258
|
+
if attachment is None:
|
|
259
|
+
raise HTTPException(
|
|
260
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
261
|
+
detail=f"Attachment {attachment_id} not found",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
storage_backend_name = attachment.get("storage_backend")
|
|
265
|
+
|
|
266
|
+
# Verify signed URL for local/database backends
|
|
267
|
+
if storage_backend_name in ("local", "database", None):
|
|
268
|
+
secret = _get_signing_secret(settings)
|
|
269
|
+
if not _verify_signed_url(attachment_id, token, expires, secret):
|
|
270
|
+
raise HTTPException(
|
|
271
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
272
|
+
detail="Invalid or expired download URL",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Retrieve content
|
|
276
|
+
content: bytes
|
|
277
|
+
if storage_backend_name == "database":
|
|
278
|
+
# Content stored in database
|
|
279
|
+
if attachment.get("content") is None:
|
|
280
|
+
raise HTTPException(
|
|
281
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
282
|
+
detail="Attachment content not found in database",
|
|
283
|
+
)
|
|
284
|
+
content = attachment["content"]
|
|
285
|
+
elif attachment.get("storage_path"):
|
|
286
|
+
# Content stored externally
|
|
287
|
+
try:
|
|
288
|
+
storage_backend = create_attachment_storage(settings)
|
|
289
|
+
content = await storage_backend.retrieve(attachment["storage_path"])
|
|
290
|
+
except FileNotFoundError as exc:
|
|
291
|
+
raise HTTPException(
|
|
292
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
293
|
+
detail="Attachment content not found in storage",
|
|
294
|
+
) from exc
|
|
295
|
+
else:
|
|
296
|
+
raise HTTPException(
|
|
297
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
298
|
+
detail="Attachment content location unknown",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Return in requested format
|
|
302
|
+
if response_format == "base64":
|
|
303
|
+
return AttachmentBase64Response(
|
|
304
|
+
content=base64.b64encode(content).decode("ascii"),
|
|
305
|
+
content_type=attachment["content_type"],
|
|
306
|
+
filename=attachment["filename"],
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
# Binary response
|
|
310
|
+
return Response(
|
|
311
|
+
content=content,
|
|
312
|
+
media_type=attachment["content_type"],
|
|
313
|
+
headers={
|
|
314
|
+
"Content-Disposition": f'attachment; filename="{attachment["filename"]}"',
|
|
315
|
+
"Content-Length": str(len(content)),
|
|
316
|
+
},
|
|
317
|
+
)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Message endpoints."""
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
3
5
|
import contextlib
|
|
4
6
|
import uuid
|
|
5
7
|
from datetime import UTC, datetime
|
|
@@ -8,36 +10,54 @@ from typing import Any
|
|
|
8
10
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
9
11
|
from pydantic import BaseModel, Field
|
|
10
12
|
|
|
13
|
+
from nornweave.core.config import Settings, get_settings
|
|
11
14
|
from nornweave.core.interfaces import ( # noqa: TC001 - needed at runtime for FastAPI
|
|
12
15
|
EmailProvider,
|
|
13
16
|
StorageInterface,
|
|
14
17
|
)
|
|
18
|
+
from nornweave.core.storage import AttachmentMetadata, create_attachment_storage
|
|
19
|
+
from nornweave.models.attachment import AttachmentUpload, SendAttachment
|
|
15
20
|
from nornweave.models.message import Message, MessageDirection
|
|
16
21
|
from nornweave.models.thread import Thread
|
|
22
|
+
from nornweave.verdandi.summarize import generate_thread_summary
|
|
17
23
|
from nornweave.yggdrasil.dependencies import get_email_provider, get_storage
|
|
18
24
|
|
|
19
25
|
router = APIRouter()
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
class MessageResponse(BaseModel):
|
|
23
|
-
"""Response model for a message."""
|
|
29
|
+
"""Response model for a message with all email metadata fields."""
|
|
24
30
|
|
|
25
31
|
id: str
|
|
26
32
|
thread_id: str
|
|
27
33
|
inbox_id: str
|
|
28
34
|
direction: str
|
|
29
35
|
provider_message_id: str | None
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
subject: str | None = None
|
|
37
|
+
from_address: str | None = None
|
|
38
|
+
to_addresses: list[str] = Field(default_factory=list)
|
|
39
|
+
cc_addresses: list[str] | None = None
|
|
40
|
+
bcc_addresses: list[str] | None = None
|
|
41
|
+
reply_to_addresses: list[str] | None = None
|
|
42
|
+
text: str | None = None
|
|
43
|
+
html: str | None = None
|
|
44
|
+
content_clean: str = ""
|
|
45
|
+
timestamp: datetime | None = None
|
|
46
|
+
labels: list[str] = Field(default_factory=list)
|
|
47
|
+
preview: str | None = None
|
|
48
|
+
size: int = 0
|
|
49
|
+
in_reply_to: str | None = None
|
|
50
|
+
references: list[str] | None = None
|
|
51
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
52
|
+
created_at: datetime | None = None
|
|
34
53
|
|
|
35
54
|
|
|
36
55
|
class MessageListResponse(BaseModel):
|
|
37
|
-
"""Response model for message list."""
|
|
56
|
+
"""Response model for message list with pagination support."""
|
|
38
57
|
|
|
39
58
|
items: list[MessageResponse]
|
|
40
59
|
count: int
|
|
60
|
+
total: int
|
|
41
61
|
|
|
42
62
|
|
|
43
63
|
class SendMessageRequest(BaseModel):
|
|
@@ -48,6 +68,9 @@ class SendMessageRequest(BaseModel):
|
|
|
48
68
|
subject: str = Field(..., min_length=1)
|
|
49
69
|
body: str = Field(..., description="Markdown body content")
|
|
50
70
|
reply_to_thread_id: str | None = None
|
|
71
|
+
attachments: list[AttachmentUpload] | None = Field(
|
|
72
|
+
None, description="Optional list of attachments to send"
|
|
73
|
+
)
|
|
51
74
|
|
|
52
75
|
|
|
53
76
|
class SendMessageResponse(BaseModel):
|
|
@@ -60,38 +83,78 @@ class SendMessageResponse(BaseModel):
|
|
|
60
83
|
|
|
61
84
|
|
|
62
85
|
def _message_to_response(msg: Message) -> MessageResponse:
|
|
63
|
-
"""Convert Message model to response."""
|
|
86
|
+
"""Convert Message model to response with all email metadata fields."""
|
|
64
87
|
return MessageResponse(
|
|
65
88
|
id=msg.id,
|
|
66
89
|
thread_id=msg.thread_id,
|
|
67
90
|
inbox_id=msg.inbox_id,
|
|
68
91
|
direction=msg.direction.value,
|
|
69
92
|
provider_message_id=msg.provider_message_id,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
subject=msg.subject,
|
|
94
|
+
from_address=msg.from_address,
|
|
95
|
+
to_addresses=msg.to if msg.to else [],
|
|
96
|
+
cc_addresses=msg.cc,
|
|
97
|
+
bcc_addresses=msg.bcc,
|
|
98
|
+
reply_to_addresses=msg.reply_to,
|
|
99
|
+
text=msg.text,
|
|
100
|
+
html=msg.html,
|
|
101
|
+
content_clean=msg.content_clean or "",
|
|
102
|
+
timestamp=msg.timestamp,
|
|
103
|
+
labels=msg.labels if msg.labels else [],
|
|
104
|
+
preview=msg.preview,
|
|
105
|
+
size=msg.size,
|
|
106
|
+
in_reply_to=msg.in_reply_to,
|
|
107
|
+
references=msg.references,
|
|
108
|
+
metadata=msg.metadata if msg.metadata else {},
|
|
73
109
|
created_at=msg.created_at,
|
|
74
110
|
)
|
|
75
111
|
|
|
76
112
|
|
|
77
113
|
@router.get("/messages", response_model=MessageListResponse)
|
|
78
114
|
async def list_messages(
|
|
79
|
-
inbox_id: str,
|
|
115
|
+
inbox_id: str | None = None,
|
|
116
|
+
thread_id: str | None = None,
|
|
117
|
+
q: str | None = None,
|
|
80
118
|
limit: int = 50,
|
|
81
119
|
offset: int = 0,
|
|
82
120
|
storage: StorageInterface = Depends(get_storage),
|
|
83
121
|
) -> MessageListResponse:
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
"""
|
|
123
|
+
List and search messages with flexible filters.
|
|
124
|
+
|
|
125
|
+
At least one of inbox_id or thread_id must be provided.
|
|
126
|
+
Optional text search (q) searches across subject, body, sender, and attachment filenames.
|
|
127
|
+
"""
|
|
128
|
+
# Validate at least one filter is provided
|
|
129
|
+
if inbox_id is None and thread_id is None:
|
|
88
130
|
raise HTTPException(
|
|
89
|
-
status_code=status.
|
|
90
|
-
detail=
|
|
131
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
132
|
+
detail="At least one filter (inbox_id or thread_id) is required",
|
|
91
133
|
)
|
|
92
134
|
|
|
93
|
-
|
|
94
|
-
|
|
135
|
+
# Verify inbox exists if provided
|
|
136
|
+
if inbox_id:
|
|
137
|
+
inbox = await storage.get_inbox(inbox_id)
|
|
138
|
+
if inbox is None:
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
141
|
+
detail=f"Inbox {inbox_id} not found",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Verify thread exists if provided
|
|
145
|
+
if thread_id:
|
|
146
|
+
thread = await storage.get_thread(thread_id)
|
|
147
|
+
if thread is None:
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
150
|
+
detail=f"Thread {thread_id} not found",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Use advanced search method with all filters
|
|
154
|
+
messages, total = await storage.search_messages_advanced(
|
|
155
|
+
inbox_id=inbox_id,
|
|
156
|
+
thread_id=thread_id,
|
|
157
|
+
query=q,
|
|
95
158
|
limit=limit,
|
|
96
159
|
offset=offset,
|
|
97
160
|
)
|
|
@@ -99,6 +162,7 @@ async def list_messages(
|
|
|
99
162
|
return MessageListResponse(
|
|
100
163
|
items=[_message_to_response(m) for m in messages],
|
|
101
164
|
count=len(messages),
|
|
165
|
+
total=total,
|
|
102
166
|
)
|
|
103
167
|
|
|
104
168
|
|
|
@@ -122,11 +186,14 @@ async def send_message(
|
|
|
122
186
|
payload: SendMessageRequest,
|
|
123
187
|
storage: StorageInterface = Depends(get_storage),
|
|
124
188
|
email_provider: EmailProvider = Depends(get_email_provider),
|
|
189
|
+
settings: Settings = Depends(get_settings),
|
|
125
190
|
) -> SendMessageResponse:
|
|
126
191
|
"""Send an outbound message.
|
|
127
192
|
|
|
128
193
|
If reply_to_thread_id is provided, the message is added to that thread.
|
|
129
194
|
Otherwise, a new thread is created.
|
|
195
|
+
|
|
196
|
+
Supports optional attachments which are stored and sent with the email.
|
|
130
197
|
"""
|
|
131
198
|
# Get inbox
|
|
132
199
|
inbox = await storage.get_inbox(payload.inbox_id)
|
|
@@ -158,6 +225,80 @@ async def send_message(
|
|
|
158
225
|
created_thread = await storage.create_thread(new_thread)
|
|
159
226
|
thread_id = created_thread.id
|
|
160
227
|
|
|
228
|
+
# Generate message ID early so we can link attachments
|
|
229
|
+
message_id = str(uuid.uuid4())
|
|
230
|
+
|
|
231
|
+
# Process attachments if provided
|
|
232
|
+
attachment_records: list[dict[str, Any]] = []
|
|
233
|
+
provider_attachments: list[SendAttachment] = []
|
|
234
|
+
|
|
235
|
+
if payload.attachments:
|
|
236
|
+
# Create storage backend
|
|
237
|
+
storage_backend = create_attachment_storage(settings)
|
|
238
|
+
|
|
239
|
+
for i, attachment in enumerate(payload.attachments):
|
|
240
|
+
# Validate and decode base64 content
|
|
241
|
+
try:
|
|
242
|
+
content_bytes = base64.b64decode(attachment.content_base64)
|
|
243
|
+
except (binascii.Error, ValueError) as e:
|
|
244
|
+
raise HTTPException(
|
|
245
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
246
|
+
detail=f"Invalid base64 content in attachment {i}: {e}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if len(content_bytes) == 0:
|
|
250
|
+
raise HTTPException(
|
|
251
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
252
|
+
detail=f"Attachment {i} has empty content",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Generate attachment ID and store
|
|
256
|
+
attachment_id = str(uuid.uuid4())
|
|
257
|
+
|
|
258
|
+
metadata = AttachmentMetadata(
|
|
259
|
+
attachment_id=attachment_id,
|
|
260
|
+
message_id=message_id,
|
|
261
|
+
filename=attachment.filename,
|
|
262
|
+
content_type=attachment.content_type,
|
|
263
|
+
content_disposition=attachment.disposition.value,
|
|
264
|
+
content_id=attachment.content_id,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Store in configured backend
|
|
268
|
+
storage_result = await storage_backend.store(
|
|
269
|
+
attachment_id=attachment_id,
|
|
270
|
+
content=content_bytes,
|
|
271
|
+
metadata=metadata,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Prepare attachment record for database
|
|
275
|
+
attachment_records.append(
|
|
276
|
+
{
|
|
277
|
+
"attachment_id": attachment_id,
|
|
278
|
+
"filename": attachment.filename,
|
|
279
|
+
"content_type": attachment.content_type,
|
|
280
|
+
"size_bytes": storage_result.size_bytes,
|
|
281
|
+
"disposition": attachment.disposition.value,
|
|
282
|
+
"content_id": attachment.content_id,
|
|
283
|
+
"storage_path": storage_result.storage_key,
|
|
284
|
+
"storage_backend": storage_result.backend,
|
|
285
|
+
"content_hash": storage_result.content_hash,
|
|
286
|
+
# For database backend, also store content
|
|
287
|
+
"content": content_bytes if storage_result.backend == "database" else None,
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Prepare attachment for email provider
|
|
292
|
+
provider_attachments.append(
|
|
293
|
+
SendAttachment(
|
|
294
|
+
filename=attachment.filename,
|
|
295
|
+
content_type=attachment.content_type,
|
|
296
|
+
content_disposition=attachment.disposition,
|
|
297
|
+
content_id=attachment.content_id,
|
|
298
|
+
content=attachment.content_base64, # Keep as base64 for provider
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
161
302
|
# Send email via provider
|
|
162
303
|
# Log error but continue to store the message attempt
|
|
163
304
|
provider_message_id: str | None = None
|
|
@@ -167,11 +308,12 @@ async def send_message(
|
|
|
167
308
|
subject=payload.subject,
|
|
168
309
|
body=payload.body,
|
|
169
310
|
from_address=inbox.email_address,
|
|
311
|
+
attachments=provider_attachments if provider_attachments else None,
|
|
170
312
|
)
|
|
171
313
|
|
|
172
314
|
# Create message record
|
|
173
315
|
message = Message(
|
|
174
|
-
message_id=
|
|
316
|
+
message_id=message_id,
|
|
175
317
|
thread_id=thread_id,
|
|
176
318
|
inbox_id=payload.inbox_id,
|
|
177
319
|
provider_message_id=provider_message_id,
|
|
@@ -186,12 +328,30 @@ async def send_message(
|
|
|
186
328
|
)
|
|
187
329
|
created_message = await storage.create_message(message)
|
|
188
330
|
|
|
331
|
+
# Create attachment records linked to the message
|
|
332
|
+
for rec in attachment_records:
|
|
333
|
+
await storage.create_attachment(
|
|
334
|
+
message_id=created_message.id,
|
|
335
|
+
filename=rec["filename"],
|
|
336
|
+
content_type=rec["content_type"],
|
|
337
|
+
size_bytes=rec["size_bytes"],
|
|
338
|
+
disposition=rec["disposition"],
|
|
339
|
+
content_id=rec["content_id"],
|
|
340
|
+
storage_path=rec["storage_path"],
|
|
341
|
+
storage_backend=rec["storage_backend"],
|
|
342
|
+
content_hash=rec["content_hash"],
|
|
343
|
+
content=rec["content"],
|
|
344
|
+
)
|
|
345
|
+
|
|
189
346
|
# Update thread's last_message_at
|
|
190
347
|
thread = await storage.get_thread(thread_id)
|
|
191
348
|
if thread:
|
|
192
349
|
thread.last_message_at = created_message.created_at
|
|
193
350
|
await storage.update_thread(thread)
|
|
194
351
|
|
|
352
|
+
# Fire-and-forget thread summarization
|
|
353
|
+
await generate_thread_summary(storage, thread_id)
|
|
354
|
+
|
|
195
355
|
return SendMessageResponse(
|
|
196
356
|
id=created_message.id,
|
|
197
357
|
thread_id=thread_id,
|
|
@@ -26,6 +26,7 @@ class ThreadDetailResponse(BaseModel):
|
|
|
26
26
|
|
|
27
27
|
id: str
|
|
28
28
|
subject: str
|
|
29
|
+
summary: str | None = None
|
|
29
30
|
messages: list[ThreadMessageResponse]
|
|
30
31
|
|
|
31
32
|
|
|
@@ -35,6 +36,7 @@ class ThreadSummaryResponse(BaseModel):
|
|
|
35
36
|
id: str
|
|
36
37
|
inbox_id: str
|
|
37
38
|
subject: str
|
|
39
|
+
summary: str | None = None
|
|
38
40
|
last_message_at: datetime | None
|
|
39
41
|
participant_hash: str | None
|
|
40
42
|
|
|
@@ -74,6 +76,7 @@ async def list_threads(
|
|
|
74
76
|
id=t.id,
|
|
75
77
|
inbox_id=t.inbox_id,
|
|
76
78
|
subject=t.subject,
|
|
79
|
+
summary=t.summary,
|
|
77
80
|
last_message_at=t.last_message_at,
|
|
78
81
|
participant_hash=t.participant_hash,
|
|
79
82
|
)
|
|
@@ -138,5 +141,6 @@ async def get_thread(
|
|
|
138
141
|
return ThreadDetailResponse(
|
|
139
142
|
id=thread.id,
|
|
140
143
|
subject=thread.subject,
|
|
144
|
+
summary=thread.summary,
|
|
141
145
|
messages=thread_messages,
|
|
142
146
|
)
|
|
@@ -13,6 +13,7 @@ from nornweave.core.interfaces import (
|
|
|
13
13
|
from nornweave.models.message import Message, MessageDirection
|
|
14
14
|
from nornweave.models.thread import Thread
|
|
15
15
|
from nornweave.verdandi.parser import html_to_markdown
|
|
16
|
+
from nornweave.verdandi.summarize import generate_thread_summary
|
|
16
17
|
from nornweave.yggdrasil.dependencies import get_storage
|
|
17
18
|
|
|
18
19
|
router = APIRouter()
|
|
@@ -133,4 +134,7 @@ async def mailgun_webhook(
|
|
|
133
134
|
thread.received_timestamp = created_message.created_at
|
|
134
135
|
await storage.update_thread(thread)
|
|
135
136
|
|
|
137
|
+
# Fire-and-forget thread summarization
|
|
138
|
+
await generate_thread_summary(storage, thread_id)
|
|
139
|
+
|
|
136
140
|
return {"status": "received", "message_id": created_message.id, "thread_id": thread_id}
|