erplibre/script/todo/mail/smtp_send.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

294 lines
8.8 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Construire un message et le remettre à un serveur SMTP.
`EmailMessage` fait le gros du travail : encodage des en-têtes accentués,
choix du transfert, structure multipart. On se contente de décider QUOI mettre
dedans — et surtout de ne pas mettre le Cci dans les en-têtes, où il cesserait
d'être caché tout en restant destinataire d'enveloppe.
`date` et `msgid` sont injectables pour que les tests soient déterministes ;
en production on laisse la stdlib les produire.
"""
from __future__ import annotations
import mimetypes
from email.message import EmailMessage
from email.utils import formatdate, getaddresses, make_msgid
from pathlib import Path
from typing import Protocol
from script.todo.mail.charset import decode_bytes
from script.todo.todo_i18n import t
MAX_QUOTE_LINES = 200
class SmtpError(Exception):
"""Message impossible à construire, ou serveur qui refuse."""
def _as_list(value) -> list[str]:
if not value:
return []
if isinstance(value, str):
return [value]
return list(value)
def _addresses(*header_values) -> list[str]:
pairs = getaddresses([v for v in header_values if v])
return [addr for _, addr in pairs if addr]
def build_message(
account,
to,
subject: str,
body: str,
*,
cc=None,
bcc=None,
attachments=None,
in_reply_to: str | None = None,
references: str | None = None,
date: str | None = None,
msgid: str | None = None,
) -> EmailMessage:
to_list = _as_list(to)
cc_list = _as_list(cc)
bcc_list = _as_list(bcc)
if not (to_list or cc_list or bcc_list):
raise SmtpError(t("mail_err_message_needs_recipient"))
msg = EmailMessage()
msg["From"] = account.from_header()
if to_list:
msg["To"] = ", ".join(to_list)
if cc_list:
msg["Cc"] = ", ".join(cc_list)
msg["Subject"] = subject
msg["Date"] = date or formatdate(localtime=True)
msg["Message-ID"] = msgid or make_msgid(
domain=account.email.split("@")[-1]
)
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
if references:
msg["References"] = references
msg.set_content(body)
# Le Cci ne va PAS dans les en-têtes : il ne vit que dans l'enveloppe
# SMTP, que `recipients()` reconstitue.
if bcc_list:
msg["X-ERPLibre-Bcc"] = ", ".join(bcc_list)
for path in attachments or []:
_attach_file(msg, Path(path))
return msg
def _attach_file(msg: EmailMessage, path: Path) -> None:
if not path.is_file():
raise SmtpError(f"{t('mail_err_attachment_missing')} {path}")
guessed, _ = mimetypes.guess_type(path.name)
maintype, _, subtype = (guessed or "application/octet-stream").partition(
"/"
)
msg.add_attachment(
path.read_bytes(),
maintype=maintype,
subtype=subtype or "octet-stream",
filename=path.name,
)
def _plain_text(message) -> str:
"""Le texte d'un message, quel que soit son emballage."""
if isinstance(message, EmailMessage):
part = message.get_body(("plain",))
if part is not None:
return part.get_content()
if message.is_multipart():
for part in message.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True) or b""
# `decode_bytes` (voir sa docstring) : un charset mal
# étiqueté ne doit pas faire tomber la réponse ou le
# transfert.
return decode_bytes(payload, part.get_content_charset())
return ""
payload = message.get_payload(decode=True)
if payload is None:
return str(message.get_payload())
return decode_bytes(payload, message.get_content_charset())
def _quote(text: str) -> str:
lines = text.splitlines()[:MAX_QUOTE_LINES]
return "\n".join(f"> {line}" for line in lines)
def build_reply(
account,
message,
body: str,
*,
reply_all: bool = False,
date: str | None = None,
msgid: str | None = None,
) -> EmailMessage:
subject = message.get("Subject", "")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
to = [message.get("Reply-To") or message.get("From", "")]
cc = []
if reply_all:
mine = account.email.lower()
others = [
addr
for addr in _addresses(message.get("To"), message.get("Cc"))
if addr.lower() != mine
]
already = {a.lower() for a in _addresses(*to)}
cc = [a for a in others if a.lower() not in already]
parent_id = (message.get("Message-ID") or "").strip()
references = " ".join(
part
for part in [(message.get("References") or "").strip(), parent_id]
if part
)
return build_message(
account,
to,
subject,
f"{body}\n\n{_quote(_plain_text(message))}\n",
cc=cc,
in_reply_to=parent_id or None,
references=references or None,
date=date,
msgid=msgid,
)
def build_forward(
account,
message,
to,
body: str,
*,
date: str | None = None,
msgid: str | None = None,
) -> EmailMessage:
subject = message.get("Subject", "")
if not subject.lower().startswith("fwd:"):
subject = f"Fwd: {subject}"
msg = build_message(account, to, subject, body, date=date, msgid=msgid)
forwarded = message
if not isinstance(forwarded, EmailMessage):
import email
forwarded = email.message_from_bytes(
message.as_bytes(), _class=EmailMessage
)
# `add_attachment` dispatche vers `set_message_content` pour un `Message` :
# ce gestionnaire n'accepte pas `maintype` (toujours "message" pour lui),
# seulement `subtype` — le passer lève `TypeError` à chaque appel.
msg.add_attachment(forwarded, subtype="rfc822")
return msg
def recipients(msg) -> list[str]:
"""Les destinataires d'enveloppe : To, Cc et le Cci gardé à part."""
seen, out = set(), []
for addr in _addresses(
msg.get("To"), msg.get("Cc"), msg.get("X-ERPLibre-Bcc")
):
low = addr.lower()
if low not in seen:
seen.add(low)
out.append(addr)
return out
class SmtpTransport(Protocol):
def send_message(
self, msg, from_addr: str, to_addrs: list[str]
) -> None: ...
def quit(self) -> None: ...
class SmtplibTransport:
def __init__(self, client) -> None:
self.client = client
def send_message(self, msg, from_addr: str, to_addrs: list[str]) -> None:
self.client.send_message(msg, from_addr=from_addr, to_addrs=to_addrs)
def quit(self) -> None:
try:
self.client.quit()
except Exception:
# Best-effort : la connexion peut déjà être fermée par le serveur.
pass
def connect(account, password: str) -> SmtplibTransport:
import smtplib
conf = account.smtp
try:
if conf.security == "ssl":
client = smtplib.SMTP_SSL(conf.host, conf.port, timeout=30)
else:
client = smtplib.SMTP(conf.host, conf.port, timeout=30)
if conf.security == "starttls":
client.starttls()
client.login(conf.user, password)
except Exception as exc:
# Toute panne réseau ou d'authentification devient une seule erreur
# de haut niveau, pour un message utile à l'utilisateur.
raise SmtpError(
f"{t('mail_err_smtp_connection_prefix')} {conf.host}"
f" {t('mail_err_connection_refused_suffix')} {exc}"
) from exc
return SmtplibTransport(client)
def send(account, msg, transport: SmtpTransport) -> list[str]:
"""Remet le message. Rend les destinataires servis, lève `SmtpError` sinon."""
to_addrs = recipients(msg)
if not to_addrs:
raise SmtpError(t("mail_err_no_recipient_nothing_sent"))
outgoing = without_bcc(msg)
try:
transport.send_message(outgoing, account.email, to_addrs)
except Exception as exc:
# Le serveur peut refuser pour mille raisons (auth, quota,
# destinataire rejeté) : une seule erreur de haut niveau, avec le
# texte du serveur conservé pour l'utilisateur.
raise SmtpError(f"{t('mail_err_send_refused')} {exc}") from exc
return to_addrs
def without_bcc(msg):
"""Une copie sans le porte-Cci interne.
PUBLIQUE à dessein : `send()` n'est pas le seul chemin par lequel le
message quitte la machine. La copie déposée dans le dossier Envoyés part
par IMAP, et si elle gardait `X-ERPLibre-Bcc` le Cci serait lisible sur le
serveur — la même fuite, par une autre porte.
"""
if msg.get("X-ERPLibre-Bcc") is None:
return msg
import copy
clone = copy.deepcopy(msg)
del clone["X-ERPLibre-Bcc"]
return clone