erplibre/script/todo/mail/imap_transport.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

339 lines
12 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La seule couche qui parle vraiment IMAP.
Choix qui explique tout le reste du fichier : on ne décode PAS `ENVELOPE`.
`BODY.PEEK[HEADER.FIELDS (...)]` rend des en-têtes RFC822 bruts, que le module
`email` de la stdlib sait déjà lire — encodages, mots encodés, dates comprises.
Analyser `ENVELOPE` à la main coûterait cent lignes de plus, toutes fausses
sur un cas limite ou l'autre.
`BODY.PEEK` et non `BODY` : lire un message dans le TUI ne doit pas le marquer
lu sur le serveur à l'insu de l'utilisateur.
"""
from __future__ import annotations
import email
import email.utils
import re
from email.header import decode_header
from email.parser import BytesHeaderParser
from script.todo.mail.charset import decode_bytes
from script.todo.mail.imap_sync import FolderInfo, HeaderInfo, SelectInfo
from script.todo.todo_i18n import t
HEADER_FIELDS = "FROM TO SUBJECT DATE MESSAGE-ID"
SPECIAL_USE = {
"\\Sent": "sent",
"\\Drafts": "drafts",
"\\Trash": "trash",
"\\Junk": "junk",
"\\Archive": "archive",
"\\All": "archive",
}
_UID_RE = re.compile(rb"UID\s+(\d+)")
_SIZE_RE = re.compile(rb"RFC822\.SIZE\s+(\d+)")
_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)")
_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+("[^"]*"|NIL)\s+(.*)$')
class ImapError(Exception):
"""Le serveur a refusé, ou a répondu quelque chose d'inattendu."""
def decode_header_value(raw: str | None) -> str:
"""Un en-tête RFC 2047 rendu en texte lisible, sans jamais lever."""
if not raw:
return ""
try:
parts = decode_header(raw)
except Exception:
# `raw` vient du serveur : un en-tête mal formé ne doit jamais faire
# tomber l'affichage d'un message.
return str(raw)
out = []
for value, charset in parts:
if isinstance(value, bytes):
out.append(decode_bytes(value, charset))
else:
out.append(value)
return "".join(out).strip()
def decode_mailbox(name: str) -> str:
"""Nom de boîte en UTF-7 modifié (RFC 3501) rendu lisible.
Sur entrée invalide on rend le nom d'origine : un affichage imparfait vaut
mieux qu'un dossier qu'on n'arrive plus à sélectionner.
"""
if "&" not in name:
return name
try:
out = []
for chunk in name.split("&"):
if not out:
out.append(chunk)
continue
encoded, sep, rest = chunk.partition("-")
if not sep:
raise ValueError(t("mail_err_unterminated_ampersand"))
if encoded == "":
out.append("&" + rest)
else:
pad = "=" * (-len(encoded) % 4)
decoded = (encoded.replace(",", "/") + pad).encode("ascii")
import base64
out.append(
base64.b64decode(decoded).decode("utf-16-be") + rest
)
return "".join(out)
except Exception:
# Nom de boîte mal formé : on garde l'original plutôt que de perdre
# l'accès au dossier pour une simple erreur d'affichage.
return name
def parse_list_line(line: bytes) -> FolderInfo:
"""Une ligne de réponse LIST → nom, nom affichable, rôle."""
match = _LIST_RE.match(line.strip())
if not match:
raw_name = line.decode("utf-8", "replace").strip().strip('"')
return FolderInfo(name=raw_name, display=decode_mailbox(raw_name))
flags = match.group(1).decode("ascii", "replace").split()
name = match.group(3).decode("utf-8", "replace").strip().strip('"')
role = next((SPECIAL_USE[f] for f in flags if f in SPECIAL_USE), None)
if role is None and name.upper() == "INBOX":
role = "inbox"
bas = {f.lower() for f in flags}
return FolderInfo(
name=name,
display=decode_mailbox(name),
role=role,
selectable=not (bas & {"\\noselect", "\\nonexistent"}),
)
def parse_fetch_headers(data: list) -> list[HeaderInfo]:
"""Réponse FETCH d'en-têtes → une liste de `HeaderInfo`."""
parser = BytesHeaderParser()
out = []
for index, item in enumerate(data):
if not isinstance(item, tuple) or len(item) < 2:
continue
prefix, raw_headers = item[0], item[1]
# Les attributs peuvent SUIVRE le littéral au lieu de le précéder :
# RFC 3501 n'impose aucun ordre, et `imaplib` rend alors la fin de la
# ligne dans l'entrée suivante, hors du tuple. Ne lire que le préfixe
# ferait disparaître le message SANS erreur — et comme le moteur
# avance `last_uid` derrière, il ne serait jamais réessayé.
trailer = b""
if index + 1 < len(data) and isinstance(
data[index + 1], (bytes, bytearray)
):
trailer = bytes(data[index + 1])
meta = bytes(prefix) + b" " + trailer
uid_match = _UID_RE.search(meta)
if not uid_match:
continue
size_match = _SIZE_RE.search(meta)
flags_match = _FLAGS_RE.search(meta)
msg = parser.parsebytes(raw_headers)
date_raw = msg.get("Date")
try:
# `str()` d'abord : un `Date:` porteur d'octets 8 bits bruts —
# vu en boîte réelle — fait renvoyer un `Header` et non une
# chaîne, et `parsedate_to_datetime` y lève un AttributeError
# que ce `except` ne rattrapait pas. Une seule date illisible
# emportait alors la synchro du dossier ENTIER.
stamp = (
int(
email.utils.parsedate_to_datetime(
str(date_raw)
).timestamp()
)
if date_raw
else 0
)
except (TypeError, ValueError, AttributeError, OverflowError):
# Une date est une commodité d'affichage : aucune valeur
# d'en-tête ne justifie de perdre le message.
stamp = 0
out.append(
HeaderInfo(
uid=int(uid_match.group(1)),
date=stamp,
size=int(size_match.group(1)) if size_match else 0,
flags=(
flags_match.group(1).decode("ascii", "replace")
if flags_match
else ""
),
msgid=(msg.get("Message-ID") or "").strip(),
frm=decode_header_value(msg.get("From")),
to=decode_header_value(msg.get("To")),
subject=decode_header_value(msg.get("Subject")),
)
)
return out
class ImaplibTransport:
"""`ImapTransport` réalisé sur `imaplib`. Le client est injecté : les tests
passent un double, la production passe une connexion TLS."""
def __init__(self, client) -> None:
self.client = client
@staticmethod
def _ok(result, label: str):
status, data = result
if status != "OK":
raise ImapError(
f"{label} {t('mail_err_server_replied')} {status} ({data!r})"
)
return data
def list_folders(self) -> list[FolderInfo]:
data = self._ok(self.client.list(), "LIST")
return [parse_list_line(line) for line in data if line]
def select(self, folder: str) -> SelectInfo:
data = self._ok(self.client.select(f'"{folder}"'), f"SELECT {folder}")
exists = int(data[0]) if data and data[0] else 0
return SelectInfo(
uidvalidity=int(
self._first(self.client.response("UIDVALIDITY")) or 0
),
uidnext=int(self._first(self.client.response("UIDNEXT")) or 0),
exists=exists,
)
@staticmethod
def _first(response) -> bytes | None:
_, data = response
return data[0] if data and data[0] else None
def search_uids(self, since_uid: int) -> list[int]:
data = self._ok(
self.client.uid("SEARCH", None, f"UID {since_uid}:*"), "SEARCH"
)
raw = (data[0] or b"").split()
# `UID n:*` rend toujours au moins un UID, même inférieur à n quand la
# boîte est plus courte : on refiltre côté client.
return [int(u) for u in raw if int(u) >= since_uid]
def fetch_headers(self, uids: list[int]) -> list[HeaderInfo]:
if not uids:
return []
spec = f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS ({HEADER_FIELDS})])"
data = self._ok(
self.client.uid("FETCH", ",".join(str(u) for u in uids), spec),
"FETCH HEADERS",
)
return parse_fetch_headers(data)
def fetch_flags(self, uids: list[int]) -> list[tuple[int, str]]:
if not uids:
return []
data = self._ok(
self.client.uid(
"FETCH", ",".join(str(u) for u in uids), "(UID FLAGS)"
),
"FETCH FLAGS",
)
out = []
for line in data:
raw = line[0] if isinstance(line, tuple) else line
if not isinstance(raw, (bytes, bytearray)):
continue
uid_match = _UID_RE.search(raw)
flags_match = _FLAGS_RE.search(raw)
if uid_match:
out.append(
(
int(uid_match.group(1)),
(
flags_match.group(1).decode("ascii", "replace")
if flags_match
else ""
),
)
)
return out
def fetch_body(self, uid: int) -> bytes:
data = self._ok(
self.client.uid("FETCH", str(uid), "(BODY.PEEK[])"), "FETCH BODY"
)
for item in data:
if isinstance(item, tuple) and len(item) >= 2:
return item[1]
raise ImapError(f"{t('mail_err_no_body_for_uid')} {uid}")
def store_flags(self, uid: int, add: list[str], remove: list[str]) -> None:
if add:
self._ok(
self.client.uid(
"STORE", str(uid), "+FLAGS", f"({' '.join(add)})"
),
"STORE +FLAGS",
)
if remove:
self._ok(
self.client.uid(
"STORE", str(uid), "-FLAGS", f"({' '.join(remove)})"
),
"STORE -FLAGS",
)
def append(self, folder: str, raw: bytes, flags: list[str]) -> None:
self._ok(
self.client.append(
f'"{folder}"', f"({' '.join(flags)})", None, raw
),
f"APPEND {folder}",
)
def logout(self) -> None:
"""Fermer proprement est souhaitable, pas indispensable : on n'échoue
jamais sur la sortie."""
try:
self.client.logout()
except Exception:
# Best-effort : la connexion peut déjà être fermée par le serveur.
pass
def connect(account, password: str) -> ImaplibTransport:
"""Ouvre une connexion TLS et se connecte. Lève `ImapError` sur refus."""
import imaplib
conf = account.imap
try:
if conf.security == "ssl":
client = imaplib.IMAP4_SSL(conf.host, conf.port, timeout=30)
else:
client = imaplib.IMAP4(conf.host, conf.port, timeout=30)
if conf.security == "starttls":
client.starttls()
client.login(conf.user, password)
except UnicodeEncodeError as exc:
# `imaplib` encode la commande LOGIN en ASCII : un mot de passe
# accentué n'atteint même pas le serveur. Ce cas sort AVANT le
# rattrapage général, dont le préfixe dit « refusée » — or personne
# n'a rien refusé, et l'ancien message « ordinal not in range(128) »
# accusait le serveur d'un refus qu'il n'a jamais prononcé.
raise ImapError(t("mail_err_password_not_ascii")) from exc
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 ImapError(
f"{t('mail_err_imap_connection_prefix')} {conf.host}"
f" {t('mail_err_connection_refused_suffix')} {exc}"
) from exc
return ImaplibTransport(client)