Files
Pivot/services/gmail_label_service.py
T
2026-10-14 12:29:29 +02:00

238 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Google › E-mails met een label verplaatsen: alle berichten met een bepaald label uit een mailbox kopiëren of
verplaatsen naar een andere mailbox van dezelfde Google Workspace.
Werking (Gmail API, het serviceaccount werkt in naam van de mailbox via domeinbrede delegatie):
1. bron: users.messages.list(labelIds=[label]) → alle berichten met het label
2. bron: users.messages.get(format=raw) → het volledige bericht (met bijlagen, datum, afzender ...)
3. doel: users.messages.import(raw, labelIds=[...]) → in de doelmailbox, onder hetzelfde label
(de originele datum blijft, geen spamfilter; gelezen/ongelezen en ster gaan mee)
4. bij 'verplaatsen': bron: users.messages.trash → in de bronmailbox naar de prullenbak (30 dagen terug te halen)
Elk bericht dat al gekopieerd werd naar dezelfde doelmailbox, wordt overgeslagen (tabel gmail_label_items),
zodat je een onderbroken opdracht gewoon opnieuw kan starten.
Vereist (eenmalig, Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie, bij het serviceaccount):
https://www.googleapis.com/auth/gmail.modify
"""
import sys
import threading
import time
from datetime import datetime
from database import execute_query
from tenancy import school_singleton, met_school, google_credentials
SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
MEEGAAN = ('UNREAD', 'STARRED', 'IMPORTANT') # systeemlabels die mee naar de doelmailbox gaan
class LabelFout(Exception):
pass
def _gmail(mailbox):
from googleapiclient.discovery import build
creds = google_credentials([SCOPE], subject=mailbox)
try:
return build('gmail', 'v1', credentials=creds, static_discovery=True, cache_discovery=False)
except Exception:
return build('gmail', 'v1', credentials=creds, static_discovery=False, cache_discovery=False)
def vriendelijk(e, mailbox=None):
tekst = str(e)
laag = tekst.lower()
if 'unauthorized_client' in laag or 'insufficient' in laag or 'access_denied' in laag:
return ("Google weigert de toegang: het serviceaccount mist het Gmail-recht om berichten te lezen en te "
"importeren. Voeg in admin.google.com › Beveiliging › Toegangs- en gegevensbeheer › API-beheer › "
f"Domeinbrede delegatie bij het serviceaccount dit bereik toe: {SCOPE} (het kan enkele minuten duren).")
if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag:
return ("De Gmail API staat nog niet aan in het Google Cloud-project van het serviceaccount "
"(console.cloud.google.com › API's en services › Bibliotheek › Gmail API › Inschakelen).")
if 'mail service not enabled' in laag or 'failedprecondition' in laag:
return f"{mailbox or 'Deze gebruiker'} heeft geen werkende Gmail-mailbox (geen licentie of Gmail uitgeschakeld)."
if 'notfound' in laag.replace(' ', '') or ' 404' in tekst:
return "Niet gevonden (bestaat de mailbox of het label nog?)."
return tekst[:300]
def ensure_tables():
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_label_log (
ID INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
Gestart DATETIME NOT NULL,
Klaar DATETIME NULL,
Door VARCHAR(255) NULL,
Bron VARCHAR(255) NOT NULL,
Label VARCHAR(255) NOT NULL,
Doel VARCHAR(255) NOT NULL,
Modus VARCHAR(12) NOT NULL,
Totaal INT NULL,
Gelukt INT NULL,
Overgeslagen INT NULL,
Fouten INT NULL,
Melding VARCHAR(500) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
execute_query("""
CREATE TABLE IF NOT EXISTS gmail_label_items (
Bron VARCHAR(255) NOT NULL,
Bron_ID VARCHAR(64) NOT NULL,
Doel VARCHAR(255) NOT NULL,
Doel_ID VARCHAR(64) NULL,
Gekopieerd DATETIME NOT NULL,
PRIMARY KEY (Bron, Bron_ID, Doel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True)
def labels(mailbox):
"""Eigen labels van een mailbox, met het aantal berichten: [{'id', 'naam', 'aantal'}], alfabetisch."""
try:
g = _gmail(mailbox)
lijst = g.users().labels().list(userId='me').execute().get('labels', [])
uit = []
for l in lijst:
if l.get('type') != 'user':
continue
try:
info = g.users().labels().get(userId='me', id=l['id']).execute()
aantal = info.get('messagesTotal')
except Exception:
aantal = None
uit.append({'id': l['id'], 'naam': l.get('name', ''), 'aantal': aantal})
except Exception as e:
raise LabelFout(vriendelijk(e, mailbox))
return sorted(uit, key=lambda l: l['naam'].lower())
def geschiedenis(limiet=20):
ensure_tables()
return execute_query("SELECT * FROM gmail_label_log ORDER BY Gestart DESC LIMIT %s", (int(limiet),)) or []
class LabelVerplaatser:
def __new__(cls):
def init(inst):
inst._lock = threading.Lock()
inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'gelukt': 0, 'overgeslagen': 0, 'fouten': 0,
'fout': None, 'resultaat': None, 'opdracht': None, 'foutvoorbeelden': []}
return school_singleton(cls, init)
# ---------------- hulpfuncties ----------------
@staticmethod
def _alle_ids(g, label_id):
ids, token = [], None
while True:
resp = g.users().messages().list(userId='me', labelIds=[label_id], maxResults=500, pageToken=token,
includeSpamTrash=False).execute()
ids += [m['id'] for m in resp.get('messages', [])]
token = resp.get('nextPageToken')
if not token:
return ids
@staticmethod
def _doel_label(g, naam):
"""Id van het label `naam` in de doelmailbox (aangemaakt als het nog niet bestaat, ook sublabels)."""
bestaand = {l['name']: l['id'] for l in g.users().labels().list(userId='me').execute().get('labels', [])}
if naam in bestaand:
return bestaand[naam]
# Bovenliggende labels eerst aanmaken (A, A/B, A/B/C), zodat de nesting in Gmail klopt
delen = naam.split('/')
for i in range(1, len(delen) + 1):
pad = '/'.join(delen[:i])
if pad not in bestaand:
nieuw = g.users().labels().create(userId='me', body={
'name': pad, 'labelListVisibility': 'labelShow', 'messageListVisibility': 'show'}).execute()
bestaand[pad] = nieuw['id']
return bestaand[naam]
@staticmethod
def _herhaal(fn, pogingen=4):
"""Google-aanroep met een paar nieuwe pogingen bij 429/5xx (te snel / tijdelijk)."""
for poging in range(pogingen):
try:
return fn()
except Exception as e:
status = getattr(getattr(e, 'resp', None), 'status', None)
if status in (429, 500, 502, 503) and poging < pogingen - 1:
time.sleep(2 ** poging)
continue
raise
# ---------------- opdracht ----------------
def run(self, bron, label_id, label_naam, doel, modus, door):
if not self._lock.acquire(blocking=False):
return False, "Er loopt al een opdracht."
log_id = None
try:
ensure_tables()
self.status.update(bezig=True, voortgang=0, totaal=0, gelukt=0, overgeslagen=0, fouten=0, prullenbak=0, fout=None,
resultaat=None, foutvoorbeelden=[],
opdracht={'bron': bron, 'label': label_naam, 'doel': doel, 'modus': modus})
execute_query("INSERT INTO gmail_label_log (Gestart, Door, Bron, Label, Doel, Modus) "
"VALUES (NOW(), %s, %s, %s, %s, %s)", (door, bron, label_naam, doel, modus), commit=True)
log_id = (execute_query("SELECT MAX(ID) AS id FROM gmail_label_log WHERE Bron = %s AND Doel = %s",
(bron, doel)) or [{}])[0].get('id')
try:
gb, gd = _gmail(bron), _gmail(doel)
ids = self._alle_ids(gb, label_id)
doel_label = self._doel_label(gd, label_naam)
except Exception as e:
raise LabelFout(vriendelijk(e))
self.status['totaal'] = len(ids)
al = {r['Bron_ID'] for r in execute_query(
"SELECT Bron_ID FROM gmail_label_items WHERE Bron = %s AND Doel = %s", (bron, doel)) or []}
for mid in ids:
try:
if mid in al:
self.status['overgeslagen'] += 1
else:
bericht = self._herhaal(lambda: gb.users().messages().get(userId='me', id=mid, format='raw').execute())
extra = [l for l in bericht.get('labelIds', []) if l in MEEGAAN]
nieuw = self._herhaal(lambda: gd.users().messages().import_(
userId='me', body={'raw': bericht['raw'], 'labelIds': [doel_label] + extra},
internalDateSource='dateHeader', neverMarkSpam=True, processForCalendar=False).execute())
execute_query("INSERT IGNORE INTO gmail_label_items (Bron, Bron_ID, Doel, Doel_ID, Gekopieerd) "
"VALUES (%s, %s, %s, %s, NOW())", (bron, mid, doel, nieuw.get('id')), commit=True)
self.status['gelukt'] += 1
if modus == 'verplaatsen':
self._herhaal(lambda: gb.users().messages().trash(userId='me', id=mid).execute())
self.status['prullenbak'] = self.status.get('prullenbak', 0) + 1
except Exception as e:
self.status['fouten'] += 1
if len(self.status['foutvoorbeelden']) < 5:
self.status['foutvoorbeelden'].append(vriendelijk(e)[:200])
sys.stderr.write(f"Label verplaatsen {bron} → {doel}, bericht {mid}: {e}\n")
self.status['voortgang'] += 1
s = self.status
msg = f"Label '{label_naam}' van {bron} naar {doel}: {s['gelukt']} bericht(en) gekopieerd"
if s['overgeslagen']:
msg += f", {s['overgeslagen']} stonden er al"
if modus == 'verplaatsen':
msg += f", {s.get('prullenbak', 0)} in de bronmailbox naar de prullenbak"
if s['fouten']:
msg += f", {s['fouten']} mislukt"
s['resultaat'] = msg + '.'
return True, s['resultaat']
except Exception as e:
self.status['fout'] = vriendelijk(e)
return False, self.status['fout']
finally:
s = self.status
if log_id:
try:
execute_query("UPDATE gmail_label_log SET Klaar = NOW(), Totaal = %s, Gelukt = %s, Overgeslagen = %s, "
"Fouten = %s, Melding = %s WHERE ID = %s",
(s['totaal'], s['gelukt'], s['overgeslagen'], s['fouten'],
(s['fout'] or s['resultaat'] or '')[:500], log_id), commit=True)
except Exception:
pass
s['bezig'] = False
self._lock.release()
def start(self, *args):
if self.status['bezig']:
return False
self.status.update(bezig=True, voortgang=0, totaal=0, fout=None, resultaat=None)
threading.Thread(target=met_school(lambda: self.run(*args)), daemon=True).start()
return True