remdb 0.3.14__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 (112) hide show
  1. rem/agentic/README.md +76 -0
  2. rem/agentic/__init__.py +15 -0
  3. rem/agentic/agents/__init__.py +32 -2
  4. rem/agentic/agents/agent_manager.py +310 -0
  5. rem/agentic/agents/sse_simulator.py +502 -0
  6. rem/agentic/context.py +51 -27
  7. rem/agentic/context_builder.py +5 -3
  8. rem/agentic/llm_provider_models.py +301 -0
  9. rem/agentic/mcp/tool_wrapper.py +155 -18
  10. rem/agentic/otel/setup.py +93 -4
  11. rem/agentic/providers/phoenix.py +371 -108
  12. rem/agentic/providers/pydantic_ai.py +280 -57
  13. rem/agentic/schema.py +361 -21
  14. rem/agentic/tools/rem_tools.py +3 -3
  15. rem/api/README.md +215 -1
  16. rem/api/deps.py +255 -0
  17. rem/api/main.py +132 -40
  18. rem/api/mcp_router/resources.py +1 -1
  19. rem/api/mcp_router/server.py +28 -5
  20. rem/api/mcp_router/tools.py +555 -7
  21. rem/api/routers/admin.py +494 -0
  22. rem/api/routers/auth.py +278 -4
  23. rem/api/routers/chat/completions.py +402 -20
  24. rem/api/routers/chat/models.py +88 -10
  25. rem/api/routers/chat/otel_utils.py +33 -0
  26. rem/api/routers/chat/sse_events.py +542 -0
  27. rem/api/routers/chat/streaming.py +697 -45
  28. rem/api/routers/dev.py +81 -0
  29. rem/api/routers/feedback.py +268 -0
  30. rem/api/routers/messages.py +473 -0
  31. rem/api/routers/models.py +78 -0
  32. rem/api/routers/query.py +360 -0
  33. rem/api/routers/shared_sessions.py +406 -0
  34. rem/auth/__init__.py +13 -3
  35. rem/auth/middleware.py +186 -22
  36. rem/auth/providers/__init__.py +4 -1
  37. rem/auth/providers/email.py +215 -0
  38. rem/cli/commands/README.md +237 -64
  39. rem/cli/commands/cluster.py +1808 -0
  40. rem/cli/commands/configure.py +4 -7
  41. rem/cli/commands/db.py +386 -143
  42. rem/cli/commands/experiments.py +468 -76
  43. rem/cli/commands/process.py +14 -8
  44. rem/cli/commands/schema.py +97 -50
  45. rem/cli/commands/session.py +336 -0
  46. rem/cli/dreaming.py +2 -2
  47. rem/cli/main.py +29 -6
  48. rem/config.py +10 -3
  49. rem/models/core/core_model.py +7 -1
  50. rem/models/core/experiment.py +58 -14
  51. rem/models/core/rem_query.py +5 -2
  52. rem/models/entities/__init__.py +25 -0
  53. rem/models/entities/domain_resource.py +38 -0
  54. rem/models/entities/feedback.py +123 -0
  55. rem/models/entities/message.py +30 -1
  56. rem/models/entities/ontology.py +1 -1
  57. rem/models/entities/ontology_config.py +1 -1
  58. rem/models/entities/session.py +83 -0
  59. rem/models/entities/shared_session.py +180 -0
  60. rem/models/entities/subscriber.py +175 -0
  61. rem/models/entities/user.py +1 -0
  62. rem/registry.py +10 -4
  63. rem/schemas/agents/core/agent-builder.yaml +134 -0
  64. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  65. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  66. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  67. rem/schemas/agents/rem.yaml +7 -3
  68. rem/services/__init__.py +3 -1
  69. rem/services/content/service.py +92 -19
  70. rem/services/email/__init__.py +10 -0
  71. rem/services/email/service.py +459 -0
  72. rem/services/email/templates.py +360 -0
  73. rem/services/embeddings/api.py +4 -4
  74. rem/services/embeddings/worker.py +16 -16
  75. rem/services/phoenix/client.py +154 -14
  76. rem/services/postgres/README.md +197 -15
  77. rem/services/postgres/__init__.py +2 -1
  78. rem/services/postgres/diff_service.py +547 -0
  79. rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
  80. rem/services/postgres/repository.py +132 -0
  81. rem/services/postgres/schema_generator.py +205 -4
  82. rem/services/postgres/service.py +6 -6
  83. rem/services/rem/parser.py +44 -9
  84. rem/services/rem/service.py +36 -2
  85. rem/services/session/compression.py +137 -51
  86. rem/services/session/reload.py +15 -8
  87. rem/settings.py +515 -27
  88. rem/sql/background_indexes.sql +21 -16
  89. rem/sql/migrations/001_install.sql +387 -54
  90. rem/sql/migrations/002_install_models.sql +2304 -377
  91. rem/sql/migrations/003_optional_extensions.sql +326 -0
  92. rem/sql/migrations/004_cache_system.sql +548 -0
  93. rem/sql/migrations/005_schema_update.sql +145 -0
  94. rem/utils/README.md +45 -0
  95. rem/utils/__init__.py +18 -0
  96. rem/utils/date_utils.py +2 -2
  97. rem/utils/files.py +157 -1
  98. rem/utils/model_helpers.py +156 -1
  99. rem/utils/schema_loader.py +220 -22
  100. rem/utils/sql_paths.py +146 -0
  101. rem/utils/sql_types.py +3 -1
  102. rem/utils/vision.py +1 -1
  103. rem/workers/__init__.py +3 -1
  104. rem/workers/db_listener.py +579 -0
  105. rem/workers/unlogged_maintainer.py +463 -0
  106. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
  107. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
  108. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
  109. rem/sql/002_install_models.sql +0 -1068
  110. rem/sql/install_models.sql +0 -1051
  111. rem/sql/migrations/003_seed_default_user.sql +0 -48
  112. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
@@ -278,6 +278,7 @@ class ContentService:
278
278
  category: str | None = None,
279
279
  tags: list[str] | None = None,
280
280
  is_local_server: bool = False,
281
+ resource_type: str | None = None,
281
282
  ) -> dict[str, Any]:
282
283
  """
283
284
  Complete file ingestion pipeline: read → store → parse → chunk → embed.
@@ -322,6 +323,9 @@ class ContentService:
322
323
  category: Optional category tag (document, code, audio, etc.)
323
324
  tags: Optional list of tags
324
325
  is_local_server: True if running as local/stdio MCP server
326
+ resource_type: Optional resource type (case-insensitive). Supports:
327
+ - "resource", "resources", "Resource" → Resource (default)
328
+ - "domain-resource", "domain_resource", "DomainResource" → DomainResource
325
329
 
326
330
  Returns:
327
331
  dict with:
@@ -366,11 +370,32 @@ class ContentService:
366
370
  file_size = len(file_content)
367
371
  logger.info(f"Read {file_size} bytes from {file_uri} (source: {source_type})")
368
372
 
369
- # Step 2: Write to internal storage (user-scoped)
373
+ # Step 1.5: Early schema detection for YAML/JSON files
374
+ # Skip File entity creation for schemas (agents/evaluators)
375
+ file_suffix = Path(file_name).suffix.lower()
376
+ if file_suffix in ['.yaml', '.yml', '.json']:
377
+ import yaml
378
+ import json
379
+ try:
380
+ content_text = file_content.decode('utf-8') if isinstance(file_content, bytes) else file_content
381
+ data = yaml.safe_load(content_text) if file_suffix in ['.yaml', '.yml'] else json.loads(content_text)
382
+ if isinstance(data, dict):
383
+ json_schema_extra = data.get('json_schema_extra', {})
384
+ kind = json_schema_extra.get('kind', '')
385
+ if kind in ['agent', 'evaluator']:
386
+ # Route directly to schema processing, skip File entity
387
+ logger.info(f"Detected {kind} schema: {file_name}, routing to _process_schema")
388
+ result = self.process_uri(file_uri)
389
+ return await self._process_schema(result, file_uri, user_id)
390
+ except Exception as e:
391
+ logger.debug(f"Early schema detection failed for {file_name}: {e}")
392
+ # Fall through to standard file processing
393
+
394
+ # Step 2: Write to internal storage (public or user-scoped)
370
395
  file_id = str(uuid4())
371
396
  storage_uri, internal_key, content_type, _ = await fs_service.write_to_internal_storage(
372
397
  content=file_content,
373
- tenant_id=user_id, # Using user_id for storage scoping
398
+ tenant_id=user_id or "public", # Storage path: public/ or user_id/
374
399
  file_name=file_name,
375
400
  file_id=file_id,
376
401
  )
@@ -379,7 +404,7 @@ class ContentService:
379
404
  # Step 3: Create File entity
380
405
  file_entity = File(
381
406
  id=file_id,
382
- tenant_id=user_id, # Set tenant_id to user_id (application scoped to user)
407
+ tenant_id=user_id, # None = public/shared
383
408
  user_id=user_id,
384
409
  name=file_name,
385
410
  uri=storage_uri,
@@ -418,6 +443,7 @@ class ContentService:
418
443
  processing_result = await self.process_and_save(
419
444
  uri=storage_uri,
420
445
  user_id=user_id,
446
+ resource_type=resource_type,
421
447
  )
422
448
  processing_status = processing_result.get("status", "completed")
423
449
  resources_created = processing_result.get("chunk_count", 0)
@@ -459,7 +485,12 @@ class ContentService:
459
485
  "message": f"File ingested and {processing_status}. Created {resources_created} resources.",
460
486
  }
461
487
 
462
- async def process_and_save(self, uri: str, user_id: str | None = None) -> dict[str, Any]:
488
+ async def process_and_save(
489
+ self,
490
+ uri: str,
491
+ user_id: str | None = None,
492
+ resource_type: str | None = None,
493
+ ) -> dict[str, Any]:
463
494
  """
464
495
  Process file end-to-end: extract → markdown → chunk → save.
465
496
 
@@ -474,6 +505,8 @@ class ContentService:
474
505
  Args:
475
506
  uri: File URI (s3://bucket/key or local path)
476
507
  user_id: Optional user ID for multi-tenancy
508
+ resource_type: Optional resource type (case-insensitive). Defaults to "Resource".
509
+ Supports: resource, domain-resource, domain_resource, DomainResource, etc.
477
510
 
478
511
  Returns:
479
512
  dict with file metadata and chunk count
@@ -526,7 +559,7 @@ class ContentService:
526
559
  size_bytes=result["metadata"].get("size"),
527
560
  mime_type=result["metadata"].get("content_type"),
528
561
  processing_status="completed",
529
- tenant_id=user_id or "default", # Required field
562
+ tenant_id=user_id, # None = public/shared
530
563
  user_id=user_id,
531
564
  )
532
565
 
@@ -534,28 +567,66 @@ class ContentService:
534
567
  await self.file_repo.upsert(file)
535
568
  logger.info(f"Saved File: {filename}")
536
569
 
537
- # Create Resource entities for each chunk
538
- resources = [
539
- Resource(
570
+ # Resolve resource model class from type parameter (case-insensitive)
571
+ from typing import cast, Type
572
+ from pydantic import BaseModel
573
+ from rem.utils.model_helpers import model_from_arbitrary_casing, get_table_name
574
+
575
+ resource_model: Type[BaseModel] = Resource # Default
576
+ if resource_type:
577
+ try:
578
+ resource_model = model_from_arbitrary_casing(resource_type)
579
+ logger.info(f"Using resource model: {resource_model.__name__}")
580
+ except ValueError as e:
581
+ logger.warning(f"Invalid resource_type '{resource_type}', using default Resource: {e}")
582
+ resource_model = Resource
583
+
584
+ # Get table name for the resolved model
585
+ table_name = get_table_name(resource_model)
586
+
587
+ # Create resource entities for each chunk
588
+ resources: list[BaseModel] = [
589
+ resource_model(
540
590
  name=f"{filename}#chunk-{i}",
541
591
  uri=f"{uri}#chunk-{i}",
542
592
  ordinal=i,
543
593
  content=chunk,
544
594
  category="document",
545
- tenant_id=user_id or "default", # Required field
595
+ tenant_id=user_id, # None = public/shared
546
596
  user_id=user_id,
547
597
  )
548
598
  for i, chunk in enumerate(chunks)
549
599
  ]
550
600
 
551
- if self.resource_repo:
552
- await self.resource_repo.upsert(
553
- resources,
554
- embeddable_fields=["content"],
555
- generate_embeddings=True,
556
- )
557
- logger.info(f"Saved {len(resources)} Resource chunks")
558
- logger.info(f"Queued {len(resources)} embedding generation tasks for content field")
601
+ # Save resources to the appropriate table
602
+ if resources:
603
+ from rem.services.postgres import get_postgres_service
604
+
605
+ postgres = get_postgres_service()
606
+ if postgres:
607
+ await postgres.connect()
608
+ try:
609
+ await postgres.batch_upsert(
610
+ records=cast(list[BaseModel | dict], resources),
611
+ model=resource_model,
612
+ table_name=table_name,
613
+ entity_key_field="name",
614
+ embeddable_fields=["content"],
615
+ generate_embeddings=True,
616
+ )
617
+ logger.info(f"Saved {len(resources)} {resource_model.__name__} chunks to {table_name}")
618
+ logger.info(f"Queued {len(resources)} embedding generation tasks for content field")
619
+ finally:
620
+ await postgres.disconnect()
621
+ elif self.resource_repo:
622
+ # Fallback to injected repo (only works for default Resource)
623
+ await self.resource_repo.upsert(
624
+ resources,
625
+ embeddable_fields=["content"],
626
+ generate_embeddings=True,
627
+ )
628
+ logger.info(f"Saved {len(resources)} Resource chunks")
629
+ logger.info(f"Queued {len(resources)} embedding generation tasks for content field")
559
630
 
560
631
  return {
561
632
  "file": file.model_dump(),
@@ -595,8 +666,10 @@ class ContentService:
595
666
  # IMPORTANT: category field distinguishes agents from evaluators
596
667
  # - kind=agent → category="agent" (AI agents with tools/resources)
597
668
  # - kind=evaluator → category="evaluator" (LLM-as-a-Judge evaluators)
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
598
671
  schema_entity = Schema(
599
- tenant_id=user_id or "default",
672
+ tenant_id=user_id or "system",
600
673
  user_id=user_id,
601
674
  name=name,
602
675
  spec=schema_data,
@@ -667,7 +740,7 @@ class ContentService:
667
740
  processor = EngramProcessor(postgres)
668
741
  result = await processor.process_engram(
669
742
  data=data,
670
- tenant_id=user_id or "default",
743
+ tenant_id=user_id, # None = public/shared
671
744
  user_id=user_id,
672
745
  )
673
746
  logger.info(f"✅ Engram processed: {result.get('resource_id')} with {len(result.get('moment_ids', []))} moments")
@@ -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)