diff --git a/ariadne/services/mailu.py b/ariadne/services/mailu.py index dd03f6f..7cf7ca0 100644 --- a/ariadne/services/mailu.py +++ b/ariadne/services/mailu.py @@ -39,6 +39,10 @@ class MailuUserSyncResult: updated: int = 0 skipped: int = 0 failures: int = 0 + + +class PasswordTooLongError(RuntimeError): + pass mailboxes: int = 0 @@ -240,13 +244,34 @@ class MailuService: mailbox_ok = False failed = False + rotated = False try: mailbox_ok = self._ensure_mailbox(conn, mailu_email, app_password, _display_name(user)) + except PasswordTooLongError as exc: + rotated = True + app_password = random_password(24) + try: + keycloak_admin.set_user_attribute(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": username, + }, + ) + mailbox_ok = self._ensure_mailbox(conn, mailu_email, app_password, _display_name(user)) + except Exception as retry_exc: + self._log_sync_error(username, str(retry_exc)) + failed = True except Exception as exc: self._log_sync_error(username, str(exc)) failed = True result = MailuUserSyncResult(skipped=1, updated=updated) + if rotated: + result = MailuUserSyncResult(skipped=1, updated=max(updated, 1)) if failed: result = MailuUserSyncResult(failures=1, updated=updated) elif mailbox_ok: @@ -266,10 +291,15 @@ class MailuService: if not _domain_matches(email): return False if _password_too_long(password): - raise ValueError("mailu password exceeds bcrypt limit") + raise PasswordTooLongError("mailu password exceeds bcrypt limit") localpart, domain = email.split("@", 1) - hashed = bcrypt_sha256.hash(password) + 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( diff --git a/tests/test_services.py b/tests/test_services.py index b30e716..ec4889c 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -5,6 +5,7 @@ import types import pytest +from ariadne.services import mailu as mailu_module from ariadne.services.firefly import FireflyService from ariadne.services.mailu import MailuService from ariadne.services.nextcloud import NextcloudService @@ -451,6 +452,69 @@ def test_mailu_sync_rotates_long_password(monkeypatch) -> None: assert mailbox_calls assert mailbox_calls[0][1] == "short-pass-123" + +def test_mailu_sync_retries_on_password_limit(monkeypatch) -> None: + dummy_settings = types.SimpleNamespace( + mailu_domain="bstein.dev", + mailu_db_host="localhost", + mailu_db_port=5432, + mailu_db_name="mailu", + mailu_db_user="mailu", + mailu_db_password="secret", + mailu_default_quota=20000000000, + mailu_system_users=[], + mailu_system_password="", + ) + monkeypatch.setattr("ariadne.services.mailu.settings", dummy_settings) + monkeypatch.setattr("ariadne.services.mailu.keycloak_admin.ready", lambda: True) + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.iter_users", + lambda *args, **kwargs: [ + { + "id": "1", + "username": "alice", + "enabled": True, + "email": "alice@example.com", + "attributes": {"mailu_app_password": ["short-pass"]}, + "firstName": "Alice", + "lastName": "Example", + } + ], + ) + monkeypatch.setattr("ariadne.services.mailu.random_password", lambda *_args, **_kwargs: "retry-pass-1") + + set_calls: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + "ariadne.services.mailu.keycloak_admin.set_user_attribute", + lambda username, key, value: set_calls.append((username, key, value)), + ) + + call_count = {"count": 0} + + def fake_ensure(self, _conn, _email, password, _display): + call_count["count"] += 1 + if call_count["count"] == 1: + raise mailu_module.PasswordTooLongError("password cannot be longer than 72 bytes") + assert password == "retry-pass-1" + return True + + monkeypatch.setattr("ariadne.services.mailu.MailuService._ensure_mailbox", fake_ensure) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr("ariadne.services.mailu.psycopg.connect", lambda *args, **kwargs: DummyConn()) + + svc = MailuService() + summary = svc.sync("provision", force=True) + + assert summary.processed == 1 + assert call_count["count"] == 2 + assert set_calls def test_mailu_sync_skips_disabled(monkeypatch) -> None: dummy_settings = types.SimpleNamespace( mailu_domain="bstein.dev",