remdb 0.3.141__py3-none-any.whl → 0.3.163__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (44) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +310 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +18 -3
  5. rem/api/deps.py +3 -5
  6. rem/api/main.py +22 -3
  7. rem/api/mcp_router/server.py +2 -0
  8. rem/api/mcp_router/tools.py +90 -0
  9. rem/api/middleware/tracking.py +5 -5
  10. rem/api/routers/auth.py +346 -5
  11. rem/api/routers/chat/completions.py +4 -2
  12. rem/api/routers/chat/streaming.py +77 -22
  13. rem/api/routers/messages.py +24 -15
  14. rem/auth/__init__.py +13 -3
  15. rem/auth/jwt.py +352 -0
  16. rem/auth/middleware.py +108 -6
  17. rem/auth/providers/__init__.py +4 -1
  18. rem/auth/providers/email.py +215 -0
  19. rem/cli/commands/experiments.py +32 -46
  20. rem/models/core/experiment.py +4 -14
  21. rem/models/entities/__init__.py +4 -0
  22. rem/models/entities/subscriber.py +175 -0
  23. rem/models/entities/user.py +1 -0
  24. rem/schemas/agents/core/agent-builder.yaml +134 -0
  25. rem/services/__init__.py +3 -1
  26. rem/services/content/service.py +4 -3
  27. rem/services/email/__init__.py +10 -0
  28. rem/services/email/service.py +511 -0
  29. rem/services/email/templates.py +360 -0
  30. rem/services/postgres/README.md +38 -0
  31. rem/services/postgres/diff_service.py +19 -3
  32. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  33. rem/services/postgres/repository.py +5 -4
  34. rem/services/session/compression.py +113 -50
  35. rem/services/session/reload.py +14 -7
  36. rem/services/user_service.py +29 -0
  37. rem/settings.py +199 -4
  38. rem/sql/migrations/005_schema_update.sql +145 -0
  39. rem/utils/README.md +45 -0
  40. rem/utils/files.py +157 -1
  41. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/METADATA +7 -5
  42. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/RECORD +44 -35
  43. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/WHEEL +0 -0
  44. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/entry_points.txt +0 -0
@@ -1,13 +1,49 @@
1
1
  """Session message compression and rehydration for efficient context loading.
2
2
 
3
- This module implements message compression to keep conversation history within
4
- context windows while preserving full content via REM LOOKUP.
5
-
6
- Design Pattern:
7
- - Long assistant messages (>400 chars) are stored as separate Message entities
8
- - In-memory conversation uses truncated versions with REM lookup hints
9
- - Full content retrieved on-demand via LOOKUP queries
10
- - Compression disabled when Postgres is disabled
3
+ This module implements message storage and compression to keep conversation history
4
+ within context windows while preserving full content via REM LOOKUP.
5
+
6
+ Message Types and Storage Strategy
7
+ ===================================
8
+
9
+ All messages are stored UNCOMPRESSED in the database for full audit/analysis.
10
+ Compression happens only on RELOAD when reconstructing context for the LLM.
11
+
12
+ Message Types:
13
+ - `user`: User messages - stored and reloaded as-is
14
+ - `tool`: Tool call messages (e.g., register_metadata) - stored and reloaded as-is
15
+ NEVER compressed - contains important structured metadata
16
+ - `assistant`: Assistant text responses - stored uncompressed, but MAY BE
17
+ compressed on reload if long (>400 chars) with REM LOOKUP hints
18
+
19
+ Example Session Flow:
20
+ ```
21
+ Turn 1 (stored uncompressed):
22
+ - user: "I have a headache"
23
+ - tool: register_metadata({confidence: 0.3, collected_fields: {...}})
24
+ - assistant: "I'm sorry to hear that. How long has this been going on?"
25
+
26
+ Turn 2 (stored uncompressed):
27
+ - user: "About 3 days, really bad"
28
+ - tool: register_metadata({confidence: 0.6, collected_fields: {...}})
29
+ - assistant: "Got it - 3 days. On a scale of 1-10..."
30
+
31
+ On reload (for LLM context):
32
+ - user messages: returned as-is
33
+ - tool messages: returned as-is (never compressed)
34
+ - assistant messages: compressed if long, with REM LOOKUP hint for full retrieval
35
+ ```
36
+
37
+ REM LOOKUP Pattern:
38
+ - Long assistant messages get truncated with hint: "... [REM LOOKUP session-{id}-msg-{idx}] ..."
39
+ - Agent can retrieve full content on-demand using the LOOKUP key
40
+ - Keeps context window efficient while preserving data integrity
41
+
42
+ Key Design Decisions:
43
+ 1. Store everything uncompressed - full audit trail in database
44
+ 2. Compress only on reload - optimize for LLM context window
45
+ 3. Never compress tool messages - structured metadata must stay intact
46
+ 4. REM LOOKUP enables on-demand retrieval of full assistant responses
11
47
  """
12
48
 
13
49
  from typing import Any
@@ -285,8 +321,23 @@ class SessionMessageStore:
285
321
  msg_copy["_entity_key"] = entity_key
286
322
  compressed_messages.append(msg_copy)
287
323
  else:
288
- # Short assistant messages, user messages, and system messages stored as-is
324
+ # Short assistant messages, user messages, tool messages, and system messages stored as-is
289
325
  # Store ALL messages in database for full audit trail
326
+ # Build metadata dict with standard fields
327
+ msg_metadata = {
328
+ "message_index": idx,
329
+ "timestamp": message.get("timestamp"),
330
+ }
331
+
332
+ # For tool messages, include tool call details in metadata
333
+ if message.get("role") == "tool":
334
+ if message.get("tool_call_id"):
335
+ msg_metadata["tool_call_id"] = message.get("tool_call_id")
336
+ if message.get("tool_name"):
337
+ msg_metadata["tool_name"] = message.get("tool_name")
338
+ if message.get("tool_arguments"):
339
+ msg_metadata["tool_arguments"] = message.get("tool_arguments")
340
+
290
341
  msg = Message(
291
342
  id=message.get("id"), # Use pre-generated ID if provided
292
343
  content=content,
@@ -296,10 +347,7 @@ class SessionMessageStore:
296
347
  user_id=user_id or self.user_id,
297
348
  trace_id=message.get("trace_id"),
298
349
  span_id=message.get("span_id"),
299
- metadata={
300
- "message_index": idx,
301
- "timestamp": message.get("timestamp"),
302
- },
350
+ metadata=msg_metadata,
303
351
  )
304
352
  await self.repo.upsert(msg)
305
353
  compressed_messages.append(message.copy())
@@ -307,18 +355,24 @@ class SessionMessageStore:
307
355
  return compressed_messages
308
356
 
309
357
  async def load_session_messages(
310
- self, session_id: str, user_id: str | None = None, decompress: bool = False
358
+ self, session_id: str, user_id: str | None = None, compress_on_load: bool = True
311
359
  ) -> list[dict[str, Any]]:
312
360
  """
313
- Load session messages from database.
361
+ Load session messages from database, optionally compressing long assistant messages.
362
+
363
+ Compression on Load:
364
+ - Tool messages (role: "tool") are NEVER compressed - they contain structured metadata
365
+ - User messages are returned as-is
366
+ - Assistant messages MAY be compressed if long (>400 chars) with REM LOOKUP hints
314
367
 
315
368
  Args:
316
369
  session_id: Session identifier
317
370
  user_id: Optional user identifier for filtering
318
- decompress: Whether to decompress messages (default: False)
371
+ compress_on_load: Whether to compress long assistant messages (default: True)
319
372
 
320
373
  Returns:
321
- List of session messages in chronological order
374
+ List of session messages in chronological order, with long assistant
375
+ messages optionally compressed with REM LOOKUP hints
322
376
  """
323
377
  if not settings.postgres.enabled:
324
378
  logger.debug("Postgres disabled, returning empty message list")
@@ -335,49 +389,58 @@ class SessionMessageStore:
335
389
 
336
390
  # Convert Message entities to dict format
337
391
  message_dicts = []
338
- for msg in messages:
392
+ for idx, msg in enumerate(messages):
393
+ role = msg.message_type or "assistant"
339
394
  msg_dict = {
340
- "role": msg.message_type or "assistant",
395
+ "role": role,
341
396
  "content": msg.content,
342
397
  "timestamp": msg.created_at.isoformat() if msg.created_at else None,
343
398
  }
344
399
 
345
- # Check if message was compressed
346
- entity_key: str | None = msg.metadata.get("entity_key") if msg.metadata else None
347
- if entity_key and len(msg.content) <= self.compressor.min_length_for_compression:
348
- # This is a compressed reference, mark it
349
- msg_dict["_compressed"] = True
350
- msg_dict["_entity_key"] = entity_key
351
- msg_dict["_original_length"] = msg.metadata.get("original_length", 0)
400
+ # For tool messages, reconstruct tool call metadata
401
+ if role == "tool" and msg.metadata:
402
+ if msg.metadata.get("tool_call_id"):
403
+ msg_dict["tool_call_id"] = msg.metadata["tool_call_id"]
404
+ if msg.metadata.get("tool_name"):
405
+ msg_dict["tool_name"] = msg.metadata["tool_name"]
406
+ if msg.metadata.get("tool_arguments"):
407
+ msg_dict["tool_arguments"] = msg.metadata["tool_arguments"]
408
+
409
+ # Compress long ASSISTANT messages on load (never tool messages)
410
+ if (
411
+ compress_on_load
412
+ and role == "assistant"
413
+ and len(msg.content) > self.compressor.min_length_for_compression
414
+ ):
415
+ # Generate entity key for REM LOOKUP
416
+ entity_key = truncate_key(f"session-{session_id}-msg-{idx}")
417
+ msg_dict = self.compressor.compress_message(msg_dict, entity_key)
352
418
 
353
419
  message_dicts.append(msg_dict)
354
420
 
355
- # Decompress if requested
356
- if decompress:
357
- decompressed_messages = []
358
- for message in message_dicts:
359
- if self.compressor.is_compressed(message):
360
- entity_key = self.compressor.get_entity_key(message)
361
- if entity_key:
362
- full_content = await self.retrieve_message(entity_key)
363
- if full_content:
364
- decompressed_messages.append(
365
- self.compressor.decompress_message(
366
- message, full_content
367
- )
368
- )
369
- else:
370
- # Fallback to compressed version if retrieval fails
371
- decompressed_messages.append(message)
372
- else:
373
- decompressed_messages.append(message)
374
- else:
375
- decompressed_messages.append(message)
376
-
377
- return decompressed_messages
378
-
421
+ logger.debug(
422
+ f"Loaded {len(message_dicts)} messages for session {session_id} "
423
+ f"(compress_on_load={compress_on_load})"
424
+ )
379
425
  return message_dicts
380
426
 
381
427
  except Exception as e:
382
428
  logger.error(f"Failed to load session messages: {e}")
383
429
  return []
430
+
431
+ async def retrieve_full_message(self, session_id: str, message_index: int) -> str | None:
432
+ """
433
+ Retrieve full message content by session and message index (for REM LOOKUP).
434
+
435
+ This is used when an agent needs to recover full content from a compressed
436
+ message that has a REM LOOKUP hint.
437
+
438
+ Args:
439
+ session_id: Session identifier
440
+ message_index: Index of message in session (from REM LOOKUP key)
441
+
442
+ Returns:
443
+ Full message content or None if not found
444
+ """
445
+ entity_key = truncate_key(f"session-{session_id}-msg-{message_index}")
446
+ return await self.retrieve_message(entity_key)
@@ -6,8 +6,14 @@ allowing conversations to be resumed across multiple API calls.
6
6
  Design Pattern:
7
7
  - Session identified by session_id from X-Session-Id header
8
8
  - All messages for session loaded in chronological order
9
- - Optional decompression of long assistant messages via REM LOOKUP
9
+ - Long assistant messages compressed on load with REM LOOKUP hints
10
+ - Tool messages (register_metadata, etc.) are NEVER compressed
10
11
  - Gracefully handles missing database (returns empty history)
12
+
13
+ Message Types on Reload:
14
+ - user: Returned as-is
15
+ - tool: Returned as-is with metadata (tool_call_id, tool_name, tool_arguments)
16
+ - assistant: Compressed on load if long (>400 chars), with REM LOOKUP for recovery
11
17
  """
12
18
 
13
19
  from loguru import logger
@@ -19,7 +25,7 @@ from rem.settings import settings
19
25
  async def reload_session(
20
26
  session_id: str,
21
27
  user_id: str,
22
- decompress_messages: bool = False,
28
+ compress_on_load: bool = True,
23
29
  ) -> list[dict]:
24
30
  """
25
31
  Reload all messages for a session from the database.
@@ -27,7 +33,8 @@ async def reload_session(
27
33
  Args:
28
34
  session_id: Session/conversation identifier
29
35
  user_id: User identifier for data isolation
30
- decompress_messages: Whether to decompress long messages via REM LOOKUP
36
+ compress_on_load: Whether to compress long assistant messages (default: True)
37
+ Tool messages are NEVER compressed.
31
38
 
32
39
  Returns:
33
40
  List of message dicts in chronological order (oldest first)
@@ -41,7 +48,7 @@ async def reload_session(
41
48
  history = await reload_session(
42
49
  session_id=context.session_id,
43
50
  user_id=context.user_id,
44
- decompress_messages=False, # Use compressed versions for efficiency
51
+ compress_on_load=True, # Compress long assistant messages
45
52
  )
46
53
 
47
54
  # Combine with new user message
@@ -60,14 +67,14 @@ async def reload_session(
60
67
  # Create message store for this session
61
68
  store = SessionMessageStore(user_id=user_id)
62
69
 
63
- # Load messages (optionally decompressed)
70
+ # Load messages (assistant messages compressed on load, tool messages never compressed)
64
71
  messages = await store.load_session_messages(
65
- session_id=session_id, user_id=user_id, decompress=decompress_messages
72
+ session_id=session_id, user_id=user_id, compress_on_load=compress_on_load
66
73
  )
67
74
 
68
75
  logger.debug(
69
76
  f"Reloaded {len(messages)} messages for session {session_id} "
70
- f"(decompressed={decompress_messages})"
77
+ f"(compress_on_load={compress_on_load})"
71
78
  )
72
79
 
73
80
  return messages
@@ -73,6 +73,35 @@ class UserService:
73
73
  logger.info(f"Created new user: {email}")
74
74
  return user
75
75
 
76
+ async def get_user_by_id(self, user_id: str) -> Optional[User]:
77
+ """
78
+ Get a user by their UUID.
79
+
80
+ Args:
81
+ user_id: The user's UUID
82
+
83
+ Returns:
84
+ User if found, None otherwise
85
+ """
86
+ try:
87
+ return await self.repo.get_by_id(user_id)
88
+ except Exception as e:
89
+ logger.warning(f"Could not find user by id {user_id}: {e}")
90
+ return None
91
+
92
+ async def get_user_by_email(self, email: str) -> Optional[User]:
93
+ """
94
+ Get a user by their email address.
95
+
96
+ Args:
97
+ email: The user's email
98
+
99
+ Returns:
100
+ User if found, None otherwise
101
+ """
102
+ users = await self.repo.find(filters={"email": email}, limit=1)
103
+ return users[0] if users else None
104
+
76
105
  async def link_anonymous_session(self, user: User, anon_id: str) -> None:
77
106
  """
78
107
  Link an anonymous session ID to a user account.
rem/settings.py CHANGED
@@ -21,8 +21,8 @@ Example .env file:
21
21
  LLM__OPENAI_API_KEY=sk-...
22
22
  LLM__ANTHROPIC_API_KEY=sk-ant-...
23
23
 
24
- # Database (port 5050 for Docker Compose)
25
- POSTGRES__CONNECTION_STRING=postgresql://rem:rem@localhost:5050/rem
24
+ # Database (port 5051 for Docker Compose prebuilt, 5050 for local dev)
25
+ POSTGRES__CONNECTION_STRING=postgresql://rem:rem@localhost:5051/rem
26
26
  POSTGRES__POOL_MIN_SIZE=5
27
27
  POSTGRES__POOL_MAX_SIZE=20
28
28
  POSTGRES__STATEMENT_TIMEOUT=30000
@@ -464,10 +464,11 @@ class PostgresSettings(BaseSettings):
464
464
  )
465
465
 
466
466
  connection_string: str = Field(
467
- default="postgresql://rem:rem@localhost:5050/rem",
468
- description="PostgreSQL connection string (default uses Docker Compose port 5050)",
467
+ default="postgresql://rem:rem@localhost:5051/rem",
468
+ description="PostgreSQL connection string (default uses Docker Compose prebuilt port 5051)",
469
469
  )
470
470
 
471
+
471
472
  pool_size: int = Field(
472
473
  default=10,
473
474
  description="Connection pool size (deprecated, use pool_min_size/pool_max_size)",
@@ -1060,6 +1061,8 @@ class APISettings(BaseSettings):
1060
1061
  API__RELOAD - Enable auto-reload for development
1061
1062
  API__WORKERS - Number of worker processes (production)
1062
1063
  API__LOG_LEVEL - Logging level (debug, info, warning, error)
1064
+ API__API_KEY_ENABLED - Enable X-API-Key header authentication
1065
+ API__API_KEY - API key for X-API-Key authentication
1063
1066
  """
1064
1067
 
1065
1068
  model_config = SettingsConfigDict(
@@ -1094,6 +1097,31 @@ class APISettings(BaseSettings):
1094
1097
  description="Logging level (debug, info, warning, error, critical)",
1095
1098
  )
1096
1099
 
1100
+ api_key_enabled: bool = Field(
1101
+ default=False,
1102
+ description=(
1103
+ "Enable X-API-Key header authentication for API endpoints. "
1104
+ "When enabled, requests must include X-API-Key header with valid key. "
1105
+ "This provides simple API key auth independent of OAuth."
1106
+ ),
1107
+ )
1108
+
1109
+ api_key: str | None = Field(
1110
+ default=None,
1111
+ description=(
1112
+ "API key for X-API-Key authentication. Required when api_key_enabled=true. "
1113
+ "Generate with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
1114
+ ),
1115
+ )
1116
+
1117
+ rate_limit_enabled: bool = Field(
1118
+ default=True,
1119
+ description=(
1120
+ "Enable rate limiting for API endpoints. "
1121
+ "Set to false to disable rate limiting entirely (useful for development)."
1122
+ ),
1123
+ )
1124
+
1097
1125
 
1098
1126
  class ModelsSettings(BaseSettings):
1099
1127
  """
@@ -1451,6 +1479,172 @@ class DBListenerSettings(BaseSettings):
1451
1479
  return [c.strip() for c in self.channels.split(",") if c.strip()]
1452
1480
 
1453
1481
 
1482
+ class EmailSettings(BaseSettings):
1483
+ """
1484
+ Email service settings for SMTP.
1485
+
1486
+ Supports passwordless login via email codes and transactional emails.
1487
+ Uses Gmail SMTP with App Passwords by default.
1488
+
1489
+ Generate app password at: https://myaccount.google.com/apppasswords
1490
+
1491
+ Environment variables:
1492
+ EMAIL__ENABLED - Enable email service (default: false)
1493
+ EMAIL__SMTP_HOST - SMTP server host (default: smtp.gmail.com)
1494
+ EMAIL__SMTP_PORT - SMTP server port (default: 587 for TLS)
1495
+ EMAIL__SENDER_EMAIL - Sender email address
1496
+ EMAIL__SENDER_NAME - Sender display name
1497
+ EMAIL__APP_PASSWORD - Gmail app password (from secrets)
1498
+ EMAIL__USE_TLS - Use TLS encryption (default: true)
1499
+ EMAIL__LOGIN_CODE_EXPIRY_MINUTES - Login code expiry (default: 10)
1500
+
1501
+ Branding environment variables (for email templates):
1502
+ EMAIL__APP_NAME - Application name in emails (default: REM)
1503
+ EMAIL__LOGO_URL - Logo URL for email templates (40x40 recommended)
1504
+ EMAIL__TAGLINE - Tagline shown in email footer
1505
+ EMAIL__WEBSITE_URL - Main website URL for email links
1506
+ EMAIL__PRIVACY_URL - Privacy policy URL for email footer
1507
+ EMAIL__TERMS_URL - Terms of service URL for email footer
1508
+ """
1509
+
1510
+ model_config = SettingsConfigDict(
1511
+ env_prefix="EMAIL__",
1512
+ env_file=".env",
1513
+ env_file_encoding="utf-8",
1514
+ extra="ignore",
1515
+ )
1516
+
1517
+ enabled: bool = Field(
1518
+ default=False,
1519
+ description="Enable email service (requires app_password to be set)",
1520
+ )
1521
+
1522
+ smtp_host: str = Field(
1523
+ default="smtp.gmail.com",
1524
+ description="SMTP server host",
1525
+ )
1526
+
1527
+ smtp_port: int = Field(
1528
+ default=587,
1529
+ description="SMTP server port (587 for TLS, 465 for SSL)",
1530
+ )
1531
+
1532
+ sender_email: str = Field(
1533
+ default="",
1534
+ description="Sender email address",
1535
+ )
1536
+
1537
+ sender_name: str = Field(
1538
+ default="REM",
1539
+ description="Sender display name",
1540
+ )
1541
+
1542
+ # Branding settings for email templates
1543
+ app_name: str = Field(
1544
+ default="REM",
1545
+ description="Application name shown in email templates",
1546
+ )
1547
+
1548
+ logo_url: str | None = Field(
1549
+ default=None,
1550
+ description="Logo URL for email templates (40x40 recommended)",
1551
+ )
1552
+
1553
+ tagline: str = Field(
1554
+ default="Your AI-powered platform",
1555
+ description="Tagline shown in email footer",
1556
+ )
1557
+
1558
+ website_url: str = Field(
1559
+ default="https://rem.ai",
1560
+ description="Main website URL for email links",
1561
+ )
1562
+
1563
+ privacy_url: str = Field(
1564
+ default="https://rem.ai/privacy",
1565
+ description="Privacy policy URL for email footer",
1566
+ )
1567
+
1568
+ terms_url: str = Field(
1569
+ default="https://rem.ai/terms",
1570
+ description="Terms of service URL for email footer",
1571
+ )
1572
+
1573
+ app_password: str | None = Field(
1574
+ default=None,
1575
+ description="Gmail app password for SMTP authentication",
1576
+ )
1577
+
1578
+ use_tls: bool = Field(
1579
+ default=True,
1580
+ description="Use TLS encryption for SMTP",
1581
+ )
1582
+
1583
+ login_code_expiry_minutes: int = Field(
1584
+ default=10,
1585
+ description="Login code expiry in minutes",
1586
+ )
1587
+
1588
+ trusted_email_domains: str = Field(
1589
+ default="",
1590
+ description=(
1591
+ "Comma-separated list of trusted email domains for new user registration. "
1592
+ "Existing users can always login regardless of domain. "
1593
+ "New users must have an email from a trusted domain. "
1594
+ "Empty string means all domains are allowed. "
1595
+ "Example: 'siggymd.ai,example.com'"
1596
+ ),
1597
+ )
1598
+
1599
+ @property
1600
+ def trusted_domain_list(self) -> list[str]:
1601
+ """Get trusted domains as a list, filtering empty strings."""
1602
+ if not self.trusted_email_domains:
1603
+ return []
1604
+ return [d.strip().lower() for d in self.trusted_email_domains.split(",") if d.strip()]
1605
+
1606
+ def is_domain_trusted(self, email: str) -> bool:
1607
+ """Check if an email's domain is in the trusted list.
1608
+
1609
+ Args:
1610
+ email: Email address to check
1611
+
1612
+ Returns:
1613
+ True if domain is trusted (or if no trusted domains configured)
1614
+ """
1615
+ domains = self.trusted_domain_list
1616
+ if not domains:
1617
+ # No restrictions configured
1618
+ return True
1619
+
1620
+ email_domain = email.lower().split("@")[-1].strip()
1621
+ return email_domain in domains
1622
+
1623
+ @property
1624
+ def is_configured(self) -> bool:
1625
+ """Check if email service is properly configured."""
1626
+ return bool(self.sender_email and self.app_password)
1627
+
1628
+ @property
1629
+ def template_kwargs(self) -> dict:
1630
+ """
1631
+ Get branding kwargs for email templates.
1632
+
1633
+ Returns a dict that can be passed to template functions:
1634
+ login_code_template(..., **settings.email.template_kwargs)
1635
+ """
1636
+ kwargs = {
1637
+ "app_name": self.app_name,
1638
+ "tagline": self.tagline,
1639
+ "website_url": self.website_url,
1640
+ "privacy_url": self.privacy_url,
1641
+ "terms_url": self.terms_url,
1642
+ }
1643
+ if self.logo_url:
1644
+ kwargs["logo_url"] = self.logo_url
1645
+ return kwargs
1646
+
1647
+
1454
1648
  class TestSettings(BaseSettings):
1455
1649
  """
1456
1650
  Test environment settings.
@@ -1565,6 +1759,7 @@ class Settings(BaseSettings):
1565
1759
  chunking: ChunkingSettings = Field(default_factory=ChunkingSettings)
1566
1760
  content: ContentSettings = Field(default_factory=ContentSettings)
1567
1761
  schema_search: SchemaSettings = Field(default_factory=SchemaSettings)
1762
+ email: EmailSettings = Field(default_factory=EmailSettings)
1568
1763
  test: TestSettings = Field(default_factory=TestSettings)
1569
1764
 
1570
1765