""" Email service for sending transactional emails via SMTP. Supports both sync (smtplib) and async (aiosmtplib) sending. Configuration is resolved from settings JSON file, then env vars. """ import os import logging import smtplib from datetime import datetime from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from typing import Optional logger = logging.getLogger(__name__) def _get_smtp_config() -> dict: """Resolve SMTP config from admin settings file then env vars.""" config = {} # Read from admin provider settings (same file as /admin/settings page) try: import json from pathlib import Path settings_path = Path("data/provider_settings.json") if settings_path.exists(): with open(settings_path, "r") as f: settings = json.load(f) smtp = settings.get("smtp", {}) if smtp and smtp.get("host"): config["host"] = smtp.get("host", "") config["port"] = smtp.get("port", 587) config["user"] = smtp.get("username", "") config["password"] = smtp.get("password", "") config["from_email"] = smtp.get("from_email", "") or smtp.get("username", "") config["use_tls"] = smtp.get("use_tls", True) except Exception: pass # Env vars fill gaps config.setdefault("host", os.getenv("SMTP_HOST", "")) config.setdefault("port", int(os.getenv("SMTP_PORT", "587"))) config.setdefault("user", os.getenv("SMTP_USER", os.getenv("SMTP_USERNAME", ""))) config.setdefault("password", os.getenv("SMTP_PASSWORD", "")) config.setdefault("from_email", os.getenv("SMTP_FROM_EMAIL", os.getenv("SMTP_USER", os.getenv("SMTP_USERNAME", "")))) config.setdefault("use_tls", os.getenv("SMTP_USE_TLS", "true").lower() == "true") return config def is_smtp_configured() -> bool: """Check if SMTP is properly configured.""" cfg = _get_smtp_config() return bool(cfg.get("host") and cfg.get("user")) def send_email(to: str, subject: str, body: str) -> bool: """Send an email via SMTP (sync). Returns True if sent successfully.""" cfg = _get_smtp_config() if not cfg.get("host"): logger.warning("SMTP not configured, email not sent") return False try: msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = cfg["from_email"] msg["To"] = to msg.attach(MIMEText(body, "html")) port = int(cfg.get("port", 587)) use_tls = cfg.get("use_tls", True) if use_tls: server = smtplib.SMTP(cfg["host"], port) server.starttls() else: server = smtplib.SMTP(cfg["host"], port) if cfg.get("user") and cfg.get("password"): server.login(cfg["user"], cfg["password"]) server.sendmail(cfg["from_email"], [to], msg.as_string()) server.quit() logger.info(f"Email sent to {to}: {subject}") return True except Exception as e: logger.error(f"Failed to send email to {to}: {e}") return False async def send_email_async(to: str, subject: str, body: str) -> bool: """Send an email via SMTP (async). Returns True if sent successfully.""" cfg = _get_smtp_config() if not cfg.get("host"): logger.warning("SMTP not configured, email not sent") return False try: from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = cfg["from_email"] msg["To"] = to msg.attach(MIMEText(body, "html")) port = int(cfg.get("port", 587)) # Use sync smtplib inside a thread for reliability (aiosmtplib has quirks with raw messages) import asyncio return await asyncio.get_event_loop().run_in_executor( None, send_email, to, subject, body ) except Exception as e: logger.error(f"Failed to send email (async) to {to}: {e}") return False async def send_subscription_email_async( to_email: str, user_name: str, event_type: str, details: dict ) -> bool: """ Send subscription transactional email in French. event_type can be: 'activated', 'cancelled', 'ended', 'payment_failed', 'credits_purchased' """ display_name = user_name or to_email.split("@")[0] frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") dashboard_url = f"{frontend_url}/dashboard" profile_url = f"{frontend_url}/dashboard/profile?tab=subscription" pricing_url = f"{frontend_url}/pricing" subject = "Notification Office Translator" header_title = "Office Translator" content_html = "" if event_type == "activated": plan_name = details.get("plan_name", "Premium").capitalize() ends_at = details.get("ends_at", "") subject = "Votre abonnement Office Translator est activé ! 🚀" header_title = "Bienvenue chez Office Translator" content_html = f"""

Bonjour {display_name},

Nous sommes ravis de vous compter parmi nos membres Premium ! Votre abonnement au forfait {plan_name} est désormais actif.

Détails de votre abonnement :

Forfait : {plan_name}

{"

Date de renouvellement : " + ends_at + "

" if ends_at else ""}

Vous pouvez dès à présent profiter de toutes les fonctionnalités premium, y compris la traduction illimitée de documents et l'accès à nos meilleurs modèles d'IA.

Accéder à mon tableau de bord
""" elif event_type == "cancelled": ends_at = details.get("ends_at", "") subject = "Confirmation d'annulation de votre abonnement - Office Translator" header_title = "Abonnement annulé" content_html = f"""

Bonjour {display_name},

Nous vous confirmons que l'annulation de votre abonnement a bien été prise en compte à votre demande.

Informations importantes :
{"

Accès actif jusqu'au : " + ends_at + "

" if ends_at else ""}

Aucun autre prélèvement ne sera effectué.

Vous conservez un accès complet à toutes les fonctionnalités premium de votre forfait jusqu'à la fin de la période de facturation en cours.

Si vous changez d'avis, vous pouvez réactiver votre abonnement à tout moment depuis votre profil.

Gérer mon compte
""" elif event_type == "ended": subject = "Votre accès premium a expiré - Office Translator" header_title = "Accès premium terminé" content_html = f"""

Bonjour {display_name},

Votre abonnement Office Translator a pris fin et votre compte a été basculé vers le forfait Gratuit.

Vous pouvez toujours traduire des documents dans la limite de votre quota gratuit. Vos documents et votre historique restent disponibles.

Pour retrouver l'accès à la traduction rapide, sans limites et à nos modèles IA avancés, vous pouvez vous réabonner en un clic.

Voir nos forfaits
""" elif event_type == "payment_failed": subject = "Échec de paiement - Action requise - Office Translator ⚠️" header_title = "Problème de paiement" content_html = f"""

Bonjour {display_name},

Nous n'avons pas pu prélever le règlement de votre abonnement Office Translator. Notre tentative de paiement a échoué.

Pour éviter toute interruption de votre service premium, nous vous invitons à mettre à jour vos coordonnées de paiement dès que possible.

Mettre Ă  jour mon moyen de paiement

Si vous avez des questions, n'hésitez pas à répondre à cet e-mail.

""" elif event_type == "credits_purchased": credits = details.get("credits", 0) subject = "Achat de crédits réussi - Office Translator 💳" header_title = "Crédits ajoutés" content_html = f"""

Bonjour {display_name},

Nous vous remercions pour votre achat ! Vos crédits supplémentaires ont bien été crédités sur votre compte.

Détails de l'achat :

Crédits achetés : +{credits}

Chaque crédit vous permet de traduire une page de document supplémentaire.

Vous pouvez suivre votre solde de crédits et l'historique de vos traductions depuis votre tableau de bord.

Traduire un document
""" else: return False full_html = f"""

{header_title}

{content_html}
""" return await send_email_async(to_email, subject, full_html)