238 lines
12 KiB
Python
238 lines
12 KiB
Python
"""
|
||
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
|