All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""
|
|
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"""
|
|
<p>Bonjour {display_name},</p>
|
|
<p>Nous sommes ravis de vous compter parmi nos membres Premium ! Votre abonnement au forfait <strong>{plan_name}</strong> est désormais actif.</p>
|
|
<div class="info-box">
|
|
<div class="info-title">Détails de votre abonnement :</div>
|
|
<p style="margin: 4px 0;"><strong>Forfait :</strong> {plan_name}</p>
|
|
{"<p style='margin: 4px 0;'><strong>Date de renouvellement :</strong> " + ends_at + "</p>" if ends_at else ""}
|
|
</div>
|
|
<p>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.</p>
|
|
<div class="button-container">
|
|
<a href="{dashboard_url}" class="button">Accéder à mon tableau de bord</a>
|
|
</div>
|
|
"""
|
|
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"""
|
|
<p>Bonjour {display_name},</p>
|
|
<p>Nous vous confirmons que l'annulation de votre abonnement a bien été prise en compte à votre demande.</p>
|
|
<div class="info-box">
|
|
<div class="info-title">Informations importantes :</div>
|
|
{"<p style='margin: 4px 0;'><strong>Accès actif jusqu'au :</strong> " + ends_at + "</p>" if ends_at else ""}
|
|
<p style="margin: 4px 0;">Aucun autre prélèvement ne sera effectué.</p>
|
|
</div>
|
|
<p>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.</p>
|
|
<p>Si vous changez d'avis, vous pouvez réactiver votre abonnement à tout moment depuis votre profil.</p>
|
|
<div class="button-container">
|
|
<a href="{profile_url}" class="button">Gérer mon compte</a>
|
|
</div>
|
|
"""
|
|
elif event_type == "ended":
|
|
subject = "Votre accès premium a expiré - Office Translator"
|
|
header_title = "Accès premium terminé"
|
|
content_html = f"""
|
|
<p>Bonjour {display_name},</p>
|
|
<p>Votre abonnement Office Translator a pris fin et votre compte a été basculé vers le forfait <strong>Gratuit</strong>.</p>
|
|
<p>Vous pouvez toujours traduire des documents dans la limite de votre quota gratuit. Vos documents et votre historique restent disponibles.</p>
|
|
<p>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.</p>
|
|
<div class="button-container">
|
|
<a href="{pricing_url}" class="button">Voir nos forfaits</a>
|
|
</div>
|
|
"""
|
|
elif event_type == "payment_failed":
|
|
subject = "Échec de paiement - Action requise - Office Translator ⚠️"
|
|
header_title = "Problème de paiement"
|
|
content_html = f"""
|
|
<p>Bonjour {display_name},</p>
|
|
<p>Nous n'avons pas pu prélever le règlement de votre abonnement Office Translator. Notre tentative de paiement a échoué.</p>
|
|
<p>Pour éviter toute interruption de votre service premium, nous vous invitons à mettre à jour vos coordonnées de paiement dès que possible.</p>
|
|
<div class="button-container">
|
|
<a href="{profile_url}" class="button">Mettre à jour mon moyen de paiement</a>
|
|
</div>
|
|
<p>Si vous avez des questions, n'hésitez pas à répondre à cet e-mail.</p>
|
|
"""
|
|
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"""
|
|
<p>Bonjour {display_name},</p>
|
|
<p>Nous vous remercions pour votre achat ! Vos crédits supplémentaires ont bien été crédités sur votre compte.</p>
|
|
<div class="info-box">
|
|
<div class="info-title">Détails de l'achat :</div>
|
|
<p style="margin: 4px 0;"><strong>Crédits achetés :</strong> +{credits}</p>
|
|
<p style="margin: 4px 0;">Chaque crédit vous permet de traduire une page de document supplémentaire.</p>
|
|
</div>
|
|
<p>Vous pouvez suivre votre solde de crédits et l'historique de vos traductions depuis votre tableau de bord.</p>
|
|
<div class="button-container">
|
|
<a href="{dashboard_url}" class="button">Traduire un document</a>
|
|
</div>
|
|
"""
|
|
else:
|
|
return False
|
|
|
|
full_html = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background-color: #f8fafc;
|
|
color: #334155;
|
|
margin: 0;
|
|
padding: 0;
|
|
}}
|
|
.wrapper {{
|
|
width: 100%;
|
|
background-color: #f8fafc;
|
|
padding: 40px 0;
|
|
}}
|
|
.container {{
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
background: #ffffff;
|
|
border-radius: 16px;
|
|
overflow: hidden;
|
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
border: 1px solid #e2e8f0;
|
|
}}
|
|
.header {{
|
|
background: linear-gradient(135deg, #4f46e5, #06b6d4);
|
|
padding: 32px;
|
|
text-align: center;
|
|
color: #ffffff;
|
|
}}
|
|
.header h1 {{
|
|
margin: 0;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.025em;
|
|
}}
|
|
.content {{
|
|
padding: 32px;
|
|
line-height: 1.6;
|
|
}}
|
|
.content p {{
|
|
margin-top: 0;
|
|
margin-bottom: 16px;
|
|
}}
|
|
.button-container {{
|
|
text-align: center;
|
|
margin: 32px 0;
|
|
}}
|
|
.button {{
|
|
background-color: #4f46e5;
|
|
color: #ffffff !important;
|
|
padding: 12px 32px;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
border-radius: 8px;
|
|
display: inline-block;
|
|
box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.2);
|
|
}}
|
|
.footer {{
|
|
background-color: #f1f5f9;
|
|
padding: 24px;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
color: #64748b;
|
|
border-top: 1px solid #e2e8f0;
|
|
}}
|
|
.footer p {{
|
|
margin: 4px 0;
|
|
}}
|
|
.info-box {{
|
|
background-color: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 24px;
|
|
}}
|
|
.info-title {{
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
margin-bottom: 8px;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrapper">
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>{header_title}</h1>
|
|
</div>
|
|
<div class="content">
|
|
{content_html}
|
|
</div>
|
|
<div class="footer">
|
|
<p>Cet e-mail automatique a été envoyé par <strong>Office Translator</strong>.</p>
|
|
<p>© {datetime.now().year} Office Translator. Tous droits réservés.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
return await send_email_async(to_email, subject, full_html)
|
|
|