#!/usr/bin/env python3 # airtria-menage.py — Vagues de ménage de la boîte pro AIRTRIA (MailPlus sur ARCHIVE2, IMAP), # règles de airtria-rules.md. Même logique que orange-menage.py. # # python3 airtria-menage.py → DRY-RUN de toutes les règles (compte, ne touche rien) # python3 airtria-menage.py --rule A1.02 → dry-run d'une seule règle # python3 airtria-menage.py --rule A1.02 --go # python3 airtria-menage.py --go → exécute TOUTES les règles (après dry-run validé) # python3 airtria-menage.py --quotidien → tri du soir (cron) : corbeille + veille sur 3 jours # python3 airtria-menage.py --quotidien --dry-run # # Garde-fous : jamais de suppression (UID MOVE vers Trash / Archives. / Veille, aucun # EXPUNGE) ; whitelist appliquée message par message ; lots de 200, pause 5 s ; journal # append-only airtria-housekeeping.md ; vague 1 : messages > 90 jours seulement. import imaplib, ssl, socket, re, sys, time, email, email.utils, email.header from datetime import datetime, timedelta from collections import defaultdict HOST, SNI, USER = "10.10.10.22", "airtria-dsm2.direct.quickconnect.to", "TMZF5455" WS = "/home/node/.openclaw/workspace" LOG = f"{WS}/airtria-housekeeping.md" TRASH, ARCH, VEILLE = "Trash", "Archives", "Veille" # séparateur MailPlus : « . » BATCH, PAUSE, AGE_DAYS = 200, 5, 90 GO = "--go" in sys.argv DAILY = "--quotidien" in sys.argv if DAILY and "--dry-run" not in sys.argv: GO = True ONLY = sys.argv[sys.argv.index("--rule") + 1] if "--rule" in sys.argv else None WL_DOMAINS = {"paypal.fr", "cic.fr", "bpgo.fr", "bp.banquepopulaire.fr", "contact.axa.fr", "courriel.axa.fr", "stoik.io", "bouygues-telecom.fr", "orange.com", "orange.fr", "laboutique-solaire-pro.com", "bretagne-materiaux.fr", "ilosit.com", "edissio.com", "tourinsoft.com", "notice.2.ismartlife.me", "airtria.fr", "airtria.net", "airtria.com", "airtria.pro"} WL_ADDR = {"confirmation-commande@amazon.fr", "order-update@amazon.fr", "shipment-tracking@amazon.fr", "marketplace-messages@amazon.fr", "account-update@amazon.fr", "donotreply@amazon.fr", "no-reply@amazon.fr", "nepasrepondre@interencheres.com", "donotreply@audible.fr", "copieur@apriwey.fr", "developer-notify@tuya.com", "thierry.tallagrand@wanadoo.fr", "tallagrand.thierry@gmail.com"} def whitelisted(addr): a = addr.lower(); return a in WL_ADDR or a.split("@")[-1] in WL_DOMAINS # kind : trash | trash_unsub (corbeille seulement si en-tête List-Unsubscribe) | veille | year RULES = [ ("A1.01", "IDFP / Top Infos Pros", "trash", 'OR FROM "infodespros.fr" FROM "top-infos-pros.fr"', r"@(infodespros|top-infos-pros)\.fr$"), ("A1.02", "TonerPartenaire", "trash", 'FROM "tonerpartenaire.fr"', r"@tonerpartenaire\.fr$"), ("A1.03", "Amazon promos", "trash", 'OR OR OR FROM "annonce@amazon.fr" FROM "store-news@amazon.fr" FROM "announce@amazon.fr" FROM "amazonmusic.com"', r"^(annonce|store-news|announce)@amazon\.fr$|@amazonmusic\.com$"), ("A1.04", "Prime Video", "trash", 'FROM "primevideo.com"', r"@primevideo\.com$"), ("A1.05", "Geocoyote", "trash", 'FROM "geo-coyote.fr"', r"@geo-coyote\.fr$"), ("A1.06", "Reolink", "trash", 'FROM "reolinksupport.com"', r"@news\.reolinksupport\.com$"), ("A1.07", "Interencheres newsletters", "trash", 'FROM "interencheres.com"', r"^(newsletter@interencheres\.com|bonjour@newsletter\.interencheres\.com)$"), ("A1.08", "Airton", "trash", 'FROM "airton.shop"', r"@airton\.shop$"), ("A1.09", "Sage", "trash", 'FROM "go.sage.com"', r"@go\.sage\.com$"), ("A1.10", "Génération Numérique", "trash", 'FROM "genum.fr"', r"@genum\.fr$"), ("A1.11", "EaseUS", "trash", 'FROM "easeus.com"', r"@easeus\.com$"), ("A1.12", "Audible promos", "trash", 'FROM "audible.fr"', r"^noreply@(mail\.)?audible\.fr$"), ("A1.13", "Espacebusiness", "trash", 'FROM "espacebusiness"', r"@(espacebusiness\.com|mb[0-9]\.espacebusiness\.pro)$"), ("A1.14", "Corus", "trash", 'FROM "corus.pro"', r"@newsletter\.corus\.pro$"), ("A1.15", "Auctibid", "trash", 'FROM "auctibid.com"', r"@mkt\.auctibid\.com$"), ("A1.16", "Point.P", "trash", 'FROM "news.pointp.fr"', r"@news\.pointp\.fr$"), ("A1.17", "Vertical-Mail", "trash", 'FROM "lescomptes.fr"', r"@lescomptes\.fr$"), ("A1.18", "Eventbrite", "trash", 'FROM "eventbrite.com"', r"@campaign\.eventbrite\.com$"), ("A1.19", "Pour-les-pro", "trash", 'FROM "pour-les-pro.fr"', r"@pour-les-pro\.fr$"), ("A1.20", "bwonline", "trash", 'FROM "bwonline.in"', r"@bwonline\.in$"), ("A1.21", "i-Run", "trash", 'FROM "i-run.me"', r"@i-run\.me$"), ("A1.22", "TP-Link newsletter", "trash", 'FROM "newsletter.tp-link.com"', r"@newsletter\.tp-link\.com$"), ("A1.23", "Overland-Tandberg", "trash", 'FROM "overlandtandberg.com"', r"@overlandtandberg\.com$"), ("A1.24", "Bpifrance media", "trash", 'FROM "media.bpifrance.fr"', r"@media\.bpifrance\.fr$"), ("A1.25", "Aprudit", "trash", 'FROM "aprudit.com"', r"@aprudit\.com$"), ("A1.26", "Culligan", "trash", 'FROM "vmle-poiz.com"', r"^culligan@vmle-poiz\.com$"), ("A1.27", "NVIDIA GeForce", "trash", 'FROM "nvgaming.nvidia.com"', r"@nvgaming\.nvidia\.com$"), ("A1.28", "Amazon Business (désabo)", "trash_unsub", 'FROM "business.amazon.fr"', r"^no-reply@business\.amazon\.fr$"), ("A1.29", "aprem-hi (prospection)", "trash", 'FROM "aprem-hi.com"', r"@aprem-hi\.com$"), ("A3.01", "NVIDIA / Victron → Veille", "veille", 'OR FROM "news@nvidia.com" FROM "victronenergy.com"', r"^(news@nvidia\.com|(news|noreply)@victronenergy\.com)$"), ("A3.02", "Forum Victron → Veille", "veille", 'FROM "victron.discoursemail.com"', r"@victron\.discoursemail\.com$"), ("A2.01", "Archive 2023 (tout)", "year", 2023, None), ("A2.02", "Archive 2024 (tout)", "year", 2024, None), ("A2.03", "Archive 2025 (tout)", "year", 2025, None), ] env = dict(l.strip().split("=", 1) for l in open("/home/node/.openclaw/.env") if "=" in l and not l.startswith("#")) PWD = env["AIRTRIA_IMAP_PASSWORD"].strip().strip('"').strip("'") class _IMAPSurSocket(imaplib.IMAP4): def __init__(self, sock): self._s = sock; imaplib.IMAP4.__init__(self, "", 993) def open(self, host, port, timeout=None): self.host, self.port, self.sock = host, port, self._s; self.file = self.sock.makefile("rb") def connect(): ctx = ssl.create_default_context() # certificat vérifié sur le nom SNI QuickConnect m = _IMAPSurSocket(ctx.wrap_socket(socket.create_connection((HOST, 993), 10), server_hostname=SNI)) m.login(USER, PWD); m.select("INBOX", readonly=not GO); return m def imap_date(d): return d.strftime("%d-%b-%Y") RX_META = re.compile(rb'UID (\d+).*?INTERNALDATE "([^"]+)"', re.S) def fetch_meta(m, uids): """{uid: (adresse, année, a_desabo)} via BODY.PEEK.""" out = {} for i in range(0, len(uids), 500): chunk = uids[i:i + 500] typ, resp = m.uid("fetch", ",".join(u.decode() for u in chunk), "(UID INTERNALDATE BODY.PEEK[HEADER.FIELDS (FROM LIST-UNSUBSCRIBE)])") for part in resp: if not isinstance(part, tuple): continue mm = RX_META.search(part[0]) if not mm: continue msg = email.message_from_bytes(part[1]) 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", "") addr = email.utils.parseaddr(raw)[1].lower() try: year = email.utils.parsedate_to_datetime(mm.group(2).decode()).year except Exception: year = None out[mm.group(1)] = (addr, year, bool(msg.get("List-Unsubscribe"))) return out def ensure_folder(m, name): typ, data = m.list('""', f'"{name}"') if typ == "OK" and data and data[0]: return if GO: m.create(name); m.subscribe(name); print(f" dossier créé : {name}") else: print(f" (dry-run) dossier à créer : {name}") def move(m, uids, dest): n = 0 for i in range(0, len(uids), BATCH): chunk = uids[i:i + BATCH] typ, _ = m.uid("move", ",".join(u.decode() for u in chunk), dest) if typ != "OK": raise RuntimeError(f"MOVE {dest} : {typ}") n += len(chunk) if i + BATCH < len(uids): time.sleep(PAUSE) return n def log(line): with open(LOG, "a") as f: f.write(line + "\n") m = connect() cutoff = imap_date(datetime.now() - timedelta(days=AGE_DAYS)) since_daily = imap_date(datetime.now() - timedelta(days=3)) mode = ("QUOTIDIEN " if DAILY else "") + ("GO" if GO else "DRY-RUN") print(f"=== airtria-menage {mode} — {datetime.now():%Y-%m-%d %H:%M} — vague 1 : avant le {cutoff} ===") if GO: log(f"\n## {datetime.now():%Y-%m-%d %H:%M:%S} — airtria-menage {'--quotidien' if DAILY else '--go'}{' --rule ' + ONLY if ONLY else ''}\n\n| Règle | Description | Déplacés | Destination | Écartés (whitelist) |\n|---|---|---:|---|---:|") total = 0 for rid, desc, kind, crit, rx in RULES: if ONLY and rid != ONLY: continue if DAILY and kind == "year": continue if kind in ("trash", "trash_unsub", "veille"): if kind == "veille": q = f'({crit} SINCE {since_daily})' if DAILY else f'({crit})' else: q = f'({crit} SINCE {since_daily})' if DAILY else f'({crit} BEFORE {cutoff})' typ, data = m.uid("search", None, q) cand = data[0].split(); info = fetch_meta(m, cand) keep = [u for u in cand if u in info and re.search(rx, info[u][0]) and not whitelisted(info[u][0]) and (kind != "trash_unsub" or info[u][2])] wl = sum(1 for u in cand if u in info and whitelisted(info[u][0])) dest = VEILLE if kind == "veille" else TRASH if kind == "veille": ensure_folder(m, VEILLE) if not DAILY or keep: print(f"{rid} {desc:30s} candidats {len(cand):5d} → à déplacer {len(keep):5d} vers {dest} (whitelist écartés {wl}, hors motif {len(cand) - len(keep) - wl})") if GO and keep: n = move(m, keep, dest); total += n; log(f"| {rid} | {desc} | {n} | {dest} | {wl} |") elif kind == "year": y = crit typ, data = m.uid("search", None, f'(SINCE 01-Jan-{y} BEFORE 01-Jan-{y + 1})') cand = data[0].split(); dest = f"{ARCH}.{y}" print(f"{rid} {desc:30s} à déplacer {len(cand):5d} vers {dest}") ensure_folder(m, dest) if GO and cand: n = move(m, cand, dest); total += n; log(f"| {rid} | {desc} | {n} | {dest} | 0 |") m.logout() print(f"=== fin {mode} : {total} messages déplacés ===") if GO: log(f"\nTotal déplacés : {total}")