Files
office_translator/services/email_service.py
sepehr 744a97f58d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m30s
fix: import datetime in email_service.py to fix NameError, add Stripe emails unit tests
2026-05-31 23:25:20 +02:00

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>&copy; {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)