nornweave 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. nornweave/__init__.py +3 -0
  2. nornweave/adapters/__init__.py +1 -0
  3. nornweave/adapters/base.py +5 -0
  4. nornweave/adapters/mailgun.py +196 -0
  5. nornweave/adapters/resend.py +510 -0
  6. nornweave/adapters/sendgrid.py +492 -0
  7. nornweave/adapters/ses.py +824 -0
  8. nornweave/cli.py +186 -0
  9. nornweave/core/__init__.py +26 -0
  10. nornweave/core/config.py +172 -0
  11. nornweave/core/exceptions.py +25 -0
  12. nornweave/core/interfaces.py +390 -0
  13. nornweave/core/storage.py +192 -0
  14. nornweave/core/utils.py +23 -0
  15. nornweave/huginn/__init__.py +10 -0
  16. nornweave/huginn/client.py +296 -0
  17. nornweave/huginn/config.py +52 -0
  18. nornweave/huginn/resources.py +165 -0
  19. nornweave/huginn/server.py +202 -0
  20. nornweave/models/__init__.py +113 -0
  21. nornweave/models/attachment.py +136 -0
  22. nornweave/models/event.py +275 -0
  23. nornweave/models/inbox.py +33 -0
  24. nornweave/models/message.py +284 -0
  25. nornweave/models/thread.py +172 -0
  26. nornweave/muninn/__init__.py +14 -0
  27. nornweave/muninn/tools.py +207 -0
  28. nornweave/search/__init__.py +1 -0
  29. nornweave/search/embeddings.py +1 -0
  30. nornweave/search/vector_store.py +1 -0
  31. nornweave/skuld/__init__.py +1 -0
  32. nornweave/skuld/rate_limiter.py +1 -0
  33. nornweave/skuld/scheduler.py +1 -0
  34. nornweave/skuld/sender.py +25 -0
  35. nornweave/skuld/webhooks.py +1 -0
  36. nornweave/storage/__init__.py +20 -0
  37. nornweave/storage/database.py +165 -0
  38. nornweave/storage/gcs.py +144 -0
  39. nornweave/storage/local.py +152 -0
  40. nornweave/storage/s3.py +164 -0
  41. nornweave/urdr/__init__.py +14 -0
  42. nornweave/urdr/adapters/__init__.py +16 -0
  43. nornweave/urdr/adapters/base.py +385 -0
  44. nornweave/urdr/adapters/postgres.py +50 -0
  45. nornweave/urdr/adapters/sqlite.py +51 -0
  46. nornweave/urdr/migrations/env.py +94 -0
  47. nornweave/urdr/migrations/script.py.mako +26 -0
  48. nornweave/urdr/migrations/versions/.gitkeep +0 -0
  49. nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
  50. nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
  51. nornweave/urdr/orm.py +641 -0
  52. nornweave/verdandi/__init__.py +45 -0
  53. nornweave/verdandi/attachments.py +471 -0
  54. nornweave/verdandi/content.py +420 -0
  55. nornweave/verdandi/headers.py +404 -0
  56. nornweave/verdandi/parser.py +25 -0
  57. nornweave/verdandi/sanitizer.py +9 -0
  58. nornweave/verdandi/threading.py +359 -0
  59. nornweave/yggdrasil/__init__.py +1 -0
  60. nornweave/yggdrasil/app.py +86 -0
  61. nornweave/yggdrasil/dependencies.py +190 -0
  62. nornweave/yggdrasil/middleware/__init__.py +1 -0
  63. nornweave/yggdrasil/middleware/auth.py +1 -0
  64. nornweave/yggdrasil/middleware/logging.py +1 -0
  65. nornweave/yggdrasil/routes/__init__.py +1 -0
  66. nornweave/yggdrasil/routes/v1/__init__.py +1 -0
  67. nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
  68. nornweave/yggdrasil/routes/v1/messages.py +200 -0
  69. nornweave/yggdrasil/routes/v1/search.py +84 -0
  70. nornweave/yggdrasil/routes/v1/threads.py +142 -0
  71. nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
  72. nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
  73. nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
  74. nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
  75. nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
  76. nornweave-0.1.2.dist-info/METADATA +324 -0
  77. nornweave-0.1.2.dist-info/RECORD +80 -0
  78. nornweave-0.1.2.dist-info/WHEEL +4 -0
  79. nornweave-0.1.2.dist-info/entry_points.txt +5 -0
  80. nornweave-0.1.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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}