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
233 lines
7.7 KiB
Python
233 lines
7.7 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 json
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from script.todo.mail.accounts import (
|
|
PRESETS,
|
|
Account,
|
|
AccountError,
|
|
account_from_preset,
|
|
find,
|
|
load,
|
|
save,
|
|
write_template,
|
|
)
|
|
|
|
|
|
class TestPresets(unittest.TestCase):
|
|
def test_four_presets(self):
|
|
self.assertEqual(
|
|
set(PRESETS), {"gmail", "outlook", "icloud", "generic"}
|
|
)
|
|
|
|
def test_gmail_servers(self):
|
|
self.assertEqual(PRESETS["gmail"]["imap"]["host"], "imap.gmail.com")
|
|
self.assertEqual(PRESETS["gmail"]["imap"]["port"], 993)
|
|
self.assertEqual(PRESETS["gmail"]["smtp"]["host"], "smtp.gmail.com")
|
|
self.assertEqual(PRESETS["gmail"]["smtp"]["port"], 587)
|
|
|
|
def test_security_values_are_known(self):
|
|
for key, preset in PRESETS.items():
|
|
for proto in ("imap", "smtp"):
|
|
self.assertIn(
|
|
preset[proto]["security"],
|
|
("ssl", "starttls", "none"),
|
|
f"{key}.{proto}",
|
|
)
|
|
|
|
def test_app_password_flag(self):
|
|
self.assertTrue(PRESETS["gmail"]["app_password"])
|
|
self.assertTrue(PRESETS["icloud"]["app_password"])
|
|
self.assertFalse(PRESETS["generic"]["app_password"])
|
|
|
|
|
|
class TestAccountFromPreset(unittest.TestCase):
|
|
def test_fills_servers_and_user(self):
|
|
acc = account_from_preset("perso", "moi@gmail.com", "gmail")
|
|
self.assertEqual(acc.imap.host, "imap.gmail.com")
|
|
self.assertEqual(acc.imap.user, "moi@gmail.com")
|
|
self.assertEqual(acc.smtp.user, "moi@gmail.com")
|
|
|
|
def test_user_override(self):
|
|
acc = account_from_preset(
|
|
"perso", "moi@x.ca", "generic", user="login-different"
|
|
)
|
|
self.assertEqual(acc.imap.user, "login-different")
|
|
|
|
def test_secret_ref_defaults_to_kdbx(self):
|
|
acc = account_from_preset("perso", "moi@x.ca", "generic")
|
|
self.assertEqual(acc.secret_ref, "kdbx:ERPLibre/Mail/perso")
|
|
|
|
def test_secret_ref_keyring(self):
|
|
acc = account_from_preset(
|
|
"perso", "moi@x.ca", "generic", vault="keyring"
|
|
)
|
|
self.assertEqual(acc.secret_ref, "keyring:perso")
|
|
|
|
def test_cache_key_ref(self):
|
|
acc = account_from_preset("perso", "moi@x.ca", "generic")
|
|
self.assertEqual(
|
|
acc.cache_key_ref(), "kdbx:ERPLibre/Mail/perso/cache-key"
|
|
)
|
|
|
|
def test_cache_mode_inherits_by_default(self):
|
|
self.assertIsNone(
|
|
account_from_preset("perso", "moi@x.ca", "generic").cache_mode
|
|
)
|
|
|
|
def test_unknown_preset_raises(self):
|
|
with self.assertRaises(AccountError):
|
|
account_from_preset("perso", "moi@x.ca", "aol")
|
|
|
|
def test_empty_name_raises(self):
|
|
with self.assertRaises(AccountError):
|
|
account_from_preset("", "moi@x.ca", "generic")
|
|
|
|
def test_name_with_slash_raises(self):
|
|
"""Le nom sert de segment de chemin et de référence kdbx."""
|
|
with self.assertRaises(AccountError):
|
|
account_from_preset("per/so", "moi@x.ca", "generic")
|
|
|
|
|
|
class TestRoundtrip(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = Path(self.tmp.name) / "accounts.json"
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_save_then_load(self):
|
|
acc = account_from_preset("perso", "moi@gmail.com", "gmail")
|
|
save([acc], self.path)
|
|
loaded = load(self.path)
|
|
self.assertEqual(len(loaded), 1)
|
|
self.assertEqual(loaded[0].to_dict(), acc.to_dict())
|
|
|
|
def test_file_is_0600(self):
|
|
save([account_from_preset("perso", "moi@x.ca", "generic")], self.path)
|
|
mode = stat.S_IMODE(os.stat(self.path).st_mode)
|
|
self.assertEqual(mode, 0o600)
|
|
|
|
def test_parent_dir_is_0700(self):
|
|
nested = Path(self.tmp.name) / "mail" / "accounts.json"
|
|
save([account_from_preset("perso", "moi@x.ca", "generic")], nested)
|
|
mode = stat.S_IMODE(os.stat(nested.parent).st_mode)
|
|
self.assertEqual(mode, 0o700)
|
|
|
|
def test_no_window_at_the_process_umask(self):
|
|
"""`write_text` puis `chmod` laisserait le fichier lisible à l'umask
|
|
du process le temps entre les deux appels. Au moment où `chmod` est
|
|
appelé, le fichier doit déjà être en 0600 — la preuve qu'il n'a
|
|
jamais existé autrement."""
|
|
from unittest.mock import patch
|
|
|
|
seen = []
|
|
original_chmod = os.chmod
|
|
|
|
def spy(path, mode):
|
|
if Path(path) == self.path:
|
|
seen.append(stat.S_IMODE(os.stat(path).st_mode))
|
|
return original_chmod(path, mode)
|
|
|
|
with patch("os.chmod", side_effect=spy):
|
|
save(
|
|
[account_from_preset("perso", "moi@x.ca", "generic")],
|
|
self.path,
|
|
)
|
|
|
|
self.assertEqual(seen, [0o600])
|
|
|
|
def test_load_missing_file_returns_empty(self):
|
|
self.assertEqual(load(Path(self.tmp.name) / "absent.json"), [])
|
|
|
|
def test_no_password_key_is_written(self):
|
|
save([account_from_preset("perso", "moi@x.ca", "generic")], self.path)
|
|
raw = self.path.read_text()
|
|
self.assertNotIn("password", raw.lower())
|
|
|
|
def test_corrupt_json_raises(self):
|
|
self.path.write_text("{ pas du json")
|
|
with self.assertRaises(AccountError):
|
|
load(self.path)
|
|
|
|
def test_duplicate_name_raises_on_save(self):
|
|
acc = account_from_preset("perso", "moi@x.ca", "generic")
|
|
with self.assertRaises(AccountError):
|
|
save([acc, acc], self.path)
|
|
|
|
def test_find(self):
|
|
accs = [
|
|
account_from_preset("perso", "a@x.ca", "generic"),
|
|
account_from_preset("travail", "b@x.ca", "generic"),
|
|
]
|
|
self.assertEqual(find(accs, "travail").email, "b@x.ca")
|
|
self.assertIsNone(find(accs, "absent"))
|
|
|
|
|
|
class TestTemplate(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = Path(self.tmp.name) / "accounts.json"
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def test_writes_valid_json(self):
|
|
write_template(self.path)
|
|
data = json.loads(self.path.read_text())
|
|
self.assertEqual(data["version"], 1)
|
|
|
|
def test_has_one_example_per_preset(self):
|
|
write_template(self.path)
|
|
data = json.loads(self.path.read_text())
|
|
presets = {a["preset"] for a in data["accounts"]}
|
|
self.assertEqual(presets, set(PRESETS))
|
|
|
|
def test_examples_are_disabled(self):
|
|
"""Un modèle ne doit rien tenter de synchroniser tel quel."""
|
|
write_template(self.path)
|
|
data = json.loads(self.path.read_text())
|
|
self.assertTrue(all(not a["enabled"] for a in data["accounts"]))
|
|
|
|
def test_carries_comments(self):
|
|
write_template(self.path)
|
|
data = json.loads(self.path.read_text())
|
|
self.assertIn("_comment", data)
|
|
|
|
def test_refuses_to_overwrite(self):
|
|
self.path.write_text("{}")
|
|
with self.assertRaises(AccountError):
|
|
write_template(self.path)
|
|
|
|
def test_force_overwrites(self):
|
|
self.path.write_text("{}")
|
|
write_template(self.path, force=True)
|
|
self.assertIn("accounts", json.loads(self.path.read_text()))
|
|
|
|
def test_no_window_at_the_process_umask(self):
|
|
from unittest.mock import patch
|
|
|
|
seen = []
|
|
original_chmod = os.chmod
|
|
|
|
def spy(path, mode):
|
|
if Path(path) == self.path:
|
|
seen.append(stat.S_IMODE(os.stat(path).st_mode))
|
|
return original_chmod(path, mode)
|
|
|
|
with patch("os.chmod", side_effect=spy):
|
|
write_template(self.path)
|
|
|
|
self.assertEqual(seen, [0o600])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|