workspace: rapport solaire, prevision de nuit, concierge, courrier, fiche allegee
- 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.
This commit is contained in:
98
workspace/mail-audit.py
Normal file
98
workspace/mail-audit.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/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")
|
||||
Reference in New Issue
Block a user