"""Construction et remise des emails. `deliver_many` est le point d'injection unique (remplacé dans les tests). La remise est « best effort » : une erreur SMTP est journalisée (le message du serveur, pas de PII) et ne remonte pas — l'appel HTTP n'échoue pas pour ça. Pour diagnostiquer : `python -m app.email.check `.""" import logging import smtplib import ssl from collections.abc import Iterable, Mapping, Sequence from email.message import EmailMessage from app.config import Settings, get_settings from app.email.html import LOGO_CID, render_html logger = logging.getLogger("app.email") def _smtp_error_summary(exc: smtplib.SMTPException) -> str: """Résumé RGPD-safe d'une erreur SMTP : codes/messages serveur seulement. Certaines exceptions de `smtplib` embarquent l'adresse du destinataire dans leurs `args` (notamment `SMTPRecipientsRefused.recipients`, un `{adresse: (code, message)}`) — `str(exc)` les journaliserait telles quelles. On n'en garde que les codes/messages, jamais les adresses. """ if isinstance(exc, smtplib.SMTPRecipientsRefused): codes = sorted({code for code, _msg in exc.recipients.values()}) return f"destinataire(s) refusé(s), code(s) {codes}" if isinstance(exc, smtplib.SMTPResponseException): return f"{exc.smtp_code} {exc.smtp_error!r}" return exc.__class__.__name__ # (octets, content-type, nom de fichier) d'une pièce jointe. Attachment = tuple[bytes, str, str] def build_message( to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None, attachments: Sequence[Attachment] = (), html_body: str | None = None, html_blocks: Mapping[str, str] | None = None, ) -> EmailMessage: """`html_body` / `html_blocks` : corps de la version HTML quand il diffère du texte — repères remplacés par des blocs HTML déjà construits (ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ; le texte brut garde sa propre version lisible. `logo` = (octets, content-type) (voir `app/email/branding.py: edition_logo`) : ajoute une version HTML au thème de l'appli (`app/email/html.py`) en alternative au texte brut — le texte reste la version de référence, envoyée telle quelle si aucun client ne préfère le HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline » (Content-ID) référencée par la partie HTML — jamais une URL externe, qui aurait exposé le jeton d'accès du formulaire public à quiconque lit la source du message (voir `app/email/branding.py`). `attachments` : vraies pièces jointes (ex. reçu fiscal mécénat), ajoutées après le logo — le message devient alors `multipart/mixed`.""" message = EmailMessage() message["From"] = get_settings().smtp_from message["To"] = to message["Subject"] = subject message.set_content(body) message.add_alternative( render_html( subject=subject, body=html_body if html_body is not None else body, has_logo=logo is not None, blocks=html_blocks, ), subtype="html", ) if logo is not None: content, content_type = logo maintype, _, subtype = content_type.partition("/") html_part = message.get_payload()[-1] html_part.add_related( content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>" ) for content, content_type, filename in attachments: maintype, _, subtype = content_type.partition("/") message.add_attachment(content, maintype=maintype, subtype=subtype, filename=filename) return message def open_smtp(settings: Settings) -> smtplib.SMTP: """Ouvre une session SMTP authentifiée. Lève en cas d'échec.""" use_ssl = settings.smtp_ssl use_starttls = settings.smtp_starttls if settings.smtp_port == 465 and not use_ssl: # Le port 465 (SMTPS, RFC 8314) parle TLS d'emblée : STARTTLS échoue. logger.info("Port 465 → SSL implicite (SMTP_SSL/STARTTLS ignorés).") use_ssl, use_starttls = True, False context = ssl.create_default_context() if use_ssl: smtp: smtplib.SMTP = smtplib.SMTP_SSL( settings.smtp_host, settings.smtp_port, timeout=30, context=context ) else: smtp = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) if use_starttls: smtp.starttls(context=context) if settings.smtp_user: smtp.login(settings.smtp_user, settings.smtp_password) return smtp def deliver_many(messages: list[EmailMessage]) -> None: settings = get_settings() if not settings.email_enabled: # RGPD : ni destinataire ni corps dans les logs. logger.info( "Emails non envoyés (EMAIL_ENABLED=false) — %d message(s)", len(messages), ) return if not messages: return try: smtp = open_smtp(settings) except (OSError, smtplib.SMTPException) as exc: logger.error( "SMTP indisponible (%s:%s, ssl=%s starttls=%s) : %s", settings.smtp_host, settings.smtp_port, settings.smtp_ssl, settings.smtp_starttls, exc, ) return sent = 0 with smtp: for message in messages: try: smtp.send_message(message) sent += 1 except smtplib.SMTPException as exc: # Ex. « 550 sender not authorized », « 535 auth failed ». logger.error( "Email refusé par le serveur (From=%s) : %s", message["From"], _smtp_error_summary(exc), ) # RGPD : ni destinataire, ni corps, ni sujet — un sujet de récapitulatif # de planning (`app/api/notifications.py`) peut être personnalisé par la # coordination avec le nom du bénévole (`{prenom}`/`{nom}`), ce ne serait # alors plus un simple texte d'interface. logger.info("Emails remis : %d/%d", sent, len(messages)) def deliver_test(message: EmailMessage) -> tuple[str, str | None]: """Envoi d'un email de test, **synchrone** et sans « best effort » : renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la coordination voie tout de suite ce qui ne va pas (`POST .../notifications/test`). Statuts : `sent`, `disabled` (`EMAIL_ENABLED=false`), `connection_failed` (connexion ou authentification), `rejected` (le serveur a refusé le message). Détail : message du serveur ou type d'erreur — jamais l'adresse du destinataire (`_smtp_error_summary`).""" settings = get_settings() if not settings.email_enabled: logger.info("Email de test non envoyé (EMAIL_ENABLED=false)") return "disabled", None try: smtp = open_smtp(settings) except smtplib.SMTPException as exc: logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc)) return "connection_failed", _smtp_error_summary(exc) except OSError as exc: logger.error("Email de test : SMTP injoignable : %s", exc) return "connection_failed", f"{exc.__class__.__name__} : {exc}" with smtp: try: smtp.send_message(message) except smtplib.SMTPException as exc: logger.error( "Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc) ) return "rejected", _smtp_error_summary(exc) logger.info("Email de test remis") return "sent", None def send( to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None, attachments: Sequence[Attachment] = (), ) -> None: deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)]) def send_messages(messages: list[EmailMessage]) -> None: """Remet des messages déjà construits (`build_message`) — ex. le récapitulatif de planning, dont la version HTML porte un tableau.""" deliver_many(messages) def send_many( items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None ) -> None: """`items` = itérable de `(destinataire, sujet, corps)` — même logo pour tous (un seul appel par édition, voir `app/api/notifications.py`).""" deliver_many([build_message(*item, logo=logo) for item in items])