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
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from typing import Any, Dict, Literal, Mapping
|
|
|
|
CONFIG_FILE = "./script/todo/todo.json"
|
|
CONFIG_OVERRIDE_FILE = "./private/todo/todo_override.json"
|
|
CONFIG_OVERRIDE_PRIVATE_FILE = "./private/todo/todo_override_private.json"
|
|
LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt"
|
|
|
|
logging.basicConfig(
|
|
format=(
|
|
"%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]"
|
|
" %(message)s"
|
|
),
|
|
datefmt="%Y-%m-%d:%H:%M:%S",
|
|
level=logging.INFO,
|
|
)
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConfigFile:
|
|
def get_config(self, key_param: str) -> Any:
|
|
config_base: dict = {}
|
|
config_override: dict = {}
|
|
config_private: dict = {}
|
|
|
|
if os.path.exists(CONFIG_FILE):
|
|
with open(CONFIG_FILE) as cfg:
|
|
config_base = json.load(cfg)
|
|
|
|
if os.path.exists(CONFIG_OVERRIDE_FILE):
|
|
with open(CONFIG_OVERRIDE_FILE) as cfg:
|
|
config_override = json.load(cfg)
|
|
|
|
if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE):
|
|
with open(CONFIG_OVERRIDE_PRIVATE_FILE) as cfg:
|
|
config_private = json.load(cfg)
|
|
|
|
merged_base_private = self.deep_merge_with_lists(
|
|
config_base, config_private, list_strategy="extend"
|
|
)
|
|
merged_config = self.deep_merge_with_lists(
|
|
merged_base_private, config_override, list_strategy="extend"
|
|
)
|
|
|
|
return merged_config.get(key_param)
|
|
|
|
def get_config_value(self, params: list[str]) -> Any:
|
|
config_data = self.get_config(params[0])
|
|
for param in params[1:]:
|
|
if param in config_data:
|
|
config_data = config_data.get(param)
|
|
return config_data
|
|
|
|
def set_config_value(self, keys: list[str], value: Any) -> None:
|
|
"""Écrit `value` sous le chemin `keys` dans
|
|
CONFIG_OVERRIDE_PRIVATE_FILE.
|
|
|
|
C'est le seul des trois fichiers fusionnés par `get_config` qui
|
|
soit gitignored (`git check-ignore` le confirme ; `private/` lui-
|
|
même est un dossier versionné, et CONFIG_OVERRIDE_FILE ne l'est
|
|
pas) — donc le seul où une valeur personnelle comme `kdbx.path`
|
|
peut être écrite sans finir commitée.
|
|
|
|
Fusionne avec le contenu existant plutôt que de l'écraser, et
|
|
écrit de façon atomique : fichier temporaire créé en 0600 dans le
|
|
même dossier, puis `os.replace` (qui hérite du mode de la source).
|
|
Le fichier réel n'est donc jamais vu à moitié écrit, et un fichier
|
|
déjà présent avec des permissions trop larges se retrouve corrigé.
|
|
"""
|
|
data: Dict[str, Any] = {}
|
|
if os.path.exists(CONFIG_OVERRIDE_PRIVATE_FILE):
|
|
with open(CONFIG_OVERRIDE_PRIVATE_FILE) as cfg:
|
|
data = json.load(cfg)
|
|
|
|
node = data
|
|
for key in keys[:-1]:
|
|
node = node.setdefault(key, {})
|
|
node[keys[-1]] = value
|
|
|
|
parent = os.path.dirname(CONFIG_OVERRIDE_PRIVATE_FILE) or "."
|
|
os.makedirs(parent, exist_ok=True)
|
|
os.chmod(parent, 0o700)
|
|
|
|
tmp_path = f"{CONFIG_OVERRIDE_PRIVATE_FILE}.tmp"
|
|
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w") as tmp_file:
|
|
json.dump(data, tmp_file, indent=2, ensure_ascii=False)
|
|
os.replace(tmp_path, CONFIG_OVERRIDE_PRIVATE_FILE)
|
|
|
|
def get_logo_ascii_file_path(self) -> str:
|
|
return LOGO_ASCII_FILE
|
|
|
|
def deep_merge_with_lists(
|
|
self,
|
|
dest: Mapping[str, Any],
|
|
src: Mapping[str, Any],
|
|
list_strategy: Literal["replace", "extend"] = "replace",
|
|
) -> Dict[str, Any]:
|
|
result: Dict[str, Any] = {}
|
|
for k, v in dest.items():
|
|
result[k] = v.copy() if isinstance(v, dict) else v
|
|
|
|
for k, v in src.items():
|
|
if (
|
|
k in result
|
|
and isinstance(result[k], dict)
|
|
and isinstance(v, dict)
|
|
):
|
|
result[k] = self.deep_merge_with_lists(
|
|
result[k], v, list_strategy
|
|
)
|
|
elif (
|
|
k in result
|
|
and isinstance(result[k], list)
|
|
and isinstance(v, list)
|
|
and list_strategy == "extend"
|
|
):
|
|
# Extend: dest_list + src_list
|
|
result[k] = result[k] + v
|
|
elif k in result and isinstance(result[k], str):
|
|
if v:
|
|
result[k] = v
|
|
else:
|
|
result[k] = v
|
|
return result
|