Files
photobooth/backend/mailer.py
Jules e47ff04933 Email spool : file d'attente quand envoi echoue, retry toutes les 60s
- Si l'envoi SMTP echoue (pas de connexion, config incomplete, etc.),
  l'email est stocke dans data/email_spool.json
- Tache asyncio periodique (60s) tente de vider le spool
- L'adresse est quand meme ajoutee a l'historique de suggestions
- Frontend affiche "Email en file d'attente" au lieu de "Erreur"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-22 19:17:58 +02:00

176 lines
5.2 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())
def traiter_spool() -> int:
"""Tente d'envoyer les emails en attente. Retourne le nombre envoyes."""
with _spool_lock:
spool = _charger_spool()
if not spool:
return 0
envoyes = 0
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
else:
restants.append(entry)
break # si un echoue, inutile de continuer (pas de connexion)
if envoyes > 0 or len(restants) < len(spool):
# garder les non-envoyes + ceux pas encore tentes
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
async def tache_spool_periodique():
"""Tache asyncio qui vide le spool toutes les 60s."""
while True:
await asyncio.sleep(60)
if taille_spool() > 0:
log.info(f"Spool: tentative d'envoi ({taille_spool()} en attente)")
await asyncio.get_event_loop().run_in_executor(None, traiter_spool)
FICHIER_EMAILS = RACINE / "data" / "emails_history.json"
def _sauvegarder_email_historique(email: str):
historique = charger_emails_historique()
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(historique, f, ensure_ascii=False)
except OSError:
pass
def charger_emails_historique() -> list:
if not FICHIER_EMAILS.exists():
return []
try:
with open(FICHIER_EMAILS, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return []
def effacer_emails_historique():
if FICHIER_EMAILS.exists():
FICHIER_EMAILS.unlink()