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.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +310 -0
- rem/agentic/context_builder.py +5 -3
- rem/agentic/mcp/tool_wrapper.py +48 -6
- rem/agentic/providers/phoenix.py +91 -21
- rem/agentic/providers/pydantic_ai.py +77 -43
- rem/api/deps.py +2 -2
- rem/api/main.py +1 -1
- rem/api/mcp_router/server.py +2 -0
- rem/api/mcp_router/tools.py +90 -0
- rem/api/routers/auth.py +208 -4
- rem/api/routers/chat/streaming.py +77 -22
- rem/auth/__init__.py +13 -3
- rem/auth/middleware.py +66 -1
- rem/auth/providers/__init__.py +4 -1
- rem/auth/providers/email.py +215 -0
- rem/cli/commands/configure.py +3 -4
- rem/cli/commands/experiments.py +50 -49
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/models/core/experiment.py +4 -14
- rem/models/entities/__init__.py +4 -0
- rem/models/entities/ontology.py +1 -1
- rem/models/entities/ontology_config.py +1 -1
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +1 -0
- 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/services/__init__.py +3 -1
- rem/services/content/service.py +4 -3
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +459 -0
- rem/services/email/templates.py +360 -0
- rem/services/postgres/README.md +38 -0
- rem/services/postgres/diff_service.py +19 -3
- rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
- rem/services/session/compression.py +113 -50
- rem/services/session/reload.py +14 -7
- rem/settings.py +191 -4
- rem/sql/migrations/002_install_models.sql +91 -91
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/utils/README.md +45 -0
- rem/utils/files.py +157 -1
- rem/utils/vision.py +1 -1
- {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/METADATA +7 -5
- {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/RECORD +51 -42
- {remdb-0.3.133.dist-info → remdb-0.3.157.dist-info}/WHEEL +0 -0
- {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"
|
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"]
|
rem/services/content/service.py
CHANGED
|
@@ -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
|
-
#
|
|
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=
|
|
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)
|