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
294 lines
8.8 KiB
Python
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
|