remdb 0.3.141__py3-none-any.whl → 0.3.163__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.

Files changed (44) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +310 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +18 -3
  5. rem/api/deps.py +3 -5
  6. rem/api/main.py +22 -3
  7. rem/api/mcp_router/server.py +2 -0
  8. rem/api/mcp_router/tools.py +90 -0
  9. rem/api/middleware/tracking.py +5 -5
  10. rem/api/routers/auth.py +346 -5
  11. rem/api/routers/chat/completions.py +4 -2
  12. rem/api/routers/chat/streaming.py +77 -22
  13. rem/api/routers/messages.py +24 -15
  14. rem/auth/__init__.py +13 -3
  15. rem/auth/jwt.py +352 -0
  16. rem/auth/middleware.py +108 -6
  17. rem/auth/providers/__init__.py +4 -1
  18. rem/auth/providers/email.py +215 -0
  19. rem/cli/commands/experiments.py +32 -46
  20. rem/models/core/experiment.py +4 -14
  21. rem/models/entities/__init__.py +4 -0
  22. rem/models/entities/subscriber.py +175 -0
  23. rem/models/entities/user.py +1 -0
  24. rem/schemas/agents/core/agent-builder.yaml +134 -0
  25. rem/services/__init__.py +3 -1
  26. rem/services/content/service.py +4 -3
  27. rem/services/email/__init__.py +10 -0
  28. rem/services/email/service.py +511 -0
  29. rem/services/email/templates.py +360 -0
  30. rem/services/postgres/README.md +38 -0
  31. rem/services/postgres/diff_service.py +19 -3
  32. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  33. rem/services/postgres/repository.py +5 -4
  34. rem/services/session/compression.py +113 -50
  35. rem/services/session/reload.py +14 -7
  36. rem/services/user_service.py +29 -0
  37. rem/settings.py +199 -4
  38. rem/sql/migrations/005_schema_update.sql +145 -0
  39. rem/utils/README.md +45 -0
  40. rem/utils/files.py +157 -1
  41. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/METADATA +7 -5
  42. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/RECORD +44 -35
  43. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/WHEEL +0 -0
  44. {remdb-0.3.141.dist-info → remdb-0.3.163.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,511 @@
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 UUID v5 with DNS namespace for consistency.
204
+ Same email always produces same UUID.
205
+
206
+ Args:
207
+ email: Email address
208
+
209
+ Returns:
210
+ UUID string
211
+ """
212
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, email.lower().strip()))
213
+
214
+ async def send_login_code(
215
+ self,
216
+ email: str,
217
+ db: "PostgresService | None" = None,
218
+ tenant_id: str = "default",
219
+ template_kwargs: dict | None = None,
220
+ ) -> dict:
221
+ """
222
+ Send a login code to an email address.
223
+
224
+ Access control logic:
225
+ 1. If user exists and tier is BLOCKED -> reject with "Account blocked"
226
+ 2. If user exists and tier is not BLOCKED -> allow (send code)
227
+ 3. If user doesn't exist -> check trusted_email_domains setting:
228
+ - If domain is trusted (or no restrictions) -> create user and send code
229
+ - If domain is not trusted -> reject with "Domain not allowed"
230
+
231
+ This method:
232
+ 1. Generates a 6-digit login code
233
+ 2. Checks user existence and access permissions
234
+ 3. Upserts the user with login code in metadata
235
+ 4. Sends the code via email
236
+
237
+ Args:
238
+ email: User's email address
239
+ db: PostgresService instance for repository operations
240
+ tenant_id: Tenant identifier for multi-tenancy
241
+ template_kwargs: Additional arguments for template customization
242
+
243
+ Returns:
244
+ Dict with status and details:
245
+ {
246
+ "success": bool,
247
+ "email": str,
248
+ "user_id": str,
249
+ "code_sent": bool,
250
+ "expires_at": str (ISO format),
251
+ "error": str (if failed)
252
+ }
253
+ """
254
+ from ...settings import settings
255
+
256
+ email = email.lower().strip()
257
+ code = self.generate_login_code()
258
+ user_id = self.generate_user_id_from_email(email)
259
+ expires_at = datetime.now(timezone.utc) + timedelta(
260
+ minutes=self._login_code_expiry_minutes
261
+ )
262
+
263
+ result = {
264
+ "success": False,
265
+ "email": email,
266
+ "user_id": user_id,
267
+ "code_sent": False,
268
+ "expires_at": expires_at.isoformat(),
269
+ }
270
+
271
+ # Check user access and upsert login code using repository
272
+ if db:
273
+ try:
274
+ access_result = await self._check_and_upsert_user_login_code(
275
+ db=db,
276
+ email=email,
277
+ user_id=user_id,
278
+ code=code,
279
+ expires_at=expires_at,
280
+ tenant_id=tenant_id,
281
+ settings=settings,
282
+ )
283
+ if not access_result["allowed"]:
284
+ result["error"] = access_result["error"]
285
+ return result
286
+ except Exception as e:
287
+ logger.error(f"Failed to upsert user login code: {e}")
288
+ result["error"] = "Failed to store login code"
289
+ return result
290
+
291
+ # Send the email with branding from settings
292
+ # Merge settings.email.template_kwargs with any explicit overrides
293
+ kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
294
+ template = login_code_template(code=code, email=email, **kwargs)
295
+ sent = self.send_email(to_email=email, template=template)
296
+
297
+ if sent:
298
+ result["success"] = True
299
+ result["code_sent"] = True
300
+
301
+ # Store code for mock mode retrieval
302
+ if self._mock_mode:
303
+ EmailService._last_login_code[email] = code
304
+ logger.info(
305
+ f"[MOCK MODE] Login code for {email}: {code} "
306
+ f"(expires at {expires_at.isoformat()})"
307
+ )
308
+
309
+ logger.info(
310
+ f"Login code sent to {email}, "
311
+ f"user_id={user_id}, expires at {expires_at.isoformat()}"
312
+ )
313
+ else:
314
+ result["error"] = "Failed to send email"
315
+
316
+ return result
317
+
318
+ async def _check_and_upsert_user_login_code(
319
+ self,
320
+ db: "PostgresService",
321
+ email: str,
322
+ user_id: str,
323
+ code: str,
324
+ expires_at: datetime,
325
+ tenant_id: str = "default",
326
+ settings: Any = None,
327
+ ) -> dict:
328
+ """
329
+ Check user access and upsert login code in metadata using repository pattern.
330
+
331
+ Access control logic:
332
+ 1. If user exists and tier is BLOCKED -> reject
333
+ 2. If user exists and tier is not BLOCKED -> allow and update
334
+ 3. If user doesn't exist -> check trusted_email_domains:
335
+ - If domain is trusted -> create user
336
+ - If domain is not trusted -> reject
337
+
338
+ Args:
339
+ db: PostgresService instance
340
+ email: User's email
341
+ user_id: Deterministic UUID from email
342
+ code: Generated login code
343
+ expires_at: Code expiration datetime
344
+ tenant_id: Tenant identifier
345
+ settings: Settings instance for domain checking
346
+
347
+ Returns:
348
+ Dict with {"allowed": bool, "error": str | None}
349
+ """
350
+ from ...models.entities import User, UserTier
351
+ from ..postgres.repository import Repository
352
+
353
+ now = datetime.now(timezone.utc)
354
+ login_metadata = {
355
+ "login_code": code,
356
+ "login_code_expires_at": expires_at.isoformat(),
357
+ "login_code_sent_at": now.isoformat(),
358
+ }
359
+
360
+ # Use repository pattern for User operations
361
+ user_repo = Repository(User, db=db)
362
+
363
+ # Try to get existing user first
364
+ existing_user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
365
+
366
+ if existing_user:
367
+ # Check if user is blocked
368
+ if existing_user.tier == UserTier.BLOCKED:
369
+ logger.warning(f"Blocked user attempted login: {email}")
370
+ return {"allowed": False, "error": "Account is blocked"}
371
+
372
+ # User exists and is not blocked - merge login code into existing metadata
373
+ existing_user.metadata = {**(existing_user.metadata or {}), **login_metadata}
374
+ existing_user.email = email # Ensure email is current
375
+ await user_repo.upsert(existing_user)
376
+ return {"allowed": True, "error": None}
377
+ else:
378
+ # New user - check if domain is trusted
379
+ if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
380
+ if not settings.email.is_domain_trusted(email):
381
+ email_domain = email.split("@")[-1]
382
+ logger.warning(f"Untrusted domain attempted signup: {email_domain}")
383
+ return {"allowed": False, "error": "Email domain not allowed for signup"}
384
+
385
+ # Domain is trusted (or no restrictions) - create new user
386
+ # Users from trusted domains get admin role
387
+ user_role = None
388
+ if settings and hasattr(settings, 'email') and settings.email.trusted_domain_list:
389
+ if settings.email.is_domain_trusted(email):
390
+ user_role = "admin"
391
+ logger.info(f"New user {email} assigned admin role (trusted domain)")
392
+
393
+ new_user = User(
394
+ id=uuid.UUID(user_id),
395
+ tenant_id=tenant_id,
396
+ name=email.split("@")[0], # Default name from email
397
+ email=email,
398
+ role=user_role,
399
+ metadata=login_metadata,
400
+ )
401
+ await user_repo.upsert(new_user)
402
+ return {"allowed": True, "error": None}
403
+
404
+ async def verify_login_code(
405
+ self,
406
+ email: str,
407
+ code: str,
408
+ db: "PostgresService",
409
+ tenant_id: str = "default",
410
+ ) -> dict:
411
+ """
412
+ Verify a login code for an email address.
413
+
414
+ On success, clears the code from metadata and returns user info.
415
+
416
+ Args:
417
+ email: User's email address
418
+ code: The login code to verify
419
+ db: PostgresService instance
420
+ tenant_id: Tenant identifier
421
+
422
+ Returns:
423
+ Dict with verification result:
424
+ {
425
+ "valid": bool,
426
+ "user_id": str (if valid),
427
+ "email": str,
428
+ "error": str (if invalid)
429
+ }
430
+ """
431
+ from ...models.entities import User
432
+ from ..postgres.repository import Repository
433
+
434
+ email = email.lower().strip()
435
+ user_id = self.generate_user_id_from_email(email)
436
+
437
+ result = {
438
+ "valid": False,
439
+ "email": email,
440
+ }
441
+
442
+ try:
443
+ # Use repository pattern for User operations
444
+ user_repo = Repository(User, db=db)
445
+
446
+ # Get user by deterministic ID
447
+ user = await user_repo.get_by_id(user_id, tenant_id=tenant_id)
448
+
449
+ if not user:
450
+ result["error"] = "User not found"
451
+ return result
452
+
453
+ metadata = user.metadata or {}
454
+ stored_code = metadata.get("login_code")
455
+ expires_at_str = metadata.get("login_code_expires_at")
456
+
457
+ if not stored_code or not expires_at_str:
458
+ result["error"] = "No login code requested"
459
+ return result
460
+
461
+ # Check expiration
462
+ expires_at = datetime.fromisoformat(expires_at_str)
463
+ if datetime.now(timezone.utc) > expires_at:
464
+ result["error"] = "Login code expired"
465
+ return result
466
+
467
+ # Check code match
468
+ if stored_code != code:
469
+ result["error"] = "Invalid login code"
470
+ return result
471
+
472
+ # Code is valid - clear it from metadata
473
+ user.metadata = {
474
+ k: v for k, v in metadata.items()
475
+ if k not in ("login_code", "login_code_expires_at", "login_code_sent_at")
476
+ }
477
+ await user_repo.upsert(user)
478
+
479
+ result["valid"] = True
480
+ result["user_id"] = str(user.id)
481
+ logger.info(f"Login code verified for {email}, user_id={result['user_id']}")
482
+
483
+ except Exception as e:
484
+ logger.error(f"Error verifying login code: {e}")
485
+ result["error"] = "Verification failed"
486
+
487
+ return result
488
+
489
+ async def send_welcome_email(
490
+ self,
491
+ email: str,
492
+ name: Optional[str] = None,
493
+ template_kwargs: dict | None = None,
494
+ ) -> bool:
495
+ """
496
+ Send a welcome email to a new user.
497
+
498
+ Args:
499
+ email: User's email address
500
+ name: Optional user's name
501
+ template_kwargs: Additional arguments for template customization
502
+
503
+ Returns:
504
+ True if sent successfully
505
+ """
506
+ from ...settings import settings
507
+
508
+ # Merge settings.email.template_kwargs with any explicit overrides
509
+ kwargs = {**settings.email.template_kwargs, **(template_kwargs or {})}
510
+ template = welcome_template(name=name, **kwargs)
511
+ return self.send_email(to_email=email, template=template)