erplibre/script/todo/mail/charset.py
Mathieu Benoit 16aecc2d33 [ADD] mail: read and send email from the TODO CLI
An IMAP/SMTP client in the menu, with its tests against real servers. Most
of the work went into refusals: an application password is named only when
the server actually refuses, an accented password is reported as never
having left the machine, and a refusal is recognised by what the server
SAYS rather than by matching its wording. A malformed date no longer takes
the whole folder down, and a received email is never read as markup.

--- FR ---

Un client IMAP/SMTP dans le menu, avec ses tests contre de vrais serveurs.
L'essentiel du travail porte sur les refus : le mot de passe d'application
n'est nommé que lorsque le serveur refuse vraiment, un mot de passe accentué
est signalé comme n'ayant jamais quitté la machine, et un refus se reconnaît
à ce que le serveur DIT plutôt qu'à ses mots. Une date illisible n'emporte
plus le dossier entier, et un courriel reçu n'est jamais lu comme du balisage.

Assisted-by: Claude Opus 5
2026-08-16 03:56:34 -04:00

28 lines
1.3 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Décoder des octets dont le nom de charset vient du serveur, sans jamais lever.
Un charset annoncé par un en-tête (`Content-Type`, un mot encodé RFC 2047)
peut être n'importe quelle chaîne — y compris une étiquette que Python ne
reconnaît pas, comme `unknown-8bit`, que certains MTA posent sur un en-tête
8 bits mal formé. `bytes.decode(charset, errors="replace")` lève quand même
`LookupError` dans ce cas : la RECHERCHE du codec échoue AVANT que `errors`
ne soit consulté — `errors="replace"` ne protège donc de rien ici.
Cette fonction a été réinventée quatre fois dans ce paquet
(`imap_transport.decode_header_value`, `imap_sync.snippet_from_raw`,
`smtp_send._plain_text`, `tui_text._decode_part`) avant d'être extraite ici :
un charset non fiable ne doit jamais faire tomber l'affichage ou la
synchronisation d'un message entier.
"""
from __future__ import annotations
def decode_bytes(payload: bytes, charset: str | None) -> str:
"""`payload` décodé avec `charset`, replié sur UTF-8 si `charset` est
absent ou inconnu de Python."""
try:
return payload.decode(charset or "utf-8", "replace")
except LookupError:
return payload.decode("utf-8", "replace")