import threading import queue import asyncio from datetime import datetime, timedelta import smtplib import ssl import socket from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication from config.config import settings import logging from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from io import BytesIO from reportlab.lib.units import mm from reportlab.lib.colors import HexColor logger = logging.getLogger(__name__) class EmailQueue: def __init__(self): self.queue = queue.Queue() self.workers = [] self.running = False self.session_factory = None self.sage_client = None def start(self, num_workers: int = 3): if self.running: return self.running = True for i in range(num_workers): worker = threading.Thread( target=self._worker, name=f"EmailWorker-{i}", daemon=True ) worker.start() self.workers.append(worker) logger.info(f"Queue email démarrée avec {num_workers} worker(s)") def stop(self): self.running = False try: self.queue.join() except Exception: pass def enqueue(self, email_log_id: str): self.queue.put(email_log_id) def _worker(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: while self.running: try: email_log_id = self.queue.get(timeout=1) loop.run_until_complete(self._process_email(email_log_id)) self.queue.task_done() except queue.Empty: continue except Exception as e: logger.error(f"Erreur worker: {e}") try: self.queue.task_done() except Exception: pass finally: loop.close() async def _process_email(self, email_log_id: str): from database import EmailLog, StatutEmail from sqlalchemy import select if not self.session_factory: logger.error("session_factory non configuré") return async with self.session_factory() as session: result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) email_log = result.scalar_one_or_none() if not email_log: logger.error(f"Email log {email_log_id} introuvable") return email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() try: await self._send_with_retry(email_log) email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None except Exception as e: error_msg = str(e) email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = error_msg[:1000] if email_log.nb_tentatives < settings.max_retry_attempts: delay = settings.retry_delay_seconds * ( 2 ** (email_log.nb_tentatives - 1) ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() await session.commit() async def _send_with_retry(self, email_log): msg = MIMEMultipart() msg["From"] = settings.smtp_from msg["To"] = email_log.destinataire msg["Subject"] = email_log.sujet msg.attach(MIMEText(email_log.corps_html, "html")) # Attachement des PDFs if email_log.document_ids: document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document for doc_id in document_ids: doc_id = doc_id.strip() if not doc_id: continue try: pdf_bytes = await asyncio.to_thread( self._generate_pdf, doc_id, type_doc ) if pdf_bytes: part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") part["Content-Disposition"] = ( f'attachment; filename="{doc_id}.pdf"' ) msg.attach(part) except Exception as e: logger.error(f"Erreur génération PDF {doc_id}: {e}") # Envoi SMTP await asyncio.to_thread(self._send_smtp, msg) def _send_smtp(self, msg): server = None try: # Résolution DNS socket.getaddrinfo(settings.smtp_host, settings.smtp_port) # Connexion server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) # EHLO server.ehlo() # STARTTLS if settings.smtp_use_tls: if server.has_extn("STARTTLS"): context = ssl.create_default_context() server.starttls(context=context) server.ehlo() # Authentification if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) # Envoi refused = server.send_message(msg) if refused: raise Exception(f"Destinataires refusés: {refused}") # Fermeture server.quit() except Exception as e: if server: try: server.quit() except Exception: pass raise Exception(f"Erreur SMTP: {str(e)}") def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: if not self.sage_client: raise Exception("sage_client non disponible") try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: raise Exception(f"Document {doc_id} inaccessible : {e}") if not doc: raise Exception(f"Document {doc_id} introuvable") buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 # Couleurs green_color = HexColor("#2A6F4F") gray_400 = HexColor("#9CA3AF") gray_600 = HexColor("#4B5563") gray_800 = HexColor("#1F2937") # Marges margin = 8 * mm content_width = width - 2 * margin y = height - margin # ===== HEADER ===== y -= 20 * mm # Logo/Nom entreprise à gauche pdf.setFont("Helvetica-Bold", 18) pdf.setFillColor(green_color) pdf.drawString(margin, y, "Bijou S.A.S") # Informations document à droite pdf.setFillColor(gray_800) pdf.setFont("Helvetica-Bold", 20) numero = doc.get("numero") or "BROUILLON" pdf.drawRightString(width - margin, y, numero.upper()) y -= 7 * mm pdf.setFont("Helvetica", 9) pdf.setFillColor(gray_600) date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y") pdf.drawRightString(width - margin, y, f"Date : {date_str}") y -= 5 * mm date_livraison = ( doc.get("date_livraison") or doc.get("date_echeance") or date_str ) pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}") y -= 5 * mm reference = doc.get("reference") or "—" pdf.drawRightString(width - margin, y, f"Réf : {reference}") # ===== ADDRESSES ===== y -= 20 * mm # Émetteur (gauche) col1_x = margin col2_x = margin + content_width / 2 + 6 * mm col_width = content_width / 2 - 6 * mm pdf.setFont("Helvetica-Bold", 8) pdf.setFillColor(gray_400) pdf.drawString(col1_x, y, "ÉMETTEUR") y_emetteur = y - 5 * mm pdf.setFont("Helvetica-Bold", 10) pdf.setFillColor(gray_800) pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S") y_emetteur -= 5 * mm pdf.setFont("Helvetica", 9) pdf.setFillColor(gray_600) pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République") y_emetteur -= 4 * mm pdf.drawString(col1_x, y_emetteur, "75011 Paris, France") y_emetteur -= 5 * mm pdf.drawString(col1_x, y_emetteur, "contact@bijou.com") # Destinataire (droite, avec fond gris) box_y = y - 4 * mm box_height = 28 * mm pdf.setFillColorRGB(0.97, 0.97, 0.97) # bg-gray-50 pdf.roundRect( col2_x, box_y - box_height, col_width, box_height, 3 * mm, fill=1, stroke=0 ) pdf.setFillColor(gray_400) pdf.setFont("Helvetica-Bold", 8) pdf.drawString(col2_x + 4 * mm, y, "DESTINATAIRE") y_dest = y - 5 * mm pdf.setFont("Helvetica-Bold", 10) pdf.setFillColor(gray_800) client_name = doc.get("client_intitule") or "Client" pdf.drawString(col2_x + 4 * mm, y_dest, client_name) y_dest -= 5 * mm pdf.setFont("Helvetica", 9) pdf.setFillColor(gray_600) pdf.drawString(col2_x + 4 * mm, y_dest, "10 rue des Clients") y_dest -= 4 * mm pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris") # ===== LIGNES D'ARTICLES ===== y = min(y_emetteur, y_dest) - 20 * mm # En-têtes des colonnes col_designation = margin col_quantite = width - margin - 80 * mm col_prix_unit = width - margin - 64 * mm col_taux_taxe = width - margin - 40 * mm col_montant = width - margin - 24 * mm pdf.setFont("Helvetica-Bold", 9) pdf.setFillColor(gray_800) pdf.drawString(col_designation, y, "Désignation") pdf.drawRightString(col_quantite, y, "Qté") pdf.drawRightString(col_prix_unit, y, "Prix Unit. HT") pdf.drawRightString(col_taux_taxe, y, "TVA") pdf.drawRightString(col_montant, y, "Montant HT") y -= 7 * mm # Lignes d'articles pdf.setFont("Helvetica", 8) lignes = doc.get("lignes", []) for ligne in lignes: if y < 60 * mm: # Nouvelle page si nécessaire pdf.showPage() y = height - margin - 20 * mm pdf.setFont("Helvetica", 8) designation = ( ligne.get("designation") or ligne.get("designation_article") or "" ) if len(designation) > 60: designation = designation[:57] + "..." pdf.setFillColor(gray_800) pdf.setFont("Helvetica-Bold", 8) pdf.drawString(col_designation, y, designation) y -= 4 * mm # Description (si différente) description = ligne.get("description", "") if description and description != designation: pdf.setFont("Helvetica", 7) pdf.setFillColor(gray_600) if len(description) > 70: description = description[:67] + "..." pdf.drawString(col_designation, y, description) y -= 4 * mm # Valeurs y += 4 * mm # Remonter pour aligner avec la désignation pdf.setFont("Helvetica", 8) pdf.setFillColor(gray_800) quantite = ligne.get("quantite") or 0 pdf.drawRightString(col_quantite, y, str(quantite)) prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire", 0) pdf.drawRightString(col_prix_unit, y, f"{prix_unit:.2f} €") taux_taxe = ligne.get("taux_taxe1") or 20 pdf.drawRightString(col_taux_taxe, y, f"{taux_taxe}%") montant = ligne.get("montant_ligne_ht") or ligne.get("montant_ht", 0) pdf.setFont("Helvetica-Bold", 8) pdf.drawRightString(col_montant, y, f"{montant:.2f} €") y -= 8 * mm # Si aucune ligne if not lignes: pdf.setFont("Helvetica-Oblique", 9) pdf.setFillColor(gray_400) pdf.drawCentredString(width / 2, y, "Aucune ligne") y -= 15 * mm # ===== TOTAUX ===== y -= 10 * mm totals_x = width - margin - 64 * mm totals_label_width = 40 * mm pdf.setFont("Helvetica", 9) pdf.setFillColor(gray_600) # Total HT pdf.drawString(totals_x, y, "Total HT") total_ht = doc.get("total_ht_net") or doc.get("total_ht") or 0 pdf.drawRightString(width - margin, y, f"{total_ht:.2f} €") y -= 6 * mm # TVA pdf.drawString(totals_x, y, "TVA") total_ttc = doc.get("total_ttc") or 0 tva = total_ttc - total_ht pdf.drawRightString(width - margin, y, f"{tva:.2f} €") y -= 8 * mm # Ligne de séparation pdf.setStrokeColor(gray_400) pdf.line(totals_x, y + 2 * mm, width - margin, y + 2 * mm) # Net à payer pdf.setFont("Helvetica-Bold", 12) pdf.setFillColor(green_color) pdf.drawString(totals_x, y, "Net à payer") pdf.drawRightString(width - margin, y, f"{total_ttc:.2f} €") # ===== NOTES ===== notes = doc.get("notes_publique") or doc.get("notes") if notes: y -= 15 * mm pdf.setStrokeColor(gray_400) pdf.line(margin, y + 5 * mm, width - margin, y + 5 * mm) y -= 5 * mm pdf.setFont("Helvetica-Bold", 8) pdf.setFillColor(gray_400) pdf.drawString(margin, y, "NOTES & CONDITIONS") y -= 5 * mm pdf.setFont("Helvetica", 8) pdf.setFillColor(gray_600) # Gérer les sauts de ligne dans les notes for line in notes.split("\n"): if y < 25 * mm: break pdf.drawString(margin, y, line[:100]) y -= 4 * mm # ===== FOOTER ===== pdf.setFont("Helvetica", 7) pdf.setFillColor(gray_400) pdf.drawCentredString(width / 2, 15 * mm, "Page 1 / 1") pdf.save() buffer.seek(0) return buffer.read() email_queue = EmailQueue()