diff --git a/.env.example b/.env.example index eda3b77..f1581da 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,19 @@ GRIST_BASE_URL=https://grist.votre-instance.fr TABLE_BALS=BALs TABLE_HISTORIQUE=Historique -# Colonnes Grist -COL_EMAIL=Courriel -COL_SYNC=Synchronisation -COL_NB_SYNCS=Nb_syncs +# Colonnes Grist TABLE_BALS +COL_EMAIL=Courriel #Addresse a synchoniser +COL_SYNC=Synchronisation #Bool si vrai, synchoniser +COL_PRIORITY=priority +COL_NB_SYNCS=Nb_syncs #contient le nombre de synchonisation de la BAL + + # Colonnes Grist TABLE_HISTORIQUE + COL_BAL=Bal + COL_STATUS=Status + COL_NB_EMAILS=Nb_emails + COL_DUREE_SEC=Duree_sec + COL_LOG=Log + COL_DATE=Date # ── Serveurs IMAP ───────────────────────────────────────────── HOST1=imap.source.fr diff --git a/README.md b/README.md index c051d15..f890d00 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,22 @@ cp imapsync-daemon.service /etc/systemd/system/ systemctl daemon-reload systemctl enable --now imapsync-daemon journalctl -fu imapsync-daemon + + +# Etapes de migration de BAL +## 1 Création +* creer la bal sur Dimail +* desactiver le smtp +* remplir la fiche +* mettre le secondary pour importation +* rattacher l'identity ProConnect (si BAL non générique) +* récupérer les catégories (si catégories trouvées sur O365) +## 2 Procedure de synchro +Procédure facultative si nouvelle boite +* faire des syncho en boucle (jamais plus d'une toutes les 15 minutes) +## 3 finaliser la migration +* desactiver la bal coté 365 (groupe "migration dinum") +* activer le smtp coté Dimail +* récupérer les catégories (si trouvées sur O365) BIS +* recupérer le calendrier (si trouvé sur O365) +* continuer les synchros pendant 6H, toutes les 5 minutes diff --git a/imapsync_daemon.py b/imapsync_daemon.py index 6d26fc9..48bd22c 100755 --- a/imapsync_daemon.py +++ b/imapsync_daemon.py @@ -37,6 +37,9 @@ Variables d'environnement requises (fichier .env ou export shell) : """ import argparse +import http +import http.client +import json import logging import math import os @@ -54,7 +57,6 @@ import requests # ───────────────────────────────────────────── # Arguments CLI # ───────────────────────────────────────────── - parser = argparse.ArgumentParser( description="Daemon imapsync piloté par Grist", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -71,11 +73,10 @@ parser.add_argument( ) ARGS = parser.parse_args() + # ───────────────────────────────────────────── # Configuration depuis l'environnement # ───────────────────────────────────────────── - - def require_env(name: str) -> str: val = os.environ.get(name) if not val: @@ -104,7 +105,7 @@ load_dotenv() GRIST_API_KEY = require_env("GRIST_API_KEY") GRIST_DOC_ID = require_env("GRIST_DOC_ID") -GRIST_BASE_URL = opt_env("GRIST_BASE_URL", "http://localhost:8484") +GRIST_BASE_URL = require_env("GRIST_BASE_URL") HOST1 = require_env("HOST1") HOST2 = require_env("HOST2") USER1_PREFIX = require_env("USER1_PREFIX") @@ -120,18 +121,23 @@ N_WORKERS = int(opt_env("N_WORKERS", "4")) POLL_INTERVAL = int(opt_env("POLL_INTERVAL", "30")) NEW_RATIO = float(opt_env("NEW_RATIO", "0.20")) -# Interval minimum entre 2 vérification d'une meme BAL +# Interval en minute minimum entre 2 vérification d'une meme BAL MIN_INTERVAL_MINUTES = 15 - -# Noms des tables Grist (casse exacte telle qu'affichée dans Grist) -TABLE_BALS = "BALs" -TABLE_HISTORIQUE = "Historique" +TABLE_BALS = require_env("TABLE_BALS") # Colonnes Grist -COL_EMAIL = "Courriel" -COL_SYNC = "Synchronisation" # booléen déclencheur -COL_NB_SYNCS = "Nb_syncs" # compteur calculé par formule Grist +COL_EMAIL = require_env("COL_EMAIL") +COL_SYNC = require_env("COL_SYNC") +COL_NB_SYNCS = require_env("COL_NB_SYNCS") +COL_PRIORITY = require_env("COL_PRIORITY") +TABLE_HISTORIQUE = require_env("TABLE_HISTORIQUE") +COL_BAL = require_env("COL_BAL") +COL_STATUS = require_env("COL_STATUS") +COL_NB_EMAILS = require_env("COL_NB_EMAILS") +COL_DUREE_SEC = require_env("COL_DUREE_SEC") +COL_LOG = require_env("COL_LOG") +COL_DATE = require_env("COL_DATE") TOKEN_VALIDITY = 30 # Minutes @@ -140,7 +146,6 @@ _LAST_RENEWAL_TIME = 0.0 # ───────────────────────────────────────────── # Logging # ───────────────────────────────────────────── - logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", @@ -152,11 +157,10 @@ logging.basicConfig( ) log = logging.getLogger(__name__) + # ───────────────────────────────────────────── # Renouvellement du token OAuth2 # ───────────────────────────────────────────── - - def renew_oauth2_token() -> bool: """ Exécute : OAUTH2IMAP --token_file OAUTH2_TOKEN_FILE USER1_PREFIX @@ -201,181 +205,22 @@ def renew_oauth2_token() -> bool: # ───────────────────────────────────────────── # Mode --test # ───────────────────────────────────────────── - - def run_tests() -> bool: - """ - Vérifie tous les prérequis de la configuration. - Retourne True si tout est OK, False sinon. - """ - ok = True - sep = "─" * 56 - - def check(label: str, passed: bool, detail: str = ""): - nonlocal ok - icon = "✓" if passed else "✗" - msg = f" {icon} {label}" - if detail: - msg += f" → {detail}" - print(msg) - if not passed: - ok = False - - print() - print("═" * 56) - print(" imapsync_daemon — test de configuration") - print("═" * 56) - - # ── Variables d'environnement ──────────────── - print(f"\n{sep}") - print(" Variables d'environnement") - print(sep) - env_checks = [ - ("GRIST_API_KEY", GRIST_API_KEY, None), - ("GRIST_DOC_ID", GRIST_DOC_ID, None), - ("GRIST_BASE_URL", GRIST_BASE_URL, GRIST_BASE_URL), - ("HOST1", HOST1, HOST1), - ("HOST2", HOST2, HOST2), - ("USER1_PREFIX", USER1_PREFIX, USER1_PREFIX), - ("USER2_PREFIX", USER2_PREFIX, USER2_PREFIX), - ("OAUTH2_TOKEN_FILE", OAUTH2_TOKEN_FILE, OAUTH2_TOKEN_FILE), - ("PASS2", PASS2, "défini"), - ("IMAPSYNC", IMAPSYNC, IMAPSYNC), - ("OAUTH2IMAP", OAUTH2IMAP, OAUTH2IMAP), - ("TMPDIR_CACHE", TMPDIR_CACHE, TMPDIR_CACHE), - ("N_WORKERS", str(N_WORKERS), str(N_WORKERS)), - ("POLL_INTERVAL", str(POLL_INTERVAL), f"{POLL_INTERVAL}s"), - ("NEW_RATIO", str(NEW_RATIO), f"{NEW_RATIO:.0%}"), - ] - for name, val, shown in env_checks: - set_ok = bool(val) - check(name, set_ok, shown if set_ok else "MANQUANTE") - - # ── Fichiers et répertoires ────────────────── - print(f"\n{sep}") - print(" Fichiers et répertoires") - print(sep) - - for exe_name, exe_path in [("imapsync", IMAPSYNC), ("oauth2imap", OAUTH2IMAP)]: - p = Path(exe_path) - found = p.exists() and os.access(p, os.X_OK) - check( - f"{exe_name} exécutable", - found, - str(p) + (" (trouvé)" if found else " (introuvable)"), - ) - - token_p = Path(OAUTH2_TOKEN_FILE) - check( - "OAUTH2_TOKEN_FILE", - token_p.exists() and token_p.is_file(), - str(token_p) + (" (trouvé)" if token_p.exists() else " (introuvable)"), - ) - - tmpdir_p = Path(TMPDIR_CACHE) - if tmpdir_p.exists(): - check("TMPDIR_CACHE", True, f"{tmpdir_p} (existe)") - else: - try: - tmpdir_p.mkdir(parents=True, exist_ok=True) - check("TMPDIR_CACHE", True, f"{tmpdir_p} (créé)") - except OSError as e: - check("TMPDIR_CACHE", False, str(e)) - - # ── Renouvellement token ───────────────────── - print(f"\n{sep}") - print(" Renouvellement token OAuth2") - print(sep) - token_ok = renew_oauth2_token() - check( - "oauth2imap --token_file … renew", - token_ok, - "succès" if token_ok else "échec — vérifier oauth2imap et le token", - ) - - # ── Connectivité Grist ─────────────────────── - print(f"\n{sep}") - print(" Connectivité Grist") - print(sep) - - grist_headers = { - "Authorization": f"Bearer {GRIST_API_KEY}", - "Content-Type": "application/json", - } - - try: - resp = requests.get( - f"{GRIST_BASE_URL}/api/docs/{GRIST_DOC_ID}", - headers=grist_headers, - timeout=10, - ) - resp.raise_for_status() - doc_name = resp.json().get("name", "?") - check("Accès document Grist", True, f'"{doc_name}"') - except requests.RequestException as e: - check("Accès document Grist", False, str(e)) - - for table in (TABLE_BALS, TABLE_HISTORIQUE): - try: - resp = requests.get( - f"{GRIST_BASE_URL}/api/docs/{GRIST_DOC_ID}/tables/{table}/records", - headers=grist_headers, - timeout=10, - ) - resp.raise_for_status() - nb = len(resp.json().get("records", [])) - - if table == TABLE_BALS and nb > 0: - sample = resp.json()["records"][0]["fields"] - has_nb_syncs = COL_NB_SYNCS in sample - detail = ( - f"{nb} enregistrements" - if has_nb_syncs - else f"{nb} enregistrements ⚠ colonne '{COL_NB_SYNCS}' absente — à créer dans Grist" - ) - check(f"Lecture table {table}", True, detail) - else: - check(f"Lecture table {table}", True, f"{nb} enregistrements") - except requests.RequestException as e: - check(f"Lecture table {table}", False, str(e)) - - grist_add_historique("mathieu.maura@sdis66.fr", "Test", 0, 10, "Test") - - # ── Paramètres calculés ────────────────────── - print(f"\n{sep}") - print(" Paramètres calculés") - print(sep) - slots_new = math.ceil(N_WORKERS * NEW_RATIO) - slots_rep = N_WORKERS - slots_new - print(f" • N_WORKERS={N_WORKERS} NEW_RATIO={NEW_RATIO:.0%}") - print(f" → {slots_new} slot(s) nouvelles BAL") - print(f" → {slots_rep} slot(s) re-passes") - if ARGS.dry: - print(" • Mode --dry actif : imapsync sera lancé avec --dry") - - # ── Résumé ─────────────────────────────────── - print() - print("═" * 56) - if ok: - print(" Résultat : OK — configuration valide") - else: - print(" Résultat : ERREUR — corriger les points marqués ✗") - print("═" * 56) - print() - return ok + http.client.HTTPConnection.debuglevel = 1 + print(f" → payload: {grist_priority_bals()}") + return True # ───────────────────────────────────────────── # Grist API # ───────────────────────────────────────────── - GRIST_HEADERS = { "Authorization": f"Bearer {GRIST_API_KEY}", "Content-Type": "application/json", } -def grist_url(table: str) -> str: +def grist_records_url(table: str) -> str: return f"{GRIST_BASE_URL}/api/docs/{GRIST_DOC_ID}/tables/{table}/records" @@ -389,7 +234,10 @@ def grist_fetch_bals() -> list[dict]: # 1. Récupération triée via l'API Grist params = {"sort": "Date_derniere_passe"} resp = requests.get( - grist_url(TABLE_BALS), headers=GRIST_HEADERS, params=params, timeout=15 + grist_records_url(TABLE_BALS), + headers=GRIST_HEADERS, + params=params, + timeout=15, ) resp.raise_for_status() records = resp.json().get("records", []) @@ -445,25 +293,29 @@ def grist_priority_bals() -> dict: log.info("grist_priority_bals") try: # 1. Récupération triée via l'API Grist - params = {"limit": 1, "filter": {"priority": ["true"], COL_SYNC: ["true"]}} + params = { + "limit": 1, + "filter": json.dumps({COL_PRIORITY: [True], COL_SYNC: [True]}), + } resp = requests.get( - grist_url(TABLE_BALS), headers=GRIST_HEADERS, json=params, timeout=15 + grist_records_url(TABLE_BALS), + headers=GRIST_HEADERS, + params=params, + timeout=15, ) resp.raise_for_status() records = resp.json().get("records", []) - # for r in records: r = records[0] - log.info(f"grist_priority_bals: {r.id}") fields = r["fields"] # Vérification de la condition de synchronisation globale if fields.get(COL_SYNC) is not True: return {} - # Si le timestamp existe, on valide l'écart de temps - if fields.get("Date_derniere_passe") is not None: + # Au moins une passe déjà faite + if fields.get("Date_derniere_passe") is None: return {} return r @@ -487,12 +339,12 @@ def grist_add_historique( "records": [ { "fields": { - "Bal": email, - "Status": final_status, - "Nb_emails": nb_emails, - "Duree_sec": duree_sec, - "Log": log_text[:4000], - "Date": datetime.now(timezone.utc).isoformat(), + COL_BAL: email, + COL_STATUS: final_status, + COL_NB_EMAILS: nb_emails, + COL_DUREE_SEC: duree_sec, + COL_LOG: log_text[:4000], + COL_DATE: datetime.now(timezone.utc).isoformat(), } } ] @@ -500,7 +352,10 @@ def grist_add_historique( # print(f" → payload: {payload}") try: resp = requests.post( - grist_url(TABLE_HISTORIQUE), headers=GRIST_HEADERS, json=payload, timeout=15 + grist_records_url(TABLE_HISTORIQUE), + headers=GRIST_HEADERS, + json=payload, + timeout=15, ) resp.raise_for_status() log.info(f"[{email}] Résultat écrit dans HISTORIQUE ({final_status})") @@ -511,8 +366,6 @@ def grist_add_historique( # ───────────────────────────────────────────── # Partition nouvelles BAL / re-passes # ───────────────────────────────────────────── - - def partition_bals(rows: list[dict], available: int) -> list[dict]: """ Répartit les slots disponibles (`available`) entre nouvelles BAL et re-passes.