#!/usr/bin/env python3 """ Sync Keycloak users to Mailu mailboxes. - Generates/stores a mailu_app_password attribute in Keycloak (admin-only) - Upserts the mailbox in Mailu Postgres using that password """ import os import sys import json import time import secrets import string import datetime import requests import psycopg2 from psycopg2.extras import RealDictCursor from passlib.hash import bcrypt_sha256 KC_BASE = os.environ["KEYCLOAK_BASE_URL"].rstrip("/") KC_REALM = os.environ["KEYCLOAK_REALM"] KC_CLIENT_ID = os.environ["KEYCLOAK_CLIENT_ID"] KC_CLIENT_SECRET = os.environ["KEYCLOAK_CLIENT_SECRET"] MAILU_DOMAIN = os.environ["MAILU_DOMAIN"] MAILU_DEFAULT_QUOTA = int(os.environ.get("MAILU_DEFAULT_QUOTA", "20000000000")) MAILU_ENABLED_ATTR = os.environ.get("MAILU_ENABLED_ATTR", "mailu_enabled") MAILU_EMAIL_ATTR = "mailu_email" DB_CONFIG = { "host": os.environ["MAILU_DB_HOST"], "port": int(os.environ.get("MAILU_DB_PORT", "5432")), "dbname": os.environ["MAILU_DB_NAME"], "user": os.environ["MAILU_DB_USER"], "password": os.environ["MAILU_DB_PASSWORD"], } SESSION = requests.Session() def log(msg): sys.stdout.write(f"{msg}\n") sys.stdout.flush() def retry_request(label, func, attempts=10): for attempt in range(1, attempts + 1): try: return func() except requests.RequestException as exc: if attempt == attempts: raise log(f"{label} failed (attempt {attempt}/{attempts}): {exc}") time.sleep(attempt * 2) def retry_db_connect(attempts=10): for attempt in range(1, attempts + 1): try: return psycopg2.connect(**DB_CONFIG) except psycopg2.Error as exc: if attempt == attempts: raise log(f"Database connection failed (attempt {attempt}/{attempts}): {exc}") time.sleep(attempt * 2) def get_kc_token(): resp = SESSION.post( f"{KC_BASE}/realms/{KC_REALM}/protocol/openid-connect/token", data={ "grant_type": "client_credentials", "client_id": KC_CLIENT_ID, "client_secret": KC_CLIENT_SECRET, }, timeout=15, ) resp.raise_for_status() return resp.json()["access_token"] def kc_get_users(token): users = [] first = 0 max_results = 200 headers = {"Authorization": f"Bearer {token}"} while True: resp = SESSION.get( f"{KC_BASE}/admin/realms/{KC_REALM}/users", params={ "first": first, "max": max_results, "enabled": "true", "briefRepresentation": "false", }, headers=headers, timeout=20, ) resp.raise_for_status() batch = resp.json() users.extend(batch) if len(batch) < max_results: break first += max_results return users def kc_update_attributes(token, user, attributes): headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } user_url = f"{KC_BASE}/admin/realms/{KC_REALM}/users/{user['id']}" current = SESSION.get( user_url, headers={"Authorization": f"Bearer {token}"}, params={"briefRepresentation": "false"}, timeout=15, ) current.raise_for_status() current_payload = current.json() current_attrs = current_payload.get("attributes") if isinstance(current_payload, dict) else None if not isinstance(current_attrs, dict): current_attrs = {} current_attrs.update(attributes) resp = SESSION.put(user_url, headers=headers, json={"attributes": current_attrs}, timeout=20) resp.raise_for_status() verify = SESSION.get( user_url, headers={"Authorization": f"Bearer {token}"}, params={"briefRepresentation": "false"}, timeout=15, ) verify.raise_for_status() attrs = verify.json().get("attributes") or {} if not attrs.get("mailu_app_password"): raise Exception(f"attribute not persisted for {user.get('email') or user['username']}") def random_password(): alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(24)) def get_attribute_value(attributes, key): raw = (attributes or {}).get(key) if isinstance(raw, list): return raw[0] if raw else None if isinstance(raw, str): return raw return None def mailu_enabled(attributes) -> bool: raw = get_attribute_value(attributes, MAILU_ENABLED_ATTR) if raw is None: return bool(get_attribute_value(attributes, MAILU_EMAIL_ATTR)) return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"} def resolve_mailu_email(user, attributes): explicit = get_attribute_value(attributes, MAILU_EMAIL_ATTR) if explicit: return explicit email = user.get("email") or "" if "@" in email and email.lower().endswith(f"@{MAILU_DOMAIN.lower()}"): return email return f"{user['username']}@{MAILU_DOMAIN}" def ensure_mailu_user(cursor, email, password, display_name): localpart, domain = email.split("@", 1) if domain.lower() != MAILU_DOMAIN.lower(): return hashed = bcrypt_sha256.hash(password) now = datetime.datetime.now(datetime.timezone.utc) cursor.execute( """ INSERT INTO "user" ( email, localpart, domain_name, password, quota_bytes, quota_bytes_used, global_admin, enabled, enable_imap, enable_pop, allow_spoofing, forward_enabled, forward_destination, forward_keep, reply_enabled, reply_subject, reply_body, reply_startdate, reply_enddate, displayed_name, spam_enabled, spam_mark_as_read, spam_threshold, change_pw_next_login, created_at, updated_at, comment ) VALUES ( %(email)s, %(localpart)s, %(domain)s, %(password)s, %(quota)s, 0, false, true, true, true, false, false, '', true, false, NULL, NULL, DATE '1900-01-01', DATE '2999-12-31', %(display)s, true, true, 80, false, CURRENT_DATE, %(now)s, '' ) ON CONFLICT (email) DO UPDATE SET password = EXCLUDED.password, enabled = true, updated_at = EXCLUDED.updated_at """, { "email": email, "localpart": localpart, "domain": domain, "password": hashed, "quota": MAILU_DEFAULT_QUOTA, "display": display_name or localpart, "now": now, }, ) def main(): token = retry_request("Keycloak token", get_kc_token) users = retry_request("Keycloak user list", lambda: kc_get_users(token)) if not users: log("No users found; exiting.") return conn = retry_db_connect() conn.autocommit = True cursor = conn.cursor(cursor_factory=RealDictCursor) for user in users: attrs = user.get("attributes", {}) or {} if user.get("enabled") is False: continue needs_update = False if get_attribute_value(attrs, MAILU_ENABLED_ATTR) is None and get_attribute_value(attrs, MAILU_EMAIL_ATTR): attrs[MAILU_ENABLED_ATTR] = ["true"] needs_update = True if not mailu_enabled(attrs): continue app_pw = get_attribute_value(attrs, "mailu_app_password") mailu_email = resolve_mailu_email(user, attrs) if not get_attribute_value(attrs, MAILU_EMAIL_ATTR): attrs[MAILU_EMAIL_ATTR] = [mailu_email] needs_update = True if not app_pw: app_pw = random_password() attrs["mailu_app_password"] = [app_pw] needs_update = True if needs_update: kc_update_attributes(token, user, attrs) log(f"Updated Mailu attributes for {mailu_email}") display_name = " ".join( part for part in [user.get("firstName"), user.get("lastName")] if part ).strip() ensure_mailu_user(cursor, mailu_email, app_pw, display_name) log(f"Synced mailbox for {mailu_email}") cursor.close() conn.close() if __name__ == "__main__": try: main() except Exception as exc: log(f"ERROR: {exc}") sys.exit(1)