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 WATCHDOG_INTERVAL = 60 # 1 minute async def _flush_spools(): """Vide les spools email + galerie.""" from backend.destinations import traiter_booth_spool, taille_booth_spool loop = asyncio.get_event_loop() 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) 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) async def tache_spool_periodique(): """Watchdog connectivite (60s) + retry spool (5 min).""" from backend.wifi import watchdog_tick from backend.destinations import taille_booth_spool await asyncio.sleep(15) loop = asyncio.get_event_loop() ticks_depuis_flush = 0 while True: result = await loop.run_in_executor(None, watchdog_tick) if result == "restored": await _flush_spools() ticks_depuis_flush = 0 else: ticks_depuis_flush += 1 if ticks_depuis_flush >= RETRY_INTERVAL // WATCHDOG_INTERVAL: has_spool = taille_spool() > 0 or taille_booth_spool() > 0 if has_spool: await _flush_spools() ticks_depuis_flush = 0 await asyncio.sleep(WATCHDOG_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