sendgrid.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import logging
  2. import os
  3. from typing import Optional
  4. from sendgrid import SendGridAPIClient
  5. from sendgrid.helpers.mail import Content, From, Mail
  6. from core.base import EmailConfig, EmailProvider
  7. logger = logging.getLogger(__name__)
  8. class SendGridEmailProvider(EmailProvider):
  9. """Email provider implementation using SendGrid API"""
  10. def __init__(self, config: EmailConfig):
  11. super().__init__(config)
  12. self.api_key = config.sendgrid_api_key or os.getenv("SENDGRID_API_KEY")
  13. if not self.api_key or not isinstance(self.api_key, str):
  14. raise ValueError("A valid SendGrid API key is required.")
  15. self.from_email = config.from_email or os.getenv("R2R_FROM_EMAIL")
  16. if not self.from_email or not isinstance(self.from_email, str):
  17. raise ValueError("A valid from email is required.")
  18. self.frontend_url = config.frontend_url or os.getenv(
  19. "R2R_FRONTEND_URL"
  20. )
  21. if not self.frontend_url or not isinstance(self.frontend_url, str):
  22. raise ValueError("A valid frontend URL is required.")
  23. self.verify_email_template_id = (
  24. config.verify_email_template_id
  25. or os.getenv("SENDGRID_EMAIL_TEMPLATE_ID")
  26. )
  27. self.reset_password_template_id = (
  28. config.reset_password_template_id
  29. or os.getenv("SENDGRID_RESET_TEMPLATE_ID")
  30. )
  31. self.client = SendGridAPIClient(api_key=self.api_key)
  32. self.sender_name = config.sender_name
  33. # Logo and documentation URLs
  34. self.docs_base_url = f"{self.frontend_url}/documentation"
  35. def _get_base_template_data(self, to_email: str) -> dict:
  36. """Get base template data used across all email templates"""
  37. return {
  38. "user_email": to_email,
  39. "docs_url": self.docs_base_url,
  40. "quickstart_url": f"{self.docs_base_url}/quickstart",
  41. "frontend_url": self.frontend_url,
  42. }
  43. async def send_email(
  44. self,
  45. to_email: str,
  46. subject: Optional[str] = None,
  47. body: Optional[str] = None,
  48. html_body: Optional[str] = None,
  49. template_id: Optional[str] = None,
  50. dynamic_template_data: Optional[dict] = None,
  51. ) -> None:
  52. try:
  53. logger.info("Preparing SendGrid message...")
  54. message = Mail(
  55. from_email=From(self.from_email, self.sender_name),
  56. to_emails=to_email,
  57. )
  58. if template_id:
  59. logger.info(f"Using dynamic template with ID: {template_id}")
  60. message.template_id = template_id
  61. base_data = self._get_base_template_data(to_email)
  62. message.dynamic_template_data = {
  63. **base_data,
  64. **(dynamic_template_data or {}),
  65. }
  66. else:
  67. if not subject:
  68. raise ValueError(
  69. "Subject is required when not using a template"
  70. )
  71. message.subject = subject
  72. message.add_content(Content("text/plain", body or ""))
  73. if html_body:
  74. message.add_content(Content("text/html", html_body))
  75. import asyncio
  76. response = await asyncio.to_thread(self.client.send, message)
  77. if response.status_code >= 400:
  78. raise RuntimeError(
  79. f"Failed to send email: {response.status_code}"
  80. )
  81. elif response.status_code == 202:
  82. logger.info("Message sent successfully!")
  83. else:
  84. error_msg = f"Failed to send email. Status code: {response.status_code}, Body: {response.body}"
  85. logger.error(error_msg)
  86. raise RuntimeError(error_msg)
  87. except Exception as e:
  88. error_msg = f"Failed to send email to {to_email}: {str(e)}"
  89. logger.error(error_msg)
  90. raise RuntimeError(error_msg) from e
  91. async def send_verification_email(
  92. self,
  93. to_email: str,
  94. verification_code: str,
  95. dynamic_template_data: Optional[dict] = None,
  96. ) -> None:
  97. try:
  98. if self.verify_email_template_id:
  99. verification_data = {
  100. "verification_link": f"{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}",
  101. "verification_code": verification_code, # Include code separately for flexible template usage
  102. }
  103. # Merge with any additional template data
  104. template_data = {
  105. **(dynamic_template_data or {}),
  106. **verification_data,
  107. }
  108. await self.send_email(
  109. to_email=to_email,
  110. template_id=self.verify_email_template_id,
  111. dynamic_template_data=template_data,
  112. )
  113. else:
  114. # Fallback to basic email if no template ID is configured
  115. subject = "Verify Your R2R Account"
  116. html_body = f"""
  117. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  118. <h1>Welcome to R2R!</h1>
  119. <p>Please verify your email address to get started with R2R - the most advanced AI retrieval system.</p>
  120. <p>Click the link below to verify your email:</p>
  121. <p><a href="{self.frontend_url}/verify-email?token={verification_code}&email={to_email}"
  122. style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
  123. Verify Email
  124. </a></p>
  125. <p>Or enter this verification code: <strong>{verification_code}</strong></p>
  126. <p>If you didn't create an account with R2R, please ignore this email.</p>
  127. </div>
  128. """
  129. await self.send_email(
  130. to_email=to_email,
  131. subject=subject,
  132. html_body=html_body,
  133. body=f"Welcome to R2R! Please verify your email using this code: {verification_code}",
  134. )
  135. except Exception as e:
  136. error_msg = (
  137. f"Failed to send verification email to {to_email}: {str(e)}"
  138. )
  139. logger.error(error_msg)
  140. raise RuntimeError(error_msg) from e
  141. async def send_password_reset_email(
  142. self,
  143. to_email: str,
  144. reset_token: str,
  145. dynamic_template_data: Optional[dict] = None,
  146. ) -> None:
  147. try:
  148. if self.reset_password_template_id:
  149. reset_data = {
  150. "reset_link": f"{self.frontend_url}/reset-password?token={reset_token}",
  151. "reset_token": reset_token,
  152. }
  153. template_data = {**(dynamic_template_data or {}), **reset_data}
  154. await self.send_email(
  155. to_email=to_email,
  156. template_id=self.reset_password_template_id,
  157. dynamic_template_data=template_data,
  158. )
  159. else:
  160. subject = "Reset Your R2R Password"
  161. html_body = f"""
  162. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  163. <h1>Password Reset Request</h1>
  164. <p>You've requested to reset your R2R password.</p>
  165. <p>Click the link below to reset your password:</p>
  166. <p><a href="{self.frontend_url}/reset-password?token={reset_token}"
  167. style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
  168. Reset Password
  169. </a></p>
  170. <p>Or use this reset token: <strong>{reset_token}</strong></p>
  171. <p>If you didn't request a password reset, please ignore this email.</p>
  172. </div>
  173. """
  174. await self.send_email(
  175. to_email=to_email,
  176. subject=subject,
  177. html_body=html_body,
  178. body=f"Reset your R2R password using this token: {reset_token}",
  179. )
  180. except Exception as e:
  181. error_msg = (
  182. f"Failed to send password reset email to {to_email}: {str(e)}"
  183. )
  184. logger.error(error_msg)
  185. raise RuntimeError(error_msg) from e