email_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. """Email service for sending authentication-related emails."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. import secrets
  6. import smtplib
  7. import string
  8. from email.mime.multipart import MIMEMultipart
  9. from email.mime.text import MIMEText
  10. from typing import Any
  11. from sqlalchemy import select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.models.notification_template import NotificationTemplate
  14. from backend.app.models.settings import Settings
  15. from backend.app.schemas.auth import SMTPSettings
  16. logger = logging.getLogger(__name__)
  17. def generate_secure_password(length: int = 16) -> str:
  18. """Generate a secure random password.
  19. Args:
  20. length: Length of the password (default: 16)
  21. Returns:
  22. A secure random password containing uppercase, lowercase, digits, and special characters
  23. """
  24. import random
  25. # Define character sets
  26. lowercase = string.ascii_lowercase
  27. uppercase = string.ascii_uppercase
  28. digits = string.digits
  29. special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
  30. # Ensure at least one character from each set
  31. password_chars = [
  32. secrets.choice(lowercase),
  33. secrets.choice(uppercase),
  34. secrets.choice(digits),
  35. secrets.choice(special),
  36. ]
  37. # Fill the rest with random characters from all sets
  38. all_chars = lowercase + uppercase + digits + special
  39. password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
  40. # Shuffle to avoid predictable patterns
  41. random.shuffle(password_chars)
  42. return "".join(password_chars)
  43. async def get_notification_template(db: AsyncSession, event_type: str) -> NotificationTemplate | None:
  44. """Get a notification template by event type from database.
  45. Args:
  46. db: Database session
  47. event_type: Type of event (e.g., 'user_created', 'password_reset')
  48. Returns:
  49. NotificationTemplate object or None if not found
  50. """
  51. result = await db.execute(
  52. select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
  53. )
  54. return result.scalar_one_or_none()
  55. def render_template(template_str: str, variables: dict[str, Any]) -> str:
  56. """Render a template string with variables.
  57. Args:
  58. template_str: Template string with {variable} placeholders
  59. variables: Dictionary of variables to substitute
  60. Returns:
  61. Rendered template string
  62. """
  63. result = template_str
  64. for key, value in variables.items():
  65. result = result.replace("{" + key + "}", str(value) if value is not None else "")
  66. # Remove any remaining unreplaced placeholders
  67. result = re.sub(r"\{[a-z_]+\}", "", result)
  68. return result
  69. async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
  70. """Get SMTP settings from database.
  71. Args:
  72. db: Database session
  73. Returns:
  74. SMTPSettings object or None if not configured
  75. """
  76. # Fetch all SMTP-related settings
  77. result = await db.execute(
  78. select(Settings).where(
  79. Settings.key.in_([
  80. "smtp_host",
  81. "smtp_port",
  82. "smtp_username",
  83. "smtp_password",
  84. "smtp_use_tls",
  85. "smtp_security",
  86. "smtp_auth_enabled",
  87. "smtp_from_email",
  88. "smtp_from_name",
  89. ])
  90. )
  91. )
  92. settings_dict = {s.key: s.value for s in result.scalars().all()}
  93. # Check if minimum required settings are present
  94. required_keys = ["smtp_host", "smtp_port", "smtp_from_email"]
  95. if not all(key in settings_dict for key in required_keys):
  96. return None
  97. # Handle migration: convert old smtp_use_tls to smtp_security if needed
  98. smtp_security = settings_dict.get("smtp_security")
  99. if not smtp_security:
  100. # Migrate from old smtp_use_tls format
  101. smtp_use_tls = settings_dict.get("smtp_use_tls", "true").lower() == "true"
  102. smtp_security = "starttls" if smtp_use_tls else "ssl"
  103. smtp_auth_enabled = settings_dict.get("smtp_auth_enabled", "true").lower() == "true"
  104. return SMTPSettings(
  105. smtp_host=settings_dict["smtp_host"],
  106. smtp_port=int(settings_dict["smtp_port"]),
  107. smtp_username=settings_dict.get("smtp_username"),
  108. smtp_password=settings_dict.get("smtp_password"),
  109. smtp_security=smtp_security,
  110. smtp_auth_enabled=smtp_auth_enabled,
  111. smtp_from_email=settings_dict["smtp_from_email"],
  112. smtp_from_name=settings_dict.get("smtp_from_name", "BamBuddy"),
  113. )
  114. async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:
  115. """Save SMTP settings to database.
  116. Args:
  117. db: Database session
  118. smtp_settings: SMTP settings to save
  119. """
  120. from sqlalchemy import func
  121. from sqlalchemy.dialects.sqlite import insert as sqlite_insert
  122. settings_data = {
  123. "smtp_host": smtp_settings.smtp_host,
  124. "smtp_port": str(smtp_settings.smtp_port),
  125. "smtp_security": smtp_settings.smtp_security,
  126. "smtp_auth_enabled": "true" if smtp_settings.smtp_auth_enabled else "false",
  127. "smtp_from_email": smtp_settings.smtp_from_email,
  128. "smtp_from_name": smtp_settings.smtp_from_name,
  129. }
  130. # Only save username if auth is enabled or if provided
  131. if smtp_settings.smtp_username:
  132. settings_data["smtp_username"] = smtp_settings.smtp_username
  133. # Only save password if provided
  134. if smtp_settings.smtp_password:
  135. settings_data["smtp_password"] = smtp_settings.smtp_password
  136. for key, value in settings_data.items():
  137. stmt = sqlite_insert(Settings).values(key=key, value=value)
  138. stmt = stmt.on_conflict_do_update(
  139. index_elements=["key"],
  140. set_={"value": value, "updated_at": func.now()},
  141. )
  142. await db.execute(stmt)
  143. def send_email(
  144. smtp_settings: SMTPSettings,
  145. to_email: str,
  146. subject: str,
  147. body_text: str,
  148. body_html: str | None = None,
  149. ) -> None:
  150. """Send an email using SMTP.
  151. Args:
  152. smtp_settings: SMTP configuration
  153. to_email: Recipient email address
  154. subject: Email subject
  155. body_text: Plain text body
  156. body_html: Optional HTML body
  157. Raises:
  158. Exception: If email sending fails
  159. """
  160. msg = MIMEMultipart("alternative")
  161. msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
  162. msg["To"] = to_email
  163. msg["Subject"] = subject
  164. # Attach plain text part
  165. msg.attach(MIMEText(body_text, "plain"))
  166. # Attach HTML part if provided
  167. if body_html:
  168. msg.attach(MIMEText(body_html, "html"))
  169. # Send email
  170. try:
  171. security = smtp_settings.smtp_security
  172. auth_enabled = smtp_settings.smtp_auth_enabled
  173. # Validate username is provided when authentication is enabled
  174. if auth_enabled and smtp_settings.smtp_password:
  175. if not smtp_settings.smtp_username:
  176. raise ValueError("SMTP username is required when authentication is enabled")
  177. if security == "ssl":
  178. # Direct SSL connection (typically port 465)
  179. with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  180. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  181. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  182. server.send_message(msg)
  183. elif security == "starttls":
  184. # STARTTLS upgrade (typically port 587)
  185. with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  186. server.starttls()
  187. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  188. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  189. server.send_message(msg)
  190. else:
  191. # No encryption (typically port 25) - use with caution
  192. with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  193. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  194. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  195. server.send_message(msg)
  196. logger.info(f"Email sent successfully to {to_email}")
  197. except Exception as e:
  198. logger.error(f"Failed to send email to {to_email}: {e}")
  199. raise
  200. def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
  201. """Create welcome email content for new user.
  202. Args:
  203. username: Username of the new user
  204. password: Auto-generated password
  205. login_url: URL to login page
  206. Returns:
  207. Tuple of (subject, text_body, html_body)
  208. """
  209. subject = "Welcome to BamBuddy - Your Account Details"
  210. text_body = f"""Welcome to BamBuddy!
  211. Your account has been created. Here are your login details:
  212. Username: {username}
  213. Password: {password}
  214. You can login at: {login_url}
  215. For security reasons, please change your password after your first login.
  216. Best regards,
  217. BamBuddy Team
  218. """
  219. html_body = f"""<!DOCTYPE html>
  220. <html>
  221. <head>
  222. <meta charset="utf-8">
  223. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  224. </head>
  225. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  226. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
  227. <h1 style="color: white; margin: 0; font-size: 24px;">Welcome to BamBuddy!</h1>
  228. </div>
  229. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  230. <p style="font-size: 16px;">Your account has been created. Here are your login details:</p>
  231. <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
  232. <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
  233. <p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
  234. </div>
  235. <div style="text-align: center; margin: 30px 0;">
  236. <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>
  237. </div>
  238. <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
  239. <strong>Security Note:</strong> For security reasons, please change your password after your first login.
  240. </p>
  241. <p style="font-size: 14px; color: #999; margin-top: 30px;">
  242. Best regards,<br>
  243. BamBuddy Team
  244. </p>
  245. </div>
  246. </body>
  247. </html>
  248. """
  249. return subject, text_body, html_body
  250. def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
  251. """Create password reset email content.
  252. Args:
  253. username: Username of the user
  254. password: New auto-generated password
  255. login_url: URL to login page
  256. Returns:
  257. Tuple of (subject, text_body, html_body)
  258. """
  259. subject = "BamBuddy - Your Password Has Been Reset"
  260. text_body = f"""Your BamBuddy password has been reset.
  261. Your login details:
  262. Username: {username}
  263. New Password: {password}
  264. You can login at: {login_url}
  265. For security reasons, please change your password after logging in.
  266. If you did not request this password reset, please contact your administrator immediately.
  267. Best regards,
  268. BamBuddy Team
  269. """
  270. html_body = f"""<!DOCTYPE html>
  271. <html>
  272. <head>
  273. <meta charset="utf-8">
  274. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  275. </head>
  276. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  277. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
  278. <h1 style="color: white; margin: 0; font-size: 24px;">Password Reset</h1>
  279. </div>
  280. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  281. <p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
  282. <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
  283. <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
  284. <p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
  285. </div>
  286. <div style="text-align: center; margin: 30px 0;">
  287. <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>
  288. </div>
  289. <div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
  290. <p style="margin: 0; font-size: 14px; color: #856404;">
  291. <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
  292. </p>
  293. </div>
  294. <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
  295. <strong>Security Note:</strong> For security reasons, please change your password after logging in.
  296. </p>
  297. <p style="font-size: 14px; color: #999; margin-top: 30px;">
  298. Best regards,<br>
  299. BamBuddy Team
  300. </p>
  301. </div>
  302. </body>
  303. </html>
  304. """
  305. return subject, text_body, html_body
  306. async def create_welcome_email_from_template(
  307. db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
  308. ) -> tuple[str, str, str]:
  309. """Create welcome email content using notification template from database.
  310. Args:
  311. db: Database session
  312. username: Username of the new user
  313. password: Auto-generated password
  314. login_url: URL to login page
  315. app_name: Application name (default: BamBuddy)
  316. Returns:
  317. Tuple of (subject, text_body, html_body)
  318. """
  319. # Try to get template from database
  320. template = await get_notification_template(db, "user_created")
  321. if template:
  322. # Render template with variables
  323. variables = {
  324. "app_name": app_name,
  325. "username": username,
  326. "password": password,
  327. "login_url": login_url,
  328. }
  329. subject = render_template(template.title_template, variables)
  330. text_body = render_template(template.body_template, variables)
  331. # Create HTML version with embedded login button
  332. html_body = f"""<!DOCTYPE html>
  333. <html>
  334. <head>
  335. <meta charset="utf-8">
  336. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  337. </head>
  338. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  339. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
  340. <h1 style="color: white; margin: 0; font-size: 24px;">{subject}</h1>
  341. </div>
  342. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  343. <div style="white-space: pre-wrap; font-size: 16px;">{text_body}</div>
  344. <div style="text-align: center; margin: 30px 0;">
  345. <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>
  346. </div>
  347. </div>
  348. </body>
  349. </html>
  350. """
  351. logger.info("Using custom welcome email template from database")
  352. return subject, text_body, html_body
  353. else:
  354. # Fallback to hardcoded template
  355. logger.warning("No welcome email template found in database, using default")
  356. return create_welcome_email(username, password, login_url)
  357. async def create_password_reset_email_from_template(
  358. db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
  359. ) -> tuple[str, str, str]:
  360. """Create password reset email content using notification template from database.
  361. Args:
  362. db: Database session
  363. username: Username of the user
  364. password: New auto-generated password
  365. login_url: URL to login page
  366. app_name: Application name (default: BamBuddy)
  367. Returns:
  368. Tuple of (subject, text_body, html_body)
  369. """
  370. # Try to get template from database
  371. template = await get_notification_template(db, "password_reset")
  372. if template:
  373. # Render template with variables
  374. variables = {
  375. "app_name": app_name,
  376. "username": username,
  377. "password": password,
  378. "login_url": login_url,
  379. }
  380. subject = render_template(template.title_template, variables)
  381. text_body = render_template(template.body_template, variables)
  382. # Create HTML version with embedded login button
  383. html_body = f"""<!DOCTYPE html>
  384. <html>
  385. <head>
  386. <meta charset="utf-8">
  387. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  388. </head>
  389. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  390. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
  391. <h1 style="color: white; margin: 0; font-size: 24px;">{subject}</h1>
  392. </div>
  393. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  394. <div style="white-space: pre-wrap; font-size: 16px;">{text_body}</div>
  395. <div style="text-align: center; margin: 30px 0;">
  396. <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>
  397. </div>
  398. <div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
  399. <p style="margin: 0; font-size: 14px; color: #856404;">
  400. <strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
  401. </p>
  402. </div>
  403. </div>
  404. </body>
  405. </html>
  406. """
  407. logger.info("Using custom password reset email template from database")
  408. return subject, text_body, html_body
  409. else:
  410. # Fallback to hardcoded template
  411. logger.warning("No password reset email template found in database, using default")
  412. return create_password_reset_email(username, password, login_url)