remdb 0.3.133__py3-none-any.whl → 0.3.157__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 (51) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +310 -0
  3. rem/agentic/context_builder.py +5 -3
  4. rem/agentic/mcp/tool_wrapper.py +48 -6
  5. rem/agentic/providers/phoenix.py +91 -21
  6. rem/agentic/providers/pydantic_ai.py +77 -43
  7. rem/api/deps.py +2 -2
  8. rem/api/main.py +1 -1
  9. rem/api/mcp_router/server.py +2 -0
  10. rem/api/mcp_router/tools.py +90 -0
  11. rem/api/routers/auth.py +208 -4
  12. rem/api/routers/chat/streaming.py +77 -22
  13. rem/auth/__init__.py +13 -3
  14. rem/auth/middleware.py +66 -1
  15. rem/auth/providers/__init__.py +4 -1
  16. rem/auth/providers/email.py +215 -0
  17. rem/cli/commands/configure.py +3 -4
  18. rem/cli/commands/experiments.py +50 -49
  19. rem/cli/commands/session.py +336 -0
  20. rem/cli/dreaming.py +2 -2
  21. rem/cli/main.py +2 -0
  22. rem/models/core/experiment.py +4 -14
  23. rem/models/entities/__init__.py +4 -0
  24. rem/models/entities/ontology.py +1 -1
  25. rem/models/entities/ontology_config.py +1 -1
  26. rem/models/entities/subscriber.py +175 -0
  27. rem/models/entities/user.py +1 -0
  28. rem/schemas/agents/core/agent-builder.yaml +134 -0
  29. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  30. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  31. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  32. rem/services/__init__.py +3 -1
  33. rem/services/content/service.py +4 -3
  34. rem/services/email/__init__.py +10 -0
  35. rem/services/email/service.py +459 -0
  36. rem/services/email/templates.py +360 -0
  37. rem/services/postgres/README.md +38 -0
  38. rem/services/postgres/diff_service.py +19 -3
  39. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  40. rem/services/session/compression.py +113 -50
  41. rem/services/session/reload.py +14 -7
  42. rem/settings.py +191 -4
  43. rem/sql/migrations/002_install_models.sql +91 -91
  44. rem/sql/migrations/005_schema_update.sql +145 -0
  45. rem/utils/README.md +45 -0
  46. rem/utils/files.py +157 -1
  47. rem/utils/vision.py +1 -1
  48. {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/METADATA +7 -5
  49. {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/RECORD +51 -42
  50. {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/WHEEL +0 -0
  51. {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,134 @@
1
+ type: object
2
+ description: |
3
+ # Agent Builder - Create Custom AI Agents Through Conversation
4
+
5
+ You help users create custom AI agents by chatting with them naturally.
6
+ Gather requirements conversationally, show previews, and save the agent when ready.
7
+
8
+ ## Your Workflow
9
+
10
+ 1. **Understand the need**: Ask what they want the agent to do
11
+ 2. **Define personality**: Help them choose tone and style
12
+ 3. **Structure outputs**: If needed, define what data the agent captures
13
+ 4. **Preview**: Show them what the agent will look like
14
+ 5. **Save**: Use `save_agent` tool to persist it
15
+
16
+ ## Conversation Style
17
+
18
+ Be friendly and helpful. Ask one or two questions at a time.
19
+ Don't overwhelm with options - guide them step by step.
20
+
21
+ ## Gathering Requirements
22
+
23
+ Ask about:
24
+ - What should this agent help with?
25
+ - What tone should it have? (casual, professional, empathetic, etc.)
26
+ - Should it capture any specific information? (optional)
27
+ - What should it be called?
28
+
29
+ ## Preview Format
30
+
31
+ Before saving, show a preview using markdown:
32
+
33
+ ```
34
+ ## Agent Preview: {name}
35
+
36
+ **Personality:**
37
+ {brief description of tone and approach}
38
+
39
+ **System Prompt:**
40
+ {the actual prompt that will guide the agent}
41
+
42
+ **Structured Fields:** (if any)
43
+ | Field | Type | Description |
44
+ |-------|------|-------------|
45
+ | answer | string | Response to user |
46
+ | ... | ... | ... |
47
+ ```
48
+
49
+ Ask: "Does this look good? I can save it now or we can adjust anything."
50
+
51
+ ## Saving the Agent
52
+
53
+ When the user approves, call `save_agent` with:
54
+ - `name`: kebab-case name (e.g., "customer-support-bot")
55
+ - `description`: The full system prompt
56
+ - `properties`: Structured output fields (optional, defaults to just "answer")
57
+ - `required`: Which fields are required (optional, defaults to ["answer"])
58
+ - `tags`: Relevant tags for organization
59
+
60
+ ## Default Tools
61
+
62
+ All agents automatically get `search_rem` and `register_metadata` tools.
63
+ You don't need to specify these.
64
+
65
+ ## Example Conversation
66
+
67
+ User: "I want an agent that helps write professional emails"
68
+
69
+ You: "Great idea! Let me help you create an email writing assistant.
70
+
71
+ What tone should it have? For example:
72
+ - Professional and formal
73
+ - Friendly but polished
74
+ - Direct and concise"
75
+
76
+ User: "Friendly but polished"
77
+
78
+ You: "Perfect! Should it just write emails, or also help with things like:
79
+ - Suggesting subject lines
80
+ - Adjusting tone for different recipients
81
+ - Summarizing long email threads"
82
+
83
+ User: "All of those would be helpful"
84
+
85
+ You: "Let me show you a preview:
86
+
87
+ ## Agent Preview: email-assistant
88
+
89
+ **Personality:** Friendly, professional, helpful writing partner
90
+
91
+ **System Prompt:**
92
+ You are a friendly email writing assistant. Help users craft polished,
93
+ professional emails while maintaining a warm tone. You can:
94
+ - Write new emails from scratch
95
+ - Suggest compelling subject lines
96
+ - Adjust tone for different audiences
97
+ - Summarize long email threads
98
+
99
+ Always ask clarifying questions if the request is unclear.
100
+
101
+ **Structured Fields:**
102
+ | Field | Type | Description |
103
+ |-------|------|-------------|
104
+ | answer | string | Your response or the drafted email |
105
+
106
+ Does this look good? I can save it now or adjust anything."
107
+
108
+ User: "Looks great, save it!"
109
+
110
+ You: *calls save_agent tool*
111
+ "Done! Your email-assistant is ready. Use `/custom-agent email-assistant` to start chatting with it."
112
+
113
+ properties:
114
+ answer:
115
+ type: string
116
+ description: Your conversational response to the user
117
+
118
+ required:
119
+ - answer
120
+
121
+ json_schema_extra:
122
+ kind: agent
123
+ name: agent-builder
124
+ version: "1.0.0"
125
+ tags:
126
+ - meta
127
+ - builder
128
+ tools:
129
+ - name: save_agent
130
+ description: "Save the agent schema to make it available for use"
131
+ - name: search_rem
132
+ description: "Search for existing agents as examples"
133
+ - name: register_metadata
134
+ description: "Record session metadata"
@@ -308,7 +308,7 @@ json_schema_extra:
308
308
  - provider_name: anthropic
309
309
  model_name: claude-sonnet-4-5-20250929
310
310
  - provider_name: openai
311
- model_name: gpt-4o
311
+ model_name: gpt-4.1
312
312
  embedding_fields:
313
313
  - contract_title
314
314
  - contract_type
@@ -131,4 +131,4 @@ json_schema_extra:
131
131
  - provider_name: anthropic
132
132
  model_name: claude-sonnet-4-5-20250929
133
133
  - provider_name: openai
134
- model_name: gpt-4o
134
+ model_name: gpt-4.1
@@ -255,7 +255,7 @@ json_schema_extra:
255
255
  - provider_name: anthropic
256
256
  model_name: claude-sonnet-4-5-20250929
257
257
  - provider_name: openai
258
- model_name: gpt-4o
258
+ model_name: gpt-4.1
259
259
  embedding_fields:
260
260
  - candidate_name
261
261
  - professional_summary
rem/services/__init__.py CHANGED
@@ -4,13 +4,15 @@ REM Services
4
4
  Service layer for REM system operations:
5
5
  - PostgresService: PostgreSQL/CloudNativePG database operations
6
6
  - RemService: REM query execution and graph operations
7
+ - EmailService: Transactional emails and passwordless login
7
8
 
8
9
  For file/S3 operations, use rem.services.fs instead:
9
10
  from rem.services.fs import FS, S3Provider
10
11
  """
11
12
 
13
+ from .email import EmailService
12
14
  from .fs.service import FileSystemService
13
15
  from .postgres import PostgresService
14
16
  from .rem import RemService
15
17
 
16
- __all__ = ["PostgresService", "RemService", "FileSystemService"]
18
+ __all__ = ["EmailService", "PostgresService", "RemService", "FileSystemService"]
@@ -666,10 +666,11 @@ class ContentService:
666
666
  # IMPORTANT: category field distinguishes agents from evaluators
667
667
  # - kind=agent → category="agent" (AI agents with tools/resources)
668
668
  # - kind=evaluator → category="evaluator" (LLM-as-a-Judge evaluators)
669
- # Schemas (agents/evaluators) default to system tenant for shared access
669
+ # User-scoped schemas: if user_id provided, scope to user's tenant
670
+ # System schemas: if no user_id, use "system" tenant for shared access
670
671
  schema_entity = Schema(
671
- tenant_id="system",
672
- user_id=None,
672
+ tenant_id=user_id or "system",
673
+ user_id=user_id,
673
674
  name=name,
674
675
  spec=schema_data,
675
676
  category=kind, # Maps kind → category for database filtering
@@ -0,0 +1,10 @@
1
+ """
2
+ Email Service Module.
3
+
4
+ Provides EmailService for sending transactional emails and passwordless login.
5
+ """
6
+
7
+ from .service import EmailService
8
+ from .templates import EmailTemplate, login_code_template
9
+
10
+ __all__ = ["EmailService", "EmailTemplate", "login_code_template"]
@@ -0,0 +1,459 @@
1
+ """
2
+ Email Service.
3
+
4
+ Provides methods for sending transactional emails via SMTP.
5
+ Supports passwordless login via email codes.
6
+ """
7
+
8
+ import uuid
9
+ import random
10
+ import string
11
+ import smtplib
12
+ import logging
13
+ from email.mime.text import MIMEText
14
+ from email.mime.multipart import MIMEMultipart
15
+ from datetime import datetime, timedelta, timezone
16
+ from typing import Any, Optional, TYPE_CHECKING
17
+
18
+ from .templates import EmailTemplate, login_code_template, welcome_template
19
+
20
+ if TYPE_CHECKING:
21
+ from ..postgres import PostgresService
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class EmailService:
27
+ """Service for sending transactional emails and passwordless login."""
28
+
29
+ def __init__(
30
+ self,
31
+ smtp_host: str | None = None,
32
+ smtp_port: int | None = None,
33
+ sender_email: str | None = None,
34
+ sender_name: str | None = None,
35
+ app_password: str | None = None,
36
+ use_tls: bool = True,
37
+ login_code_expiry_minutes: int = 10,
38
+ ):
39
+ """
40
+ Initialize EmailService.
41
+
42
+ If no arguments provided, uses settings from rem.settings.
43
+ This allows no-arg construction for simple usage.
44
+
45
+ Args:
46
+ smtp_host: SMTP server host
47
+ smtp_port: SMTP server port
48
+ sender_email: Sender email address
49
+ sender_name: Sender display name
50
+ app_password: SMTP app password
51
+ use_tls: Use TLS encryption
52
+ login_code_expiry_minutes: Login code expiry in minutes
53
+ """
54
+ # Import settings lazily to avoid circular imports
55
+ from ...settings import settings
56
+
57
+ self._smtp_host = smtp_host or settings.email.smtp_host
58
+ self._smtp_port = smtp_port or settings.email.smtp_port
59
+ self._sender_email = sender_email or settings.email.sender_email
60
+ self._sender_name = sender_name or settings.email.sender_name
61
+ self._app_password = app_password or settings.email.app_password
62
+ self._use_tls = use_tls
63
+ self._login_code_expiry_minutes = (
64
+ login_code_expiry_minutes
65
+ or settings.email.login_code_expiry_minutes
66
+ )
67
+
68
+ if not self._app_password:
69
+ logger.warning(
70
+ "Email app password not configured. "
71
+ "Set EMAIL__APP_PASSWORD to enable email sending."
72
+ )
73
+
74
+ @property
75
+ def is_configured(self) -> bool:
76
+ """Check if email service is properly configured."""
77
+ return bool(self._sender_email and self._app_password)
78
+
79
+ def _create_smtp_connection(self) -> smtplib.SMTP:
80
+ """Create and authenticate SMTP connection."""
81
+ server = smtplib.SMTP(self._smtp_host, self._smtp_port)
82
+
83
+ if self._use_tls:
84
+ server.starttls()
85
+
86
+ server.login(self._sender_email, self._app_password)
87
+ return server
88
+
89
+ def send_email(
90
+ self,
91
+ to_email: str,
92
+ template: EmailTemplate,
93
+ reply_to: Optional[str] = None,
94
+ ) -> bool:
95
+ """
96
+ Send an email using a template.
97
+
98
+ Args:
99
+ to_email: Recipient email address
100
+ template: EmailTemplate with subject and HTML body
101
+ reply_to: Optional reply-to address
102
+
103
+ Returns:
104
+ True if sent successfully, False otherwise
105
+ """
106
+ if not self.is_configured:
107
+ logger.error("Email service not configured. Cannot send email.")
108
+ return False
109
+
110
+ try:
111
+ # Create message
112
+ msg = MIMEMultipart("alternative")
113
+ msg["Subject"] = template.subject
114
+ msg["From"] = f"{self._sender_name} <{self._sender_email}>"
115
+ msg["To"] = to_email
116
+
117
+ if reply_to:
118
+ msg["Reply-To"] = reply_to
119
+
120
+ # Attach HTML body
121
+ html_part = MIMEText(template.html_body, "html", "utf-8")
122
+ msg.attach(html_part)
123
+
124
+ # Send email
125
+ with self._create_smtp_connection() as server:
126
+ server.sendmail(
127
+ self._sender_email,
128
+ to_email,
129
+ msg.as_string(),
130
+ )
131
+
132
+ logger.info(f"Email sent successfully to {to_email}: {template.subject}")
133
+ return True
134
+
135
+ except smtplib.SMTPAuthenticationError as e:
136
+ logger.error(f"SMTP authentication failed: {e}")
137
+ return False
138
+ except smtplib.SMTPException as e:
139
+ logger.error(f"SMTP error sending email to {to_email}: {e}")
140
+ return False
141
+ except Exception as e:
142
+ logger.error(f"Unexpected error sending email to {to_email}: {e}")
143
+ return False
144
+
145
+ @staticmethod
146
+ def generate_login_code() -> str:
147
+ """
148
+ Generate a 6-digit login code.
149
+
150
+ Returns:
151
+ 6-digit numeric string
152
+ """
153
+ return "".join(random.choices(string.digits, k=6))
154
+
155
+ @staticmethod
156
+ def generate_user_id_from_email(email: str) -> str:
157
+ """
158
+ Generate a deterministic UUID from email address.
159
+
160
+ Uses UUID v5 with DNS namespace for consistency.
161
+ Same email always produces same UUID.
162
+
163
+ Args:
164
+ email: Email address
165
+
166
+ Returns:
167
+ UUID string
168
+ """
169
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, email.lower().strip()))
170
+
171
+ async def send_login_code(
172
+ self,
173
+ email: str,
174
+ db: "PostgresService | None" = None,
175
+ tenant_id: str = "default",
176
+ template_kwargs: dict | None = None,
177
+ ) -> dict:
178
+ """
179
+ Send a login code to an email address.
180
+
181
+ Access control logic:
182
+ 1. If user exists and tier is BLOCKED -> reject with "Account blocked"
183
+ 2. If user exists and tier is not BLOCKED -> allow (send code)
184
+ 3. If user doesn't exist -> check trusted_email_domains setting:
185
+ - If domain is trusted (or no restrictions) -> create user and send code
186
+ - If domain is not trusted -> reject with "Domain not allowed"
187
+
188
+ This method:
189
+ 1. Generates a 6-digit login code
190
+ 2. Checks user existence and access permissions
191
+ 3. Upserts the user with login code in metadata
192
+ 4. Sends the code via email
193
+
194
+ Args:
195
+ email: User's email address
196
+ db: PostgresService instance for repository operations
197
+ tenant_id: Tenant identifier for multi-tenancy
198
+ template_kwargs: Additional arguments for template customization
199
+
200
+ Returns:
201
+ Dict with status and details:
202
+ {
203
+ "success": bool,
204
+ "email": str,
205
+ "user_id": str,
206
+ "code_sent": bool,
207
+ "expires_at": str (ISO format),
208
+ "error": str (if failed)
209
+ }
210
+ """
211
+ from ...settings import settings
212
+
213
+ email = email.lower().strip()
214
+ code = self.generate_login_code()
215
+ user_id = self.generate_user_id_from_email(email)
216
+ expires_at = datetime.now(timezone.utc) + timedelta(
217
+ minutes=self._login_code_expiry_minutes
218
+ )
219
+
220
+ result = {
221
+ "success": False,
222
+ "email": email,
223
+ "user_id": user_id,
224
+ "code_sent": False,
225
+ "expires_at": expires_at.isoformat(),
226
+ }
227
+
228
+ # Check user access and upsert login code using repository
229
+ if db:
230
+ try:
231
+ access_result = await self._check_and_upsert_user_login_code(
232
+ db=db,
233
+ email=email,
234
+ user_id=user_id,
235
+ code=code,
236
+ expires_at=expires_at,
237
+ tenant_id=tenant_id,
238
+ settings=settings,
239
+ )
240
+ if not access_result["allowed"]:
241
+ result["error"] = access_result["error"]
242
+ return result
243
+ except Exception as e:
244
+ logger.error(f"Failed to upsert user login code: {e}")
245
+ result["error"] = "Failed to store login code"
246
+ return result
247
+
248
+ # Send the email with branding from settings
249
+ # Merge settings.email.template_kwargs with any explicit overrides
250
+ kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
251
+ template = login_code_template(code=code, email=email, **kwargs)
252
+ sent = self.send_email(to_email=email, template=template)
253
+
254
+ if sent:
255
+ result["success"] = True
256
+ result["code_sent"] = True
257
+ logger.info(
258
+ f"Login code sent to {email}, "
259
+ f"user_id={user_id}, expires at {expires_at.isoformat()}"
260
+ )
261
+ else:
262
+ result["error"] = "Failed to send email"
263
+
264
+ return result
265
+
266
+ async def _check_and_upsert_user_login_code(
267
+ self,
268
+ db: "PostgresService",
269
+ email: str,
270
+ user_id: str,
271
+ code: str,
272
+ expires_at: datetime,
273
+ tenant_id: str = "default",
274
+ settings: Any = None,
275
+ ) -> dict:
276
+ """
277
+ Check user access and upsert login code in metadata using repository pattern.
278
+
279
+ Access control logic:
280
+ 1. If user exists and tier is BLOCKED -> reject
281
+ 2. If user exists and tier is not BLOCKED -> allow and update
282
+ 3. If user doesn't exist -> check trusted_email_domains:
283
+ - If domain is trusted -> create user
284
+ - If domain is not trusted -> reject
285
+
286
+ Args:
287
+ db: PostgresService instance
288
+ email: User's email
289
+ user_id: Deterministic UUID from email
290
+ code: Generated login code
291
+ expires_at: Code expiration datetime
292
+ tenant_id: Tenant identifier
293
+ settings: Settings instance for domain checking
294
+
295
+ Returns:
296
+ Dict with {"allowed": bool, "error": str | None}
297
+ """
298
+ from ...models.entities import User, UserTier
299
+ from ..postgres.repository import Repository
300
+
301
+ now = datetime.now(timezone.utc)
302
+ login_metadata = {
303
+ "login_code": code,
304
+ "login_code_expires_at": expires_at.isoformat(),
305
+ "login_code_sent_at": now.isoformat(),
306
+ }
307
+
308
+ # Use repository pattern for User operations
309
+ user_repo = Repository(User, db=db)
310
+
311
+ # Try to get existing user first
312
+ existing_user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
313
+
314
+ if existing_user:
315
+ # Check if user is blocked
316
+ if existing_user.tier == UserTier.BLOCKED:
317
+ logger.warning(f"Blocked user attempted login: {email}")
318
+ return {"allowed": False, "error": "Account is blocked"}
319
+
320
+ # User exists and is not blocked - merge login code into existing metadata
321
+ existing_user.metadata = {**(existing_user.metadata or {}), **login_metadata}
322
+ existing_user.email = email # Ensure email is current
323
+ await user_repo.upsert(existing_user)
324
+ return {"allowed": True, "error": None}
325
+ else:
326
+ # New user - check if domain is trusted
327
+ if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
328
+ if not settings.email.is_domain_trusted(email):
329
+ email_domain = email.split("@")[-1]
330
+ logger.warning(f"Untrusted domain attempted signup: {email_domain}")
331
+ return {"allowed": False, "error": "Email domain not allowed for signup"}
332
+
333
+ # Domain is trusted (or no restrictions) - create new user
334
+ # Users from trusted domains get admin role
335
+ user_role = None
336
+ if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
337
+ if settings.email.is_domain_trusted(email):
338
+ user_role = "admin"
339
+ logger.info(f"New user {email} assigned admin role (trusted domain)")
340
+
341
+ new_user = User(
342
+ id=uuid.UUID(user_id),
343
+ tenant_id=tenant_id,
344
+ name=email.split("@")[0], # Default name from email
345
+ email=email,
346
+ role=user_role,
347
+ metadata=login_metadata,
348
+ )
349
+ await user_repo.upsert(new_user)
350
+ return {"allowed": True, "error": None}
351
+
352
+ async def verify_login_code(
353
+ self,
354
+ email: str,
355
+ code: str,
356
+ db: "PostgresService",
357
+ tenant_id: str = "default",
358
+ ) -> dict:
359
+ """
360
+ Verify a login code for an email address.
361
+
362
+ On success, clears the code from metadata and returns user info.
363
+
364
+ Args:
365
+ email: User's email address
366
+ code: The login code to verify
367
+ db: PostgresService instance
368
+ tenant_id: Tenant identifier
369
+
370
+ Returns:
371
+ Dict with verification result:
372
+ {
373
+ "valid": bool,
374
+ "user_id": str (if valid),
375
+ "email": str,
376
+ "error": str (if invalid)
377
+ }
378
+ """
379
+ from ...models.entities import User
380
+ from ..postgres.repository import Repository
381
+
382
+ email = email.lower().strip()
383
+ user_id = self.generate_user_id_from_email(email)
384
+
385
+ result = {
386
+ "valid": False,
387
+ "email": email,
388
+ }
389
+
390
+ try:
391
+ # Use repository pattern for User operations
392
+ user_repo = Repository(User, db=db)
393
+
394
+ # Get user by deterministic ID
395
+ user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
396
+
397
+ if not user:
398
+ result["error"] = "User not found"
399
+ return result
400
+
401
+ metadata = user.metadata or {}
402
+ stored_code = metadata.get("login_code")
403
+ expires_at_str = metadata.get("login_code_expires_at")
404
+
405
+ if not stored_code or not expires_at_str:
406
+ result["error"] = "No login code requested"
407
+ return result
408
+
409
+ # Check expiration
410
+ expires_at = datetime.fromisoformat(expires_at_str)
411
+ if datetime.now(timezone.utc) > expires_at:
412
+ result["error"] = "Login code expired"
413
+ return result
414
+
415
+ # Check code match
416
+ if stored_code != code:
417
+ result["error"] = "Invalid login code"
418
+ return result
419
+
420
+ # Code is valid - clear it from metadata
421
+ user.metadata = {
422
+ k: v for k, v in metadata.items()
423
+ if k not in ("login_code", "login_code_expires_at", "login_code_sent_at")
424
+ }
425
+ await user_repo.upsert(user)
426
+
427
+ result["valid"] = True
428
+ result["user_id"] = str(user.id)
429
+ logger.info(f"Login code verified for {email}, user_id={result['user_id']}")
430
+
431
+ except Exception as e:
432
+ logger.error(f"Error verifying login code: {e}")
433
+ result["error"] = "Verification failed"
434
+
435
+ return result
436
+
437
+ async def send_welcome_email(
438
+ self,
439
+ email: str,
440
+ name: Optional[str] = None,
441
+ template_kwargs: dict | None = None,
442
+ ) -> bool:
443
+ """
444
+ Send a welcome email to a new user.
445
+
446
+ Args:
447
+ email: User's email address
448
+ name: Optional user's name
449
+ template_kwargs: Additional arguments for template customization
450
+
451
+ Returns:
452
+ True if sent successfully
453
+ """
454
+ from ...settings import settings
455
+
456
+ # Merge settings.email.template_kwargs with any explicit overrides
457
+ kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
458
+ template = welcome_template(name=name, **kwargs)
459
+ return self.send_email(to_email=email, template=template)