Files
photobooth/backend/mailer.py
Jules d21d87347d Retry periodique email + spool galerie en cas de coupure internet
- Spool galerie booth : si upload echoue, photo stockee dans
  data/booth_spool.json et retentee toutes les 5 min
- Email spool : retry periodique (5 min) au lieu de juste au demarrage
- Les deux spools traites dans la meme boucle async
- Message frontend spool : "sera envoyee des que possible"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-18 10:05:21 +02:00

258 lines
8.1 KiB
Python

import asyncio
import json
import logging
import smtplib
import threading
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email import encoders
from pathlib import Path
from backend.config import charger_config, RACINE
log = logging.getLogger("photobooth.mailer")
FICHIER_SPOOL = RACINE / "data" / "email_spool.json"
_spool_lock = threading.Lock()
def envoyer_photo(destinataire: str, chemin_photo: Path) -> bool:
"""Envoie une photo par email."""
config = charger_config()
conf_email = config.get("email", {})
conf_event = config.get("evenement", {})
smtp_host = conf_email.get("smtp_host", "")
smtp_port = conf_email.get("smtp_port", 587)
smtp_user = conf_email.get("smtp_user", "")
smtp_password = conf_email.get("smtp_password", "")
expediteur = conf_email.get("expediteur", smtp_user)
if not smtp_host or not smtp_user:
log.error("Configuration email incomplete")
return False
sujet = conf_email.get("sujet", "Votre photo - {evenement}").format(
evenement=conf_event.get("nom", "Photobooth")
)
message = conf_email.get("message", "Voici votre photo !").format(
evenement=conf_event.get("nom", "Photobooth")
)
msg = MIMEMultipart()
msg["From"] = expediteur
msg["To"] = destinataire
msg["Subject"] = sujet
msg.attach(MIMEText(message, "plain", "utf-8"))
# Piece jointe
if chemin_photo.exists():
with open(chemin_photo, "rb") as f:
piece = MIMEBase("image", "jpeg")
piece.set_payload(f.read())
encoders.encode_base64(piece)
piece.add_header(
"Content-Disposition",
f"attachment; filename={chemin_photo.name}"
)
msg.attach(piece)
try:
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as serveur:
serveur.starttls()
serveur.login(smtp_user, smtp_password)
serveur.send_message(msg)
log.info(f"Email envoye a {destinataire}")
_sauvegarder_email_historique(destinataire)
return True
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi email : {e}")
return False
# --- Spool : file d'attente pour emails en echec ---
def ajouter_au_spool(destinataire: str, chemin_photo: str):
with _spool_lock:
spool = _charger_spool()
spool.append({"email": destinataire, "photo": chemin_photo})
_sauver_spool(spool)
log.info(f"Email pour {destinataire} mis en file d'attente ({len(spool)} en attente)")
def _charger_spool() -> list:
if not FICHIER_SPOOL.exists():
return []
try:
with open(FICHIER_SPOOL, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def _sauver_spool(spool: list):
try:
with open(FICHIER_SPOOL, "w", encoding="utf-8") as f:
json.dump(spool, f, ensure_ascii=False)
except OSError:
pass
def taille_spool() -> int:
with _spool_lock:
return len(_charger_spool())
EMAIL_RAPPORT = "contact@copydev.fr"
def traiter_spool() -> tuple[int, list[str]]:
"""Tente d'envoyer les emails en attente. Retourne (nb envoyes, liste destinataires)."""
with _spool_lock:
spool = _charger_spool()
if not spool:
return 0, []
envoyes = 0
destinataires = []
restants = []
for entry in spool:
chemin = Path(entry["photo"])
if not chemin.exists():
log.warning(f"Spool: photo introuvable {chemin}, email ignore")
continue
ok = envoyer_photo(entry["email"], chemin)
if ok:
envoyes += 1
destinataires.append(entry["email"])
else:
restants.append(entry)
break
if envoyes > 0 or len(restants) < len(spool):
idx = envoyes + len(restants)
restants.extend(spool[idx:])
with _spool_lock:
_sauver_spool(restants)
log.info(f"Spool: {envoyes} envoye(s), {len(restants)} en attente")
return envoyes, destinataires
def envoyer_rapport_spool(nb_envoyes: int, destinataires: list[str], nb_echoues: int):
"""Envoie un rapport a contact@copydev.fr apres vidage du spool."""
config = charger_config()
conf_email = config.get("email", {})
conf_event = config.get("evenement", {})
nom_event = conf_event.get("nom", "Photobooth")
smtp_host = conf_email.get("smtp_host", "")
smtp_port = conf_email.get("smtp_port", 587)
smtp_user = conf_email.get("smtp_user", "")
smtp_password = conf_email.get("smtp_password", "")
expediteur = conf_email.get("expediteur", smtp_user)
if not smtp_host or not smtp_user:
return
liste = "\n".join(f" - {d}" for d in destinataires)
corps = f"{nb_envoyes} photo(s) envoyée(s) par email.\n\n"
corps += f"Destinataires :\n{liste}\n"
if nb_echoues > 0:
corps += f"\n{nb_echoues} email(s) en échec (restent en file d'attente).\n"
msg = MIMEMultipart()
msg["From"] = expediteur
msg["To"] = EMAIL_RAPPORT
msg["Subject"] = f"[{nom_event}] {nb_envoyes} photo(s) envoyée(s)"
msg.attach(MIMEText(corps, "plain", "utf-8"))
try:
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as serveur:
serveur.starttls()
serveur.login(smtp_user, smtp_password)
serveur.send_message(msg)
log.info(f"Rapport spool envoye a {EMAIL_RAPPORT}")
except (smtplib.SMTPException, OSError) as e:
log.error(f"Erreur envoi rapport spool : {e}")
RETRY_INTERVAL = 300 # 5 minutes
async def tache_spool_periodique():
"""Retente emails + galerie en spool toutes les 5 min."""
from backend.destinations import traiter_booth_spool, taille_booth_spool
await asyncio.sleep(15)
while True:
loop = asyncio.get_event_loop()
# Emails
total_mail = taille_spool()
if total_mail > 0:
log.info(f"Spool email: retry ({total_mail} en attente)")
nb, dests = await loop.run_in_executor(None, traiter_spool)
if nb > 0:
echoues = total_mail - nb
await loop.run_in_executor(None, envoyer_rapport_spool, nb, dests, echoues)
# Galerie booth
total_booth = taille_booth_spool()
if total_booth > 0:
log.info(f"Spool galerie: retry ({total_booth} en attente)")
await loop.run_in_executor(None, traiter_booth_spool)
await asyncio.sleep(RETRY_INTERVAL)
async def tache_spool_demarrage():
"""Alias pour compatibilite — lance la boucle periodique."""
await tache_spool_periodique()
FICHIER_EMAILS = RACINE / "data" / "emails_history.json"
def _event_id_actif() -> str:
return charger_config().get("evenement", {}).get("event_id") or "_sans_evenement"
def _charger_toutes_historiques() -> dict:
if not FICHIER_EMAILS.exists():
return {}
try:
with open(FICHIER_EMAILS, "r", encoding="utf-8") as f:
data = json.load(f)
# Compatibilite avec l'ancien format (liste globale non cloisonnee)
if isinstance(data, list):
return {"_sans_evenement": data}
return data
except (json.JSONDecodeError, OSError):
return {}
def _sauvegarder_email_historique(email: str):
toutes = _charger_toutes_historiques()
event_id = _event_id_actif()
historique = toutes.setdefault(event_id, [])
email_lower = email.lower().strip()
if email_lower not in historique:
historique.append(email_lower)
try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(toutes, f, ensure_ascii=False)
except OSError:
pass
def charger_emails_historique() -> list:
return _charger_toutes_historiques().get(_event_id_actif(), [])
def effacer_emails_historique():
toutes = _charger_toutes_historiques()
toutes.pop(_event_id_actif(), None)
try:
with open(FICHIER_EMAILS, "w", encoding="utf-8") as f:
json.dump(toutes, f, ensure_ascii=False)
except OSError:
pass