Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout de suite un email de test, résultat réel affiché (envoyé, désactivé, connexion ou message refusé, réponse du serveur SMTP) — POST .../notifications/test. Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ; un bénévole sans nom s'affiche « Sans nom » / « ? » partout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
7.6 KiB
Python
198 lines
7.6 KiB
Python
"""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 <destinataire>`."""
|
|
|
|
import logging
|
|
import smtplib
|
|
import ssl
|
|
from collections.abc import Iterable, 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] = (),
|
|
) -> EmailMessage:
|
|
"""`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=body, has_logo=logo is not None),
|
|
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_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])
|