import smtplib import logging import os from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders from typing import Optional, Dict, Any, List from uuid import UUID from jinja2 import Environment, FileSystemLoader from sqlalchemy.orm import Session from app.core.settings import settings from app.core.crypto import decrypt_data logger = logging.getLogger(__name__) template_dir = os.path.join( os.path.dirname(os.path.dirname(__file__)), "templates", "emails" ) try: jinja_env = Environment(loader=FileSystemLoader(template_dir)) except Exception as e: logger.warning(f"Failed to initialize Jinja template environment: {e}") jinja_env = None def send_email( subject: str, recipient: str, body: Optional[str] = None, html_body: Optional[str] = None, template_name: Optional[str] = None, template_context: Optional[Dict[str, Any]] = None, db: Optional[Session] = None, tenant_id: Optional[UUID] = None, user_id: Optional[int] = None, attachments: Optional[List[Dict[str, Any]]] = None, ): """ Sends an email using the configured SMTP settings. In development, if SMTP is not configured, it logs the email. """ if template_name and template_context is not None and jinja_env: try: template = jinja_env.get_template(template_name) # asset_base_url is injected for every template so image URLs follow # the deployed frontend instead of being hardcoded. A call site can # override it by passing its own value in template_context. context = { "asset_base_url": settings.EMAIL_ASSET_BASE_URL, **template_context, } html_body = template.render(**context) if not body: body = f"Please view this email in an HTML-compatible client. Subject: {subject}" except Exception as e: logger.error(f"Failed to render email template {template_name}: {e}") return elif template_name and not jinja_env: logger.error("Jinja2 environment not initialized. Cannot render template '%s'", template_name) return if not body and not html_body: logger.error("Email must have either a body or html_body") return smtp_config = _resolve_smtp_config(db=db, tenant_id=tenant_id, user_id=user_id) logger.info( "SMTP source selected: %s (tenant_id=%s)", smtp_config.get("source", "unknown"), str(tenant_id) if tenant_id else "none", ) if not smtp_config["smtp_host"]: return try: _send_with_config( smtp_config=smtp_config, subject=subject, recipient=recipient, body=body, html_body=html_body, attachments=attachments, ) logger.info( "Email sent successfully to %s via %s SMTP", recipient, smtp_config.get("source", "unknown"), ) return except Exception as e: logger.error(f"Failed to send email to {recipient} via {smtp_config.get('source', 'unknown')} SMTP: {e}") fallback_config = None if smtp_config.get("source") == "user": fallback_config = _resolve_smtp_config(db=db, tenant_id=tenant_id, user_id=None) elif smtp_config.get("source") == "tenant": fallback_config = _resolve_smtp_config(db=None, tenant_id=None, user_id=None) if fallback_config and fallback_config.get("smtp_host"): logger.warning( "Retrying email delivery via fallback SMTP after %s SMTP failure (tenant_id=%s)", smtp_config.get("source", "unknown"), str(tenant_id) if tenant_id else "none", ) try: _send_with_config( smtp_config=fallback_config, subject=subject, recipient=recipient, body=body, html_body=html_body, attachments=attachments, ) logger.info( "Email sent successfully to %s via fallback SMTP after %s SMTP failure", recipient, smtp_config.get("source", "unknown"), ) return except Exception as fallback_error: logger.error( "Fallback SMTP also failed for %s: %s", recipient, fallback_error, ) def _send_with_config( smtp_config: Dict[str, Any], subject: str, recipient: str, body: Optional[str], html_body: Optional[str], attachments: Optional[List[Dict[str, Any]]] = None, ) -> None: msg = MIMEMultipart() msg["Subject"] = subject msg["From"] = smtp_config["mail_from"] msg["To"] = recipient if body or html_body: content_part = MIMEMultipart("alternative") if body: content_part.attach(MIMEText(body, "plain")) if html_body: content_part.attach(MIMEText(html_body, "html")) msg.attach(content_part) if attachments: for attachment in attachments: part = MIMEBase("application", "octet-stream") part.set_payload(attachment["content"]) encoders.encode_base64(part) part.add_header( "Content-Disposition", f"attachment; filename={attachment['filename']}", ) msg.attach(part) if smtp_config["smtp_secure"] or smtp_config["smtp_port"] == 465: with smtplib.SMTP_SSL(smtp_config["smtp_host"], smtp_config["smtp_port"]) as server: if smtp_config["smtp_user"] and smtp_config["smtp_password"]: server.login(smtp_config["smtp_user"], smtp_config["smtp_password"]) server.send_message(msg) else: with smtplib.SMTP(smtp_config["smtp_host"], smtp_config["smtp_port"]) as server: if smtp_config["smtp_user"] and smtp_config["smtp_password"]: server.starttls() server.login(smtp_config["smtp_user"], smtp_config["smtp_password"]) server.send_message(msg) def _resolve_smtp_config( db: Optional[Session] = None, tenant_id: Optional[UUID] = None, user_id: Optional[int] = None, ) -> Dict[str, Any]: fallback = { "source": "fallback", "smtp_host": settings.SMTP_HOST, "smtp_port": settings.SMTP_PORT or 587, "smtp_user": settings.SMTP_USER, "smtp_password": settings.SMTP_PASSWORD, "smtp_secure": settings.SMTP_SECURE, "mail_from": settings.MAIL_FROM, } close_db = False if db is None and (tenant_id is not None or user_id is not None): try: from app.db.database import SessionLocal db = SessionLocal() close_db = True except Exception as err: logger.error("Failed to create temporary DB session for SMTP resolution: %s", err) try: base_config = fallback if db is not None and tenant_id is not None: try: from app.modules.tenant.repositories.tenant_smtp_repository import TenantSMTPRepository cfg = TenantSMTPRepository(db).get_by_tenant_id(tenant_id) if cfg and cfg.is_active: base_config = { "source": "tenant", "smtp_host": cfg.smtp_host, "smtp_port": cfg.smtp_port, "smtp_user": cfg.smtp_user, "smtp_password": ( decrypt_data(cfg.encrypted_smtp_password) if cfg.encrypted_smtp_password else None ), "smtp_secure": cfg.smtp_secure, "mail_from": cfg.mail_from, } except Exception as e: logger.error( "SMTP config lookup failed for tenant_id=%s, using fallback SMTP: %s", tenant_id, e, ) if db is None or user_id is None: return base_config try: from app.modules.auth.models.user_model import User user = db.query(User).filter(User.id == user_id).first() if not user or not user.smtp_user or not user.encrypted_smtp_password: return base_config return { "source": "user", "smtp_host": base_config["smtp_host"], "smtp_port": base_config["smtp_port"], "smtp_user": user.smtp_user, "smtp_password": decrypt_data(user.encrypted_smtp_password), "smtp_secure": base_config["smtp_secure"], "mail_from": user.mail_from or base_config["mail_from"], } except Exception as e: logger.error( "User SMTP credential lookup failed for user_id=%s, using base SMTP: %s", user_id, e, ) return base_config finally: if close_db and db is not None: db.close()