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,124 @@
|
|
|
1
|
+
"""Inbox endpoints."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from nornweave.core.config import Settings, get_settings
|
|
10
|
+
from nornweave.core.interfaces import StorageInterface # noqa: TC001 - needed at runtime
|
|
11
|
+
from nornweave.models.inbox import Inbox, InboxCreate
|
|
12
|
+
from nornweave.yggdrasil.dependencies import get_storage
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InboxResponse(BaseModel):
|
|
18
|
+
"""Response model for inbox."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
email_address: str
|
|
22
|
+
name: str | None
|
|
23
|
+
provider_config: dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InboxListResponse(BaseModel):
|
|
27
|
+
"""Response model for inbox list."""
|
|
28
|
+
|
|
29
|
+
items: list[InboxResponse]
|
|
30
|
+
count: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/inboxes", response_model=InboxResponse, status_code=status.HTTP_201_CREATED)
|
|
34
|
+
async def create_inbox(
|
|
35
|
+
payload: InboxCreate,
|
|
36
|
+
storage: StorageInterface = Depends(get_storage),
|
|
37
|
+
settings: Settings = Depends(get_settings),
|
|
38
|
+
) -> InboxResponse:
|
|
39
|
+
"""Create a new inbox.
|
|
40
|
+
|
|
41
|
+
The email address is constructed from the username and configured domain.
|
|
42
|
+
"""
|
|
43
|
+
# Construct full email address
|
|
44
|
+
email_address = f"{payload.email_username}@{settings.email_domain}"
|
|
45
|
+
|
|
46
|
+
# Check if email already exists
|
|
47
|
+
existing = await storage.get_inbox_by_email(email_address)
|
|
48
|
+
if existing:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
51
|
+
detail=f"Inbox with email {email_address} already exists",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Create inbox
|
|
55
|
+
inbox = Inbox(
|
|
56
|
+
id=str(uuid.uuid4()),
|
|
57
|
+
email_address=email_address,
|
|
58
|
+
name=payload.name,
|
|
59
|
+
provider_config={},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
created = await storage.create_inbox(inbox)
|
|
63
|
+
return InboxResponse(
|
|
64
|
+
id=created.id,
|
|
65
|
+
email_address=created.email_address,
|
|
66
|
+
name=created.name,
|
|
67
|
+
provider_config=created.provider_config,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/inboxes", response_model=InboxListResponse)
|
|
72
|
+
async def list_inboxes(
|
|
73
|
+
limit: int = 50,
|
|
74
|
+
offset: int = 0,
|
|
75
|
+
storage: StorageInterface = Depends(get_storage),
|
|
76
|
+
) -> InboxListResponse:
|
|
77
|
+
"""List all inboxes."""
|
|
78
|
+
inboxes = await storage.list_inboxes(limit=limit, offset=offset)
|
|
79
|
+
return InboxListResponse(
|
|
80
|
+
items=[
|
|
81
|
+
InboxResponse(
|
|
82
|
+
id=i.id,
|
|
83
|
+
email_address=i.email_address,
|
|
84
|
+
name=i.name,
|
|
85
|
+
provider_config=i.provider_config,
|
|
86
|
+
)
|
|
87
|
+
for i in inboxes
|
|
88
|
+
],
|
|
89
|
+
count=len(inboxes),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.get("/inboxes/{inbox_id}", response_model=InboxResponse)
|
|
94
|
+
async def get_inbox(
|
|
95
|
+
inbox_id: str,
|
|
96
|
+
storage: StorageInterface = Depends(get_storage),
|
|
97
|
+
) -> InboxResponse:
|
|
98
|
+
"""Get an inbox by ID."""
|
|
99
|
+
inbox = await storage.get_inbox(inbox_id)
|
|
100
|
+
if inbox is None:
|
|
101
|
+
raise HTTPException(
|
|
102
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
103
|
+
detail=f"Inbox {inbox_id} not found",
|
|
104
|
+
)
|
|
105
|
+
return InboxResponse(
|
|
106
|
+
id=inbox.id,
|
|
107
|
+
email_address=inbox.email_address,
|
|
108
|
+
name=inbox.name,
|
|
109
|
+
provider_config=inbox.provider_config,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.delete("/inboxes/{inbox_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
114
|
+
async def delete_inbox(
|
|
115
|
+
inbox_id: str,
|
|
116
|
+
storage: StorageInterface = Depends(get_storage),
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Delete an inbox."""
|
|
119
|
+
deleted = await storage.delete_inbox(inbox_id)
|
|
120
|
+
if not deleted:
|
|
121
|
+
raise HTTPException(
|
|
122
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
123
|
+
detail=f"Inbox {inbox_id} not found",
|
|
124
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Message endpoints."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from nornweave.core.interfaces import ( # noqa: TC001 - needed at runtime for FastAPI
|
|
12
|
+
EmailProvider,
|
|
13
|
+
StorageInterface,
|
|
14
|
+
)
|
|
15
|
+
from nornweave.models.message import Message, MessageDirection
|
|
16
|
+
from nornweave.models.thread import Thread
|
|
17
|
+
from nornweave.yggdrasil.dependencies import get_email_provider, get_storage
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessageResponse(BaseModel):
|
|
23
|
+
"""Response model for a message."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
thread_id: str
|
|
27
|
+
inbox_id: str
|
|
28
|
+
direction: str
|
|
29
|
+
provider_message_id: str | None
|
|
30
|
+
content_raw: str
|
|
31
|
+
content_clean: str
|
|
32
|
+
metadata: dict[str, Any]
|
|
33
|
+
created_at: datetime | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MessageListResponse(BaseModel):
|
|
37
|
+
"""Response model for message list."""
|
|
38
|
+
|
|
39
|
+
items: list[MessageResponse]
|
|
40
|
+
count: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SendMessageRequest(BaseModel):
|
|
44
|
+
"""Request to send an outbound message."""
|
|
45
|
+
|
|
46
|
+
inbox_id: str
|
|
47
|
+
to: list[str] = Field(..., min_length=1)
|
|
48
|
+
subject: str = Field(..., min_length=1)
|
|
49
|
+
body: str = Field(..., description="Markdown body content")
|
|
50
|
+
reply_to_thread_id: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SendMessageResponse(BaseModel):
|
|
54
|
+
"""Response after sending a message."""
|
|
55
|
+
|
|
56
|
+
id: str
|
|
57
|
+
thread_id: str
|
|
58
|
+
provider_message_id: str | None
|
|
59
|
+
status: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _message_to_response(msg: Message) -> MessageResponse:
|
|
63
|
+
"""Convert Message model to response."""
|
|
64
|
+
return MessageResponse(
|
|
65
|
+
id=msg.id,
|
|
66
|
+
thread_id=msg.thread_id,
|
|
67
|
+
inbox_id=msg.inbox_id,
|
|
68
|
+
direction=msg.direction.value,
|
|
69
|
+
provider_message_id=msg.provider_message_id,
|
|
70
|
+
content_raw=msg.content_raw,
|
|
71
|
+
content_clean=msg.content_clean,
|
|
72
|
+
metadata=msg.metadata,
|
|
73
|
+
created_at=msg.created_at,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/messages", response_model=MessageListResponse)
|
|
78
|
+
async def list_messages(
|
|
79
|
+
inbox_id: str,
|
|
80
|
+
limit: int = 50,
|
|
81
|
+
offset: int = 0,
|
|
82
|
+
storage: StorageInterface = Depends(get_storage),
|
|
83
|
+
) -> MessageListResponse:
|
|
84
|
+
"""List messages for an inbox."""
|
|
85
|
+
# Verify inbox exists
|
|
86
|
+
inbox = await storage.get_inbox(inbox_id)
|
|
87
|
+
if inbox is None:
|
|
88
|
+
raise HTTPException(
|
|
89
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
90
|
+
detail=f"Inbox {inbox_id} not found",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
messages = await storage.list_messages_for_inbox(
|
|
94
|
+
inbox_id,
|
|
95
|
+
limit=limit,
|
|
96
|
+
offset=offset,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return MessageListResponse(
|
|
100
|
+
items=[_message_to_response(m) for m in messages],
|
|
101
|
+
count=len(messages),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.get("/messages/{message_id}", response_model=MessageResponse)
|
|
106
|
+
async def get_message(
|
|
107
|
+
message_id: str,
|
|
108
|
+
storage: StorageInterface = Depends(get_storage),
|
|
109
|
+
) -> MessageResponse:
|
|
110
|
+
"""Get a message by ID."""
|
|
111
|
+
message = await storage.get_message(message_id)
|
|
112
|
+
if message is None:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
115
|
+
detail=f"Message {message_id} not found",
|
|
116
|
+
)
|
|
117
|
+
return _message_to_response(message)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.post("/messages", response_model=SendMessageResponse, status_code=status.HTTP_201_CREATED)
|
|
121
|
+
async def send_message(
|
|
122
|
+
payload: SendMessageRequest,
|
|
123
|
+
storage: StorageInterface = Depends(get_storage),
|
|
124
|
+
email_provider: EmailProvider = Depends(get_email_provider),
|
|
125
|
+
) -> SendMessageResponse:
|
|
126
|
+
"""Send an outbound message.
|
|
127
|
+
|
|
128
|
+
If reply_to_thread_id is provided, the message is added to that thread.
|
|
129
|
+
Otherwise, a new thread is created.
|
|
130
|
+
"""
|
|
131
|
+
# Get inbox
|
|
132
|
+
inbox = await storage.get_inbox(payload.inbox_id)
|
|
133
|
+
if inbox is None:
|
|
134
|
+
raise HTTPException(
|
|
135
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
136
|
+
detail=f"Inbox {payload.inbox_id} not found",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Get or create thread
|
|
140
|
+
thread_id: str
|
|
141
|
+
if payload.reply_to_thread_id:
|
|
142
|
+
thread = await storage.get_thread(payload.reply_to_thread_id)
|
|
143
|
+
if thread is None:
|
|
144
|
+
raise HTTPException(
|
|
145
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
146
|
+
detail=f"Thread {payload.reply_to_thread_id} not found",
|
|
147
|
+
)
|
|
148
|
+
thread_id = thread.id
|
|
149
|
+
else:
|
|
150
|
+
# Create a new thread
|
|
151
|
+
new_thread = Thread(
|
|
152
|
+
thread_id=str(uuid.uuid4()),
|
|
153
|
+
inbox_id=payload.inbox_id,
|
|
154
|
+
subject=payload.subject,
|
|
155
|
+
timestamp=datetime.now(UTC),
|
|
156
|
+
participant_hash=None, # Will be set when we have participants
|
|
157
|
+
)
|
|
158
|
+
created_thread = await storage.create_thread(new_thread)
|
|
159
|
+
thread_id = created_thread.id
|
|
160
|
+
|
|
161
|
+
# Send email via provider
|
|
162
|
+
# Log error but continue to store the message attempt
|
|
163
|
+
provider_message_id: str | None = None
|
|
164
|
+
with contextlib.suppress(Exception):
|
|
165
|
+
provider_message_id = await email_provider.send_email(
|
|
166
|
+
to=payload.to,
|
|
167
|
+
subject=payload.subject,
|
|
168
|
+
body=payload.body,
|
|
169
|
+
from_address=inbox.email_address,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Create message record
|
|
173
|
+
message = Message(
|
|
174
|
+
message_id=str(uuid.uuid4()),
|
|
175
|
+
thread_id=thread_id,
|
|
176
|
+
inbox_id=payload.inbox_id,
|
|
177
|
+
provider_message_id=provider_message_id,
|
|
178
|
+
direction=MessageDirection.OUTBOUND,
|
|
179
|
+
text=payload.body,
|
|
180
|
+
extracted_text=payload.body, # Already markdown
|
|
181
|
+
headers={
|
|
182
|
+
"to": ",".join(payload.to), # Join list into comma-separated string
|
|
183
|
+
"subject": payload.subject,
|
|
184
|
+
},
|
|
185
|
+
created_at=datetime.now(UTC),
|
|
186
|
+
)
|
|
187
|
+
created_message = await storage.create_message(message)
|
|
188
|
+
|
|
189
|
+
# Update thread's last_message_at
|
|
190
|
+
thread = await storage.get_thread(thread_id)
|
|
191
|
+
if thread:
|
|
192
|
+
thread.last_message_at = created_message.created_at
|
|
193
|
+
await storage.update_thread(thread)
|
|
194
|
+
|
|
195
|
+
return SendMessageResponse(
|
|
196
|
+
id=created_message.id,
|
|
197
|
+
thread_id=thread_id,
|
|
198
|
+
provider_message_id=provider_message_id,
|
|
199
|
+
status="sent" if provider_message_id else "pending",
|
|
200
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Search endpoint."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime # noqa: TC003 - needed at runtime for Pydantic
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from nornweave.core.interfaces import StorageInterface # noqa: TC001 - needed at runtime
|
|
10
|
+
from nornweave.yggdrasil.dependencies import get_storage
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SearchRequest(BaseModel):
|
|
16
|
+
"""Search request payload."""
|
|
17
|
+
|
|
18
|
+
query: str = Field(..., min_length=1, description="Search query")
|
|
19
|
+
inbox_id: str = Field(..., description="Inbox to search in")
|
|
20
|
+
limit: int = Field(default=50, ge=1, le=100)
|
|
21
|
+
offset: int = Field(default=0, ge=0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SearchResultItem(BaseModel):
|
|
25
|
+
"""Individual search result."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
thread_id: str
|
|
29
|
+
inbox_id: str
|
|
30
|
+
direction: str
|
|
31
|
+
content_clean: str
|
|
32
|
+
created_at: datetime | None
|
|
33
|
+
metadata: dict[str, Any]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SearchResponse(BaseModel):
|
|
37
|
+
"""Search response."""
|
|
38
|
+
|
|
39
|
+
items: list[SearchResultItem]
|
|
40
|
+
count: int
|
|
41
|
+
query: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.post("/search", response_model=SearchResponse)
|
|
45
|
+
async def search_messages(
|
|
46
|
+
payload: SearchRequest,
|
|
47
|
+
storage: StorageInterface = Depends(get_storage),
|
|
48
|
+
) -> SearchResponse:
|
|
49
|
+
"""Search messages by content.
|
|
50
|
+
|
|
51
|
+
Phase 1: Uses SQL ILIKE/LIKE on content_clean and content_raw.
|
|
52
|
+
Phase 3: Will use vector embeddings for semantic search.
|
|
53
|
+
"""
|
|
54
|
+
# Verify inbox exists
|
|
55
|
+
inbox = await storage.get_inbox(payload.inbox_id)
|
|
56
|
+
if inbox is None:
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
59
|
+
detail=f"Inbox {payload.inbox_id} not found",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
messages = await storage.search_messages(
|
|
63
|
+
inbox_id=payload.inbox_id,
|
|
64
|
+
query=payload.query,
|
|
65
|
+
limit=payload.limit,
|
|
66
|
+
offset=payload.offset,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return SearchResponse(
|
|
70
|
+
items=[
|
|
71
|
+
SearchResultItem(
|
|
72
|
+
id=m.id,
|
|
73
|
+
thread_id=m.thread_id,
|
|
74
|
+
inbox_id=m.inbox_id,
|
|
75
|
+
direction=m.direction.value,
|
|
76
|
+
content_clean=m.content_clean,
|
|
77
|
+
created_at=m.created_at,
|
|
78
|
+
metadata=m.metadata,
|
|
79
|
+
)
|
|
80
|
+
for m in messages
|
|
81
|
+
],
|
|
82
|
+
count=len(messages),
|
|
83
|
+
query=payload.query,
|
|
84
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Thread endpoints."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime # noqa: TC003 - needed at runtime for Pydantic
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from nornweave.core.interfaces import StorageInterface # noqa: TC001 - needed at runtime
|
|
9
|
+
from nornweave.models.message import MessageDirection
|
|
10
|
+
from nornweave.yggdrasil.dependencies import get_storage
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ThreadMessageResponse(BaseModel):
|
|
16
|
+
"""Message within a thread response (LLM-ready format)."""
|
|
17
|
+
|
|
18
|
+
role: str # "user" for inbound, "assistant" for outbound
|
|
19
|
+
author: str # email address from metadata or inbox
|
|
20
|
+
content: str # clean markdown content
|
|
21
|
+
timestamp: datetime | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ThreadDetailResponse(BaseModel):
|
|
25
|
+
"""Detailed thread response with messages (LLM context format per PRD)."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
subject: str
|
|
29
|
+
messages: list[ThreadMessageResponse]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ThreadSummaryResponse(BaseModel):
|
|
33
|
+
"""Thread summary for list views."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
inbox_id: str
|
|
37
|
+
subject: str
|
|
38
|
+
last_message_at: datetime | None
|
|
39
|
+
participant_hash: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ThreadListResponse(BaseModel):
|
|
43
|
+
"""Response model for thread list."""
|
|
44
|
+
|
|
45
|
+
items: list[ThreadSummaryResponse]
|
|
46
|
+
count: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/threads", response_model=ThreadListResponse)
|
|
50
|
+
async def list_threads(
|
|
51
|
+
inbox_id: str,
|
|
52
|
+
limit: int = 20,
|
|
53
|
+
offset: int = 0,
|
|
54
|
+
storage: StorageInterface = Depends(get_storage),
|
|
55
|
+
) -> ThreadListResponse:
|
|
56
|
+
"""List threads for an inbox, ordered by most recent activity."""
|
|
57
|
+
# Verify inbox exists
|
|
58
|
+
inbox = await storage.get_inbox(inbox_id)
|
|
59
|
+
if inbox is None:
|
|
60
|
+
raise HTTPException(
|
|
61
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
62
|
+
detail=f"Inbox {inbox_id} not found",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
threads = await storage.list_threads_for_inbox(
|
|
66
|
+
inbox_id,
|
|
67
|
+
limit=limit,
|
|
68
|
+
offset=offset,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return ThreadListResponse(
|
|
72
|
+
items=[
|
|
73
|
+
ThreadSummaryResponse(
|
|
74
|
+
id=t.id,
|
|
75
|
+
inbox_id=t.inbox_id,
|
|
76
|
+
subject=t.subject,
|
|
77
|
+
last_message_at=t.last_message_at,
|
|
78
|
+
participant_hash=t.participant_hash,
|
|
79
|
+
)
|
|
80
|
+
for t in threads
|
|
81
|
+
],
|
|
82
|
+
count=len(threads),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("/threads/{thread_id}", response_model=ThreadDetailResponse)
|
|
87
|
+
async def get_thread(
|
|
88
|
+
thread_id: str,
|
|
89
|
+
limit: int = 100,
|
|
90
|
+
offset: int = 0,
|
|
91
|
+
storage: StorageInterface = Depends(get_storage),
|
|
92
|
+
) -> ThreadDetailResponse:
|
|
93
|
+
"""Get a thread with its messages in LLM-ready format.
|
|
94
|
+
|
|
95
|
+
Returns a Markdown-formatted conversation history optimized for context windows,
|
|
96
|
+
as specified in the PRD.
|
|
97
|
+
"""
|
|
98
|
+
thread = await storage.get_thread(thread_id)
|
|
99
|
+
if thread is None:
|
|
100
|
+
raise HTTPException(
|
|
101
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
102
|
+
detail=f"Thread {thread_id} not found",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Get messages for the thread
|
|
106
|
+
messages = await storage.list_messages_for_thread(
|
|
107
|
+
thread_id,
|
|
108
|
+
limit=limit,
|
|
109
|
+
offset=offset,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Get inbox for author mapping
|
|
113
|
+
inbox = await storage.get_inbox(thread.inbox_id)
|
|
114
|
+
inbox_email = inbox.email_address if inbox else "unknown@example.com"
|
|
115
|
+
|
|
116
|
+
# Convert to LLM-ready format
|
|
117
|
+
thread_messages = []
|
|
118
|
+
for msg in messages:
|
|
119
|
+
# Determine role and author based on direction
|
|
120
|
+
if msg.direction == MessageDirection.INBOUND:
|
|
121
|
+
role = "user"
|
|
122
|
+
# Try to get from address from metadata (headers)
|
|
123
|
+
metadata = msg.metadata or {}
|
|
124
|
+
author = metadata.get("from", "unknown@example.com")
|
|
125
|
+
else:
|
|
126
|
+
role = "assistant"
|
|
127
|
+
author = inbox_email
|
|
128
|
+
|
|
129
|
+
thread_messages.append(
|
|
130
|
+
ThreadMessageResponse(
|
|
131
|
+
role=role,
|
|
132
|
+
author=str(author),
|
|
133
|
+
content=msg.content_clean or msg.content_raw,
|
|
134
|
+
timestamp=msg.created_at,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return ThreadDetailResponse(
|
|
139
|
+
id=thread.id,
|
|
140
|
+
subject=thread.subject,
|
|
141
|
+
messages=thread_messages,
|
|
142
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Webhook routes (Mailgun, SES, SendGrid)."""
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Mailgun webhook handler."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
8
|
+
|
|
9
|
+
from nornweave.adapters.mailgun import MailgunAdapter
|
|
10
|
+
from nornweave.core.interfaces import (
|
|
11
|
+
StorageInterface, # noqa: TC001 - needed at runtime for FastAPI
|
|
12
|
+
)
|
|
13
|
+
from nornweave.models.message import Message, MessageDirection
|
|
14
|
+
from nornweave.models.thread import Thread
|
|
15
|
+
from nornweave.verdandi.parser import html_to_markdown
|
|
16
|
+
from nornweave.yggdrasil.dependencies import get_storage
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/mailgun", status_code=status.HTTP_200_OK)
|
|
23
|
+
async def mailgun_webhook(
|
|
24
|
+
request: Request,
|
|
25
|
+
storage: StorageInterface = Depends(get_storage),
|
|
26
|
+
) -> dict[str, str]:
|
|
27
|
+
"""Handle inbound email webhook from Mailgun.
|
|
28
|
+
|
|
29
|
+
Mailgun sends inbound emails as multipart/form-data.
|
|
30
|
+
This handler:
|
|
31
|
+
1. Parses the webhook payload
|
|
32
|
+
2. Finds the inbox by recipient email
|
|
33
|
+
3. Creates or resolves a thread
|
|
34
|
+
4. Stores the message
|
|
35
|
+
"""
|
|
36
|
+
# Parse form data from Mailgun
|
|
37
|
+
form_data = await request.form()
|
|
38
|
+
payload = dict(form_data.items())
|
|
39
|
+
|
|
40
|
+
logger.info("Received Mailgun webhook for recipient: %s", payload.get("recipient"))
|
|
41
|
+
logger.debug("Mailgun payload keys: %s", list(payload.keys()))
|
|
42
|
+
|
|
43
|
+
# Parse the webhook payload using the Mailgun adapter
|
|
44
|
+
adapter = MailgunAdapter(api_key="", domain="") # Keys not needed for parsing
|
|
45
|
+
try:
|
|
46
|
+
inbound = adapter.parse_inbound_webhook(payload)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error("Failed to parse Mailgun webhook: %s", e)
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
51
|
+
detail=f"Failed to parse webhook payload: {e}",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Find inbox by recipient email address
|
|
55
|
+
inbox = await storage.get_inbox_by_email(inbound.to_address)
|
|
56
|
+
if inbox is None:
|
|
57
|
+
logger.warning("No inbox found for recipient: %s", inbound.to_address)
|
|
58
|
+
# Return 200 to prevent Mailgun from retrying, but log the issue
|
|
59
|
+
return {"status": "no_inbox"}
|
|
60
|
+
|
|
61
|
+
logger.info("Found inbox %s for recipient %s", inbox.id, inbound.to_address)
|
|
62
|
+
|
|
63
|
+
# Try to find existing thread by Message-ID references (for replies)
|
|
64
|
+
thread_id: str | None = None
|
|
65
|
+
|
|
66
|
+
# Check In-Reply-To header first
|
|
67
|
+
if inbound.in_reply_to:
|
|
68
|
+
existing_msg = await storage.get_message_by_provider_id(inbox.id, inbound.in_reply_to)
|
|
69
|
+
if existing_msg:
|
|
70
|
+
thread_id = existing_msg.thread_id
|
|
71
|
+
logger.debug("Found thread via In-Reply-To: %s", thread_id)
|
|
72
|
+
|
|
73
|
+
# Check References header
|
|
74
|
+
if not thread_id and inbound.references:
|
|
75
|
+
for ref in inbound.references:
|
|
76
|
+
existing_msg = await storage.get_message_by_provider_id(inbox.id, ref)
|
|
77
|
+
if existing_msg:
|
|
78
|
+
thread_id = existing_msg.thread_id
|
|
79
|
+
logger.debug("Found thread via References: %s", thread_id)
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
# If no thread found, create a new one
|
|
83
|
+
if thread_id:
|
|
84
|
+
thread = await storage.get_thread(thread_id)
|
|
85
|
+
else:
|
|
86
|
+
# Create new thread
|
|
87
|
+
new_thread = Thread(
|
|
88
|
+
thread_id=str(uuid.uuid4()),
|
|
89
|
+
inbox_id=inbox.id,
|
|
90
|
+
subject=inbound.subject,
|
|
91
|
+
timestamp=inbound.timestamp,
|
|
92
|
+
senders=[inbound.from_address],
|
|
93
|
+
recipients=[inbound.to_address],
|
|
94
|
+
)
|
|
95
|
+
thread = await storage.create_thread(new_thread)
|
|
96
|
+
thread_id = thread.id
|
|
97
|
+
logger.info("Created new thread %s for subject: %s", thread_id, inbound.subject)
|
|
98
|
+
|
|
99
|
+
# Convert HTML to Markdown for clean content
|
|
100
|
+
content_clean = inbound.stripped_text or inbound.body_plain
|
|
101
|
+
if inbound.stripped_html:
|
|
102
|
+
content_clean = html_to_markdown(inbound.stripped_html)
|
|
103
|
+
elif inbound.body_html:
|
|
104
|
+
content_clean = html_to_markdown(inbound.body_html)
|
|
105
|
+
|
|
106
|
+
# Create the message
|
|
107
|
+
message = Message(
|
|
108
|
+
message_id=str(uuid.uuid4()),
|
|
109
|
+
thread_id=thread_id,
|
|
110
|
+
inbox_id=inbox.id,
|
|
111
|
+
provider_message_id=inbound.message_id,
|
|
112
|
+
direction=MessageDirection.INBOUND,
|
|
113
|
+
from_address=inbound.from_address,
|
|
114
|
+
to=[inbound.to_address],
|
|
115
|
+
subject=inbound.subject,
|
|
116
|
+
text=inbound.body_plain,
|
|
117
|
+
html=inbound.body_html,
|
|
118
|
+
extracted_text=content_clean,
|
|
119
|
+
extracted_html=inbound.stripped_html,
|
|
120
|
+
in_reply_to=inbound.in_reply_to,
|
|
121
|
+
references=inbound.references if inbound.references else None,
|
|
122
|
+
headers=inbound.headers,
|
|
123
|
+
timestamp=inbound.timestamp,
|
|
124
|
+
created_at=datetime.now(UTC),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
created_message = await storage.create_message(message)
|
|
128
|
+
logger.info("Created message %s in thread %s", created_message.id, thread_id)
|
|
129
|
+
|
|
130
|
+
# Update thread's last_message_at
|
|
131
|
+
if thread:
|
|
132
|
+
thread.last_message_at = created_message.created_at
|
|
133
|
+
thread.received_timestamp = created_message.created_at
|
|
134
|
+
await storage.update_thread(thread)
|
|
135
|
+
|
|
136
|
+
return {"status": "received", "message_id": created_message.id, "thread_id": thread_id}
|