- 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.
165 lines
11 KiB
Python
165 lines
11 KiB
Python
#!/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.<année> / 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}")
|