email_service.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. """Email service for sending authentication-related emails."""
  2. from __future__ import annotations
  3. import html
  4. import logging
  5. import re
  6. import secrets
  7. import smtplib
  8. import string
  9. from datetime import datetime, timezone
  10. from email.mime.image import MIMEImage
  11. from email.mime.multipart import MIMEMultipart
  12. from email.mime.text import MIMEText
  13. from email.utils import formataddr, formatdate, make_msgid, parseaddr
  14. from typing import Any
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.models.notification_template import NotificationTemplate
  18. from backend.app.models.settings import Settings
  19. from backend.app.schemas.auth import SMTPSettings
  20. logger = logging.getLogger(__name__)
  21. def generate_secure_password(length: int = 16) -> str:
  22. """Generate a secure random password.
  23. Args:
  24. length: Length of the password (default: 16)
  25. Returns:
  26. A secure random password containing uppercase, lowercase, digits, and special characters
  27. """
  28. # Define character sets
  29. lowercase = string.ascii_lowercase
  30. uppercase = string.ascii_uppercase
  31. digits = string.digits
  32. special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
  33. # Ensure at least one character from each set
  34. password_chars = [
  35. secrets.choice(lowercase),
  36. secrets.choice(uppercase),
  37. secrets.choice(digits),
  38. secrets.choice(special),
  39. ]
  40. # Fill the rest with random characters from all sets
  41. all_chars = lowercase + uppercase + digits + special
  42. password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
  43. # Shuffle with CSPRNG — random.shuffle() is seeded from time and not cryptographically safe
  44. secrets.SystemRandom().shuffle(password_chars)
  45. return "".join(password_chars)
  46. async def get_notification_template(db: AsyncSession, event_type: str) -> NotificationTemplate | None:
  47. """Get a notification template by event type from database.
  48. Args:
  49. db: Database session
  50. event_type: Type of event (e.g., 'user_created', 'password_reset')
  51. Returns:
  52. NotificationTemplate object or None if not found
  53. """
  54. result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
  55. return result.scalar_one_or_none()
  56. def render_template(template_str: str, variables: dict[str, Any]) -> str:
  57. """Render a template string with variables.
  58. Args:
  59. template_str: Template string with {variable} placeholders
  60. variables: Dictionary of variables to substitute
  61. Returns:
  62. Rendered template string
  63. """
  64. result = template_str
  65. for key, value in variables.items():
  66. result = result.replace("{" + key + "}", str(value) if value is not None else "")
  67. # Remove any remaining unreplaced placeholders (case-insensitive, alphanumeric + underscore)
  68. result = re.sub(r"\{[a-zA-Z0-9_]+\}", "", result)
  69. return result
  70. async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
  71. """Get SMTP settings from database.
  72. Args:
  73. db: Database session
  74. Returns:
  75. SMTPSettings object or None if not configured
  76. """
  77. # Fetch all SMTP-related settings
  78. result = await db.execute(
  79. select(Settings).where(
  80. Settings.key.in_(
  81. [
  82. "smtp_host",
  83. "smtp_port",
  84. "smtp_username",
  85. "smtp_password",
  86. "smtp_use_tls",
  87. "smtp_security",
  88. "smtp_auth_enabled",
  89. "smtp_from_email",
  90. "smtp_from_name",
  91. ]
  92. )
  93. )
  94. )
  95. settings_dict = {s.key: s.value for s in result.scalars().all()}
  96. # Check if minimum required settings are present
  97. required_keys = ["smtp_host", "smtp_port", "smtp_from_email"]
  98. if not all(key in settings_dict for key in required_keys):
  99. return None
  100. # Handle migration: convert old smtp_use_tls to smtp_security if needed
  101. smtp_security = settings_dict.get("smtp_security")
  102. if not smtp_security:
  103. # Migrate from old smtp_use_tls format
  104. smtp_use_tls = settings_dict.get("smtp_use_tls", "true").lower() == "true"
  105. smtp_security = "starttls" if smtp_use_tls else "ssl"
  106. smtp_auth_enabled = settings_dict.get("smtp_auth_enabled", "true").lower() == "true"
  107. return SMTPSettings(
  108. smtp_host=settings_dict["smtp_host"],
  109. smtp_port=int(settings_dict["smtp_port"]),
  110. smtp_username=settings_dict.get("smtp_username"),
  111. smtp_password=settings_dict.get("smtp_password"),
  112. smtp_security=smtp_security,
  113. smtp_auth_enabled=smtp_auth_enabled,
  114. smtp_from_email=settings_dict["smtp_from_email"],
  115. smtp_from_name=settings_dict.get("smtp_from_name", "BamBuddy"),
  116. )
  117. async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:
  118. """Save SMTP settings to database.
  119. Args:
  120. db: Database session
  121. smtp_settings: SMTP settings to save
  122. """
  123. from backend.app.core.db_dialect import upsert_setting
  124. settings_data = {
  125. "smtp_host": smtp_settings.smtp_host,
  126. "smtp_port": str(smtp_settings.smtp_port),
  127. "smtp_security": smtp_settings.smtp_security,
  128. "smtp_auth_enabled": "true" if smtp_settings.smtp_auth_enabled else "false",
  129. "smtp_from_email": smtp_settings.smtp_from_email,
  130. "smtp_from_name": smtp_settings.smtp_from_name,
  131. }
  132. # Only save username if auth is enabled or if provided
  133. if smtp_settings.smtp_username:
  134. settings_data["smtp_username"] = smtp_settings.smtp_username
  135. # Only save password if provided
  136. if smtp_settings.smtp_password:
  137. settings_data["smtp_password"] = smtp_settings.smtp_password
  138. for key, value in settings_data.items():
  139. await upsert_setting(db, Settings, key, value)
  140. def add_required_headers(msg: MIMEMultipart, from_email: str) -> None:
  141. """Add the Date and Message-ID headers RFC 5322 requires (#3376).
  142. Neither smtplib nor the MIME classes add them, and strict receivers
  143. (amavis's bad_header policy) quarantine mail without a Date after the
  144. SMTP server already accepted it. The Message-ID domain comes from the
  145. sender address; without one, make_msgid() would look up the container's
  146. hostname, which can block on DNS and leaks an internal name.
  147. """
  148. _local, at, domain = parseaddr(from_email)[1].rpartition("@")
  149. if not at or not domain:
  150. domain = "bambuddy.local"
  151. if "Date" not in msg:
  152. msg["Date"] = formatdate(localtime=True)
  153. if "Message-ID" not in msg:
  154. msg["Message-ID"] = make_msgid(domain=domain)
  155. def send_email(
  156. smtp_settings: SMTPSettings,
  157. to_email: str,
  158. subject: str,
  159. body_text: str,
  160. body_html: str | None = None,
  161. image_data: bytes | None = None,
  162. image_cid: str = "bambuddy-inline-photo",
  163. ) -> None:
  164. """Send an email using SMTP.
  165. Args:
  166. smtp_settings: SMTP configuration
  167. to_email: Recipient email address
  168. subject: Email subject
  169. body_text: Plain text body
  170. body_html: Optional HTML body
  171. image_data: Optional JPEG bytes to embed inline. The caller is
  172. responsible for putting a matching ``<img src="cid:{image_cid}">``
  173. in body_html — this just attaches the bytes under that Content-ID.
  174. image_cid: Content-ID the embedded image is referenced by in body_html.
  175. Raises:
  176. Exception: If email sending fails
  177. """
  178. if image_data and body_html:
  179. # multipart/related wraps multipart/alternative so the inline image
  180. # travels with the HTML part without becoming a visible attachment.
  181. msg = MIMEMultipart("related")
  182. msg["From"] = formataddr((smtp_settings.smtp_from_name, smtp_settings.smtp_from_email))
  183. msg["To"] = to_email
  184. msg["Subject"] = subject
  185. alt = MIMEMultipart("alternative")
  186. alt.attach(MIMEText(body_text, "plain"))
  187. alt.attach(MIMEText(body_html, "html"))
  188. msg.attach(alt)
  189. img = MIMEImage(image_data, _subtype="jpeg")
  190. img.add_header("Content-ID", f"<{image_cid}>")
  191. img.add_header("Content-Disposition", "inline", filename="photo.jpg")
  192. msg.attach(img)
  193. else:
  194. msg = MIMEMultipart("alternative")
  195. msg["From"] = formataddr((smtp_settings.smtp_from_name, smtp_settings.smtp_from_email))
  196. msg["To"] = to_email
  197. msg["Subject"] = subject
  198. # Attach plain text part
  199. msg.attach(MIMEText(body_text, "plain"))
  200. # Attach HTML part if provided
  201. if body_html:
  202. msg.attach(MIMEText(body_html, "html"))
  203. add_required_headers(msg, smtp_settings.smtp_from_email)
  204. # Send email
  205. try:
  206. security = smtp_settings.smtp_security
  207. auth_enabled = smtp_settings.smtp_auth_enabled
  208. # Validate username is provided when authentication is enabled
  209. if auth_enabled and smtp_settings.smtp_password:
  210. if not smtp_settings.smtp_username:
  211. raise ValueError("SMTP username is required when authentication is enabled")
  212. if security == "ssl":
  213. # Direct SSL connection (typically port 465)
  214. with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  215. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  216. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  217. server.send_message(msg)
  218. elif security == "starttls":
  219. # STARTTLS upgrade (typically port 587)
  220. with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  221. server.starttls()
  222. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  223. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  224. server.send_message(msg)
  225. else:
  226. # No encryption (typically port 25) - use with caution
  227. with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
  228. if auth_enabled and smtp_settings.smtp_password and smtp_settings.smtp_username:
  229. server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
  230. server.send_message(msg)
  231. logger.info(f"Email accepted by SMTP server for {to_email}")
  232. except Exception as e:
  233. logger.error(f"Failed to send email to {to_email}: {e}")
  234. raise
  235. def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
  236. """Create welcome email content for new user.
  237. Args:
  238. username: Username of the new user
  239. password: Auto-generated password
  240. login_url: URL to login page
  241. Returns:
  242. Tuple of (subject, text_body, html_body)
  243. """
  244. subject = "Welcome to BamBuddy - Your Account Details"
  245. text_body = f"""Welcome to BamBuddy!
  246. Your account has been created. Here are your login details:
  247. Username: {username}
  248. Password: {password}
  249. You can login at: {login_url}
  250. For security reasons, please change your password after your first login.
  251. Best regards,
  252. BamBuddy Team
  253. """
  254. html_body = f"""<!DOCTYPE html>
  255. <html>
  256. <head>
  257. <meta charset="utf-8">
  258. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  259. </head>
  260. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  261. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
  262. <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">Welcome to BamBuddy!</h1>
  263. </div>
  264. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  265. <p style="font-size: 16px;">Your account has been created. Here are your login details:</p>
  266. <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
  267. <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
  268. <p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
  269. </div>
  270. <div style="text-align: center; margin: 30px 0;">
  271. <a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
  272. </div>
  273. <p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
  274. <strong>Security Note:</strong> For security reasons, please change your password after your first login.
  275. </p>
  276. <p style="font-size: 14px; color: #999; margin-top: 30px;">
  277. Best regards,<br>
  278. BamBuddy Team
  279. </p>
  280. </div>
  281. </body>
  282. </html>
  283. """
  284. return subject, text_body, html_body
  285. def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
  286. """Create a password-reset email that contains a secure link (not a plaintext password)."""
  287. subject = "BamBuddy - Password Reset Request"
  288. text_body = f"""A password reset was requested for your BamBuddy account.
  289. Username: {username}
  290. Click the link below to set a new password (valid for 1 hour):
  291. {reset_url}
  292. If you did not request this reset, you can safely ignore this email.
  293. Best regards,
  294. BamBuddy Team
  295. """
  296. html_body = f"""<!DOCTYPE html>
  297. <html>
  298. <head>
  299. <meta charset="utf-8">
  300. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  301. </head>
  302. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  303. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
  304. <h1 style="color: #ffffff; margin: 0; font-size: 24px;">Password Reset Request</h1>
  305. </div>
  306. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  307. <p style="font-size: 16px;">A password reset was requested for your BamBuddy account (<strong>{username}</strong>).</p>
  308. <p>Click the button below to set a new password. This link is valid for <strong>1 hour</strong>.</p>
  309. <div style="text-align: center; margin: 30px 0;">
  310. <a href="{reset_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
  311. </div>
  312. <div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
  313. <p style="margin: 0; font-size: 14px; color: #856404;">
  314. <strong>Did not request this?</strong> You can safely ignore this email. Your password has not been changed.
  315. </p>
  316. </div>
  317. <p style="font-size: 14px; color: #999; margin-top: 30px;">
  318. Best regards,<br>BamBuddy Team
  319. </p>
  320. </div>
  321. </body>
  322. </html>
  323. """
  324. return subject, text_body, html_body
  325. async def create_password_reset_link_email_from_template(
  326. db: AsyncSession, username: str, reset_url: str, app_name: str = "BamBuddy"
  327. ) -> tuple[str, str, str]:
  328. """Create the password-reset link email from the "password_reset" template.
  329. The link is never a template variable: the code adds it, on a line of its
  330. own at the end and as the button, so the template's text cannot change it.
  331. A {reset_url} written into the template renders as nothing. No template:
  332. the built-in email.
  333. Returns:
  334. Tuple of (subject, text_body, html_body)
  335. """
  336. template = await get_notification_template(db, "password_reset")
  337. if not template:
  338. return create_password_reset_link_email(username, reset_url)
  339. variables = {
  340. "app_name": app_name,
  341. "username": username,
  342. "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
  343. }
  344. subject = render_template(template.title_template, variables)
  345. text_body = f"{render_template(template.body_template, variables).rstrip()}\n\n{reset_url}"
  346. escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
  347. html_body = f"""<!DOCTYPE html>
  348. <html>
  349. <head>
  350. <meta charset="utf-8">
  351. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  352. </head>
  353. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  354. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
  355. <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
  356. </div>
  357. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  358. <div style="font-size: 16px;">{escaped_text_body}</div>
  359. <div style="text-align: center; margin: 30px 0;">
  360. <a href="{html.escape(reset_url)}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
  361. </div>
  362. </div>
  363. </body>
  364. </html>
  365. """
  366. return subject, text_body, html_body
  367. async def create_welcome_email_from_template(
  368. db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
  369. ) -> tuple[str, str, str]:
  370. """Create welcome email content using notification template from database.
  371. The password is never a template variable: Bambuddy adds the login details
  372. itself, at the end of the text and in a box in the HTML, so the template's
  373. text cannot change them. A {password} in the template renders as nothing.
  374. Args:
  375. db: Database session
  376. username: Username of the new user
  377. password: Auto-generated password
  378. login_url: URL to login page
  379. app_name: Application name (default: BamBuddy)
  380. Returns:
  381. Tuple of (subject, text_body, html_body)
  382. """
  383. # Try to get template from database
  384. template = await get_notification_template(db, "user_created")
  385. if template:
  386. # Render template with variables
  387. variables = {
  388. "app_name": app_name,
  389. "username": username,
  390. "login_url": login_url,
  391. "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
  392. }
  393. subject = render_template(template.title_template, variables)
  394. body = render_template(template.body_template, variables).rstrip()
  395. text_body = f"{body}\n\nUsername: {username}\nPassword: {password}"
  396. # Create HTML version with embedded login button
  397. # Escape the body to prevent XSS vulnerabilities and convert newlines to <br> tags
  398. escaped_body = html.escape(body).replace("\n", "<br>\n")
  399. html_body = f"""<!DOCTYPE html>
  400. <html>
  401. <head>
  402. <meta charset="utf-8">
  403. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  404. </head>
  405. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  406. <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
  407. <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
  408. </div>
  409. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  410. <div style="font-size: 16px;">{escaped_body}</div>
  411. <div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
  412. <p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{html.escape(username)}</code></p>
  413. <p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{html.escape(password)}</code></p>
  414. </div>
  415. <div style="text-align: center; margin: 30px 0;">
  416. <a href="{html.escape(login_url)}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
  417. </div>
  418. </div>
  419. </body>
  420. </html>
  421. """
  422. logger.info("Using custom welcome email template from database")
  423. return subject, text_body, html_body
  424. else:
  425. # Fallback to hardcoded template
  426. logger.warning("No welcome email template found in database, using default")
  427. return create_welcome_email(username, password, login_url)
  428. async def send_user_print_notification(
  429. db: AsyncSession,
  430. event_type: str,
  431. user_email: str,
  432. username: str,
  433. variables: dict,
  434. image_data: bytes | None = None,
  435. ) -> None:
  436. """Send a print notification email to a user using Advanced Auth SMTP settings.
  437. Args:
  438. db: Database session
  439. event_type: One of 'user_print_start', 'user_print_complete', 'user_print_failed', 'user_print_stopped'
  440. user_email: Recipient email address
  441. username: Username of the recipient
  442. variables: Template variables (printer, filename, etc.)
  443. image_data: Camera snapshot bytes, if one was captured for the event.
  444. Inlined only when the template explicitly references
  445. {finish_photo_url} — same opt-in as the provider-based email path
  446. (#1792), so a user who never asked for a photo never gets one.
  447. """
  448. # Check that advanced auth is enabled (SMTP settings must be configured)
  449. smtp_settings = await get_smtp_settings(db)
  450. if not smtp_settings:
  451. logger.warning("Cannot send user print notification: SMTP settings not configured")
  452. return
  453. # Get the template
  454. template = await get_notification_template(db, event_type)
  455. if template is None:
  456. logger.warning("No template found for event type: %s", event_type)
  457. return
  458. # Add common variables (username, timestamp, app_name) merged with caller-supplied variables
  459. all_variables = {
  460. "username": username,
  461. "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
  462. "app_name": "Bambuddy",
  463. **variables,
  464. }
  465. subject = render_template(template.title_template, all_variables)
  466. text_body = render_template(template.body_template, all_variables)
  467. finish_photo_url = variables.get("finish_photo_url")
  468. inline_photo = bool(image_data and finish_photo_url and finish_photo_url in text_body)
  469. # Build HTML body — content comes entirely from the database template
  470. escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
  471. if inline_photo:
  472. escaped_url = html.escape(finish_photo_url)
  473. img_tag = (
  474. '<img src="cid:bambuddy-user-print-photo" '
  475. 'alt="Printer camera snapshot" '
  476. 'style="max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;">'
  477. )
  478. escaped_text_body = escaped_text_body.replace(escaped_url, img_tag)
  479. html_body = f"""<!DOCTYPE html>
  480. <html>
  481. <head>
  482. <meta charset="utf-8">
  483. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  484. </head>
  485. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
  486. <div style="background: linear-gradient(135deg, #1db954 0%, #158a3e 100%); background-color: #1db954; padding: 20px; border-radius: 8px 8px 0 0;">
  487. <h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
  488. </div>
  489. <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
  490. <div style="font-size: 16px;">{escaped_text_body}</div>
  491. </div>
  492. </body>
  493. </html>
  494. """
  495. try:
  496. send_email(
  497. smtp_settings,
  498. user_email,
  499. subject,
  500. text_body,
  501. html_body,
  502. image_data=image_data if inline_photo else None,
  503. image_cid="bambuddy-user-print-photo",
  504. )
  505. logger.info("Sent %s notification email to %s", event_type, user_email)
  506. except Exception as e:
  507. logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e)