erplibre/script/todo/mail/accounts.py

286 lines
8.9 KiB
Python
Raw 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 comptes courriel : description, préréglages, fichier de config.
`accounts.json` ne contient QUE ce qui n'est pas secret. Le mot de passe vit
dans le coffre (voir `secrets.py`) et le fichier n'en garde qu'une référence.
Le fichier reste donc lisible, éditable à la main et réparable, sans devenir
un endroit d'où une fuite ferait mal.
Les préréglages `gmail`, `outlook` et `icloud` supposent un MOT DE PASSE
D'APPLICATION : l'authentification simple ne passe plus autrement chez ces
fournisseurs. C'est la limite assumée de la phase 1 ; la phase 2 apporte OAuth.
"""
from __future__ import annotations
import json
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from script.todo.todo_i18n import t
SCHEMA_VERSION = 1
SECURITIES = ("ssl", "starttls", "none")
PRESETS: dict[str, dict] = {
"gmail": {
"label": "Google / Gmail",
"imap": {"host": "imap.gmail.com", "port": 993, "security": "ssl"},
"smtp": {
"host": "smtp.gmail.com",
"port": 587,
"security": "starttls",
},
"sent_folder": "[Gmail]/Sent Mail",
"app_password": True,
"note_key": "mail_preset_note_gmail",
},
"outlook": {
"label": "Microsoft / Outlook",
"imap": {
"host": "outlook.office365.com",
"port": 993,
"security": "ssl",
},
"smtp": {
"host": "smtp.office365.com",
"port": 587,
"security": "starttls",
},
"sent_folder": "Sent Items",
"app_password": True,
"note_key": "mail_preset_note_outlook",
},
"icloud": {
"label": "Apple / iCloud",
"imap": {"host": "imap.mail.me.com", "port": 993, "security": "ssl"},
"smtp": {
"host": "smtp.mail.me.com",
"port": 587,
"security": "starttls",
},
"sent_folder": "Sent Messages",
"app_password": True,
"note_key": "mail_preset_note_icloud",
},
"generic": {
"label": "Serveur standard (IMAP/SMTP)",
"imap": {"host": "", "port": 993, "security": "ssl"},
"smtp": {"host": "", "port": 587, "security": "starttls"},
"sent_folder": "Sent",
"app_password": False,
"note_key": "mail_preset_note_generic",
},
}
class AccountError(Exception):
"""Configuration de compte invalide, illisible ou en conflit."""
@dataclass
class ServerConf:
host: str
port: int
security: str
user: str
def __post_init__(self) -> None:
if self.security not in SECURITIES:
raise AccountError(
f"{t('mail_err_unknown_security')} {self.security!r}"
f" {t('mail_err_expected')} {SECURITIES})"
)
@dataclass
class Account:
name: str
email: str
imap: ServerConf
smtp: ServerConf
secret_ref: str
display_name: str = ""
preset: str = "generic"
cache_mode: str | None = None
sent_folder: str = "Sent"
enabled: bool = True
def __post_init__(self) -> None:
if not self.name:
raise AccountError(t("mail_err_account_needs_name"))
if (
"/" in self.name
or os.sep in self.name
or self.name.startswith(".")
):
raise AccountError(
f"{t('mail_err_invalid_account_name')} {self.name!r}"
f" {t('mail_err_account_name_reason')}"
)
if self.cache_mode not in (None, "clear", "encrypted", "ephemeral"):
raise AccountError(
f"{t('mail_err_unknown_cache_mode')} {self.cache_mode!r}"
)
def cache_key_ref(self) -> str:
"""Référence de la clé de chiffrement, distincte du mot de passe."""
return f"{self.secret_ref}/cache-key"
def from_header(self) -> str:
return (
f"{self.display_name} <{self.email}>"
if self.display_name
else self.email
)
def to_dict(self) -> dict:
data = asdict(self)
return data
@classmethod
def from_dict(cls, d: dict) -> "Account":
try:
return cls(
name=d["name"],
email=d["email"],
imap=ServerConf(**d["imap"]),
smtp=ServerConf(**d["smtp"]),
secret_ref=d["secret_ref"],
display_name=d.get("display_name", ""),
preset=d.get("preset", "generic"),
cache_mode=d.get("cache_mode"),
sent_folder=d.get("sent_folder", "Sent"),
enabled=d.get("enabled", True),
)
except (KeyError, TypeError) as exc:
raise AccountError(
f"{t('mail_err_account_unreadable')} {exc}"
) from exc
def accounts_path() -> Path:
return Path(os.path.expanduser("~/.erplibre/mail/accounts.json"))
def _prepare_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
def _write_private(path: Path, text: str) -> None:
"""Écrit `text` dans un fichier créé en 0600 dès sa création.
Écrire puis `chmod` laisserait le fichier — mot de passe absent, mais
`secret_ref` et adresses y sont — lisible à l'umask du process le temps
entre les deux appels. `os.open` avec le mode dès l'ouverture ferme cette
fenêtre ; le `chmod` qui suit corrige aussi un fichier déjà là écrit par
un umask permissif.
"""
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(text)
os.chmod(path, 0o600)
def account_from_preset(
name: str,
email: str,
preset_key: str,
*,
user: str | None = None,
display_name: str = "",
vault: str = "kdbx",
) -> Account:
preset = PRESETS.get(preset_key)
if preset is None:
raise AccountError(f"{t('mail_err_unknown_preset')} {preset_key!r}")
login = user or email
ref = (
f"kdbx:ERPLibre/Mail/{name}" if vault == "kdbx" else f"keyring:{name}"
)
return Account(
name=name,
email=email,
display_name=display_name,
preset=preset_key,
imap=ServerConf(user=login, **preset["imap"]),
smtp=ServerConf(user=login, **preset["smtp"]),
secret_ref=ref,
cache_mode=None,
sent_folder=preset["sent_folder"],
enabled=True,
)
def load(path: Path | None = None) -> list[Account]:
path = Path(path) if path else accounts_path()
if not path.exists():
return []
try:
data = json.loads(path.read_text())
except ValueError as exc:
raise AccountError(
f"{path} {t('mail_err_not_valid_json')} {exc}"
) from exc
if not isinstance(data, dict):
raise AccountError(
f"{path} {t('mail_err_should_contain_json_object')}"
)
return [Account.from_dict(d) for d in data.get("accounts", [])]
def save(accounts: list[Account], path: Path | None = None) -> None:
path = Path(path) if path else accounts_path()
names = [a.name for a in accounts]
duplicates = {n for n in names if names.count(n) > 1}
if duplicates:
raise AccountError(
f"{t('mail_err_duplicate_account_names')} {sorted(duplicates)}"
)
_prepare_parent(path)
payload = {
"version": SCHEMA_VERSION,
"default_account": names[0] if names else None,
"accounts": [a.to_dict() for a in accounts],
}
_write_private(path, json.dumps(payload, ensure_ascii=False, indent=2))
def find(accounts: list[Account], name: str) -> Account | None:
return next((a for a in accounts if a.name == name), None)
def write_template(path: Path | None = None, force: bool = False) -> Path:
"""Écrit un accounts.json d'exemple, un compte désactivé par préréglage.
JSON n'a pas de commentaires : les explications passent par des clés
`_comment`, que `Account.from_dict` ignore.
"""
path = Path(path) if path else accounts_path()
if path.exists() and not force:
raise AccountError(f"{path} {t('mail_err_already_exists_relaunch')}")
examples = []
for key, preset in PRESETS.items():
acc = account_from_preset(
f"exemple-{key}", f"vous@exemple.ca", key
).to_dict()
acc["enabled"] = False
acc["_comment"] = t(preset["note_key"])
examples.append(acc)
payload = {
"version": SCHEMA_VERSION,
"_comment": (
"Modèle ERPLibre. Aucun mot de passe ici : `secret_ref` pointe"
" vers le coffre. Passez `enabled` à true une fois rempli."
" `cache_mode` à null hérite du réglage général."
),
"default_account": None,
"accounts": examples,
}
_prepare_parent(path)
_write_private(path, json.dumps(payload, ensure_ascii=False, indent=2))
return path