Files
service-finder/backend/app/services/email_manager.py

257 lines
12 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/services/email_manager.py
import os
import smtplib
import logging
import requests
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.i18n import locale_manager
from app.services.config_service import config
from app.db.session import AsyncSessionLocal
logger = logging.getLogger("Email-Manager-2.0")
class EmailManager:
@staticmethod
def _get_html_template(template_key: str, variables: dict, lang: str = "hu") -> str:
"""HTML sablon generálása a fordítási fájlok alapján.
The caller MUST provide a 'link' key in variables. No fallback guessing.
"""
greeting = locale_manager.get(f"email.{template_key}_greeting", lang=lang, **variables)
body = locale_manager.get(f"email.{template_key}_body", lang=lang, **variables)
button_text = locale_manager.get(f"email.{template_key}_button", lang=lang)
footer = locale_manager.get(f"email.{template_key}_footer", lang=lang)
link_fallback_text = locale_manager.get("email.link_fallback", lang=lang)
# Strict: only use variables.get('link'), no guessing
link = variables.get('link')
if not link:
logger.error(f"No 'link' variable provided for template '{template_key}' (lang={lang})")
return f"""
<html>
<body style="font-family: Arial, sans-serif; color: #333; line-height: 1.6;">
<div style="max-width: 600px; margin: 0 auto; border: 1px solid #ddd; padding: 30px; border-radius: 10px;">
<h2 style="color: #2c3e50;">{greeting}</h2>
<p>{body}</p>
<div style="text-align: center; margin: 40px 0;">
<a href="{link or '#'}"
style="background-color: #3498db; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 16px;">
{button_text}
</a>
</div>
<p style="font-size: 0.85em; color: #777; word-break: break-all;">
{link_fallback_text}<br>
<a href="{link or '#'}" style="color: #3498db;">{link or '#'}</a>
</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 0.8em; color: #999; text-align: center;">{footer}</p>
</div>
</body>
</html>
"""
@staticmethod
async def send_email(recipient: str, template_key: str, variables: dict, lang: str = "hu", db: Optional[AsyncSession] = None):
"""
E-mail küldése Brevo API-n vagy SMTP-n keresztül.
"""
session_internal = False
if db is None:
db = AsyncSessionLocal()
session_internal = True
try:
# --- FIX: Wrap DB config lookup in try-except with env fallback ---
# If the DB query fails (e.g. enum mismatch), fall back to environment variable
try:
provider = await config.get_setting(db, "email_provider", default=None)
except Exception as cfg_err:
logger.warning(f"DB config error reading email_provider: {cfg_err}. Falling back to ENV variables...")
provider = None
if provider == "disabled":
logger.info(f"Email küldés letiltva (Admin config). Cél: {recipient}")
return {"status": "success", "provider": "disabled", "message": "Email disabled by admin config"}
html = EmailManager._get_html_template(template_key, variables, lang)
subject = locale_manager.get(f"email.{template_key}_subject", lang=lang)
# Determine email provider: DB config > ENV > fallback to 'smtp'
email_provider_raw = provider if provider else os.getenv("EMAIL_PROVIDER", "smtp")
email_provider = str(email_provider_raw).lower().strip()
if email_provider == "brevo_api":
result = await EmailManager._send_via_brevo_api(recipient, subject, html, variables)
# Primary-Fallback Logic: If Brevo API fails, try SMTP
if result.get("status") == "error":
logger.error(f"Brevo API failed for {recipient}: {result.get('message')}. Falling back to SMTP.")
result = await EmailManager._send_via_smtp(recipient, subject, html)
elif email_provider == "brevo_smtp":
result = await EmailManager._send_via_brevo_smtp(recipient, subject, html)
else: # Default to SMTP
result = await EmailManager._send_via_smtp(recipient, subject, html)
logger.info(f"Email sending result for {recipient}: {result}")
return result
except Exception as e:
logger.error(f"CRITICAL: Email sending failed for {recipient}: {str(e)}")
# Don't crash - return error but don't raise exception
return {"status": "error", "message": str(e), "provider": "unknown"}
finally:
if session_internal:
await db.close()
@staticmethod
async def _send_via_brevo_api(recipient: str, subject: str, html: str, variables: dict):
"""Send email via Brevo REST API"""
try:
brevo_api_key = os.getenv("BREVO_API_KEY")
if not brevo_api_key:
logger.error("BREVO_API_KEY environment variable not set")
return {"status": "error", "message": "BREVO_API_KEY not configured", "provider": "brevo_api"}
sender_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
sender_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
# Prepare Brevo API payload
payload = {
"sender": {
"name": sender_name,
"email": sender_email
},
"to": [{"email": recipient}],
"subject": subject,
"htmlContent": html,
"tags": ["verification"]
}
# Try to disable tracking - Brevo may ignore this if account-level tracking is enabled
# According to Brevo API v3 docs, tracking settings are at account level
# We'll add tracking object but it may not work for free tier
payload["tracking"] = {
"click": False,
"open": False
}
headers = {
"accept": "application/json",
"api-key": brevo_api_key,
"content-type": "application/json"
}
response = requests.post(
"https://api.brevo.com/v3/smtp/email",
json=payload,
headers=headers,
timeout=30
)
if response.status_code == 201:
logger.info(f"Brevo API email sent successfully to {recipient}")
return {"status": "success", "provider": "brevo_api", "message_id": response.json().get("messageId")}
else:
logger.error(f"Brevo API error: {response.status_code} - {response.text}")
return {"status": "error", "provider": "brevo_api", "message": response.text}
except Exception as e:
logger.error(f"Brevo API exception: {str(e)}")
return {"status": "error", "provider": "brevo_api", "message": str(e)}
@staticmethod
async def _send_via_brevo_smtp(recipient: str, subject: str, html: str):
"""Send email via Brevo SMTP relay"""
try:
smtp_host = "smtp-relay.brevo.com"
smtp_port = 587
smtp_user = os.getenv("BREVO_SMTP_USER")
smtp_pass = os.getenv("BREVO_SMTP_PASSWORD")
if not smtp_user or not smtp_pass:
logger.error("BREVO_SMTP_USER or BREVO_SMTP_PASSWORD not set")
return {"status": "error", "message": "Brevo SMTP credentials not configured", "provider": "brevo_smtp"}
from_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
from_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
logger.info(f"Connecting to Brevo SMTP: {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info(f"Brevo SMTP email sent successfully to {recipient}")
return {"status": "success", "provider": "brevo_smtp"}
except Exception as e:
logger.error(f"Brevo SMTP exception: {str(e)}")
return {"status": "error", "provider": "brevo_smtp", "message": str(e)}
@staticmethod
async def _send_via_smtp(recipient: str, subject: str, html: str):
"""Send email via standard SMTP (fallback)"""
# Mock mode check: If APP_ENV=test or domain is example.com, skip SMTP and return success
app_env = os.getenv("APP_ENV", "").lower()
is_example_domain = recipient.endswith("@example.com") or "@example.com" in recipient
if app_env == "test" or is_example_domain:
logger.info(f"Mock mode: Skipping SMTP for {recipient} (APP_ENV={app_env}, is_example_domain={is_example_domain})")
return {"status": "success", "provider": "mock", "message": "Email skipped in test mode"}
try:
smtp_host = os.getenv("SMTP_HOST", "mail.servicefinder.hu")
smtp_port = int(os.getenv("SMTP_PORT", "465"))
smtp_user = os.getenv("SMTP_USER", "noreply@servicefinder.hu")
smtp_pass = os.getenv("SMTP_PASSWORD", "")
from_email = os.getenv("MAIL_FROM", "noreply@servicefinder.hu")
from_name = os.getenv("MAIL_FROM_NAME", "ServiceFinder")
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{from_email}>"
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(html, "html"))
# Port 465 uses SMTP_SSL directly instead of STARTTLS
if smtp_port == 465:
logger.info(f"Connecting via SMTP_SSL to {smtp_host}:{smtp_port}")
with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15) as server:
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
else:
logger.info(f"Connecting via SMTP to {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=15) as server:
# Try STARTTLS, but fall back to plain if server doesn't support it
# (e.g. local Mailpit for testing)
try:
server.starttls()
except smtplib.SMTPNotSupportedError:
logger.warning(f"STARTTLS not supported by {smtp_host}:{smtp_port}, sending in plain text")
except Exception:
logger.warning(f"STARTTLS failed on {smtp_host}:{smtp_port}, sending in plain text")
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info(f"SMTP email sent successfully to {recipient}")
return {"status": "success", "provider": "smtp"}
except Exception as e:
logger.error(f"SMTP exception: {str(e)}")
return {"status": "error", "provider": "smtp", "message": str(e)}
email_manager = EmailManager()