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.
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +32 -2
- rem/agentic/agents/agent_manager.py +310 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +51 -27
- rem/agentic/context_builder.py +5 -3
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/tool_wrapper.py +155 -18
- rem/agentic/otel/setup.py +93 -4
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +280 -57
- rem/agentic/schema.py +361 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +215 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +132 -40
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +28 -5
- rem/api/mcp_router/tools.py +555 -7
- rem/api/routers/admin.py +494 -0
- rem/api/routers/auth.py +278 -4
- rem/api/routers/chat/completions.py +402 -20
- rem/api/routers/chat/models.py +88 -10
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +542 -0
- rem/api/routers/chat/streaming.py +697 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +268 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/__init__.py +13 -3
- rem/auth/middleware.py +186 -22
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +4 -7
- rem/cli/commands/db.py +386 -143
- rem/cli/commands/experiments.py +468 -76
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +97 -50
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +29 -6
- rem/config.py +10 -3
- rem/models/core/core_model.py +7 -1
- rem/models/core/experiment.py +58 -14
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +25 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/message.py +30 -1
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- rem/registry.py +10 -4
- rem/schemas/agents/core/agent-builder.yaml +134 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
- rem/schemas/agents/examples/contract-extractor.yaml +1 -1
- rem/schemas/agents/examples/cv-parser.yaml +1 -1
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/__init__.py +3 -1
- rem/services/content/service.py +92 -19
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +459 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/client.py +154 -14
- rem/services/postgres/README.md +197 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +547 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +137 -51
- rem/services/session/reload.py +15 -8
- rem/settings.py +515 -27
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +387 -54
- rem/sql/migrations/002_install_models.sql +2304 -377
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/files.py +157 -1
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +220 -22
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +1 -1
- rem/workers/__init__.py +3 -1
- rem/workers/db_listener.py +579 -0
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1051
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
rem/services/content/service.py
CHANGED
|
@@ -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
|
|
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, #
|
|
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, #
|
|
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(
|
|
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
|
|
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
|
-
#
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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 "
|
|
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
|
|
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)
|