erplibre/test/test_mail_tui.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

431 lines
14 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import tempfile
import unittest
from pathlib import Path
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.tui import (
MailboxRef,
Session,
mailbox_refs,
open_sessions,
)
class FailingConnect:
def __init__(self, message="serveur injoignable"):
self.message = message
def __call__(self, account, password):
raise OSError(self.message)
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class FakeSecrets:
def __init__(self, password="hunter2"):
self.password = password
def get(self, ref):
return self.password
def set(self, ref, value):
self.password = value
class SessionCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = Path(self.tmp.name)
self.accounts = [
account_from_preset("perso", "moi@x.ca", "generic"),
account_from_preset("travail", "moi@y.ca", "generic"),
]
# Épinglé : sans ça, resolve_mode lirait les préférences réelles de la
# machine et le test dépendrait de ~/.erplibre.
for account in self.accounts:
account.cache_mode = "clear"
def tearDown(self):
self.tmp.cleanup()
class TestOpenSessions(SessionCase):
def test_one_session_per_account(self):
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertEqual(
[s.account.name for s in sessions], ["perso", "travail"]
)
for s in sessions:
s.close()
def test_disabled_account_is_skipped(self):
self.accounts[1].enabled = False
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertEqual([s.account.name for s in sessions], ["perso"])
for s in sessions:
s.close()
def test_cache_opens_even_when_the_network_fails(self):
"""Réseau coupé : la boîte doit rester consultable."""
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=FailingConnect(),
)
self.assertTrue(all(not s.online for s in sessions))
self.assertTrue(all(s.store is not None for s in sessions))
for s in sessions:
s.close()
def test_network_error_is_kept_for_display(self):
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=FailingConnect("530 refus"),
)
self.assertIn("530", sessions[0].error)
for s in sessions:
s.close()
def test_missing_password_marks_offline(self):
class NoSecret:
def get(self, ref):
return None
sessions = open_sessions(
self.accounts,
NoSecret(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertFalse(sessions[0].online)
for s in sessions:
s.close()
def test_online_when_everything_works(self):
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
self.assertTrue(all(s.online for s in sessions))
for s in sessions:
s.close()
class TestEphemeralCleanupOnSignals(SessionCase):
"""`atexit` ne s'exécute pas sur un signal : sans un gestionnaire
`SIGINT`/`SIGTERM` dédié, un cache éphémère survivrait à un `kill` ou un
Ctrl+C — exactement ce que ce mode promet d'éviter.
On déclenche le gestionnaire installé directement plutôt que d'envoyer un
vrai signal au processus de test, et on restaure l'ancien gestionnaire
dans `tearDown` pour ne pas polluer le reste de la suite.
Piège vécu : quand la disposition précédente n'est PAS appelable
(`SIG_DFL`, le cas par défaut), le gestionnaire se renvoie maintenant
POUR DE VRAI le signal après le nettoyage — sinon il l'avalerait (voir
`test_sigterm_actually_terminates_the_process`). Appeler ce gestionnaire
directement avec la disposition par défaut en place tuerait donc le
processus de test lui-même : `test_sigterm_removes_the_ephemeral_root`
installe d'abord un `_previous` factice et appelable pour rester une
invocation directe sûre, et laisse la vraie fin de processus au test
suivant, seul endroit sûr pour l'observer (un sous-processus dédié).
"""
def setUp(self):
super().setUp()
import signal
self._orig_sigint = signal.getsignal(signal.SIGINT)
self._orig_sigterm = signal.getsignal(signal.SIGTERM)
def tearDown(self):
import signal
signal.signal(signal.SIGINT, self._orig_sigint)
signal.signal(signal.SIGTERM, self._orig_sigterm)
super().tearDown()
def test_sigterm_removes_the_ephemeral_root(self):
import signal
# Un `_previous` factice mais appelable : la branche de repli qui
# renvoie le signal pour de vrai (cas `SIG_DFL`) n'est PAS sûre à
# emprunter ici, puisqu'on invoque le gestionnaire directement dans
# le processus de test — elle est couverte séparément, en
# sous-processus, par `test_sigterm_actually_terminates_the_process`.
signal.signal(signal.SIGTERM, lambda signum, frame: None)
for account in self.accounts:
account.cache_mode = "ephemeral"
sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
roots = [s.store.root for s in sessions]
self.assertTrue(all(r.exists() for r in roots))
handler = signal.getsignal(signal.SIGTERM)
handler(signal.SIGTERM, None)
self.assertFalse(any(r.exists() for r in roots))
def test_sigterm_actually_terminates_the_process(self):
"""Un test qui ne vérifie QUE le nettoyage ne peut pas distinguer
« nettoyé puis sorti » de « nettoyé puis resté vivant » — c'est
exactement cette distinction que la régression a ratée : le
gestionnaire chaîné n'appelait le précédent handler que s'il était
`callable`, or la disposition par défaut de SIGTERM (`SIG_DFL`) est
l'entier 0, pas un appelable — le signal était donc avalé.
On lance un vrai sous-processus, on lui envoie SIGTERM pour de
vrai (pas un appel direct du handler), et on vérifie qu'il MEURT.
"""
import signal
import subprocess
import sys
repo_root = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory() as base_dir:
script = f"""
import os
import signal
import sys
import time
sys.path.insert(0, {str(repo_root)!r})
from script.todo.mail.accounts import account_from_preset
from script.todo.mail.tui import open_sessions
class FakeTransport:
def list_folders(self):
return []
def logout(self):
pass
class FakeSecrets:
def get(self, ref):
return "hunter2"
def set(self, ref, value):
pass
account = account_from_preset("perso", "moi@x.ca", "generic")
account.cache_mode = "ephemeral"
sessions = open_sessions(
[account],
FakeSecrets(),
base={str(base_dir)!r},
connect_fn=lambda a, p: FakeTransport(),
)
print(str(sessions[0].store.root), flush=True)
os.kill(os.getpid(), signal.SIGTERM)
# Ne doit JAMAIS s'imprimer : y arriver veut dire que le signal a été avalé.
time.sleep(2)
print("FAILURE: still alive after SIGTERM", flush=True)
"""
script_path = Path(base_dir) / "sigterm_child.py"
script_path.write_text(script)
result = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True,
text=True,
timeout=10,
)
lines = [
line for line in result.stdout.splitlines() if line.strip()
]
self.assertTrue(
lines, f"aucune sortie de l'enfant : {result.stderr}"
)
root = Path(lines[0])
self.assertNotIn("still alive", result.stdout)
self.assertEqual(result.returncode, -signal.SIGTERM)
self.assertFalse(root.exists())
class TestMailboxRefs(SessionCase):
def setUp(self):
super().setUp()
self.sessions = open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
def tearDown(self):
for s in self.sessions:
s.close()
super().tearDown()
def test_empty_when_no_folder(self):
self.assertEqual(mailbox_refs(self.sessions), [])
def test_lists_folders_of_every_account(self):
self.sessions[0].store.upsert_folder("INBOX", "INBOX", "inbox")
self.sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox")
refs = mailbox_refs(self.sessions)
self.assertEqual(
[(r.account_name, r.folder_name) for r in refs],
[("perso", "INBOX"), ("travail", "INBOX")],
)
def test_inbox_comes_first(self):
store = self.sessions[0].store
store.upsert_folder("Archives", "Archives", None)
store.upsert_folder("INBOX", "INBOX", "inbox")
names = [r.folder_name for r in mailbox_refs(self.sessions)]
self.assertEqual(names[0], "INBOX")
def test_carries_unseen_count(self):
store = self.sessions[0].store
store.upsert_folder("INBOX", "INBOX", "inbox")
store.set_folder_state("INBOX", unseen=4)
self.assertEqual(mailbox_refs(self.sessions)[0].unseen, 4)
def test_display_falls_back_to_name(self):
self.sessions[0].store.upsert_folder("Projets")
self.assertEqual(mailbox_refs(self.sessions)[0].display, "Projets")
class TestBrokenCache(SessionCase):
"""Un cache illisible sur UN compte ne doit pas couler les autres."""
def _corrupt(self, name):
root = self.base / name
root.mkdir(parents=True, exist_ok=True)
(root / "cache.db").write_bytes(b"pas une base sqlite" * 50)
def _open(self):
return open_sessions(
self.accounts,
FakeSecrets(),
base=self.base,
connect_fn=lambda a, p: FakeTransport(),
)
def test_the_other_accounts_still_open(self):
self._corrupt("perso")
sessions = self._open()
self.assertEqual(
[s.account.name for s in sessions], ["perso", "travail"]
)
self.assertIsNone(sessions[0].store)
self.assertIsNotNone(sessions[1].store)
for session in sessions:
session.close()
def test_the_broken_account_keeps_its_error(self):
self._corrupt("perso")
sessions = self._open()
self.assertIn("cache.db", sessions[0].error)
for session in sessions:
session.close()
def test_the_broken_account_is_offline(self):
self._corrupt("perso")
sessions = self._open()
self.assertFalse(sessions[0].online)
for session in sessions:
session.close()
def test_mailbox_refs_skips_it_without_raising(self):
self._corrupt("perso")
sessions = self._open()
sessions[1].store.upsert_folder("INBOX", "INBOX", "inbox")
refs = mailbox_refs(sessions)
self.assertEqual([r.account_name for r in refs], ["travail"])
for session in sessions:
session.close()
def test_closing_a_broken_session_does_not_raise(self):
self._corrupt("perso")
sessions = self._open()
for session in sessions:
session.close()
class TestImportsWithoutTextual(unittest.TestCase):
def test_module_imports_without_textual(self):
"""Le module doit rester utilisable là où Textual n'est pas installé."""
import script.todo.mail.tui as tui
self.assertTrue(hasattr(tui, "run_tui"))
class TestSaveAttachment(unittest.TestCase):
RAW = (
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
b"--B\r\nContent-Type: application/pdf\r\n"
b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n'
b"%PDF\r\n--B--\r\n"
)
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_writes_the_file(self):
from script.todo.mail.tui import save_attachment
target = save_attachment(self.RAW, 0, self.tmp.name)
self.assertTrue(target.exists())
self.assertEqual(target.name, "devis.pdf")
def test_unknown_index_raises(self):
from script.todo.mail.tui import save_attachment
with self.assertRaises(ValueError):
save_attachment(self.RAW, 7, self.tmp.name)
def test_filename_cannot_escape_the_directory(self):
"""Le nom vient du message : il ne doit jamais écrire ailleurs."""
from script.todo.mail.tui import save_attachment
hostile = self.RAW.replace(b'"devis.pdf"', b'"../../evade.pdf"')
target = save_attachment(hostile, 0, self.tmp.name)
self.assertEqual(target.parent, Path(self.tmp.name))
if __name__ == "__main__":
unittest.main()