nornweave 0.1.2__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.
@@ -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
- content_raw: str
31
- content_clean: str
32
- metadata: dict[str, Any]
33
- created_at: datetime | None
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
- content_raw=msg.content_raw,
71
- content_clean=msg.content_clean,
72
- metadata=msg.metadata,
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
- """List messages for an inbox."""
85
- # Verify inbox exists
86
- inbox = await storage.get_inbox(inbox_id)
87
- if inbox is None:
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.HTTP_404_NOT_FOUND,
90
- detail=f"Inbox {inbox_id} not found",
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
- messages = await storage.list_messages_for_inbox(
94
- inbox_id,
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=str(uuid.uuid4()),
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}