diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/core/providers/email')
5 files changed, 792 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/core/providers/email/__init__.py b/.venv/lib/python3.12/site-packages/core/providers/email/__init__.py new file mode 100644 index 00000000..38753695 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/core/providers/email/__init__.py @@ -0,0 +1,11 @@ +from .console_mock import ConsoleMockEmailProvider +from .mailersend import MailerSendEmailProvider +from .sendgrid import SendGridEmailProvider +from .smtp import AsyncSMTPEmailProvider + +__all__ = [ + "ConsoleMockEmailProvider", + "AsyncSMTPEmailProvider", + "SendGridEmailProvider", + "MailerSendEmailProvider", +] diff --git a/.venv/lib/python3.12/site-packages/core/providers/email/console_mock.py b/.venv/lib/python3.12/site-packages/core/providers/email/console_mock.py new file mode 100644 index 00000000..459a978d --- /dev/null +++ b/.venv/lib/python3.12/site-packages/core/providers/email/console_mock.py @@ -0,0 +1,67 @@ +import logging +from typing import Optional + +from core.base import EmailProvider + +logger = logging.getLogger() + + +class ConsoleMockEmailProvider(EmailProvider): + """A simple email provider that logs emails to console, useful for + testing.""" + + async def send_email( + self, + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + *args, + **kwargs, + ) -> None: + logger.info(f""" + -------- Email Message -------- + To: {to_email} + Subject: {subject} + Body: + {body} + ----------------------------- + """) + + async def send_verification_email( + self, to_email: str, verification_code: str, *args, **kwargs + ) -> None: + logger.info(f""" + -------- Email Message -------- + To: {to_email} + Subject: Please verify your email address + Body: + Verification code: {verification_code} + ----------------------------- + """) + + async def send_password_reset_email( + self, to_email: str, reset_token: str, *args, **kwargs + ) -> None: + logger.info(f""" + -------- Email Message -------- + To: {to_email} + Subject: Password Reset Request + Body: + Reset token: {reset_token} + ----------------------------- + """) + + async def send_password_changed_email( + self, to_email: str, *args, **kwargs + ) -> None: + logger.info(f""" + -------- Email Message -------- + To: {to_email} + Subject: Your Password Has Been Changed + Body: + Your password has been successfully changed. + + For security reasons, you will need to log in again on all your devices. + ----------------------------- + """) diff --git a/.venv/lib/python3.12/site-packages/core/providers/email/mailersend.py b/.venv/lib/python3.12/site-packages/core/providers/email/mailersend.py new file mode 100644 index 00000000..10fccd56 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/core/providers/email/mailersend.py @@ -0,0 +1,281 @@ +import logging +import os +from typing import Optional + +from mailersend import emails + +from core.base import EmailConfig, EmailProvider + +logger = logging.getLogger(__name__) + + +class MailerSendEmailProvider(EmailProvider): + """Email provider implementation using MailerSend API.""" + + def __init__(self, config: EmailConfig): + super().__init__(config) + self.api_key = config.mailersend_api_key or os.getenv( + "MAILERSEND_API_KEY" + ) + if not self.api_key or not isinstance(self.api_key, str): + raise ValueError("A valid MailerSend API key is required.") + + self.from_email = config.from_email or os.getenv("R2R_FROM_EMAIL") + if not self.from_email or not isinstance(self.from_email, str): + raise ValueError("A valid from email is required.") + + self.frontend_url = config.frontend_url or os.getenv( + "R2R_FRONTEND_URL" + ) + if not self.frontend_url or not isinstance(self.frontend_url, str): + raise ValueError("A valid frontend URL is required.") + + self.verify_email_template_id = ( + config.verify_email_template_id + or os.getenv("MAILERSEND_VERIFY_EMAIL_TEMPLATE_ID") + ) + self.reset_password_template_id = ( + config.reset_password_template_id + or os.getenv("MAILERSEND_RESET_PASSWORD_TEMPLATE_ID") + ) + self.password_changed_template_id = ( + config.password_changed_template_id + or os.getenv("MAILERSEND_PASSWORD_CHANGED_TEMPLATE_ID") + ) + self.client = emails.NewEmail(self.api_key) + self.sender_name = config.sender_name or "R2R" + + # Logo and documentation URLs + self.docs_base_url = f"{self.frontend_url}/documentation" + + def _get_base_template_data(self, to_email: str) -> dict: + """Get base template data used across all email templates.""" + return { + "user_email": to_email, + "docs_url": self.docs_base_url, + "quickstart_url": f"{self.docs_base_url}/quickstart", + "frontend_url": self.frontend_url, + } + + async def send_email( + self, + to_email: str, + subject: Optional[str] = None, + body: Optional[str] = None, + html_body: Optional[str] = None, + template_id: Optional[str] = None, + dynamic_template_data: Optional[dict] = None, + ) -> None: + try: + logger.info("Preparing MailerSend message...") + + mail_body = { + "from": { + "email": self.from_email, + "name": self.sender_name, + }, + "to": [{"email": to_email}], + } + + if template_id: + # Transform the template data to MailerSend's expected format + if dynamic_template_data: + formatted_substitutions = {} + for key, value in dynamic_template_data.items(): + formatted_substitutions[key] = { + "var": key, + "value": value, + } + mail_body["variables"] = [ + { + "email": to_email, + "substitutions": formatted_substitutions, + } + ] + + mail_body["template_id"] = template_id + else: + mail_body.update( + { + "subject": subject or "", + "text": body or "", + "html": html_body or "", + } + ) + + import asyncio + + response = await asyncio.to_thread(self.client.send, mail_body) + + # Handle different response formats + if isinstance(response, str): + # Clean the string response by stripping whitespace + response_clean = response.strip() + if response_clean in ["202", "200"]: + logger.info( + f"Email accepted for delivery with status code {response_clean}" + ) + return + elif isinstance(response, int) and response in [200, 202]: + logger.info( + f"Email accepted for delivery with status code {response}" + ) + return + elif isinstance(response, dict) and response.get( + "status_code" + ) in [200, 202]: + logger.info( + f"Email accepted for delivery with status code {response.get('status_code')}" + ) + return + + # If we get here, it's an error + error_msg = f"MailerSend error: {response}" + logger.error(error_msg) + + except Exception as e: + error_msg = f"Failed to send email to {to_email}: {str(e)}" + logger.error(error_msg) + + async def send_verification_email( + self, + to_email: str, + verification_code: str, + dynamic_template_data: Optional[dict] = None, + ) -> None: + try: + if self.verify_email_template_id: + verification_data = { + "verification_link": f"{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}", + "verification_code": verification_code, # Include code separately for flexible template usage + } + + # Merge with any additional template data + template_data = { + **(dynamic_template_data or {}), + **verification_data, + } + + await self.send_email( + to_email=to_email, + template_id=self.verify_email_template_id, + dynamic_template_data=template_data, + ) + else: + # Fallback to basic email if no template ID is configured + subject = "Verify Your R2R Account" + html_body = f""" + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Welcome to R2R!</h1> + <p>Please verify your email address to get started with R2R - the most advanced AI retrieval system.</p> + <p>Click the link below to verify your email:</p> + <p><a href="{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}" + style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;"> + Verify Email + </a></p> + <p>Or enter this verification code: <strong>{verification_code}</strong></p> + <p>If you didn't create an account with R2R, please ignore this email.</p> + </div> + """ + + await self.send_email( + to_email=to_email, + subject=subject, + html_body=html_body, + body=f"Welcome to R2R! Please verify your email using this code: {verification_code}", + ) + except Exception as e: + error_msg = ( + f"Failed to send verification email to {to_email}: {str(e)}" + ) + logger.error(error_msg) + + async def send_password_reset_email( + self, + to_email: str, + reset_token: str, + dynamic_template_data: Optional[dict] = None, + ) -> None: + try: + if self.reset_password_template_id: + reset_data = { + "reset_link": f"{self.frontend_url}/reset-password?token={reset_token}", + "reset_token": reset_token, + } + + template_data = {**(dynamic_template_data or {}), **reset_data} + + await self.send_email( + to_email=to_email, + template_id=self.reset_password_template_id, + dynamic_template_data=template_data, + ) + else: + subject = "Reset Your R2R Password" + html_body = f""" + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Password Reset Request</h1> + <p>You've requested to reset your R2R password.</p> + <p>Click the link below to reset your password:</p> + <p><a href="{self.frontend_url}/reset-password?token={reset_token}" + style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;"> + Reset Password + </a></p> + <p>Or use this reset token: <strong>{reset_token}</strong></p> + <p>If you didn't request a password reset, please ignore this email.</p> + </div> + """ + + await self.send_email( + to_email=to_email, + subject=subject, + html_body=html_body, + body=f"Reset your R2R password using this token: {reset_token}", + ) + except Exception as e: + error_msg = ( + f"Failed to send password reset email to {to_email}: {str(e)}" + ) + logger.error(error_msg) + + async def send_password_changed_email( + self, + to_email: str, + dynamic_template_data: Optional[dict] = None, + *args, + **kwargs, + ) -> None: + try: + if ( + hasattr(self, "password_changed_template_id") + and self.password_changed_template_id + ): + await self.send_email( + to_email=to_email, + template_id=self.password_changed_template_id, + dynamic_template_data=dynamic_template_data, + ) + else: + subject = "Your Password Has Been Changed" + body = """ + Your password has been successfully changed. + + If you did not make this change, please contact support immediately and secure your account. + + """ + html_body = """ + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Password Changed Successfully</h1> + <p>Your password has been successfully changed.</p> + </div> + """ + await self.send_email( + to_email=to_email, + subject=subject, + html_body=html_body, + body=body, + ) + except Exception as e: + error_msg = f"Failed to send password change notification to {to_email}: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e diff --git a/.venv/lib/python3.12/site-packages/core/providers/email/sendgrid.py b/.venv/lib/python3.12/site-packages/core/providers/email/sendgrid.py new file mode 100644 index 00000000..8b2553f1 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/core/providers/email/sendgrid.py @@ -0,0 +1,257 @@ +import logging +import os +from typing import Optional + +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Content, From, Mail + +from core.base import EmailConfig, EmailProvider + +logger = logging.getLogger(__name__) + + +class SendGridEmailProvider(EmailProvider): + """Email provider implementation using SendGrid API.""" + + def __init__(self, config: EmailConfig): + super().__init__(config) + self.api_key = config.sendgrid_api_key or os.getenv("SENDGRID_API_KEY") + if not self.api_key or not isinstance(self.api_key, str): + raise ValueError("A valid SendGrid API key is required.") + + self.from_email = config.from_email or os.getenv("R2R_FROM_EMAIL") + if not self.from_email or not isinstance(self.from_email, str): + raise ValueError("A valid from email is required.") + + self.frontend_url = config.frontend_url or os.getenv( + "R2R_FRONTEND_URL" + ) + if not self.frontend_url or not isinstance(self.frontend_url, str): + raise ValueError("A valid frontend URL is required.") + + self.verify_email_template_id = ( + config.verify_email_template_id + or os.getenv("SENDGRID_EMAIL_TEMPLATE_ID") + ) + self.reset_password_template_id = ( + config.reset_password_template_id + or os.getenv("SENDGRID_RESET_TEMPLATE_ID") + ) + self.password_changed_template_id = ( + config.password_changed_template_id + or os.getenv("SENDGRID_PASSWORD_CHANGED_TEMPLATE_ID") + ) + self.client = SendGridAPIClient(api_key=self.api_key) + self.sender_name = config.sender_name + + # Logo and documentation URLs + self.docs_base_url = f"{self.frontend_url}/documentation" + + def _get_base_template_data(self, to_email: str) -> dict: + """Get base template data used across all email templates.""" + return { + "user_email": to_email, + "docs_url": self.docs_base_url, + "quickstart_url": f"{self.docs_base_url}/quickstart", + "frontend_url": self.frontend_url, + } + + async def send_email( + self, + to_email: str, + subject: Optional[str] = None, + body: Optional[str] = None, + html_body: Optional[str] = None, + template_id: Optional[str] = None, + dynamic_template_data: Optional[dict] = None, + ) -> None: + try: + logger.info("Preparing SendGrid message...") + message = Mail( + from_email=From(self.from_email, self.sender_name), + to_emails=to_email, + ) + + if template_id: + logger.info(f"Using dynamic template with ID: {template_id}") + message.template_id = template_id + base_data = self._get_base_template_data(to_email) + message.dynamic_template_data = { + **base_data, + **(dynamic_template_data or {}), + } + else: + if not subject: + raise ValueError( + "Subject is required when not using a template" + ) + message.subject = subject + message.add_content(Content("text/plain", body or "")) + if html_body: + message.add_content(Content("text/html", html_body)) + + import asyncio + + response = await asyncio.to_thread(self.client.send, message) + + if response.status_code >= 400: + raise RuntimeError( + f"Failed to send email: {response.status_code}" + ) + elif response.status_code == 202: + logger.info("Message sent successfully!") + else: + error_msg = f"Failed to send email. Status code: {response.status_code}, Body: {response.body}" + logger.error(error_msg) + raise RuntimeError(error_msg) + + except Exception as e: + error_msg = f"Failed to send email to {to_email}: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + async def send_verification_email( + self, + to_email: str, + verification_code: str, + dynamic_template_data: Optional[dict] = None, + ) -> None: + try: + if self.verify_email_template_id: + verification_data = { + "verification_link": f"{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}", + "verification_code": verification_code, # Include code separately for flexible template usage + } + + # Merge with any additional template data + template_data = { + **(dynamic_template_data or {}), + **verification_data, + } + + await self.send_email( + to_email=to_email, + template_id=self.verify_email_template_id, + dynamic_template_data=template_data, + ) + else: + # Fallback to basic email if no template ID is configured + subject = "Verify Your R2R Account" + html_body = f""" + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Welcome to R2R!</h1> + <p>Please verify your email address to get started with R2R - the most advanced AI retrieval system.</p> + <p>Click the link below to verify your email:</p> + <p><a href="{self.frontend_url}/verify-email?token={verification_code}&email={to_email}" + style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;"> + Verify Email + </a></p> + <p>Or enter this verification code: <strong>{verification_code}</strong></p> + <p>If you didn't create an account with R2R, please ignore this email.</p> + </div> + """ + + await self.send_email( + to_email=to_email, + subject=subject, + html_body=html_body, + body=f"Welcome to R2R! Please verify your email using this code: {verification_code}", + ) + except Exception as e: + error_msg = ( + f"Failed to send verification email to {to_email}: {str(e)}" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + async def send_password_reset_email( + self, + to_email: str, + reset_token: str, + dynamic_template_data: Optional[dict] = None, + ) -> None: + try: + if self.reset_password_template_id: + reset_data = { + "reset_link": f"{self.frontend_url}/reset-password?token={reset_token}", + "reset_token": reset_token, + } + + template_data = {**(dynamic_template_data or {}), **reset_data} + + await self.send_email( + to_email=to_email, + template_id=self.reset_password_template_id, + dynamic_template_data=template_data, + ) + else: + subject = "Reset Your R2R Password" + html_body = f""" + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Password Reset Request</h1> + <p>You've requested to reset your R2R password.</p> + <p>Click the link below to reset your password:</p> + <p><a href="{self.frontend_url}/reset-password?token={reset_token}" + style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;"> + Reset Password + </a></p> + <p>Or use this reset token: <strong>{reset_token}</strong></p> + <p>If you didn't request a password reset, please ignore this email.</p> + </div> + """ + + await self.send_email( + to_email=to_email, + subject=subject, + html_body=html_body, + body=f"Reset your R2R password using this token: {reset_token}", + ) + except Exception as e: + error_msg = ( + f"Failed to send password reset email to {to_email}: {str(e)}" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + async def send_password_changed_email( + self, + to_email: str, + dynamic_template_data: Optional[dict] = None, + *args, + **kwargs, + ) -> None: + try: + if ( + hasattr(self, "password_changed_template_id") + and self.password_changed_template_id + ): + await self.send_email( + to_email=to_email, + template_id=self.password_changed_template_id, + dynamic_template_data=dynamic_template_data, + ) + else: + subject = "Your Password Has Been Changed" + body = """ + Your password has been successfully changed. + + If you did not make this change, please contact support immediately and secure your account. + + """ + html_body = """ + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Password Changed Successfully</h1> + <p>Your password has been successfully changed.</p> + </div> + """ + # Move send_email inside the else block + await self.send_email( + to_email=to_email, + subject=subject, + html_body=html_body, + body=body, + ) + except Exception as e: + error_msg = f"Failed to send password change notification to {to_email}: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e diff --git a/.venv/lib/python3.12/site-packages/core/providers/email/smtp.py b/.venv/lib/python3.12/site-packages/core/providers/email/smtp.py new file mode 100644 index 00000000..bd68ff36 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/core/providers/email/smtp.py @@ -0,0 +1,176 @@ +import asyncio +import logging +import os +import smtplib +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional + +from core.base import EmailConfig, EmailProvider + +logger = logging.getLogger(__name__) + + +class AsyncSMTPEmailProvider(EmailProvider): + """Email provider implementation using Brevo SMTP relay.""" + + def __init__(self, config: EmailConfig): + super().__init__(config) + self.smtp_server = config.smtp_server or os.getenv("R2R_SMTP_SERVER") + if not self.smtp_server: + raise ValueError("SMTP server is required") + + self.smtp_port = config.smtp_port or os.getenv("R2R_SMTP_PORT") + if not self.smtp_port: + raise ValueError("SMTP port is required") + + self.smtp_username = config.smtp_username or os.getenv( + "R2R_SMTP_USERNAME" + ) + if not self.smtp_username: + raise ValueError("SMTP username is required") + + self.smtp_password = config.smtp_password or os.getenv( + "R2R_SMTP_PASSWORD" + ) + if not self.smtp_password: + raise ValueError("SMTP password is required") + + self.from_email: Optional[str] = ( + config.from_email + or os.getenv("R2R_FROM_EMAIL") + or self.smtp_username + ) + self.ssl_context = ssl.create_default_context() + + async def _send_email_sync(self, msg: MIMEMultipart) -> None: + """Synchronous email sending wrapped in asyncio executor.""" + loop = asyncio.get_running_loop() + + def _send(): + with smtplib.SMTP_SSL( + self.smtp_server, + self.smtp_port, + context=self.ssl_context, + timeout=30, + ) as server: + logger.info("Connected to SMTP server") + server.login(self.smtp_username, self.smtp_password) + logger.info("Login successful") + server.send_message(msg) + logger.info("Message sent successfully!") + + try: + await loop.run_in_executor(None, _send) + except Exception as e: + error_msg = f"Failed to send email: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + async def send_email( + self, + to_email: str, + subject: str, + body: str, + html_body: Optional[str] = None, + *args, + **kwargs, + ) -> None: + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = self.from_email # type: ignore + msg["To"] = to_email + + msg.attach(MIMEText(body, "plain")) + if html_body: + msg.attach(MIMEText(html_body, "html")) + + try: + logger.info("Initializing SMTP connection...") + async with asyncio.timeout(30): # Overall timeout + await self._send_email_sync(msg) + except asyncio.TimeoutError as e: + error_msg = "Operation timed out while trying to send email" + logger.error(error_msg) + raise RuntimeError(error_msg) from e + except Exception as e: + error_msg = f"Failed to send email: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + async def send_verification_email( + self, to_email: str, verification_code: str, *args, **kwargs + ) -> None: + body = f""" + Please verify your email address by entering the following code: + + Verification code: {verification_code} + + If you did not request this verification, please ignore this email. + """ + + html_body = f""" + <p>Please verify your email address by entering the following code:</p> + <p style="font-size: 24px; font-weight: bold; margin: 20px 0;"> + Verification code: {verification_code} + </p> + <p>If you did not request this verification, please ignore this email.</p> + """ + + await self.send_email( + to_email=to_email, + subject="Please verify your email address", + body=body, + html_body=html_body, + ) + + async def send_password_reset_email( + self, to_email: str, reset_token: str, *args, **kwargs + ) -> None: + body = f""" + You have requested to reset your password. + + Reset token: {reset_token} + + If you did not request a password reset, please ignore this email. + """ + + html_body = f""" + <p>You have requested to reset your password.</p> + <p style="font-size: 24px; font-weight: bold; margin: 20px 0;"> + Reset token: {reset_token} + </p> + <p>If you did not request a password reset, please ignore this email.</p> + """ + + await self.send_email( + to_email=to_email, + subject="Password Reset Request", + body=body, + html_body=html_body, + ) + + async def send_password_changed_email( + self, to_email: str, *args, **kwargs + ) -> None: + body = """ + Your password has been successfully changed. + + If you did not make this change, please contact support immediately and secure your account. + + """ + + html_body = """ + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1>Password Changed Successfully</h1> + <p>Your password has been successfully changed.</p> + </div> + """ + + await self.send_email( + to_email=to_email, + subject="Your Password Has Been Changed", + body=body, + html_body=html_body, + ) |