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,359 @@
|
|
|
1
|
+
"""Thread grouping logic using JWZ algorithm with Gmail-like heuristics.
|
|
2
|
+
|
|
3
|
+
This module implements email threading based on:
|
|
4
|
+
1. RFC 5322 headers: Message-ID, In-Reply-To, References
|
|
5
|
+
2. Subject normalization for subject-only matching
|
|
6
|
+
3. Time-window constraints (7-day window for subject matches)
|
|
7
|
+
4. Participant consistency checks (optional)
|
|
8
|
+
|
|
9
|
+
Reference: https://www.jwz.org/doc/threading.html
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from nornweave.core.interfaces import StorageInterface
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Subject prefixes to strip for normalization (multi-language)
|
|
23
|
+
REPLY_PREFIXES = re.compile(
|
|
24
|
+
r"^(?:re|fwd|fw|reply|aw|wg|sv|antw|vs|ref|enc|rv):\s*",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Default time window for subject-only matching (Gmail uses ~7 days)
|
|
29
|
+
SUBJECT_MATCH_WINDOW_DAYS = 7
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ThreadResolutionResult:
|
|
34
|
+
"""Result of thread resolution."""
|
|
35
|
+
|
|
36
|
+
thread_id: str
|
|
37
|
+
is_new_thread: bool
|
|
38
|
+
matched_by: str # "references", "in_reply_to", "subject", or "new"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize_subject(subject: str) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Normalize subject for thread matching.
|
|
44
|
+
|
|
45
|
+
Strips common reply/forward prefixes in multiple languages,
|
|
46
|
+
collapses whitespace, and lowercases for comparison.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
"Re: Hello" -> "hello"
|
|
50
|
+
"Fwd: Re: Meeting" -> "meeting"
|
|
51
|
+
"AW: Anfrage" -> "anfrage" (German)
|
|
52
|
+
"SV: Förfrågan" -> "förfrågan" (Swedish)
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
subject: Original email subject
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Normalized subject string for comparison
|
|
59
|
+
"""
|
|
60
|
+
if not subject:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
normalized = subject.strip()
|
|
64
|
+
|
|
65
|
+
# Iteratively strip reply/forward prefixes
|
|
66
|
+
while True:
|
|
67
|
+
result = REPLY_PREFIXES.sub("", normalized, count=1).strip()
|
|
68
|
+
if result == normalized:
|
|
69
|
+
break
|
|
70
|
+
normalized = result
|
|
71
|
+
|
|
72
|
+
# Collapse whitespace and lowercase
|
|
73
|
+
normalized = " ".join(normalized.split()).lower()
|
|
74
|
+
|
|
75
|
+
return normalized
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def compute_participant_hash(
|
|
79
|
+
from_address: str,
|
|
80
|
+
to_addresses: list[str],
|
|
81
|
+
cc_addresses: list[str] | None = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""
|
|
84
|
+
Compute a hash of thread participants for grouping.
|
|
85
|
+
|
|
86
|
+
This creates a canonical representation of participants
|
|
87
|
+
that can be used as a secondary threading signal.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
from_address: Sender email address
|
|
91
|
+
to_addresses: List of recipient addresses
|
|
92
|
+
cc_addresses: Optional list of CC addresses
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
SHA-256 hash of sorted, normalized participants
|
|
96
|
+
"""
|
|
97
|
+
# Normalize and collect all addresses
|
|
98
|
+
participants = set()
|
|
99
|
+
|
|
100
|
+
def normalize_addr(addr: str) -> str:
|
|
101
|
+
# Extract just the email part, lowercase
|
|
102
|
+
addr = addr.lower().strip()
|
|
103
|
+
# Handle "Name <email>" format
|
|
104
|
+
if "<" in addr and ">" in addr:
|
|
105
|
+
addr = addr.split("<")[1].split(">")[0]
|
|
106
|
+
return addr
|
|
107
|
+
|
|
108
|
+
participants.add(normalize_addr(from_address))
|
|
109
|
+
for addr in to_addresses:
|
|
110
|
+
participants.add(normalize_addr(addr))
|
|
111
|
+
if cc_addresses:
|
|
112
|
+
for addr in cc_addresses:
|
|
113
|
+
participants.add(normalize_addr(addr))
|
|
114
|
+
|
|
115
|
+
# Sort for consistent hashing
|
|
116
|
+
sorted_participants = sorted(participants)
|
|
117
|
+
combined = ",".join(sorted_participants)
|
|
118
|
+
|
|
119
|
+
return hashlib.sha256(combined.encode()).hexdigest()[:16]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_references_header(references: str | None) -> list[str]:
|
|
123
|
+
"""
|
|
124
|
+
Parse the References header into a list of Message-IDs.
|
|
125
|
+
|
|
126
|
+
References header contains space-separated Message-IDs:
|
|
127
|
+
"<id1@host> <id2@host> <id3@host>"
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
references: Raw References header value
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of Message-ID strings
|
|
134
|
+
"""
|
|
135
|
+
if not references:
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
# Split by whitespace and filter empty
|
|
139
|
+
refs = [ref.strip() for ref in references.split() if ref.strip()]
|
|
140
|
+
|
|
141
|
+
# Validate that each looks like a Message-ID (contains @, wrapped in <>)
|
|
142
|
+
valid_refs = []
|
|
143
|
+
for ref in refs:
|
|
144
|
+
if "@" in ref:
|
|
145
|
+
# Normalize: ensure angle brackets
|
|
146
|
+
if not ref.startswith("<"):
|
|
147
|
+
ref = "<" + ref
|
|
148
|
+
if not ref.endswith(">"):
|
|
149
|
+
ref = ref + ">"
|
|
150
|
+
valid_refs.append(ref)
|
|
151
|
+
|
|
152
|
+
return valid_refs
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def normalize_message_id(message_id: str | None) -> str | None:
|
|
156
|
+
"""
|
|
157
|
+
Normalize a Message-ID for consistent storage and lookup.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
message_id: Raw Message-ID value
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Normalized Message-ID with angle brackets, or None
|
|
164
|
+
"""
|
|
165
|
+
if not message_id:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
mid = message_id.strip()
|
|
169
|
+
if not mid:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Ensure angle brackets
|
|
173
|
+
if not mid.startswith("<"):
|
|
174
|
+
mid = "<" + mid
|
|
175
|
+
if not mid.endswith(">"):
|
|
176
|
+
mid = mid + ">"
|
|
177
|
+
|
|
178
|
+
return mid
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def resolve_thread(
|
|
182
|
+
storage: StorageInterface,
|
|
183
|
+
inbox_id: str,
|
|
184
|
+
*,
|
|
185
|
+
message_id: str | None = None, # noqa: ARG001 - reserved for future use
|
|
186
|
+
in_reply_to: str | None = None,
|
|
187
|
+
references: list[str] | None = None,
|
|
188
|
+
subject: str = "",
|
|
189
|
+
timestamp: datetime | None = None,
|
|
190
|
+
from_address: str = "", # noqa: ARG001 - reserved for future use
|
|
191
|
+
to_addresses: list[str] | None = None, # noqa: ARG001 - reserved for future use
|
|
192
|
+
) -> ThreadResolutionResult:
|
|
193
|
+
"""
|
|
194
|
+
Resolve thread ID using JWZ algorithm with Gmail-like heuristics.
|
|
195
|
+
|
|
196
|
+
Algorithm:
|
|
197
|
+
1. If References exist, find matching parent message (last ref first)
|
|
198
|
+
2. If In-Reply-To exists and no References match, try In-Reply-To
|
|
199
|
+
3. If neither header matches, try subject-only matching within 7-day window
|
|
200
|
+
4. If no match, create new thread
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
storage: Storage interface for lookups
|
|
204
|
+
inbox_id: Inbox to search within
|
|
205
|
+
message_id: This message's Message-ID (for storing)
|
|
206
|
+
in_reply_to: In-Reply-To header (parent Message-ID)
|
|
207
|
+
references: List of Message-IDs from References header
|
|
208
|
+
subject: Email subject
|
|
209
|
+
timestamp: Message timestamp (for subject matching window)
|
|
210
|
+
from_address: Sender address (for participant hash)
|
|
211
|
+
to_addresses: Recipient addresses (for participant hash)
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
ThreadResolutionResult with thread_id and metadata
|
|
215
|
+
"""
|
|
216
|
+
import uuid
|
|
217
|
+
|
|
218
|
+
timestamp = timestamp or datetime.utcnow()
|
|
219
|
+
|
|
220
|
+
# Priority 1: Check References header (most reliable)
|
|
221
|
+
if references:
|
|
222
|
+
# Check references in reverse order (most recent parent first)
|
|
223
|
+
for ref in reversed(references):
|
|
224
|
+
normalized_ref = normalize_message_id(ref)
|
|
225
|
+
if normalized_ref:
|
|
226
|
+
parent_msg = await storage.get_message_by_provider_id(inbox_id, normalized_ref)
|
|
227
|
+
if parent_msg:
|
|
228
|
+
return ThreadResolutionResult(
|
|
229
|
+
thread_id=parent_msg.thread_id,
|
|
230
|
+
is_new_thread=False,
|
|
231
|
+
matched_by="references",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Priority 2: Check In-Reply-To header
|
|
235
|
+
if in_reply_to:
|
|
236
|
+
in_reply_to = normalize_message_id(in_reply_to)
|
|
237
|
+
if in_reply_to:
|
|
238
|
+
parent_msg = await storage.get_message_by_provider_id(inbox_id, in_reply_to)
|
|
239
|
+
if parent_msg:
|
|
240
|
+
return ThreadResolutionResult(
|
|
241
|
+
thread_id=parent_msg.thread_id,
|
|
242
|
+
is_new_thread=False,
|
|
243
|
+
matched_by="in_reply_to",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Priority 3: Subject-only matching within time window
|
|
247
|
+
if subject:
|
|
248
|
+
normalized = normalize_subject(subject)
|
|
249
|
+
if normalized:
|
|
250
|
+
# Only match subjects within 7-day window
|
|
251
|
+
since_time = timestamp - timedelta(days=SUBJECT_MATCH_WINDOW_DAYS)
|
|
252
|
+
thread = await storage.get_thread_by_subject(inbox_id, normalized, since=since_time)
|
|
253
|
+
if thread:
|
|
254
|
+
return ThreadResolutionResult(
|
|
255
|
+
thread_id=thread.thread_id,
|
|
256
|
+
is_new_thread=False,
|
|
257
|
+
matched_by="subject",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# No match found: create new thread
|
|
261
|
+
new_thread_id = str(uuid.uuid4())
|
|
262
|
+
return ThreadResolutionResult(
|
|
263
|
+
thread_id=new_thread_id,
|
|
264
|
+
is_new_thread=True,
|
|
265
|
+
matched_by="new",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def resolve_thread_id(
|
|
270
|
+
_message_id: str | None,
|
|
271
|
+
_references: str | None,
|
|
272
|
+
_in_reply_to: str | None,
|
|
273
|
+
_subject: str,
|
|
274
|
+
) -> str | None:
|
|
275
|
+
"""
|
|
276
|
+
Legacy synchronous placeholder for thread resolution.
|
|
277
|
+
|
|
278
|
+
Deprecated: Use async resolve_thread() instead.
|
|
279
|
+
"""
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def build_references_chain(
|
|
284
|
+
parent_references: list[str] | None,
|
|
285
|
+
parent_message_id: str | None,
|
|
286
|
+
*,
|
|
287
|
+
max_length: int = 20,
|
|
288
|
+
) -> list[str]:
|
|
289
|
+
"""
|
|
290
|
+
Build References chain for a reply message.
|
|
291
|
+
|
|
292
|
+
Per RFC 5322, the References header should contain the Message-IDs
|
|
293
|
+
of all ancestor messages in the thread. To prevent unbounded growth,
|
|
294
|
+
we limit to the most recent ~20 entries.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
parent_references: References from the parent message
|
|
298
|
+
parent_message_id: Message-ID of the parent message
|
|
299
|
+
max_length: Maximum number of references to include
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of Message-IDs for the new References header
|
|
303
|
+
"""
|
|
304
|
+
refs: list[str] = []
|
|
305
|
+
|
|
306
|
+
# Start with existing references
|
|
307
|
+
if parent_references:
|
|
308
|
+
refs.extend(parent_references)
|
|
309
|
+
|
|
310
|
+
# Add parent's Message-ID
|
|
311
|
+
if parent_message_id:
|
|
312
|
+
parent_mid = normalize_message_id(parent_message_id)
|
|
313
|
+
if parent_mid and parent_mid not in refs:
|
|
314
|
+
refs.append(parent_mid)
|
|
315
|
+
|
|
316
|
+
# Trim to max length (keep most recent)
|
|
317
|
+
if len(refs) > max_length:
|
|
318
|
+
refs = refs[-max_length:]
|
|
319
|
+
|
|
320
|
+
return refs
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def should_thread_together(
|
|
324
|
+
subject1: str,
|
|
325
|
+
subject2: str,
|
|
326
|
+
time_diff: timedelta | None = None,
|
|
327
|
+
) -> bool:
|
|
328
|
+
"""
|
|
329
|
+
Determine if two messages should be in the same thread based on subject.
|
|
330
|
+
|
|
331
|
+
Uses normalized subject comparison and optional time constraint.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
subject1: First message subject
|
|
335
|
+
subject2: Second message subject
|
|
336
|
+
time_diff: Time difference between messages (optional)
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
True if messages should be threaded together
|
|
340
|
+
"""
|
|
341
|
+
# Normalize both subjects
|
|
342
|
+
norm1 = normalize_subject(subject1)
|
|
343
|
+
norm2 = normalize_subject(subject2)
|
|
344
|
+
|
|
345
|
+
# Empty subjects never match
|
|
346
|
+
if not norm1 or not norm2:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
# Subjects must match
|
|
350
|
+
if norm1 != norm2:
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
# Check time window if provided
|
|
354
|
+
if time_diff is not None:
|
|
355
|
+
max_diff = timedelta(days=SUBJECT_MATCH_WINDOW_DAYS)
|
|
356
|
+
if abs(time_diff) > max_diff:
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
return True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Yggdrasil: API gateway and router."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""FastAPI application factory (Yggdrasil)."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
|
|
9
|
+
from nornweave import __version__
|
|
10
|
+
from nornweave.core.config import get_settings
|
|
11
|
+
from nornweave.yggdrasil.dependencies import close_database, init_database
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import AsyncGenerator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@asynccontextmanager
|
|
18
|
+
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|
19
|
+
"""Application lifespan: initialize and close database connections."""
|
|
20
|
+
settings = get_settings()
|
|
21
|
+
await init_database(settings)
|
|
22
|
+
yield
|
|
23
|
+
await close_database()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app() -> FastAPI:
|
|
27
|
+
"""Create and configure the FastAPI application."""
|
|
28
|
+
settings = get_settings()
|
|
29
|
+
|
|
30
|
+
app = FastAPI(
|
|
31
|
+
title="NornWeave",
|
|
32
|
+
description="Open-source Inbox-as-a-Service API for AI Agents",
|
|
33
|
+
version=__version__,
|
|
34
|
+
docs_url="/docs",
|
|
35
|
+
redoc_url="/redoc",
|
|
36
|
+
lifespan=lifespan,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# CORS middleware
|
|
40
|
+
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
|
41
|
+
app.add_middleware(
|
|
42
|
+
CORSMiddleware,
|
|
43
|
+
allow_origins=origins if origins else ["*"],
|
|
44
|
+
allow_credentials=True,
|
|
45
|
+
allow_methods=["*"],
|
|
46
|
+
allow_headers=["*"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Health check endpoint
|
|
50
|
+
@app.get("/health")
|
|
51
|
+
def health() -> dict[str, str]:
|
|
52
|
+
return {"status": "ok"}
|
|
53
|
+
|
|
54
|
+
# Include API routers
|
|
55
|
+
from nornweave.yggdrasil.routes.v1 import inboxes, messages, search, threads
|
|
56
|
+
|
|
57
|
+
app.include_router(inboxes.router, prefix="/v1", tags=["inboxes"])
|
|
58
|
+
app.include_router(threads.router, prefix="/v1", tags=["threads"])
|
|
59
|
+
app.include_router(messages.router, prefix="/v1", tags=["messages"])
|
|
60
|
+
app.include_router(search.router, prefix="/v1", tags=["search"])
|
|
61
|
+
|
|
62
|
+
# Include webhook routers
|
|
63
|
+
from nornweave.yggdrasil.routes.webhooks import mailgun, resend, sendgrid, ses
|
|
64
|
+
|
|
65
|
+
app.include_router(mailgun.router, prefix="/webhooks", tags=["webhooks"])
|
|
66
|
+
app.include_router(sendgrid.router, prefix="/webhooks", tags=["webhooks"])
|
|
67
|
+
app.include_router(ses.router, prefix="/webhooks", tags=["webhooks"])
|
|
68
|
+
app.include_router(resend.router, prefix="/webhooks", tags=["webhooks"])
|
|
69
|
+
|
|
70
|
+
return app
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = create_app()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
"""CLI entry point: run uvicorn."""
|
|
78
|
+
import uvicorn
|
|
79
|
+
|
|
80
|
+
settings = get_settings()
|
|
81
|
+
uvicorn.run(
|
|
82
|
+
"nornweave.yggdrasil.app:app",
|
|
83
|
+
host=settings.host,
|
|
84
|
+
port=settings.port,
|
|
85
|
+
reload=settings.environment == "development",
|
|
86
|
+
)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Dependency injection (storage, provider)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator # noqa: TC003 - needed at runtime
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, Request
|
|
8
|
+
from sqlalchemy.ext.asyncio import (
|
|
9
|
+
AsyncEngine,
|
|
10
|
+
AsyncSession,
|
|
11
|
+
async_sessionmaker,
|
|
12
|
+
create_async_engine,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from nornweave.core.config import Settings, get_settings
|
|
16
|
+
from nornweave.core.interfaces import ( # noqa: TC001 - needed at runtime for FastAPI
|
|
17
|
+
EmailProvider,
|
|
18
|
+
StorageInterface,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# -----------------------------------------------------------------------------
|
|
22
|
+
# Database engine management
|
|
23
|
+
# -----------------------------------------------------------------------------
|
|
24
|
+
_engine: AsyncEngine | None = None
|
|
25
|
+
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_database_url(settings: Settings) -> str:
|
|
29
|
+
"""Get database URL from settings with validation."""
|
|
30
|
+
url = settings.database_url
|
|
31
|
+
|
|
32
|
+
if not url:
|
|
33
|
+
if settings.db_driver == "sqlite":
|
|
34
|
+
# Default SQLite path for local development
|
|
35
|
+
url = "sqlite+aiosqlite:///./nornweave.db"
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError("DATABASE_URL must be set for PostgreSQL")
|
|
38
|
+
|
|
39
|
+
# Validate URL matches driver
|
|
40
|
+
if settings.db_driver == "postgres":
|
|
41
|
+
if not url.startswith("postgresql"):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"DATABASE_URL must start with 'postgresql' for postgres driver, got: {url}"
|
|
44
|
+
)
|
|
45
|
+
# Ensure async driver
|
|
46
|
+
if "asyncpg" not in url:
|
|
47
|
+
url = url.replace("postgresql://", "postgresql+asyncpg://")
|
|
48
|
+
elif settings.db_driver == "sqlite":
|
|
49
|
+
if not url.startswith("sqlite"):
|
|
50
|
+
raise ValueError(f"DATABASE_URL must start with 'sqlite' for sqlite driver, got: {url}")
|
|
51
|
+
# Ensure async driver
|
|
52
|
+
if "aiosqlite" not in url:
|
|
53
|
+
url = url.replace("sqlite://", "sqlite+aiosqlite://")
|
|
54
|
+
|
|
55
|
+
return url
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def init_database(settings: Settings | None = None) -> None:
|
|
59
|
+
"""Initialize database engine and session factory."""
|
|
60
|
+
global _engine, _session_factory
|
|
61
|
+
|
|
62
|
+
if settings is None:
|
|
63
|
+
settings = get_settings()
|
|
64
|
+
|
|
65
|
+
url = get_database_url(settings)
|
|
66
|
+
|
|
67
|
+
# Engine configuration
|
|
68
|
+
engine_kwargs: dict[str, Any] = {
|
|
69
|
+
"echo": settings.environment == "development" and settings.log_level == "DEBUG",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# SQLite-specific settings
|
|
73
|
+
if settings.db_driver == "sqlite":
|
|
74
|
+
engine_kwargs["connect_args"] = {"check_same_thread": False}
|
|
75
|
+
|
|
76
|
+
_engine = create_async_engine(url, **engine_kwargs)
|
|
77
|
+
_session_factory = async_sessionmaker(
|
|
78
|
+
_engine,
|
|
79
|
+
class_=AsyncSession,
|
|
80
|
+
expire_on_commit=False,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Enable foreign keys for SQLite
|
|
84
|
+
if settings.db_driver == "sqlite":
|
|
85
|
+
from sqlalchemy import event
|
|
86
|
+
from sqlalchemy.engine import Engine
|
|
87
|
+
|
|
88
|
+
@event.listens_for(Engine, "connect")
|
|
89
|
+
def set_sqlite_pragma(dbapi_conn: Any, _connection_record: Any) -> None:
|
|
90
|
+
cursor = dbapi_conn.cursor()
|
|
91
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
92
|
+
cursor.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def close_database() -> None:
|
|
96
|
+
"""Close database engine."""
|
|
97
|
+
global _engine, _session_factory
|
|
98
|
+
|
|
99
|
+
if _engine is not None:
|
|
100
|
+
await _engine.dispose()
|
|
101
|
+
_engine = None
|
|
102
|
+
_session_factory = None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@asynccontextmanager
|
|
106
|
+
async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
107
|
+
"""Get a database session (context manager)."""
|
|
108
|
+
if _session_factory is None:
|
|
109
|
+
raise RuntimeError("Database not initialized. Call init_database() first.")
|
|
110
|
+
|
|
111
|
+
async with _session_factory() as session:
|
|
112
|
+
try:
|
|
113
|
+
yield session
|
|
114
|
+
await session.commit()
|
|
115
|
+
except Exception:
|
|
116
|
+
await session.rollback()
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# -----------------------------------------------------------------------------
|
|
121
|
+
# FastAPI Dependencies
|
|
122
|
+
# -----------------------------------------------------------------------------
|
|
123
|
+
async def get_db_session(_request: Request) -> AsyncGenerator[AsyncSession]:
|
|
124
|
+
"""FastAPI dependency to get a database session."""
|
|
125
|
+
if _session_factory is None:
|
|
126
|
+
raise RuntimeError("Database not initialized")
|
|
127
|
+
|
|
128
|
+
async with _session_factory() as session:
|
|
129
|
+
try:
|
|
130
|
+
yield session
|
|
131
|
+
await session.commit()
|
|
132
|
+
except Exception:
|
|
133
|
+
await session.rollback()
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def get_storage(
|
|
138
|
+
session: AsyncSession = Depends(get_db_session),
|
|
139
|
+
settings: Settings = Depends(get_settings),
|
|
140
|
+
) -> StorageInterface:
|
|
141
|
+
"""FastAPI dependency to get the configured storage adapter."""
|
|
142
|
+
if settings.db_driver == "postgres":
|
|
143
|
+
try:
|
|
144
|
+
from nornweave.urdr.adapters.postgres import PostgresAdapter
|
|
145
|
+
except ImportError as e:
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"PostgreSQL support requires additional dependencies. "
|
|
148
|
+
"Install with: pip install nornweave[postgres]"
|
|
149
|
+
) from e
|
|
150
|
+
return PostgresAdapter(session)
|
|
151
|
+
elif settings.db_driver == "sqlite":
|
|
152
|
+
from nornweave.urdr.adapters.sqlite import SQLiteAdapter
|
|
153
|
+
|
|
154
|
+
return SQLiteAdapter(session)
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError(f"Unknown db_driver: {settings.db_driver}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def get_email_provider(
|
|
160
|
+
settings: Settings = Depends(get_settings),
|
|
161
|
+
) -> EmailProvider:
|
|
162
|
+
"""FastAPI dependency to get the configured email provider."""
|
|
163
|
+
# Import here to avoid circular imports
|
|
164
|
+
from nornweave.adapters.mailgun import MailgunAdapter
|
|
165
|
+
from nornweave.adapters.resend import ResendAdapter
|
|
166
|
+
from nornweave.adapters.sendgrid import SendGridAdapter
|
|
167
|
+
from nornweave.adapters.ses import SESAdapter
|
|
168
|
+
|
|
169
|
+
provider = settings.email_provider
|
|
170
|
+
|
|
171
|
+
if provider == "mailgun":
|
|
172
|
+
return MailgunAdapter(
|
|
173
|
+
api_key=settings.mailgun_api_key,
|
|
174
|
+
domain=settings.mailgun_domain,
|
|
175
|
+
)
|
|
176
|
+
elif provider == "sendgrid":
|
|
177
|
+
return SendGridAdapter(api_key=settings.sendgrid_api_key)
|
|
178
|
+
elif provider == "ses":
|
|
179
|
+
return SESAdapter(
|
|
180
|
+
access_key_id=settings.aws_access_key_id,
|
|
181
|
+
secret_access_key=settings.aws_secret_access_key,
|
|
182
|
+
region=settings.aws_region,
|
|
183
|
+
)
|
|
184
|
+
elif provider == "resend":
|
|
185
|
+
return ResendAdapter(
|
|
186
|
+
api_key=settings.resend_api_key,
|
|
187
|
+
webhook_secret=settings.resend_webhook_secret,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
raise ValueError(f"Unknown email_provider: {provider}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Middleware: auth, logging."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API key authentication middleware. Placeholder."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Request/response logging middleware. Placeholder."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API routes."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""REST API v1 routes."""
|