erplibre/script/todo/mail/menu.py

552 lines
19 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les entrées de menu du client courriel.
Ce module est le SEUL point de contact entre le paquet `mail` et le CLI :
`todo.py` importe `prompt_execute_mail` et rien d'autre. Le sens de la
dépendance est volontaire — `mail` ne doit jamais importer `todo`.
"""
from __future__ import annotations
import getpass
import logging
import os
import shutil
from pathlib import Path
import click
from script.todo import todo_prefs
from script.todo.mail import account_setup
from script.todo.mail import accounts as mail_accounts
from script.todo.mail.accounts import PRESETS, AccountError
from script.todo.mail.secrets import SecretError, SecretStore
from script.todo.mail.store import Store, StoreError, resolve_mode
from script.todo.todo_i18n import t
CACHE_MODES = ("clear", "encrypted", "ephemeral")
# Chemin proposé par défaut pour un kdbx nouvellement créé — voir
# `account_setup.py`, la seule source de vérité, aussi utilisée par le TUI.
_DEFAULT_KDBX_PATH = account_setup.DEFAULT_KDBX_PATH
# `True` une fois `_configure_mail_logging` passée : évite d'empiler un
# second `FileHandler` si l'utilisateur rouvre le menu courriel plusieurs
# fois dans le même processus `todo`.
_LOG_CONFIGURED = False
def mail_log_path() -> Path:
"""Le chemin du journal du paquet `mail` — SOURCE UNIQUE, pour que
`_configure_mail_logging` (ci-dessous) et `tui.LogScreen` (touche `l`,
qui affiche sa fin) ne puissent jamais en dériver deux formules
différentes."""
return Path(os.path.expanduser("~/.erplibre")) / "mail.log"
def _configure_mail_logging() -> None:
"""Branche le journal du paquet `mail` sur `mail_log_path()`.
Les modules du paquet (`imap_sync.py`, `tui.py`, ...) ne font
qu'appeler `_logger.exception(...)` : brancher un GESTIONNAIRE est le
travail de L'APPLICATION, pas d'une bibliothèque — c'est pourquoi cette
fonction vit ici, au seul point d'entrée du paquet (voir le docstring du
module), et jamais dans `mail/*.py`. Jamais vers la console non plus :
Textual possède le terminal pendant tout le TUI, et une ligne de log qui
s'y mêlerait corromprait l'affichage.
"""
global _LOG_CONFIGURED
if _LOG_CONFIGURED:
return
log_path = mail_log_path()
log_path.parent.mkdir(parents=True, exist_ok=True)
handler = logging.FileHandler(log_path)
handler.setFormatter(
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
)
# Sur le logger PARENT de tout le paquet (`script.todo.mail`, préfixe
# commun de `script.todo.mail.tui`, `script.todo.mail.imap_sync`, ...) :
# un seul gestionnaire couvre tous les modules, sans qu'aucun d'eux
# n'ait à en connaître l'existence.
logger = logging.getLogger("script.todo.mail")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
# `todo.py` appelle `logging.basicConfig()` à l'IMPORT (ligne 68), ce qui
# pose un `StreamHandler` sur le logger RACINE. `propagate` vaut `True`
# par défaut : sans cette ligne, chaque `_logger.exception(...)` du
# paquet remonterait AUSSI jusqu'à ce gestionnaire — donc sur le
# terminal que Textual possède pendant tout le TUI, silencieusement.
# Constaté pour de vrai : la suite complète l'a fait fuir dans la sortie
# pointillée d'`unittest` dès qu'un fichier de test important déjà
# `script.todo.todo` tournait avant les tests courriel dans le même
# processus.
logger.propagate = False
_LOG_CONFIGURED = True
def secret_store_for(todo) -> SecretStore:
"""Le coffre du CLI : son kdbx s'il en a un, sinon le trousseau système."""
manager = getattr(todo, "kdbx_manager", None)
return SecretStore(kdbx_manager=manager, use_keyring=True)
def cache_summary(accounts, base=None, prefs_get=None) -> list[dict]:
"""Nom, mode effectif et taille sur disque, pour l'écran de cache."""
rows = []
for account in accounts:
mode = resolve_mode(account, prefs_get)
size = 0
try:
root = Store(account, mode=mode, base=base).root
if root.is_dir():
size = sum(
p.stat().st_size for p in root.rglob("*") if p.is_file()
)
except Exception:
# Un cache absent, un lien symbolique refusé ou un disque
# inaccessible ne doivent pas empêcher d'afficher les AUTRES
# comptes : la taille retombe simplement à zéro pour celui-ci.
size = 0
rows.append({"name": account.name, "mode": mode, "size": size})
return rows
def _load_accounts():
try:
return mail_accounts.load()
except AccountError as exc:
print(exc)
return []
def prompt_execute_mail(todo) -> None:
_configure_mail_logging()
while True:
help_info = f"""{todo._menu_header()}
[1] {t("mail_open_tui")}
[2] {t("mail_accounts_menu")}
[3] {t("mail_sync_now")}
[4] {t("mail_cache_menu")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
_open_tui(todo)
elif status == "2":
prompt_mail_accounts(todo)
elif status == "3":
_sync_now(todo)
elif status == "4":
prompt_mail_cache(todo)
else:
print(t("Command not found !"))
def _open_tui(todo) -> None:
from script.todo.mail.tui import open_sessions, run_tui
accounts = _load_accounts()
secrets = secret_store_for(todo)
sessions = open_sessions(accounts, secrets)
try:
# Un TUI sans aucun compte n'est plus une impasse : `config_file` et
# `secrets` lui permettent d'en créer un depuis l'écran d'ajout.
run_tui(
sessions=sessions,
config_file=todo.config_file,
secret_store=secrets,
)
finally:
for session in sessions:
session.close()
def _sync_now(todo) -> None:
from script.todo.mail.tui import open_sessions
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return
sessions = open_sessions(accounts, secret_store_for(todo))
try:
for session in sessions:
if not session.online:
print(f"{session.account.name} : {session.error}")
continue
report = session.sync()
print(
f"{session.account.name} : {report.new_messages}"
f" {t('mail_new_messages')}"
)
for error in report.errors:
print(f" {error}")
if report.purged:
print(
f" {t('mail_folders_resynced')}"
f" {', '.join(report.purged)}"
)
finally:
for session in sessions:
session.close()
def prompt_mail_accounts(todo) -> None:
while True:
help_info = f"""{todo._menu_header()}
[1] {t("mail_account_list")}
[2] {t("mail_account_add")}
[3] {t("mail_account_delete")}
[4] {t("mail_account_template")}
[5] {t("mail_account_test")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
_list_accounts()
elif status == "2":
_add_account(todo)
elif status == "3":
_delete_account(todo)
elif status == "4":
_write_template()
elif status == "5":
_test_account(todo)
else:
print(t("Command not found !"))
def _list_accounts() -> None:
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return
for account in accounts:
mark = "" if account.enabled else " (désactivé)"
print(
f" {account.name}{mark} — {account.email}"
f" — {account.imap.host} / {account.smtp.host}"
)
def _ensure_kdbx(todo) -> bool:
"""Vrai si un kdbx est utilisable pour la suite de `_add_account`.
Si `kdbx.path` est déjà configuré, ne pose aucune question — c'est le
cas courant après la première utilisation. Sinon, offre les deux choix
promis par la conception : créer un nouveau `.kdbx` ou en choisir un
existant.
"""
if todo.config_file.get_config_value(["kdbx", "path"]):
return True
print(t("mail_kdbx_none_configured"))
print(f" [1] {t('mail_kdbx_menu_create')}")
print(f" [2] {t('mail_kdbx_menu_choose')}")
print(f" [0] {t('mail_kdbx_menu_cancel')}")
choice = input(t("mail_kdbx_ask_choice")).strip()
if choice == "1":
return _create_kdbx_interactive(todo)
if choice == "2":
return _choose_kdbx_interactive(todo)
return False
def _create_kdbx_interactive(todo) -> bool:
prompt = f"{t('mail_kdbx_ask_path_new')} [{_DEFAULT_KDBX_PATH}]: "
path = input(prompt).strip() or _DEFAULT_KDBX_PATH
password = getpass.getpass(t("mail_kdbx_ask_password"))
confirm = getpass.getpass(t("mail_kdbx_ask_password_confirm"))
if password != confirm:
print(t("mail_kdbx_password_mismatch"))
return False
try:
account_setup.create_vault(todo.config_file, path, password)
except SecretError as exc:
print(exc)
return False
print(f"{t('mail_kdbx_created')} {path}")
return True
def _choose_kdbx_interactive(todo) -> bool:
path = input(t("mail_kdbx_ask_path_existing")).strip()
try:
account_setup.use_existing_vault(todo.config_file, path)
except SecretError:
print(f"{t('mail_kdbx_path_not_found')} {path}")
return False
print(f"{t('mail_kdbx_path_recorded')} {path}")
return True
def _add_account(todo) -> None:
if not _ensure_kdbx(todo):
return
store = secret_store_for(todo)
if not store.available_backends():
print(t("mail_no_vault"))
return
name = input(t("mail_ask_name")).strip()
email_addr = input(t("mail_ask_email")).strip()
display = input(t("mail_ask_display_name")).strip()
keys = list(PRESETS)
for index, key in enumerate(keys, start=1):
print(f" [{index}] {PRESETS[key]['label']}")
choice = input(t("mail_ask_preset")).strip()
try:
preset_key = keys[int(choice) - 1]
except (ValueError, IndexError):
preset_key = "generic"
vault = "kdbx" if "kdbx" in store.available_backends() else "keyring"
try:
account = mail_accounts.account_from_preset(
name, email_addr, preset_key, display_name=display, vault=vault
)
except AccountError as exc:
print(exc)
return
if preset_key == "generic":
account.imap.host = input(t("mail_ask_imap_host")).strip()
account.smtp.host = input(t("mail_ask_smtp_host")).strip()
attendu_app = bool(PRESETS[preset_key]["app_password"])
if attendu_app:
print(t("mail_app_password_note"))
print(f" {t(PRESETS[preset_key]['note_key'])}")
password = getpass.getpass(
t("mail_ask_app_password" if attendu_app else "mail_ask_password")
)
existing = [a for a in _load_accounts() if a.name != account.name]
try:
account_setup.save_new_account(
store, existing + [account], account, password
)
except (SecretError, AccountError, OSError) as exc:
# Une exception qui remonte ici tuerait le menu ; `save_new_account`
# a déjà annulé l'écriture du secret si la sauvegarde a échoué.
print(exc)
return
print(t("mail_account_saved"))
def _pick_account(prompt_key="mail_ask_account"):
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return None, []
for index, account in enumerate(accounts, start=1):
print(f" [{index}] {account.name}")
choice = input(t(prompt_key)).strip()
try:
return accounts[int(choice) - 1], accounts
except (ValueError, IndexError):
return None, accounts
def _delete_account(todo) -> None:
account, accounts = _pick_account()
if account is None:
return
try:
secret_store_for(todo).delete(account.secret_ref)
except SecretError:
# Le secret peut avoir déjà disparu : ce n'est pas une raison de
# garder le compte dans la configuration.
pass
mail_accounts.save([a for a in accounts if a.name != account.name])
print(t("mail_account_deleted"))
def _write_template() -> None:
try:
path = mail_accounts.write_template()
except AccountError as exc:
print(exc)
return
print(f"{t('mail_template_written')} {path}")
def _looks_like_auth_failure(cause) -> bool:
"""Le serveur a-t-il RÉPONDU, ou n'est-il rien revenu ?
La question n'est pas « le message ressemble-t-il à un refus » : la
première version cherchait « invalid credentials » et compagnie, et a
manqué le cas le plus clair qui soit — Gmail répond
« [ALERT] Application-specific password required » une fois la double
authentification active, sans employer aucun de ces mots. Une liste de
libellés attendus est toujours en retard sur les serveurs réels.
On teste donc l'inverse, qui est structurel : `imaplib` lève
`IMAP4.error` quand le SERVEUR a parlé, et un `OSError` (délai,
coupure, DNS) quand rien n'est revenu. Seul ce second cas fait taire la
note. Une cause inconnue l'affiche : c'est un conseil, pas un verdict —
le donner à tort coûte une ligne, le taire à tort coûte la panne.
"""
if cause is None:
return True
origine = getattr(cause, "__cause__", None)
if isinstance(origine, OSError):
return False
# Sans exception d'origine (une chaîne, un test), on retombe sur les
# formulations qui disent explicitement que rien n'est revenu.
bas = str(cause).lower()
return not any(
muet in bas
for muet in ("timed out", "timeout", "unreachable", "not known")
)
def retry_password(
todo, account, attempts: int = 3, connect_fn=None, cause=None
) -> bool:
"""Redemande le mot de passe jusqu'à ce qu'il passe. Vrai si le coffre a
été mis à jour.
L'écriture n'a lieu QU'APRÈS une connexion réussie : remplacer un mot de
passe valide par une faute de frappe serait pire que l'échec initial.
"""
if connect_fn is None:
from script.todo.mail.imap_transport import connect as connect_fn
store = secret_store_for(todo)
# C'est ICI que la note sert, pas seulement à l'ajout du compte : on
# vient de se faire refuser et on redemande un mot de passe. Gmail,
# Outlook et iCloud répondent « Invalid credentials » au mot de passe
# habituel exactement comme à une faute de frappe — sans cette ligne,
# l'invite pousse à retaper le même, et à se le faire refuser autant de
# fois qu'on le redemande.
preset = PRESETS.get(account.preset, {})
attendu_app = bool(preset.get("app_password"))
if attendu_app and _looks_like_auth_failure(cause):
print(t("mail_app_password_note"))
print(f" {t(preset['note_key'])}")
# L'invite elle-même nomme ce qu'on attend. « Mot de passe : » invitait
# à saisir CELUI DU COMPTE, que ces fournisseurs refusent — la note
# au-dessus se lit une fois, l'invite se relit à chaque tentative.
invite = t("mail_ask_app_password" if attendu_app else "mail_ask_password")
for _ in range(attempts):
password = getpass.getpass(invite)
if not password:
return False
try:
transport = connect_fn(account, password)
except Exception as exc:
# `connect_fn` peut lever `ImapError` ou n'importe quelle erreur
# réseau brute (`OSError`, ...) : les deux méritent la même
# invite à ressaisir, pas un plantage du menu.
print(f"{t('mail_connection_failed')} {exc}")
continue
transport.logout()
store.set(account.secret_ref, password)
print(t("mail_connection_ok"))
return True
return False
def _test_account(todo) -> None:
from script.todo.mail.imap_transport import connect
account, _ = _pick_account()
if account is None:
return
# Le coffre peut refuser de s'ouvrir — mauvais mot de passe KeePass,
# fichier absent, coffre non configuré. C'est un renoncement de
# l'utilisateur ou une erreur qu'il peut corriger, pas de quoi faire
# tomber le CLI : on le dit et on revient au menu.
try:
password = secret_store_for(todo).get(account.secret_ref)
except SecretError as exc:
print(f"{t('mail_connection_failed')} {exc}")
return
if not password:
print(t("mail_no_password_stored"))
return
try:
transport = connect(account, password)
folders = transport.list_folders()
transport.logout()
except Exception as exc:
# Connexion refusée, mot de passe expiré, ou LIST qui échoue : dans
# tous les cas, offrir de resaisir le mot de passe plutôt que de
# faire tomber le menu.
print(f"{t('mail_connection_failed')} {exc}")
retry_password(todo, account, cause=exc)
return
print(f"{t('mail_connection_ok')} {len(folders)}")
def prompt_mail_cache(todo) -> None:
while True:
current = todo_prefs.get("mail_cache_mode", "clear")
help_info = f"""{todo._menu_header()}
[1] {t("mail_cache_default_mode")} ({current})
[2] {t("mail_cache_account_mode")}
[3] {t("mail_cache_size_purge")}
[0] {t("Back")}"""
status = click.prompt(help_info)
print()
if status == "0":
return
if status == "1":
mode = input(t("mail_ask_mode")).strip()
if mode in CACHE_MODES:
todo_prefs.set("mail_cache_mode", mode)
else:
print(t("Command not found !"))
elif status == "2":
account, accounts = _pick_account()
if account is None:
continue
mode = input(t("mail_ask_mode")).strip()
account.cache_mode = mode if mode in CACHE_MODES else None
mail_accounts.save(accounts)
elif status == "3":
_cache_size_and_purge(todo)
else:
print(t("Command not found !"))
def _cache_size_and_purge(todo) -> None:
accounts = _load_accounts()
if not accounts:
print(t("mail_no_account"))
return
for row in cache_summary(accounts):
print(f" {row['name']} — {row['mode']} — {row['size'] // 1024} ko")
account, _ = _pick_account()
if account is None:
return
if input(t("mail_purge_confirm")).strip().lower() not in ("o", "y"):
return
store = Store(account, secrets=secret_store_for(todo))
try:
store.open()
except StoreError as exc:
# Le cache est illisible : on ne peut pas le vider par SQL, mais c'est
# exactement le cas où l'utilisateur a besoin qu'il disparaisse.
print(exc)
shutil.rmtree(store.root, ignore_errors=True)
print(t("mail_purged"))
return
try:
store.purge_all()
finally:
store.close()
print(t("mail_purged"))