mailersend.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import logging
  2. import os
  3. from typing import Optional
  4. from mailersend import emails
  5. from core.base import EmailConfig, EmailProvider
  6. logger = logging.getLogger(__name__)
  7. class MailerSendEmailProvider(EmailProvider):
  8. """Email provider implementation using MailerSend API."""
  9. def __init__(self, config: EmailConfig):
  10. super().__init__(config)
  11. self.api_key = config.mailersend_api_key or os.getenv(
  12. "MAILERSEND_API_KEY"
  13. )
  14. if not self.api_key or not isinstance(self.api_key, str):
  15. raise ValueError("A valid MailerSend API key is required.")
  16. self.from_email = config.from_email or os.getenv("R2R_FROM_EMAIL")
  17. if not self.from_email or not isinstance(self.from_email, str):
  18. raise ValueError("A valid from email is required.")
  19. self.frontend_url = config.frontend_url or os.getenv(
  20. "R2R_FRONTEND_URL"
  21. )
  22. if not self.frontend_url or not isinstance(self.frontend_url, str):
  23. raise ValueError("A valid frontend URL is required.")
  24. self.verify_email_template_id = (
  25. config.verify_email_template_id
  26. or os.getenv("MAILERSEND_VERIFY_EMAIL_TEMPLATE_ID")
  27. )
  28. self.reset_password_template_id = (
  29. config.reset_password_template_id
  30. or os.getenv("MAILERSEND_RESET_PASSWORD_TEMPLATE_ID")
  31. )
  32. self.password_changed_template_id = (
  33. config.password_changed_template_id
  34. or os.getenv("MAILERSEND_PASSWORD_CHANGED_TEMPLATE_ID")
  35. )
  36. self.client = emails.NewEmail(self.api_key)
  37. self.sender_name = config.sender_name or "R2R"
  38. # Logo and documentation URLs
  39. self.docs_base_url = f"{self.frontend_url}/documentation"
  40. def _get_base_template_data(self, to_email: str) -> dict:
  41. """Get base template data used across all email templates."""
  42. return {
  43. "user_email": to_email,
  44. "docs_url": self.docs_base_url,
  45. "quickstart_url": f"{self.docs_base_url}/quickstart",
  46. "frontend_url": self.frontend_url,
  47. }
  48. async def send_email(
  49. self,
  50. to_email: str,
  51. subject: Optional[str] = None,
  52. body: Optional[str] = None,
  53. html_body: Optional[str] = None,
  54. template_id: Optional[str] = None,
  55. dynamic_template_data: Optional[dict] = None,
  56. ) -> None:
  57. try:
  58. logger.info("Preparing MailerSend message...")
  59. mail_body = {
  60. "from": {
  61. "email": self.from_email,
  62. "name": self.sender_name,
  63. },
  64. "to": [{"email": to_email}],
  65. }
  66. if template_id:
  67. # Transform the template data to MailerSend's expected format
  68. if dynamic_template_data:
  69. formatted_substitutions = {}
  70. for key, value in dynamic_template_data.items():
  71. formatted_substitutions[key] = {
  72. "var": key,
  73. "value": value,
  74. }
  75. mail_body["variables"] = [
  76. {
  77. "email": to_email,
  78. "substitutions": formatted_substitutions,
  79. }
  80. ]
  81. mail_body["template_id"] = template_id
  82. else:
  83. mail_body.update(
  84. {
  85. "subject": subject or "",
  86. "text": body or "",
  87. "html": html_body or "",
  88. }
  89. )
  90. import asyncio
  91. response = await asyncio.to_thread(self.client.send, mail_body)
  92. # Handle different response formats
  93. if isinstance(response, str):
  94. # Clean the string response by stripping whitespace
  95. response_clean = response.strip()
  96. if response_clean in ["202", "200"]:
  97. logger.info(
  98. f"Email accepted for delivery with status code {response_clean}"
  99. )
  100. return
  101. elif isinstance(response, int) and response in [200, 202]:
  102. logger.info(
  103. f"Email accepted for delivery with status code {response}"
  104. )
  105. return
  106. elif isinstance(response, dict) and response.get(
  107. "status_code"
  108. ) in [200, 202]:
  109. logger.info(
  110. f"Email accepted for delivery with status code {response.get('status_code')}"
  111. )
  112. return
  113. # If we get here, it's an error
  114. error_msg = f"MailerSend error: {response}"
  115. logger.error(error_msg)
  116. except Exception as e:
  117. error_msg = f"Failed to send email to {to_email}: {str(e)}"
  118. logger.error(error_msg)
  119. async def send_verification_email(
  120. self,
  121. to_email: str,
  122. verification_code: str,
  123. dynamic_template_data: Optional[dict] = None,
  124. ) -> None:
  125. try:
  126. if self.verify_email_template_id:
  127. verification_data = {
  128. "verification_link": f"{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}",
  129. "verification_code": verification_code, # Include code separately for flexible template usage
  130. }
  131. # Merge with any additional template data
  132. template_data = {
  133. **(dynamic_template_data or {}),
  134. **verification_data,
  135. }
  136. await self.send_email(
  137. to_email=to_email,
  138. template_id=self.verify_email_template_id,
  139. dynamic_template_data=template_data,
  140. )
  141. else:
  142. # Fallback to basic email if no template ID is configured
  143. subject = "Verify Your R2R Account"
  144. html_body = f"""
  145. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  146. <h1>Welcome to R2R!</h1>
  147. <p>Please verify your email address to get started with R2R - the most advanced AI retrieval system.</p>
  148. <p>Click the link below to verify your email:</p>
  149. <p><a href="{self.frontend_url}/verify-email?verification_code={verification_code}&email={to_email}"
  150. style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
  151. Verify Email
  152. </a></p>
  153. <p>Or enter this verification code: <strong>{verification_code}</strong></p>
  154. <p>If you didn't create an account with R2R, please ignore this email.</p>
  155. </div>
  156. """
  157. await self.send_email(
  158. to_email=to_email,
  159. subject=subject,
  160. html_body=html_body,
  161. body=f"Welcome to R2R! Please verify your email using this code: {verification_code}",
  162. )
  163. except Exception as e:
  164. error_msg = (
  165. f"Failed to send verification email to {to_email}: {str(e)}"
  166. )
  167. logger.error(error_msg)
  168. async def send_password_reset_email(
  169. self,
  170. to_email: str,
  171. reset_token: str,
  172. dynamic_template_data: Optional[dict] = None,
  173. ) -> None:
  174. try:
  175. if self.reset_password_template_id:
  176. reset_data = {
  177. "reset_link": f"{self.frontend_url}/reset-password?token={reset_token}",
  178. "reset_token": reset_token,
  179. }
  180. template_data = {**(dynamic_template_data or {}), **reset_data}
  181. await self.send_email(
  182. to_email=to_email,
  183. template_id=self.reset_password_template_id,
  184. dynamic_template_data=template_data,
  185. )
  186. else:
  187. subject = "Reset Your R2R Password"
  188. html_body = f"""
  189. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  190. <h1>Password Reset Request</h1>
  191. <p>You've requested to reset your R2R password.</p>
  192. <p>Click the link below to reset your password:</p>
  193. <p><a href="{self.frontend_url}/reset-password?token={reset_token}"
  194. style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">
  195. Reset Password
  196. </a></p>
  197. <p>Or use this reset token: <strong>{reset_token}</strong></p>
  198. <p>If you didn't request a password reset, please ignore this email.</p>
  199. </div>
  200. """
  201. await self.send_email(
  202. to_email=to_email,
  203. subject=subject,
  204. html_body=html_body,
  205. body=f"Reset your R2R password using this token: {reset_token}",
  206. )
  207. except Exception as e:
  208. error_msg = (
  209. f"Failed to send password reset email to {to_email}: {str(e)}"
  210. )
  211. logger.error(error_msg)
  212. async def send_password_changed_email(
  213. self,
  214. to_email: str,
  215. dynamic_template_data: Optional[dict] = None,
  216. *args,
  217. **kwargs,
  218. ) -> None:
  219. try:
  220. if (
  221. hasattr(self, "password_changed_template_id")
  222. and self.password_changed_template_id
  223. ):
  224. await self.send_email(
  225. to_email=to_email,
  226. template_id=self.password_changed_template_id,
  227. dynamic_template_data=dynamic_template_data,
  228. )
  229. else:
  230. subject = "Your Password Has Been Changed"
  231. body = """
  232. Your password has been successfully changed.
  233. If you did not make this change, please contact support immediately and secure your account.
  234. """
  235. html_body = """
  236. <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  237. <h1>Password Changed Successfully</h1>
  238. <p>Your password has been successfully changed.</p>
  239. </div>
  240. """
  241. await self.send_email(
  242. to_email=to_email,
  243. subject=subject,
  244. html_body=html_body,
  245. body=body,
  246. )
  247. except Exception as e:
  248. error_msg = f"Failed to send password change notification to {to_email}: {str(e)}"
  249. logger.error(error_msg)
  250. raise RuntimeError(error_msg) from e