465 lines
15 KiB
Python
465 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import time
|
|
from typing import Any
|
|
|
|
import psycopg
|
|
from passlib.hash import bcrypt_sha256
|
|
|
|
from ..settings import settings
|
|
from ..utils.logging import get_logger
|
|
from ..utils.passwords import random_password
|
|
from .keycloak_admin import keycloak_admin
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
MAILU_ENABLED_ATTR = "mailu_enabled"
|
|
MAILU_EMAIL_ATTR = "mailu_email"
|
|
MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
|
|
BCRYPT_MAX_PASSWORD_BYTES = 72
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MailuSyncSummary:
|
|
processed: int
|
|
updated: int
|
|
skipped: int
|
|
failures: int
|
|
mailboxes: int
|
|
system_mailboxes: int
|
|
detail: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MailuUserSyncResult:
|
|
processed: int = 0
|
|
updated: int = 0
|
|
skipped: int = 0
|
|
failures: int = 0
|
|
mailboxes: int = 0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MailuSyncContext:
|
|
username: str
|
|
user_id: str
|
|
mailu_email: str
|
|
app_password: str
|
|
updated: int
|
|
display_name: str
|
|
|
|
|
|
class PasswordTooLongError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class MailuSyncCounters:
|
|
processed: int = 0
|
|
updated: int = 0
|
|
skipped: int = 0
|
|
failures: int = 0
|
|
mailboxes: int = 0
|
|
system_mailboxes: int = 0
|
|
|
|
def add(self, result: MailuUserSyncResult) -> None:
|
|
self.processed += result.processed
|
|
self.updated += result.updated
|
|
self.skipped += result.skipped
|
|
self.failures += result.failures
|
|
self.mailboxes += result.mailboxes
|
|
|
|
def summary(self) -> MailuSyncSummary:
|
|
return MailuSyncSummary(
|
|
processed=self.processed,
|
|
updated=self.updated,
|
|
skipped=self.skipped,
|
|
failures=self.failures,
|
|
mailboxes=self.mailboxes,
|
|
system_mailboxes=self.system_mailboxes,
|
|
)
|
|
|
|
|
|
def _extract_attr(attrs: Any, key: str) -> str | None:
|
|
if not isinstance(attrs, dict):
|
|
return None
|
|
raw = attrs.get(key)
|
|
if isinstance(raw, list):
|
|
for item in raw:
|
|
if isinstance(item, str) and item.strip():
|
|
return item.strip()
|
|
return None
|
|
if isinstance(raw, str) and raw.strip():
|
|
return raw.strip()
|
|
return None
|
|
|
|
|
|
def _display_name(user: dict[str, Any]) -> str:
|
|
parts = []
|
|
for key in ("firstName", "lastName"):
|
|
value = user.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
parts.append(value.strip())
|
|
return " ".join(parts)
|
|
|
|
|
|
def _domain_matches(email: str) -> bool:
|
|
return email.lower().endswith(f"@{settings.mailu_domain.lower()}")
|
|
|
|
|
|
def _password_too_long(password: str) -> bool:
|
|
return len(password.encode("utf-8")) > BCRYPT_MAX_PASSWORD_BYTES
|
|
|
|
|
|
class MailuService:
|
|
def __init__(self) -> None:
|
|
self._db_config = {
|
|
"host": settings.mailu_db_host,
|
|
"port": settings.mailu_db_port,
|
|
"dbname": settings.mailu_db_name,
|
|
"user": settings.mailu_db_user,
|
|
"password": settings.mailu_db_password,
|
|
}
|
|
|
|
def _connect(self) -> psycopg.Connection:
|
|
return psycopg.connect(**self._db_config)
|
|
|
|
def ready(self) -> bool:
|
|
return bool(
|
|
settings.mailu_db_host
|
|
and settings.mailu_db_name
|
|
and settings.mailu_db_user
|
|
and settings.mailu_db_password
|
|
)
|
|
|
|
@staticmethod
|
|
def resolve_mailu_email(
|
|
username: str,
|
|
attributes: dict[str, Any] | None,
|
|
fallback_email: str = "",
|
|
) -> str:
|
|
attrs = attributes or {}
|
|
explicit = _extract_attr(attrs, MAILU_EMAIL_ATTR)
|
|
if explicit:
|
|
return explicit
|
|
if fallback_email and fallback_email.lower().endswith(f"@{settings.mailu_domain.lower()}"):
|
|
return fallback_email
|
|
return f"{username}@{settings.mailu_domain}"
|
|
|
|
def _mailu_enabled(self, attrs: dict[str, Any], updates: dict[str, list[str]]) -> bool:
|
|
raw = _extract_attr(attrs, MAILU_ENABLED_ATTR)
|
|
if raw is None:
|
|
updates[MAILU_ENABLED_ATTR] = ["true"]
|
|
return True
|
|
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
|
|
@staticmethod
|
|
def _username(user: dict[str, Any]) -> str:
|
|
return user.get("username") if isinstance(user.get("username"), str) else ""
|
|
|
|
@staticmethod
|
|
def _user_id(user: dict[str, Any]) -> str:
|
|
return user.get("id") if isinstance(user.get("id"), str) else ""
|
|
|
|
@staticmethod
|
|
def _is_service_account(user: dict[str, Any], username: str) -> bool:
|
|
return bool(user.get("serviceAccountClientId") or username.startswith("service-account-"))
|
|
|
|
@staticmethod
|
|
def _log_sync_error(username: str, detail: str) -> None:
|
|
logger.info(
|
|
"mailu sync error",
|
|
extra={
|
|
"event": "mailu_sync",
|
|
"status": "error",
|
|
"detail": detail,
|
|
"username": username,
|
|
},
|
|
)
|
|
|
|
def _prepare_updates(
|
|
self,
|
|
username: str,
|
|
attrs: dict[str, Any],
|
|
mailu_email: str,
|
|
) -> tuple[bool, dict[str, list[str]], str]:
|
|
updates: dict[str, list[str]] = {}
|
|
if not _extract_attr(attrs, MAILU_EMAIL_ATTR):
|
|
updates[MAILU_EMAIL_ATTR] = [mailu_email]
|
|
|
|
enabled = self._mailu_enabled(attrs, updates)
|
|
|
|
app_password = _extract_attr(attrs, MAILU_APP_PASSWORD_ATTR)
|
|
if not app_password:
|
|
app_password = random_password(24)
|
|
updates[MAILU_APP_PASSWORD_ATTR] = [app_password]
|
|
elif _password_too_long(app_password):
|
|
app_password = random_password(24)
|
|
updates[MAILU_APP_PASSWORD_ATTR] = [app_password]
|
|
logger.info(
|
|
"mailu app password rotated",
|
|
extra={
|
|
"event": "mailu_sync",
|
|
"status": "updated",
|
|
"detail": "app password exceeded bcrypt limit",
|
|
"username": username,
|
|
},
|
|
)
|
|
|
|
return enabled, updates, app_password
|
|
|
|
def _apply_updates(self, user_id: str, updates: dict[str, list[str]], username: str) -> bool:
|
|
if not updates:
|
|
return True
|
|
try:
|
|
keycloak_admin.update_user_safe(user_id, {"attributes": updates})
|
|
except Exception as exc:
|
|
self._log_sync_error(username, str(exc))
|
|
return False
|
|
return True
|
|
|
|
def _should_skip_user(self, user: dict[str, Any], username: str) -> bool:
|
|
if not username or user.get("enabled") is False:
|
|
return True
|
|
return self._is_service_account(user, username)
|
|
|
|
def _build_sync_context(
|
|
self,
|
|
user: dict[str, Any],
|
|
) -> tuple[MailuSyncContext | None, MailuUserSyncResult | None]:
|
|
username = self._username(user)
|
|
if self._should_skip_user(user, username):
|
|
return None, MailuUserSyncResult(skipped=1)
|
|
|
|
user_id = self._user_id(user)
|
|
if not user_id:
|
|
return None, MailuUserSyncResult(failures=1)
|
|
|
|
attrs = user.get("attributes")
|
|
if not isinstance(attrs, dict):
|
|
attrs = {}
|
|
|
|
mailu_email = self.resolve_mailu_email(
|
|
username,
|
|
attrs,
|
|
user.get("email") if isinstance(user.get("email"), str) else "",
|
|
)
|
|
enabled, updates, app_password = self._prepare_updates(username, attrs, mailu_email)
|
|
|
|
if not enabled:
|
|
return None, MailuUserSyncResult(skipped=1)
|
|
if not self._apply_updates(user_id, updates, username):
|
|
return None, MailuUserSyncResult(failures=1)
|
|
updated = 1 if updates else 0
|
|
display_name = _display_name(user)
|
|
|
|
return (
|
|
MailuSyncContext(
|
|
username=username,
|
|
user_id=user_id,
|
|
mailu_email=mailu_email,
|
|
app_password=app_password,
|
|
updated=updated,
|
|
display_name=display_name,
|
|
),
|
|
None,
|
|
)
|
|
|
|
def _ensure_mailbox_with_retry(
|
|
self,
|
|
conn: psycopg.Connection,
|
|
ctx: MailuSyncContext,
|
|
) -> tuple[bool, bool, bool]:
|
|
mailbox_ok = False
|
|
rotated = False
|
|
failed = False
|
|
try:
|
|
mailbox_ok = self._ensure_mailbox(conn, ctx.mailu_email, ctx.app_password, ctx.display_name)
|
|
except PasswordTooLongError:
|
|
rotated = True
|
|
app_password = random_password(24)
|
|
try:
|
|
keycloak_admin.set_user_attribute(ctx.username, MAILU_APP_PASSWORD_ATTR, app_password)
|
|
logger.info(
|
|
"mailu app password rotated",
|
|
extra={
|
|
"event": "mailu_sync",
|
|
"status": "updated",
|
|
"detail": "app password exceeded bcrypt limit",
|
|
"username": ctx.username,
|
|
},
|
|
)
|
|
mailbox_ok = self._ensure_mailbox(conn, ctx.mailu_email, app_password, ctx.display_name)
|
|
except Exception as retry_exc:
|
|
self._log_sync_error(ctx.username, str(retry_exc))
|
|
failed = True
|
|
except Exception as exc:
|
|
self._log_sync_error(ctx.username, str(exc))
|
|
failed = True
|
|
|
|
return mailbox_ok, failed, rotated
|
|
|
|
@staticmethod
|
|
def _build_sync_result(
|
|
updated: int,
|
|
mailbox_ok: bool,
|
|
failed: bool,
|
|
rotated: bool,
|
|
) -> MailuUserSyncResult:
|
|
if failed:
|
|
return MailuUserSyncResult(failures=1, updated=updated)
|
|
if mailbox_ok:
|
|
return MailuUserSyncResult(processed=1, updated=updated, mailboxes=1)
|
|
if rotated:
|
|
return MailuUserSyncResult(skipped=1, updated=max(updated, 1))
|
|
return MailuUserSyncResult(skipped=1, updated=updated)
|
|
|
|
def _sync_user(self, conn: psycopg.Connection, user: dict[str, Any]) -> MailuUserSyncResult:
|
|
ctx, early = self._build_sync_context(user)
|
|
if early is not None:
|
|
return early
|
|
mailbox_ok, failed, rotated = self._ensure_mailbox_with_retry(conn, ctx)
|
|
return self._build_sync_result(ctx.updated, mailbox_ok, failed, rotated)
|
|
|
|
def _ensure_mailbox(
|
|
self,
|
|
conn: psycopg.Connection,
|
|
email: str,
|
|
password: str,
|
|
display_name: str,
|
|
) -> bool:
|
|
email = (email or "").strip()
|
|
if not email or "@" not in email:
|
|
return False
|
|
if not _domain_matches(email):
|
|
return False
|
|
if _password_too_long(password):
|
|
raise PasswordTooLongError("mailu password exceeds bcrypt limit")
|
|
|
|
localpart, domain = email.split("@", 1)
|
|
try:
|
|
hashed = bcrypt_sha256.hash(password)
|
|
except ValueError as exc:
|
|
if "password cannot be longer than 72 bytes" in str(exc):
|
|
raise PasswordTooLongError(str(exc)) from exc
|
|
raise
|
|
now = datetime.now(timezone.utc)
|
|
with conn.cursor() as cur:
|
|
cur.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": settings.mailu_default_quota,
|
|
"display": display_name or localpart,
|
|
"now": now,
|
|
},
|
|
)
|
|
return True
|
|
|
|
def _ensure_system_mailboxes(self, conn: psycopg.Connection) -> int:
|
|
if not settings.mailu_system_users:
|
|
return 0
|
|
if not settings.mailu_system_password:
|
|
logger.info(
|
|
"mailu system users configured but password missing",
|
|
extra={"event": "mailu_sync", "status": "error", "detail": "system password missing"},
|
|
)
|
|
return 0
|
|
if _password_too_long(settings.mailu_system_password):
|
|
logger.info(
|
|
"mailu system password too long",
|
|
extra={"event": "mailu_sync", "status": "error", "detail": "system password exceeds bcrypt limit"},
|
|
)
|
|
return 0
|
|
|
|
ensured = 0
|
|
for email in settings.mailu_system_users:
|
|
if not email:
|
|
continue
|
|
if self._ensure_mailbox(conn, email, settings.mailu_system_password, email.split("@")[0]):
|
|
ensured += 1
|
|
return ensured
|
|
|
|
def sync(self, reason: str, force: bool = False) -> MailuSyncSummary:
|
|
if not keycloak_admin.ready():
|
|
raise RuntimeError("keycloak admin client not configured")
|
|
if not self.ready():
|
|
raise RuntimeError("mailu database not configured")
|
|
|
|
counters = MailuSyncCounters()
|
|
users = keycloak_admin.iter_users(page_size=200, brief=False)
|
|
with self._connect() as conn:
|
|
for user in users:
|
|
counters.add(self._sync_user(conn, user))
|
|
counters.system_mailboxes = self._ensure_system_mailboxes(conn)
|
|
|
|
summary = counters.summary()
|
|
logger.info(
|
|
"mailu sync finished",
|
|
extra={
|
|
"event": "mailu_sync",
|
|
"status": "ok" if summary.failures == 0 else "error",
|
|
"processed": summary.processed,
|
|
"updated": summary.updated,
|
|
"skipped": summary.skipped,
|
|
"failures": summary.failures,
|
|
"mailboxes": summary.mailboxes,
|
|
"system_mailboxes": summary.system_mailboxes,
|
|
"reason": reason,
|
|
"force": force,
|
|
},
|
|
)
|
|
return summary
|
|
|
|
def mailbox_exists(self, email: str) -> bool:
|
|
email = (email or "").strip()
|
|
if not email:
|
|
return False
|
|
try:
|
|
with self._connect() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute('SELECT 1 FROM "user" WHERE email = %s LIMIT 1', (email,))
|
|
return cur.fetchone() is not None
|
|
except Exception:
|
|
return False
|
|
|
|
def wait_for_mailbox(self, email: str, timeout_sec: float = 60.0) -> bool:
|
|
deadline = time.time() + timeout_sec
|
|
while time.time() < deadline:
|
|
if self.mailbox_exists(email):
|
|
return True
|
|
time.sleep(2)
|
|
return False
|
|
|
|
|
|
mailu = MailuService()
|