Files
docqube_backend/app/core/mail.py
T
2026-09-08 15:10:59 +05:30

258 lines
9.2 KiB
Python

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()