#!/usr/bin/env python3 # mail-audit.py — AUDIT EN LECTURE SEULE d'une boîte IMAP (généralisation de orange-audit.py). # python3 mail-audit.py airtria [dossier=INBOX] # python3 mail-audit.py orange [dossier=INBOX] # Ne modifie rien (sélection en lecture seule, en-têtes lus avec BODY.PEEK). # Sorties : -audit.md et -audit-raw.json dans le workspace. # Secrets dans /home/node/.openclaw/.env, jamais en clair ici. import imaplib, ssl, re, json, sys, time, email, email.utils, email.header from collections import defaultdict from datetime import datetime WS = "/home/node/.openclaw/workspace" COMPTES = { "orange": {"host": "imap.orange.fr", "sni": "imap.orange.fr", "user": "thierry.tallagrand@wanadoo.fr", "env": "ORANGE_IMAP_PASSWORD"}, # ARCHIVE2 (MailPlus, Dovecot) : le certificat Let's Encrypt est au nom QuickConnect, on vérifie STRICTEMENT avec ce nom "airtria": {"host": "10.10.10.22", "sni": "airtria-dsm2.direct.quickconnect.to", "user": "TMZF5455", "env": "AIRTRIA_IMAP_PASSWORD"}, } CHUNK = 1000 compte = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] in COMPTES else sys.exit(f"usage: mail-audit.py {'|'.join(COMPTES)} [dossier]") FOLDER = sys.argv[2] if len(sys.argv) > 2 else "INBOX" C = COMPTES[compte] env = dict(l.strip().split("=", 1) for l in open("/home/node/.openclaw/.env") if "=" in l and not l.startswith("#")) PWD = env[C["env"]].strip().strip('"').strip("'") class _IMAPSurSocket(imaplib.IMAP4): """IMAP4 sur une socket TLS déjà ouverte, certificat VÉRIFIÉ sur le nom SNI (pas sur l'IP).""" def __init__(self, sock): self._sock_prete = sock imaplib.IMAP4.__init__(self, "", 993) def open(self, host, port, timeout=None): self.host, self.port = host, port self.sock = self._sock_prete self.file = self.sock.makefile("rb") def connect(): import socket ctx = ssl.create_default_context() # vérification stricte : CA + nom SNI sock = ctx.wrap_socket(socket.create_connection((C["host"], 993), 10), server_hostname=C["sni"]) m = _IMAPSurSocket(sock) m.login(C["user"], PWD) return m t0 = time.time() m = connect() typ, n = m.select(f'"{FOLDER}"', readonly=True) total = int(n[0]) typ, data = m.uid("search", None, "ALL") uids = data[0].split() print(f"{compte} / {FOLDER} : {total} messages, {len(uids)} UID", flush=True) RX_META = re.compile(rb'UID (\d+).*?FLAGS \((.*?)\).*?INTERNALDATE "([^"]+)"', re.S) rows = [] for i in range(0, len(uids), CHUNK): chunk = uids[i:i + CHUNK] rng = f"{chunk[0].decode()}:{chunk[-1].decode()}" typ, resp = m.uid("fetch", rng, "(UID FLAGS INTERNALDATE BODY.PEEK[HEADER.FIELDS (FROM DATE LIST-UNSUBSCRIBE)])") for part in resp: if not isinstance(part, tuple): continue meta, hdr = part mm = RX_META.search(meta) if not mm: continue uid, flags, idate = int(mm.group(1)), mm.group(2).decode(errors="replace"), mm.group(3).decode() msg = email.message_from_bytes(hdr) try: raw = str(email.header.make_header(email.header.decode_header(msg.get("From", "")))) if msg.get("From") else "" except Exception: raw = msg.get("From", "") name, addr = email.utils.parseaddr(raw) try: d = email.utils.parsedate_to_datetime(idate) except Exception: d = None rows.append({"uid": uid, "from": addr.lower(), "name": name[:60], "date": d.strftime("%Y-%m-%d") if d else idate[:11], "seen": "\\Seen" in flags, "unsub": bool(msg.get("List-Unsubscribe"))}) print(f" {min(i + CHUNK, len(uids))}/{len(uids)} ({time.time() - t0:.0f}s)", flush=True) m.logout() json.dump(rows, open(f"{WS}/{compte}-audit-raw.json", "w"), ensure_ascii=False) by_addr = defaultdict(lambda: {"n": 0, "unread": 0, "unsub": 0, "first": "9999", "last": "0000", "name": ""}) by_dom = defaultdict(lambda: {"n": 0, "unread": 0}); by_year = defaultdict(lambda: {"n": 0, "unread": 0}) for r in rows: a = by_addr[r["from"] or "(vide)"]; a["n"] += 1; a["unread"] += (not r["seen"]); a["unsub"] += r["unsub"] a["first"] = min(a["first"], r["date"]); a["last"] = max(a["last"], r["date"]); a["name"] = a["name"] or r["name"] dom = r["from"].split("@")[-1] if "@" in r["from"] else "(vide)" by_dom[dom]["n"] += 1; by_dom[dom]["unread"] += (not r["seen"]) by_year[r["date"][:4]]["n"] += 1; by_year[r["date"][:4]]["unread"] += (not r["seen"]) unread_total = sum(1 for r in rows if not r["seen"]) L = [f"# Audit {compte} ({C['user']}) — {FOLDER} — {datetime.now().strftime('%Y-%m-%d %H:%M')}", "", f"_Lecture seule, {len(rows)} messages lus en {time.time() - t0:.0f} s. Source : {compte}-audit-raw.json._", "", f"- Total : **{len(rows)}** messages, dont **{unread_total}** non lus ({100 * unread_total // max(1, len(rows))} %)", f"- Expéditeurs distincts : {len(by_addr)} ; domaines distincts : {len(by_dom)}", f"- Messages avec en-tête de désabonnement (newsletters) : {sum(1 for r in rows if r['unsub'])}", "", "## Par année", "", "| Année | Messages | Non lus |", "|---|---:|---:|"] for y in sorted(by_year): L.append(f"| {y} | {by_year[y]['n']} | {by_year[y]['unread']} |") L += ["", "## Top 40 domaines", "", "| Domaine | Messages | Non lus |", "|---|---:|---:|"] for dom, v in sorted(by_dom.items(), key=lambda kv: -kv[1]["n"])[:40]: L.append(f"| {dom} | {v['n']} | {v['unread']} |") L += ["", "## Top 150 expéditeurs", "", "| Expéditeur | Nom | Messages | Non lus | Newsletter | Premier | Dernier |", "|---|---|---:|---:|---:|---|---|"] for addr, v in sorted(by_addr.items(), key=lambda kv: -kv[1]["n"])[:150]: L.append(f"| {addr} | {v['name'].replace('|', '/')} | {v['n']} | {v['unread']} | {v['unsub']} | {v['first']} | {v['last']} |") open(f"{WS}/{compte}-audit.md", "w").write("\n".join(L) + "\n") print(f"écrit : {WS}/{compte}-audit.md et {compte}-audit-raw.json — {len(rows)} messages, {len(by_addr)} expéditeurs")