email_service.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """Email service for sending authentication-related emails."""
  2. from __future__ import annotations
  3. import logging
  4. import secrets
  5. import smtplib
  6. import string
  7. from email.mime.multipart import MIMEMultipart
  8. from email.mime.text import MIMEText
  9. from sqlalchemy import select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.models.settings import Settings
  12. from backend.app.schemas.auth import SMTPSettings
  13. logger = logging.getLogger(__name__)
  14. def generate_secure_password(length: int = 16) -> str:
  15. """Generate a secure random password.
  16. Args:
  17. length: Length of the password (default: 16)
  18. Returns:
  19. A secure random password containing uppercase, lowercase, digits, and special characters
  20. """
  21. import random
  22. # Define character sets
  23. lowercase = string.ascii_lowercase
  24. uppercase = string.ascii_uppercase
  25. digits = string.digits
  26. special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
  27. # Ensure at least one character from each set
  28. password_chars = [
  29. secrets.choice(lowercase),
  30. secrets.choice(uppercase),
  31. secrets.choice(digits),
  32. secrets.choice(special),
  33. ]
  34. # Fill the rest with random characters from all sets
  35. all_chars = lowercase + uppercase + digits + special
  36. password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
  37. # Shuffle to avoid predictable patterns
  38. random.shuffle(password_chars)
  39. return "".join(password_chars)
  40. async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
  41. """Get SMTP settings from database.
  42. Args:
  43. db: Database session
  44. Returns:
  45. SMTPSettings object or None if not configured
  46. """
  47. # Fetch all SMTP-related settings
  48. result = await db.execute(
  49. select(Settings).where(
  50. Settings.key.in_([
  51. "smtp_host",
  52. "smtp_port",
  53. "smtp_username",
  54. "smtp_password",
  55. "smtp_use_tls",
  56. "smtp_security",
  57. "smtp_auth_enabled",
  58. "smtp_from_email",
  59. "smtp_from_name",
  60. ])
  61. )
  62. )
  63. settings_dict = {s.key: s.value for s in result.scalars().all()}
  64. # Check if minimum required settings are present
  65. required_keys = ["smtp_host", "smtp_port", "smtp_from_email"]
  66. if not all(key in settings_dict for key in required_keys):
  67. return None
  68. # Handle migration: convert old smtp_use_tls to smtp_security if needed
  69. smtp_security = settings_dict.get("smtp_security")
  70. if not smtp_security:
  71. # Migrate from old smtp_use_tls format
  72. smtp_use_tls = settings_dict.get("smtp_use_tls", "true").lower() == "true"
  73. smtp_security = "starttls" if smtp_use_tls else "ssl"
  74. smtp_auth_enabled = settings_dict.get("smtp_auth_enabled", "true").lower() == "true"
  75. return SMTPSettings(
  76. smtp_host=settings_dict["smtp_host"],
  77. smtp_port=int(settings_dict["smtp_port"]),
  78. smtp_username=settings_dict.get("smtp_username"),
  79. smtp_password=settings_dict.get("smtp_password"),
  80. smtp_security=smtp_security,
  81. smtp_auth_enabled=smtp_auth_enabled,
  82. smtp_from_email=settings_dict["smtp_from_email"],
  83. smtp_from_name=settings_dict.get("smtp_from_name", "BamBuddy"),
  84. )
  85. async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:
  86. """Save SMTP settings to database.
  87. Args:
  88. db: Database session
  89. smtp_settings: SMTP settings to save
  90. """
  91. from sqlalchemy import func
  92. from sqlalchemy.dialects.sqlite import insert as sqlite_insert
  93. settings_data = {
  94. "smtp_host": smtp_settings.smtp_host,
  95. "smtp_port": str(smtp_settings.smtp_port),
  96. "smtp_security": smtp_settings.smtp_security,
  97. "smtp_auth_enabled": "true" if smtp_settings.smtp_auth_enabled else "false",
  98. "smtp_from_email": smtp_settings.smtp_from_email,
  99. "smtp_from_name": smtp_settings.smtp_from_name,
  100. }
  101. # Only save username if auth is enabled or if provided
  102. if smtp_settings.smtp_username:
  103. settings_data["smtp_username"] = smtp_settings.smtp_username
  104. # Only save password if provided
  105. if smtp_settings.smtp_password:
  106. settings_data["smtp_password"] = smtp_settings.smtp_password
  107. for key, value in settings_data.items():
  108. stmt = sqlite_insert(Settings).values(key=key, value=value)
  109. stmt = stmt.on_conflict_do_update(
  110. index_elements=["key"],
  111. set_={"value": value, "updated_at": func.now()},
  112. )
  113. await db.execute(stmt)
  114. def send_email(
  115. smtp_settings: SMTPSettings,
  116. to_email: str,
  117. subject: str,
  118. body_text: str,
  119. body_html: str | None = None,
  120. ) -> None:
  121. """Send an email using SMTP.
  122. Args:
  123. smtp_settings: SMTP configuration
  124. to_email: Recipient email address
  125. subject: Email subject
  126. body_text: Plain text body
  127. body_html: Optional HTML body
  128. Raises:
  129. Exception: If email sending fails
  130. """
  131. msg = MIMEMultipart("alternative")
  132. msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
  133. msg["To"] = to_email
  134. msg["Subject"] = subject
  135. # Attach plain text part
  136. msg.attach(MIMEText(body_text, "plain"))
  137. # Attach HTML part if provided
  138. if body_html:
  139. msg.attach(MIMEText(body_html, "html"))
  140. # Send email
  141. try:
  142. security = smtp_settings.smtp_security
  143. auth_enabled = smtp_settings.smtp_auth_enabled
  144. # Validate username is provided when authentication is enabled
  145. if auth_enabled and smtp_settings.smtp_password:
  146. if not smtp_settings.smtp_username:
  147. raise ValueError("SMTP username is required when authentication is enabled")
  148. if security == "ssl":
  149. # Direct SSL connection (typically port 465)
  150. with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  151. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  152. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  153. server.send_message(msg)
  154. elif security == "starttls":
  155. # STARTTLS upgrade (typically port 587)
  156. with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  157. server.starttls()
  158. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  159. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  160. server.send_message(msg)
  161. else:
  162. # No encryption (typically port 25) - use with caution
  163. with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  164. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  165. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  166. server.send_message(msg)
  167. logger.info(f"Email sent successfully to {to_email}")
  168. except Exception as e:
  169. logger.error(f"Failed to send email to {to_email}: {e}")
  170. raise
  171. def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
  172. """Create welcome email content for new user.
  173. Args:
  174. username: Username of the new user
  175. password: Auto-generated password
  176. login_url: URL to login page
  177. Returns:
  178. Tuple of (subject, text_body, html_body)
  179. """
  180. subject = "Welcome to BamBuddy - Your Account Details"
  181. text_body = f"""Welcome to BamBuddy!
  182. Your account has been created. Here are your login details:
  183. Username: {username}
  184. Password: {password}
  185. You can login at: {login_url}
  186. For security reasons, please change your password after your first login.
  187. Best regards,
  188. BamBuddy Team
  189. """
  190. html_body = f"""<!DOCTYPE html>
  191. <html>
  192. <head>
  193. <meta charset="utf-8">
  194. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  195. </head>
  196. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  197. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
  198. <h1 style="color: white; margin: 0; font-size: 24px;">Welcome to BamBuddy!</h1>
  199. </div>
  200. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  201. <p style="font-size: 16px;">Your account has been created. Here are your login details:</p>
  202. <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
  203. <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
  204. <p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
  205. </div>
  206. <div style="text-align: center; margin: 30px 0;">
  207. <a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
  208. </div>
  209. <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
  210. <strong>Security Note:</strong> For security reasons, please change your password after your first login.
  211. </p>
  212. <p style="font-size: 14px; color: #999; margin-top: 30px;">
  213. Best regards,<br>
  214. BamBuddy Team
  215. </p>
  216. </div>
  217. </body>
  218. </html>
  219. """
  220. return subject, text_body, html_body
  221. def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
  222. """Create password reset email content.
  223. Args:
  224. username: Username of the user
  225. password: New auto-generated password
  226. login_url: URL to login page
  227. Returns:
  228. Tuple of (subject, text_body, html_body)
  229. """
  230. subject = "BamBuddy - Your Password Has Been Reset"
  231. text_body = f"""Your BamBuddy password has been reset.
  232. Your login details:
  233. Username: {username}
  234. New Password: {password}
  235. You can login at: {login_url}
  236. For security reasons, please change your password after logging in.
  237. If you did not request this password reset, please contact your administrator immediately.
  238. Best regards,
  239. BamBuddy Team
  240. """
  241. html_body = f"""<!DOCTYPE html>
  242. <html>
  243. <head>
  244. <meta charset="utf-8">
  245. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  246. </head>
  247. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  248. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
  249. <h1 style="color: white; margin: 0; font-size: 24px;">Password Reset</h1>
  250. </div>
  251. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  252. <p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
  253. <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
  254. <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
  255. <p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
  256. </div>
  257. <div style="text-align: center; margin: 30px 0;">
  258. <a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
  259. </div>
  260. <div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
  261. <p style="margin: 0; font-size: 14px; color: #856404;">
  262. <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
  263. </p>
  264. </div>
  265. <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
  266. <strong>Security Note:</strong> For security reasons, please change your password after logging in.
  267. </p>
  268. <p style="font-size: 14px; color: #999; margin-top: 30px;">
  269. Best regards,<br>
  270. BamBuddy Team
  271. </p>
  272. </div>
  273. </body>
  274. </html>
  275. """
  276. return subject, text_body, html_body