""" Gmail › Handtekeningen: één HTML-sjabloon per school, ingevuld per gebruiker en ingesteld in Gmail. - Gebruikers: alle accounts in de gekozen OU's (Directory API, optioneel met de onderliggende OU's). - Functie en telefoon komen uit Google (gebruikersprofiel: organizations[].title en phones[]) en worden daar ook bewaard als je ze in PIVOT aanpast. Zo staan ze ook in het adresboek van de organisatie. - Toepassen: Gmail API users.settings.sendAs.patch(signature) namens de gebruiker, voor het hoofdadres en de aliassen. - Logo: geüpload in PIVOT (publiek op /handtekening-logo//) of een eigen publieke link. Gmail toont geen ingesloten (base64) afbeeldingen in handtekeningen, enkel afbeeldingen via een link. - Nachtelijk opnieuw toepassen (schakelaar): gebruikers kunnen hun handtekening in Gmail zelf wijzigen, zo staat de juiste er de volgende ochtend terug. Vereist (domeinbrede delegatie): https://www.googleapis.com/auth/gmail.settings.basic en voor functie/telefoon https://www.googleapis.com/auth/admin.directory.user (staat al in de standaardlijst). """ import hashlib import html import json import re import secrets import sys import threading import time from datetime import datetime from config import Config from database import execute_query from tenancy import school_singleton, met_school, google_credentials, huidige_school SCOPE = 'https://www.googleapis.com/auth/gmail.settings.basic' MAX_TEKENS = 10000 # limiet van Gmail voor een handtekening MAX_LOGO = 512 * 1024 LOGO_TYPES = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif'} NACHT_UUR_VAN, NACHT_UUR_TOT = 3, 6 VELDEN = [ # (sleutel, uitleg) ('voornaam', 'Voornaam'), ('achternaam', 'Achternaam'), ('naam', 'Voornaam en achternaam'), ('functie', 'Functie (uit Google)'), ('telefoon', 'Telefoon (uit Google)'), ('email', 'E-mailadres'), ('schoolnaam', 'Naam van de school'), ('schooladres', 'Adres van de school'), ('logo', 'Logo (afbeelding)'), ] STANDAARD_SJABLOON = """
{logo}
{voornaam} {achternaam}
{#functie}
{functie}
{/functie}
{schoolnaam}
{schooladres}
{#telefoon}
Tel. {telefoon}
{/telefoon}
""" class HandtekeningFout(Exception): pass # --------------------------------------------------------------------- # Opslag (database van de school) # --------------------------------------------------------------------- def ensure_tables(): execute_query(""" CREATE TABLE IF NOT EXISTS gmail_handtekening ( ID TINYINT NOT NULL PRIMARY KEY, Sjabloon MEDIUMTEXT NULL, OUs TEXT NULL, Met_sub_ous TINYINT NOT NULL DEFAULT 1, Logo MEDIUMBLOB NULL, Logo_type VARCHAR(40) NULL, Logo_sleutel VARCHAR(40) NULL, Logo_url VARCHAR(500) NULL, Logo_breedte INT NOT NULL DEFAULT 120, Publiek_adres VARCHAR(255) NULL, Nachtelijk TINYINT NOT NULL DEFAULT 0, Laatste_nacht DATE NULL, Nacht_resultaat VARCHAR(500) NULL, Bijgewerkt DATETIME NULL, Door VARCHAR(255) NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) execute_query(""" CREATE TABLE IF NOT EXISTS gmail_handtekening_log ( Email VARCHAR(255) NOT NULL PRIMARY KEY, Toegepast DATETIME NOT NULL, Ok TINYINT NOT NULL, Melding VARCHAR(500) NULL, Hash CHAR(40) NULL, Door VARCHAR(255) NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""", commit=True) execute_query("INSERT IGNORE INTO gmail_handtekening (ID, Sjabloon) VALUES (1, %s)", (STANDAARD_SJABLOON,), commit=True) def instellingen(): ensure_tables() r = (execute_query("SELECT ID, Sjabloon, OUs, Met_sub_ous, Logo_type, Logo_sleutel, Logo_url, Logo_breedte, " "Publiek_adres, Nachtelijk, Laatste_nacht, Nacht_resultaat, Bijgewerkt, Door, " "Logo IS NOT NULL AS heeft_logo FROM gmail_handtekening WHERE ID = 1") or [{}])[0] try: r['ous'] = [o for o in json.loads(r.get('OUs') or '[]') if isinstance(o, str)] except ValueError: r['ous'] = [] r['logo_link'] = logo_link(r) return r def bewaar(sjabloon, ous, met_sub, logo_url, logo_breedte, publiek_adres, door): sjabloon = (sjabloon or '').strip() if len(sjabloon) > 50000: raise HandtekeningFout("Het sjabloon is te lang.") execute_query("UPDATE gmail_handtekening SET Sjabloon = %s, OUs = %s, Met_sub_ous = %s, Logo_url = %s, " "Logo_breedte = %s, Publiek_adres = %s, Bijgewerkt = NOW(), Door = %s WHERE ID = 1", (sjabloon, json.dumps(sorted(set(ous))), 1 if met_sub else 0, (logo_url or '').strip() or None, max(20, min(600, int(logo_breedte or 120))), (publiek_adres or '').strip().rstrip('/') or None, door), commit=True) def bewaar_logo(data, mimetype): if mimetype not in LOGO_TYPES: raise HandtekeningFout("Enkel PNG, JPG of GIF als logo.") if len(data) > MAX_LOGO: raise HandtekeningFout(f"Het logo is te groot (max. {MAX_LOGO // 1024} kB).") execute_query("UPDATE gmail_handtekening SET Logo = %s, Logo_type = %s, Logo_sleutel = %s WHERE ID = 1", (data, mimetype, secrets.token_hex(12)), commit=True) def verwijder_logo(): execute_query("UPDATE gmail_handtekening SET Logo = NULL, Logo_type = NULL, Logo_sleutel = NULL WHERE ID = 1", commit=True) def logo_bestand(sleutel): ensure_tables() r = execute_query("SELECT Logo, Logo_type FROM gmail_handtekening WHERE ID = 1 AND Logo_sleutel = %s", (sleutel,)) return (r[0]['Logo'], r[0]['Logo_type']) if r and r[0]['Logo'] else (None, None) def logo_link(inst): """Publieke link naar het logo: eigen link, of het geüploade logo via PIVOT.""" if inst.get('Logo_url'): return inst['Logo_url'] if inst.get('heeft_logo') and inst.get('Logo_sleutel') and inst.get('Publiek_adres'): ext = LOGO_TYPES.get(inst.get('Logo_type'), 'png') return f"{inst['Publiek_adres']}/handtekening-logo/{huidige_school().code}/{inst['Logo_sleutel']}.{ext}" return '' def zet_nacht(aan, door): execute_query("UPDATE gmail_handtekening SET Nachtelijk = %s, Door = %s WHERE ID = 1", (1 if aan else 0, door), commit=True) def logboek(): ensure_tables() return {r['Email'].lower(): r for r in execute_query("SELECT * FROM gmail_handtekening_log") or []} # --------------------------------------------------------------------- # Sjabloon invullen # --------------------------------------------------------------------- def gegevens(gebruiker): s = huidige_school() return {'voornaam': gebruiker.get('voornaam', ''), 'achternaam': gebruiker.get('achternaam', ''), 'naam': f"{gebruiker.get('voornaam', '')} {gebruiker.get('achternaam', '')}".strip(), 'functie': gebruiker.get('functie', ''), 'telefoon': gebruiker.get('telefoon', ''), 'email': gebruiker.get('email', ''), 'schoolnaam': s.naam or '', 'schooladres': s.get('ADRES') or ''} def vul_in(sjabloon, waarden, inst): """{veld} → waarde (HTML-veilig); {#veld}...{/veld} valt weg als het veld leeg is; {logo} → .""" def blok(m): return m.group(2) if (waarden.get(m.group(1)) or '').strip() or (m.group(1) == 'logo' and inst['logo_link']) else '' uit = re.sub(r'\{#(\w+)\}(.*?)\{/\1\}', blok, sjabloon or '', flags=re.S) link = inst.get('logo_link') or '' logo = (f'{html.escape(waarden.get(') if link else '' def veld(m): k = m.group(1) if k == 'logo': return logo if k in waarden: w = html.escape(waarden[k] or '') return w.replace('\n', '
') if k == 'schooladres' else w return m.group(0) return re.sub(r'\{(\w+)\}', veld, uit) # --------------------------------------------------------------------- # Google: gebruikers, functie en telefoon # --------------------------------------------------------------------- def _directory(): from googleapiclient.discovery import build creds = google_credentials(Config.GOOGLE_API_SCOPES, subject=Config.GOOGLE_ADMIN_EMAIL) return build('admin', 'directory_v1', credentials=creds, static_discovery=False, cache_discovery=False) 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): t = str(e) laag = t.lower() if 'unauthorized_client' in laag or 'access_denied' in laag: return (f"Google weigert de toegang: voeg in de Admin-console › Beveiliging › API-beheer › Domeinbrede delegatie " f"het bereik {SCOPE} toe bij het serviceaccount van PIVOT.") if 'has not been used' in laag or 'is disabled' in laag or 'accessnotconfigured' in laag: return "De Gmail API staat niet aan in het Google Cloud-project van het serviceaccount." if 'mail service not enabled' in laag or 'failedprecondition' in laag: return "Deze gebruiker heeft geen Gmail (geen licentie of Gmail uitgeschakeld)." if 'not authorized to access this resource' in laag or 'insufficient' in laag: return "Google weigert het: de beheerder mag deze gebruiker niet aanpassen (scope admin.directory.user?)." return t[:250] def ou_lijst(): try: resp = _directory().orgunits().list(customerId='my_customer', type='all').execute() except Exception as e: raise HandtekeningFout(f"OU's ophalen mislukt: {vriendelijk(e)}") return sorted([o['orgUnitPath'] for o in resp.get('organizationUnits', [])] + ['/'], key=str.lower) def _functie(u): orgs = u.get('organizations') or [] org = next((o for o in orgs if o.get('primary')), orgs[0] if orgs else {}) return (org.get('title') or '').strip() def _telefoon(u): tel = u.get('phones') or [] t = next((p for p in tel if p.get('type') == 'work'), None) or next((p for p in tel if p.get('primary')), None) \ or (tel[0] if tel else {}) return (t.get('value') or '').strip() def gebruikers(ous, met_sub=True): """Alle (niet-geschorste) gebruikers in de OU's: [{'email','voornaam','achternaam','ou','functie','telefoon'}].""" if not ous: return [] api, uit, gezien = _directory(), [], set() for ou in ous: token = None while True: try: params = dict(customer='my_customer', maxResults=500, pageToken=token, projection='full', fields='nextPageToken,users(primaryEmail,name,orgUnitPath,organizations,phones,suspended,archived)') if ou != '/': params['query'] = "orgUnitPath='" + ou.replace("\\", "\\\\").replace("'", "\\'") + "'" resp = api.users().list(**params).execute() except Exception as e: raise HandtekeningFout(f"Gebruikers van {ou} ophalen mislukt: {vriendelijk(e)}") for u in resp.get('users', []): pad = u.get('orgUnitPath') or '/' if not met_sub and pad != ou: continue if u.get('suspended') or u.get('archived'): continue email = u['primaryEmail'].lower() if email in gezien: continue gezien.add(email) n = u.get('name') or {} uit.append({'email': email, 'voornaam': n.get('givenName', ''), 'achternaam': n.get('familyName', ''), 'ou': pad, 'functie': _functie(u), 'telefoon': _telefoon(u)}) token = resp.get('nextPageToken') if not token: break return sorted(uit, key=lambda x: (x['achternaam'].lower(), x['voornaam'].lower())) def bewaar_in_google(email, functie, telefoon): """Functie (primaire organisatie) en werktelefoon van een gebruiker in Google aanpassen; de rest blijft.""" api = _directory() try: u = api.users().get(userKey=email, projection='full', fields='organizations,phones').execute() orgs = u.get('organizations') or [] org = next((o for o in orgs if o.get('primary')), orgs[0] if orgs else None) if org is None: if functie: orgs = [{'title': functie, 'primary': True, 'customType': ''}] else: org['title'] = functie tel = [p for p in (u.get('phones') or []) if p.get('type') != 'work'] if telefoon: tel.insert(0, {'value': telefoon, 'type': 'work', 'primary': not any(p.get('primary') for p in tel)}) api.users().patch(userKey=email, body={'organizations': orgs, 'phones': tel or []}).execute() except Exception as e: raise HandtekeningFout(f"{email}: {vriendelijk(e)}") def pas_toe(gebruiker, inst, door=None, alleen_gewijzigd=False): """Zet de handtekening voor het hoofdadres en de aliassen. Geeft 'ok' / 'ongewijzigd' of gooit HandtekeningFout.""" tekst = vul_in(inst.get('Sjabloon') or '', gegevens(gebruiker), inst) if len(tekst) > MAX_TEKENS: raise HandtekeningFout(f"De handtekening is te lang voor Gmail ({len(tekst)} tekens, max. {MAX_TEKENS}).") h = hashlib.sha1(tekst.encode('utf-8')).hexdigest() email = gebruiker['email'] if alleen_gewijzigd: oud = execute_query("SELECT Hash, Ok FROM gmail_handtekening_log WHERE Email = %s", (email,)) if oud and oud[0]['Ok'] and oud[0]['Hash'] == h and _huidige_in_gmail_gelijk(email, tekst): return 'ongewijzigd' try: g = _gmail(email) adressen = g.users().settings().sendAs().list(userId='me').execute().get('sendAs', []) for a in adressen: if a.get('isPrimary') or a.get('verificationStatus') == 'accepted' or a.get('treatAsAlias'): g.users().settings().sendAs().patch(userId='me', sendAsEmail=a['sendAsEmail'], body={'signature': tekst}).execute() melding, ok = None, 1 except Exception as e: melding, ok = vriendelijk(e), 0 execute_query("INSERT INTO gmail_handtekening_log (Email, Toegepast, Ok, Melding, Hash, Door) " "VALUES (%s, NOW(), %s, %s, %s, %s) ON DUPLICATE KEY UPDATE Toegepast = NOW(), Ok = VALUES(Ok), " "Melding = VALUES(Melding), Hash = VALUES(Hash), Door = VALUES(Door)", (email, ok, melding, h if ok else None, door), commit=True) if not ok: raise HandtekeningFout(melding) return 'ok' def _huidige_in_gmail_gelijk(email, tekst): """Staat de handtekening nog ongewijzigd in Gmail? (De gebruiker kan ze zelf aangepast hebben.)""" try: for a in _gmail(email).users().settings().sendAs().list(userId='me').execute().get('sendAs', []): if a.get('isPrimary'): gewoon = lambda x: re.sub(r'\s+', '', html.unescape(x or '')).lower() return gewoon(a.get('signature')) == gewoon(tekst) except Exception: pass return False # --------------------------------------------------------------------- # Toepassen op de achtergrond (met voortgang) # --------------------------------------------------------------------- class Toepasser: def __new__(cls): def init(inst): inst._lock = threading.Lock() inst.status = {'bezig': False, 'voortgang': 0, 'totaal': 0, 'gelukt': 0, 'ongewijzigd': 0, 'fouten': 0, 'fout': None, 'resultaat': None, 'foutvoorbeelden': []} return school_singleton(cls, init) def run(self, emails=None, door=None, alleen_gewijzigd=False): if not self._lock.acquire(blocking=False): return False, "Er loopt al een opdracht." s = self.status try: s.update(bezig=True, voortgang=0, totaal=0, gelukt=0, ongewijzigd=0, fouten=0, fout=None, resultaat=None, foutvoorbeelden=[]) inst = instellingen() lijst = gebruikers(inst['ous'], bool(inst.get('Met_sub_ous'))) if emails is not None: kies = {e.lower() for e in emails} lijst = [u for u in lijst if u['email'] in kies] s['totaal'] = len(lijst) for u in lijst: try: if pas_toe(u, inst, door, alleen_gewijzigd) == 'ongewijzigd': s['ongewijzigd'] += 1 else: s['gelukt'] += 1 except Exception as e: s['fouten'] += 1 if len(s['foutvoorbeelden']) < 6: s['foutvoorbeelden'].append(f"{u['email']}: {str(e)[:180]}") s['voortgang'] += 1 msg = f"Handtekening ingesteld voor {s['gelukt']} gebruiker(s)" if s['ongewijzigd']: msg += f", {s['ongewijzigd']} waren al in orde" if s['fouten']: msg += f", {s['fouten']} mislukt" s['resultaat'] = msg + '.' return True, s['resultaat'] except Exception as e: s['fout'] = str(e)[:400] return False, s['fout'] finally: s['bezig'] = False self._lock.release() def start(self, emails=None, door=None): 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(emails, door)), daemon=True).start() return True # --------------------------------------------------------------------- # Nachtelijk opnieuw toepassen (per school met de schakelaar aan) # --------------------------------------------------------------------- def _nacht_claim(vandaag): return execute_query("UPDATE gmail_handtekening SET Laatste_nacht = %s WHERE ID = 1 AND Nachtelijk = 1 " "AND (Laatste_nacht IS NULL OR Laatste_nacht < %s)", (vandaag, vandaag), commit=True) == 1 def nacht_voor_alle_scholen(): from tenancy import alle_scholen, in_school vandaag = datetime.now().strftime('%Y-%m-%d') for school in alle_scholen(): if not school.google_sleutel: continue with in_school(school): try: ensure_tables() if not _nacht_claim(vandaag): continue ok, msg = Toepasser().run(door='PIVOT (nachtelijk)', alleen_gewijzigd=True) execute_query("UPDATE gmail_handtekening SET Nacht_resultaat = %s WHERE ID = 1", (f"{datetime.now():%d-%m-%Y %H:%M}: {msg}"[:500],), commit=True) except Exception as e: sys.stderr.write(f"Handtekeningen nachtelijk ({school.code}): {e}\n") _thread = None def start_scheduler(): global _thread if _thread: return def loop(): time.sleep(90) while True: try: if NACHT_UUR_VAN <= datetime.now().hour < NACHT_UUR_TOT: nacht_voor_alle_scholen() except Exception as e: sys.stderr.write(f"Handtekeningen nachtelijke taak: {e}\n") time.sleep(15 * 60) _thread = threading.Thread(target=loop, daemon=True) _thread.start()