251 lines
8.8 KiB
Python
251 lines
8.8 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)
|
|
html_body = template.render(**template_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()
|