- Fiche agent maison (AGENTS.md) : prevision de nuit relayee par script, import
EDF, regle JEEP midi, saisie vehicule/niveau/depart, concierge qui relaie
« ✅ Noté » ou « Rien n'est noté », groupe A deplace (28 649 -> 25 831 car.).
- Nouvelles references : reference-concierge.md, reference-workspace.md.
- Scripts : rapport-solaire.js, prevision-nuit.js, airtria-ha-vehicule.js,
concierge-live.js (answer ne sort plus jamais vide, mode CONCIERGE_SIMULATION).
- Courrier Orange / AIRTRIA / Gmail : audits, regles, menage, recents.
- Bancs EVCS tests-pv (G5 accepte « anomal »).
- .gitignore : liste blanche etendue aux nouveaux scripts et bancs.
99 lines
5.8 KiB
Python
99 lines
5.8 KiB
Python
#!/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 : <compte>-audit.md et <compte>-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")
|