remdb 0.3.127__py3-none-any.whl → 0.3.172__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/agentic/agents/__init__.py +16 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/context.py +81 -3
- rem/agentic/context_builder.py +36 -9
- rem/agentic/mcp/tool_wrapper.py +132 -15
- rem/agentic/providers/phoenix.py +371 -108
- rem/agentic/providers/pydantic_ai.py +163 -45
- rem/agentic/schema.py +8 -4
- rem/api/deps.py +3 -5
- rem/api/main.py +22 -3
- rem/api/mcp_router/resources.py +15 -10
- rem/api/mcp_router/server.py +2 -0
- rem/api/mcp_router/tools.py +94 -2
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/auth.py +349 -6
- rem/api/routers/chat/completions.py +5 -3
- rem/api/routers/chat/streaming.py +95 -22
- rem/api/routers/messages.py +24 -15
- rem/auth/__init__.py +13 -3
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +115 -10
- 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 +226 -50
- rem/cli/commands/session.py +336 -0
- rem/cli/dreaming.py +2 -2
- rem/cli/main.py +2 -0
- rem/models/core/experiment.py +58 -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 +235 -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 +513 -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/postgres/repository.py +5 -4
- rem/services/session/compression.py +113 -50
- rem/services/session/reload.py +14 -7
- rem/services/user_service.py +41 -9
- rem/settings.py +292 -5
- rem/sql/migrations/001_install.sql +1 -1
- 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/schema_loader.py +45 -7
- rem/utils/vision.py +1 -1
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/METADATA +7 -5
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/RECORD +62 -52
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
- {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
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,513 @@
|
|
|
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
|
+
# Store last login code for mock mode testing
|
|
30
|
+
_last_login_code: dict[str, str] = {}
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
smtp_host: str | None = None,
|
|
35
|
+
smtp_port: int | None = None,
|
|
36
|
+
sender_email: str | None = None,
|
|
37
|
+
sender_name: str | None = None,
|
|
38
|
+
app_password: str | None = None,
|
|
39
|
+
use_tls: bool = True,
|
|
40
|
+
login_code_expiry_minutes: int = 10,
|
|
41
|
+
mock_mode: bool | None = None,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Initialize EmailService.
|
|
45
|
+
|
|
46
|
+
If no arguments provided, uses settings from rem.settings.
|
|
47
|
+
This allows no-arg construction for simple usage.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
smtp_host: SMTP server host
|
|
51
|
+
smtp_port: SMTP server port
|
|
52
|
+
sender_email: Sender email address
|
|
53
|
+
sender_name: Sender display name
|
|
54
|
+
app_password: SMTP app password
|
|
55
|
+
use_tls: Use TLS encryption
|
|
56
|
+
login_code_expiry_minutes: Login code expiry in minutes
|
|
57
|
+
mock_mode: If True, don't send real emails (log code instead)
|
|
58
|
+
"""
|
|
59
|
+
# Import settings lazily to avoid circular imports
|
|
60
|
+
from ...settings import settings
|
|
61
|
+
|
|
62
|
+
self._smtp_host = smtp_host or settings.email.smtp_host
|
|
63
|
+
self._smtp_port = smtp_port or settings.email.smtp_port
|
|
64
|
+
self._sender_email = sender_email or settings.email.sender_email
|
|
65
|
+
self._sender_name = sender_name or settings.email.sender_name
|
|
66
|
+
self._app_password = app_password or settings.email.app_password
|
|
67
|
+
self._use_tls = use_tls
|
|
68
|
+
self._login_code_expiry_minutes = (
|
|
69
|
+
login_code_expiry_minutes
|
|
70
|
+
or settings.email.login_code_expiry_minutes
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Mock mode: enabled via setting or if not configured
|
|
74
|
+
if mock_mode is not None:
|
|
75
|
+
self._mock_mode = mock_mode
|
|
76
|
+
elif hasattr(settings.email, 'mock_mode'):
|
|
77
|
+
self._mock_mode = settings.email.mock_mode
|
|
78
|
+
else:
|
|
79
|
+
# Auto-enable mock mode if email is not configured
|
|
80
|
+
self._mock_mode = not self._app_password
|
|
81
|
+
|
|
82
|
+
if not self._app_password and not self._mock_mode:
|
|
83
|
+
logger.warning(
|
|
84
|
+
"Email app password not configured. "
|
|
85
|
+
"Set EMAIL__APP_PASSWORD to enable email sending."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if self._mock_mode:
|
|
89
|
+
logger.info(
|
|
90
|
+
"Email service running in MOCK MODE. "
|
|
91
|
+
"Codes will be logged but not emailed."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_configured(self) -> bool:
|
|
96
|
+
"""Check if email service is properly configured (or in mock mode)."""
|
|
97
|
+
return self._mock_mode or bool(self._sender_email and self._app_password)
|
|
98
|
+
|
|
99
|
+
def _create_smtp_connection(self) -> smtplib.SMTP:
|
|
100
|
+
"""Create and authenticate SMTP connection."""
|
|
101
|
+
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
|
102
|
+
|
|
103
|
+
if self._use_tls:
|
|
104
|
+
server.starttls()
|
|
105
|
+
|
|
106
|
+
server.login(self._sender_email, self._app_password)
|
|
107
|
+
return server
|
|
108
|
+
|
|
109
|
+
def send_email(
|
|
110
|
+
self,
|
|
111
|
+
to_email: str,
|
|
112
|
+
template: EmailTemplate,
|
|
113
|
+
reply_to: Optional[str] = None,
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Send an email using a template.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
to_email: Recipient email address
|
|
120
|
+
template: EmailTemplate with subject and HTML body
|
|
121
|
+
reply_to: Optional reply-to address
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if sent successfully, False otherwise
|
|
125
|
+
"""
|
|
126
|
+
if not self.is_configured:
|
|
127
|
+
logger.error("Email service not configured. Cannot send email.")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
# Mock mode - log but don't send
|
|
131
|
+
if self._mock_mode:
|
|
132
|
+
logger.info(
|
|
133
|
+
f"[MOCK EMAIL] To: {to_email}, Subject: {template.subject}"
|
|
134
|
+
)
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
# Create message
|
|
139
|
+
msg = MIMEMultipart("alternative")
|
|
140
|
+
msg["Subject"] = template.subject
|
|
141
|
+
msg["From"] = f"{self._sender_name} <{self._sender_email}>"
|
|
142
|
+
msg["To"] = to_email
|
|
143
|
+
|
|
144
|
+
if reply_to:
|
|
145
|
+
msg["Reply-To"] = reply_to
|
|
146
|
+
|
|
147
|
+
# Attach HTML body
|
|
148
|
+
html_part = MIMEText(template.html_body, "html", "utf-8")
|
|
149
|
+
msg.attach(html_part)
|
|
150
|
+
|
|
151
|
+
# Send email
|
|
152
|
+
with self._create_smtp_connection() as server:
|
|
153
|
+
server.sendmail(
|
|
154
|
+
self._sender_email,
|
|
155
|
+
to_email,
|
|
156
|
+
msg.as_string(),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
logger.info(f"Email sent successfully to {to_email}: {template.subject}")
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
except smtplib.SMTPAuthenticationError as e:
|
|
163
|
+
logger.error(f"SMTP authentication failed: {e}")
|
|
164
|
+
return False
|
|
165
|
+
except smtplib.SMTPException as e:
|
|
166
|
+
logger.error(f"SMTP error sending email to {to_email}: {e}")
|
|
167
|
+
return False
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Unexpected error sending email to {to_email}: {e}")
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def generate_login_code() -> str:
|
|
174
|
+
"""
|
|
175
|
+
Generate a 6-digit login code.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
6-digit numeric string
|
|
179
|
+
"""
|
|
180
|
+
return "".join(random.choices(string.digits, k=6))
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def get_mock_code(cls, email: str) -> str | None:
|
|
184
|
+
"""
|
|
185
|
+
Get the last login code sent to an email (mock mode only).
|
|
186
|
+
|
|
187
|
+
For testing purposes - retrieves the code that would have been
|
|
188
|
+
sent in mock mode.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
email: Email address to look up
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The login code or None if not found
|
|
195
|
+
"""
|
|
196
|
+
return cls._last_login_code.get(email.lower().strip())
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def generate_user_id_from_email(email: str) -> str:
|
|
200
|
+
"""
|
|
201
|
+
Generate a deterministic UUID from email address.
|
|
202
|
+
|
|
203
|
+
Uses the centralized email_to_user_id() for consistency.
|
|
204
|
+
Same email always produces same UUID (bijection).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
email: Email address
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
UUID string
|
|
211
|
+
"""
|
|
212
|
+
from rem.utils.user_id import email_to_user_id
|
|
213
|
+
return email_to_user_id(email)
|
|
214
|
+
|
|
215
|
+
async def send_login_code(
|
|
216
|
+
self,
|
|
217
|
+
email: str,
|
|
218
|
+
db: "PostgresService | None" = None,
|
|
219
|
+
tenant_id: str = "default",
|
|
220
|
+
template_kwargs: dict | None = None,
|
|
221
|
+
) -> dict:
|
|
222
|
+
"""
|
|
223
|
+
Send a login code to an email address.
|
|
224
|
+
|
|
225
|
+
Access control logic:
|
|
226
|
+
1. If user exists and tier is BLOCKED -> reject with "Account blocked"
|
|
227
|
+
2. If user exists and tier is not BLOCKED -> allow (send code)
|
|
228
|
+
3. If user doesn't exist -> check trusted_email_domains setting:
|
|
229
|
+
- If domain is trusted (or no restrictions) -> create user and send code
|
|
230
|
+
- If domain is not trusted -> reject with "Domain not allowed"
|
|
231
|
+
|
|
232
|
+
This method:
|
|
233
|
+
1. Generates a 6-digit login code
|
|
234
|
+
2. Checks user existence and access permissions
|
|
235
|
+
3. Upserts the user with login code in metadata
|
|
236
|
+
4. Sends the code via email
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
email: User's email address
|
|
240
|
+
db: PostgresService instance for repository operations
|
|
241
|
+
tenant_id: Tenant identifier for multi-tenancy
|
|
242
|
+
template_kwargs: Additional arguments for template customization
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Dict with status and details:
|
|
246
|
+
{
|
|
247
|
+
"success": bool,
|
|
248
|
+
"email": str,
|
|
249
|
+
"user_id": str,
|
|
250
|
+
"code_sent": bool,
|
|
251
|
+
"expires_at": str (ISO format),
|
|
252
|
+
"error": str (if failed)
|
|
253
|
+
}
|
|
254
|
+
"""
|
|
255
|
+
from ...settings import settings
|
|
256
|
+
|
|
257
|
+
email = email.lower().strip()
|
|
258
|
+
code = self.generate_login_code()
|
|
259
|
+
user_id = self.generate_user_id_from_email(email)
|
|
260
|
+
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
261
|
+
minutes=self._login_code_expiry_minutes
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
result = {
|
|
265
|
+
"success": False,
|
|
266
|
+
"email": email,
|
|
267
|
+
"user_id": user_id,
|
|
268
|
+
"code_sent": False,
|
|
269
|
+
"expires_at": expires_at.isoformat(),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# Check user access and upsert login code using repository
|
|
273
|
+
if db:
|
|
274
|
+
try:
|
|
275
|
+
access_result = await self._check_and_upsert_user_login_code(
|
|
276
|
+
db=db,
|
|
277
|
+
email=email,
|
|
278
|
+
user_id=user_id,
|
|
279
|
+
code=code,
|
|
280
|
+
expires_at=expires_at,
|
|
281
|
+
tenant_id=tenant_id,
|
|
282
|
+
settings=settings,
|
|
283
|
+
)
|
|
284
|
+
if not access_result["allowed"]:
|
|
285
|
+
result["error"] = access_result["error"]
|
|
286
|
+
return result
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error(f"Failed to upsert user login code: {e}")
|
|
289
|
+
result["error"] = "Failed to store login code"
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
# Send the email with branding from settings
|
|
293
|
+
# Merge settings.email.template_kwargs with any explicit overrides
|
|
294
|
+
kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
|
|
295
|
+
template = login_code_template(code=code, email=email, **kwargs)
|
|
296
|
+
sent = self.send_email(to_email=email, template=template)
|
|
297
|
+
|
|
298
|
+
if sent:
|
|
299
|
+
result["success"] = True
|
|
300
|
+
result["code_sent"] = True
|
|
301
|
+
|
|
302
|
+
# Store code for mock mode retrieval
|
|
303
|
+
if self._mock_mode:
|
|
304
|
+
EmailService._last_login_code[email] = code
|
|
305
|
+
logger.info(
|
|
306
|
+
f"[MOCK MODE] Login code for {email}: {code} "
|
|
307
|
+
f"(expires at {expires_at.isoformat()})"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
logger.info(
|
|
311
|
+
f"Login code sent to {email}, "
|
|
312
|
+
f"user_id={user_id}, expires at {expires_at.isoformat()}"
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
result["error"] = "Failed to send email"
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
async def _check_and_upsert_user_login_code(
|
|
320
|
+
self,
|
|
321
|
+
db: "PostgresService",
|
|
322
|
+
email: str,
|
|
323
|
+
user_id: str,
|
|
324
|
+
code: str,
|
|
325
|
+
expires_at: datetime,
|
|
326
|
+
tenant_id: str = "default",
|
|
327
|
+
settings: Any = None,
|
|
328
|
+
) -> dict:
|
|
329
|
+
"""
|
|
330
|
+
Check user access and upsert login code in metadata using repository pattern.
|
|
331
|
+
|
|
332
|
+
Access control logic:
|
|
333
|
+
1. If user exists and tier is BLOCKED -> reject
|
|
334
|
+
2. If user exists and tier is not BLOCKED -> allow and update
|
|
335
|
+
3. If user doesn't exist -> check trusted_email_domains:
|
|
336
|
+
- If domain is trusted -> create user
|
|
337
|
+
- If domain is not trusted -> reject
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
db: PostgresService instance
|
|
341
|
+
email: User's email
|
|
342
|
+
user_id: Deterministic UUID from email
|
|
343
|
+
code: Generated login code
|
|
344
|
+
expires_at: Code expiration datetime
|
|
345
|
+
tenant_id: Tenant identifier
|
|
346
|
+
settings: Settings instance for domain checking
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Dict with {"allowed": bool, "error": str | None}
|
|
350
|
+
"""
|
|
351
|
+
from ...models.entities import User, UserTier
|
|
352
|
+
from ..postgres.repository import Repository
|
|
353
|
+
|
|
354
|
+
now = datetime.now(timezone.utc)
|
|
355
|
+
login_metadata = {
|
|
356
|
+
"login_code": code,
|
|
357
|
+
"login_code_expires_at": expires_at.isoformat(),
|
|
358
|
+
"login_code_sent_at": now.isoformat(),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
# Use repository pattern for User operations
|
|
362
|
+
user_repo = Repository(User, db=db)
|
|
363
|
+
|
|
364
|
+
# Try to get existing user first
|
|
365
|
+
existing_user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
|
|
366
|
+
|
|
367
|
+
if existing_user:
|
|
368
|
+
# Check if user is blocked
|
|
369
|
+
if existing_user.tier == UserTier.BLOCKED:
|
|
370
|
+
logger.warning(f"Blocked user attempted login: {email}")
|
|
371
|
+
return {"allowed": False, "error": "Account is blocked"}
|
|
372
|
+
|
|
373
|
+
# User exists and is not blocked - merge login code into existing metadata
|
|
374
|
+
existing_user.metadata = {**(existing_user.metadata or {}), **login_metadata}
|
|
375
|
+
existing_user.email = email # Ensure email is current
|
|
376
|
+
await user_repo.upsert(existing_user)
|
|
377
|
+
return {"allowed": True, "error": None}
|
|
378
|
+
else:
|
|
379
|
+
# New user - check if domain is trusted
|
|
380
|
+
if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
|
|
381
|
+
if not settings.email.is_domain_trusted(email):
|
|
382
|
+
email_domain = email.split("@")[-1]
|
|
383
|
+
logger.warning(f"Untrusted domain attempted signup: {email_domain}")
|
|
384
|
+
return {"allowed": False, "error": "Email domain not allowed for signup"}
|
|
385
|
+
|
|
386
|
+
# Domain is trusted (or no restrictions) - create new user
|
|
387
|
+
# Users from trusted domains get admin role
|
|
388
|
+
user_role = None
|
|
389
|
+
if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
|
|
390
|
+
if settings.email.is_domain_trusted(email):
|
|
391
|
+
user_role = "admin"
|
|
392
|
+
logger.info(f"New user {email} assigned admin role (trusted domain)")
|
|
393
|
+
|
|
394
|
+
new_user = User(
|
|
395
|
+
id=uuid.UUID(user_id),
|
|
396
|
+
tenant_id=tenant_id,
|
|
397
|
+
user_id=user_id, # UUID5 hash of email (same as id)
|
|
398
|
+
name=email, # Full email as entity_key for LOOKUP
|
|
399
|
+
email=email,
|
|
400
|
+
role=user_role,
|
|
401
|
+
metadata=login_metadata,
|
|
402
|
+
)
|
|
403
|
+
await user_repo.upsert(new_user)
|
|
404
|
+
return {"allowed": True, "error": None}
|
|
405
|
+
|
|
406
|
+
async def verify_login_code(
|
|
407
|
+
self,
|
|
408
|
+
email: str,
|
|
409
|
+
code: str,
|
|
410
|
+
db: "PostgresService",
|
|
411
|
+
tenant_id: str = "default",
|
|
412
|
+
) -> dict:
|
|
413
|
+
"""
|
|
414
|
+
Verify a login code for an email address.
|
|
415
|
+
|
|
416
|
+
On success, clears the code from metadata and returns user info.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
email: User's email address
|
|
420
|
+
code: The login code to verify
|
|
421
|
+
db: PostgresService instance
|
|
422
|
+
tenant_id: Tenant identifier
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Dict with verification result:
|
|
426
|
+
{
|
|
427
|
+
"valid": bool,
|
|
428
|
+
"user_id": str (if valid),
|
|
429
|
+
"email": str,
|
|
430
|
+
"error": str (if invalid)
|
|
431
|
+
}
|
|
432
|
+
"""
|
|
433
|
+
from ...models.entities import User
|
|
434
|
+
from ..postgres.repository import Repository
|
|
435
|
+
|
|
436
|
+
email = email.lower().strip()
|
|
437
|
+
user_id = self.generate_user_id_from_email(email)
|
|
438
|
+
|
|
439
|
+
result = {
|
|
440
|
+
"valid": False,
|
|
441
|
+
"email": email,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
# Use repository pattern for User operations
|
|
446
|
+
user_repo = Repository(User, db=db)
|
|
447
|
+
|
|
448
|
+
# Get user by deterministic ID
|
|
449
|
+
user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
|
|
450
|
+
|
|
451
|
+
if not user:
|
|
452
|
+
result["error"] = "User not found"
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
metadata = user.metadata or {}
|
|
456
|
+
stored_code = metadata.get("login_code")
|
|
457
|
+
expires_at_str = metadata.get("login_code_expires_at")
|
|
458
|
+
|
|
459
|
+
if not stored_code or not expires_at_str:
|
|
460
|
+
result["error"] = "No login code requested"
|
|
461
|
+
return result
|
|
462
|
+
|
|
463
|
+
# Check expiration
|
|
464
|
+
expires_at = datetime.fromisoformat(expires_at_str)
|
|
465
|
+
if datetime.now(timezone.utc) > expires_at:
|
|
466
|
+
result["error"] = "Login code expired"
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
# Check code match
|
|
470
|
+
if stored_code != code:
|
|
471
|
+
result["error"] = "Invalid login code"
|
|
472
|
+
return result
|
|
473
|
+
|
|
474
|
+
# Code is valid - clear it from metadata
|
|
475
|
+
user.metadata = {
|
|
476
|
+
k: v for k, v in metadata.items()
|
|
477
|
+
if k not in ("login_code", "login_code_expires_at", "login_code_sent_at")
|
|
478
|
+
}
|
|
479
|
+
await user_repo.upsert(user)
|
|
480
|
+
|
|
481
|
+
result["valid"] = True
|
|
482
|
+
result["user_id"] = str(user.id)
|
|
483
|
+
logger.info(f"Login code verified for {email}, user_id={result['user_id']}")
|
|
484
|
+
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logger.error(f"Error verifying login code: {e}")
|
|
487
|
+
result["error"] = "Verification failed"
|
|
488
|
+
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
async def send_welcome_email(
|
|
492
|
+
self,
|
|
493
|
+
email: str,
|
|
494
|
+
name: Optional[str] = None,
|
|
495
|
+
template_kwargs: dict | None = None,
|
|
496
|
+
) -> bool:
|
|
497
|
+
"""
|
|
498
|
+
Send a welcome email to a new user.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
email: User's email address
|
|
502
|
+
name: Optional user's name
|
|
503
|
+
template_kwargs: Additional arguments for template customization
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
True if sent successfully
|
|
507
|
+
"""
|
|
508
|
+
from ...settings import settings
|
|
509
|
+
|
|
510
|
+
# Merge settings.email.template_kwargs with any explicit overrides
|
|
511
|
+
kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
|
|
512
|
+
template = welcome_template(name=name, **kwargs)
|
|
513
|
+
return self.send_email(to_email=email, template=template)
|