551 lines
19 KiB
Python
551 lines
19 KiB
Python
|
|
#!/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"))
|