sendgrid.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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.password_changed_template_id = (
  32. config.password_changed_template_id
  33. or os.getenv("SENDGRID_PASSWORD_CHANGED_TEMPLATE_ID")
  34. )
  35. self.client = SendGridAPIClient(api_key=self.api_key)
  36. self.sender_name = config.sender_name
  37. # Logo and documentation URLs
  38. self.docs_base_url = f"{self.frontend_url}/documentation"
  39. def _get_base_template_data(self, to_email: str) -> dict:
  40. """Get base template data used across all email templates."""
  41. return {
  42. "user_email": to_email,
  43. "docs_url": self.docs_base_url,
  44. "quickstart_url": f"{self.docs_base_url}/quickstart",
  45. "frontend_url": self.frontend_url,
  46. }
  47. async def send_email(
  48. self,
  49. to_email: str,
  50. subject: Optional[str] = None,
  51. body: Optional[str] = None,
  52. html_body: Optional[str] = None,
  53. template_id: Optional[str] = None,
  54. dynamic_template_data: Optional[dict] = None,
  55. ) -> None:
  56. try:
  57. logger.info("Preparing SendGrid message...")
  58. message = Mail(
  59. from_email=From(self.from_email, self.sender_name),
  60. to_emails=to_email,
  61. )
  62. if template_id:
  63. logger.info(f"Using dynamic template with ID: {template_id}")
  64. message.template_id = template_id
  65. base_data = self._get_base_template_data(to_email)
  66. message.dynamic_template_data = {
  67. **base_data,
  68. **(dynamic_template_data or {}),
  69. }
  70. else:
  71. if not subject:
  72. raise ValueError(
  73. "Subject is required when not using a template"
  74. )
  75. message.subject = subject
  76. message.add_content(Content("text/plain", body or ""))
  77. if html_body:
  78. message.add_content(Content("text/html", html_body))
  79. import asyncio
  80. response = await asyncio.to_thread(self.client.send, message)
  81. if response.status_code >= 400:
  82. raise RuntimeError(
  83. f"Failed to send email: {response.status_code}"
  84. )
  85. elif response.status_code == 202:
  86. logger.info("Message sent successfully!")
  87. else:
  88. error_msg = f"Failed to send email. Status code: {response.status_code}, Body: {response.body}"
  89. logger.error(error_msg)
  90. raise RuntimeError(error_msg)
  91. except Exception as e:
  92. error_msg = f"Failed to send email to {to_email}: {str(e)}"
  93. logger.error(error_msg)
  94. raise RuntimeError(error_msg) from e
  95. async def send_verification_email(
  96. self,
  97. to_email: str,
  98. verification_code: str,
  99. dynamic_template_data: Optional[dict] = None,
  100. ) -> None:
  101. try:
  102. if self.verify_email_template_id:
  103. verification_data = {
  104. "verification_link": f"{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}",
  105. "verification_code": verification_code, # Include code separately for flexible template usage
  106. }
  107. # Merge with any additional template data
  108. template_data = {
  109. **(dynamic_template_data or {}),
  110. **verification_data,
  111. }
  112. await self.send_email(
  113. to_email=to_email,
  114. template_id=self.verify_email_template_id,
  115. dynamic_template_data=template_data,
  116. )
  117. else:
  118. # Fallback to basic email if no template ID is configured
  119. subject = "Verify Your R2R Account"
  120. html_body = f"""
  121. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  122. <h1>Welcome to R2R!</h1>
  123. <p>Please verify your email address to get started with R2R - the most advanced AI retrieval system.</p>
  124. <p>Click the link below to verify your email:</p>
  125. <p><a href="{self.frontend_url}/verify-email?token={verification_code}&email={to_email}"
  126. style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
  127. Verify Email
  128. </a></p>
  129. <p>Or enter this verification code: <strong>{verification_code}</strong></p>
  130. <p>If you didn't create an account with R2R, please ignore this email.</p>
  131. </div>
  132. """
  133. await self.send_email(
  134. to_email=to_email,
  135. subject=subject,
  136. html_body=html_body,
  137. body=f"Welcome to R2R! Please verify your email using this code: {verification_code}",
  138. )
  139. except Exception as e:
  140. error_msg = (
  141. f"Failed to send verification email to {to_email}: {str(e)}"
  142. )
  143. logger.error(error_msg)
  144. raise RuntimeError(error_msg) from e
  145. async def send_password_reset_email(
  146. self,
  147. to_email: str,
  148. reset_token: str,
  149. dynamic_template_data: Optional[dict] = None,
  150. ) -> None:
  151. try:
  152. if self.reset_password_template_id:
  153. reset_data = {
  154. "reset_link": f"{self.frontend_url}/reset-password?token={reset_token}",
  155. "reset_token": reset_token,
  156. }
  157. template_data = {**(dynamic_template_data or {}), **reset_data}
  158. await self.send_email(
  159. to_email=to_email,
  160. template_id=self.reset_password_template_id,
  161. dynamic_template_data=template_data,
  162. )
  163. else:
  164. subject = "Reset Your R2R Password"
  165. html_body = f"""
  166. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  167. <h1>Password Reset Request</h1>
  168. <p>You've requested to reset your R2R password.</p>
  169. <p>Click the link below to reset your password:</p>
  170. <p><a href="{self.frontend_url}/reset-password?token={reset_token}"
  171. style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
  172. Reset Password
  173. </a></p>
  174. <p>Or use this reset token: <strong>{reset_token}</strong></p>
  175. <p>If you didn't request a password reset, please ignore this email.</p>
  176. </div>
  177. """
  178. await self.send_email(
  179. to_email=to_email,
  180. subject=subject,
  181. html_body=html_body,
  182. body=f"Reset your R2R password using this token: {reset_token}",
  183. )
  184. except Exception as e:
  185. error_msg = (
  186. f"Failed to send password reset email to {to_email}: {str(e)}"
  187. )
  188. logger.error(error_msg)
  189. raise RuntimeError(error_msg) from e
  190. async def send_password_changed_email(
  191. self,
  192. to_email: str,
  193. dynamic_template_data: Optional[dict] = None,
  194. *args,
  195. **kwargs,
  196. ) -> None:
  197. try:
  198. if (
  199. hasattr(self, "password_changed_template_id")
  200. and self.password_changed_template_id
  201. ):
  202. await self.send_email(
  203. to_email=to_email,
  204. template_id=self.password_changed_template_id,
  205. dynamic_template_data=dynamic_template_data,
  206. )
  207. else:
  208. subject = "Your Password Has Been Changed"
  209. body = """
  210. Your password has been successfully changed.
  211. If you did not make this change, please contact support immediately and secure your account.
  212. """
  213. html_body = """
  214. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  215. <h1>Password Changed Successfully</h1>
  216. <p>Your password has been successfully changed.</p>
  217. </div>
  218. """
  219. # Move send_email inside the else block
  220. await self.send_email(
  221. to_email=to_email,
  222. subject=subject,
  223. html_body=html_body,
  224. body=body,
  225. )
  226. except Exception as e:
  227. error_msg = f"Failed to send password change notification to {to_email}: {str(e)}"
  228. logger.error(error_msg)
  229. raise RuntimeError(error_msg) from e