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,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."""