270 lines
8.2 KiB
Python

#!/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)