From e6eff8165ab7a7d596e8f1d8bbca3e8bdd1ab086 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 3 Jan 2026 02:35:47 -0300 Subject: [PATCH] mailu: sync via mailu_email attribute --- .gitignore | 2 + scripts/mailu_sync.py | 47 ++++++++++++++++------- scripts/nextcloud-mail-sync.sh | 27 +++++++++---- scripts/tests/test_mailu_sync.py | 11 ++++-- services/nextcloud/mail-sync-cronjob.yaml | 2 + 5 files changed, 63 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 88b0632..7bf3646 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.md !README.md +__pycache__/ +*.py[cod] diff --git a/scripts/mailu_sync.py b/scripts/mailu_sync.py index 4d08a62..74b170a 100644 --- a/scripts/mailu_sync.py +++ b/scripts/mailu_sync.py @@ -110,13 +110,33 @@ 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 resolve_mailu_email(user, attributes): + explicit = get_attribute_value(attributes, "mailu_email") + 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.utcnow() + now = datetime.datetime.now(datetime.timezone.utc) cursor.execute( """ INSERT INTO "user" ( @@ -167,30 +187,29 @@ def main(): for user in users: attrs = user.get("attributes", {}) or {} - app_pw_value = attrs.get("mailu_app_password") - if isinstance(app_pw_value, list): - app_pw = app_pw_value[0] if app_pw_value else None - elif isinstance(app_pw_value, str): - app_pw = app_pw_value - else: - app_pw = None + app_pw = get_attribute_value(attrs, "mailu_app_password") + mailu_email = resolve_mailu_email(user, attrs) - email = user.get("email") - if not email: - email = f"{user['username']}@{MAILU_DOMAIN}" + needs_update = False + if not get_attribute_value(attrs, "mailu_email"): + attrs["mailu_email"] = [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"Set mailu_app_password for {email}") + 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, email, app_pw, display_name) - log(f"Synced mailbox for {email}") + ensure_mailu_user(cursor, mailu_email, app_pw, display_name) + log(f"Synced mailbox for {mailu_email}") cursor.close() conn.close() diff --git a/scripts/nextcloud-mail-sync.sh b/scripts/nextcloud-mail-sync.sh index 4476e7f..e31da48 100755 --- a/scripts/nextcloud-mail-sync.sh +++ b/scripts/nextcloud-mail-sync.sh @@ -5,6 +5,7 @@ KC_BASE="${KC_BASE:?}" KC_REALM="${KC_REALM:?}" KC_ADMIN_USER="${KC_ADMIN_USER:?}" KC_ADMIN_PASS="${KC_ADMIN_PASS:?}" +MAILU_DOMAIN="${MAILU_DOMAIN:?}" if ! command -v jq >/dev/null 2>&1; then apt-get update && apt-get install -y jq curl >/dev/null @@ -45,16 +46,26 @@ users=$(curl -s -H "Authorization: Bearer ${token}" \ echo "${users}" | jq -c '.[]' | while read -r user; do username=$(echo "${user}" | jq -r '.username') - email=$(echo "${user}" | jq -r '.email // empty') + keycloak_email=$(echo "${user}" | jq -r '.email // empty') + mailu_email=$(echo "${user}" | jq -r '(.attributes.mailu_email[0] // .attributes.mailu_email // empty)') app_pw=$(echo "${user}" | jq -r '(.attributes.mailu_app_password[0] // .attributes.mailu_app_password // empty)') - [[ -z "${email}" || -z "${app_pw}" ]] && continue - if account_exists "${username}" "${email}"; then - echo "Skipping ${email}, already exists" + + if [[ -z "${mailu_email}" ]]; then + if [[ -n "${keycloak_email}" && "${keycloak_email,,}" == *"@${MAILU_DOMAIN,,}" ]]; then + mailu_email="${keycloak_email}" + else + mailu_email="${username}@${MAILU_DOMAIN}" + fi + fi + + [[ -z "${mailu_email}" || -z "${app_pw}" ]] && continue + if account_exists "${username}" "${mailu_email}"; then + echo "Skipping ${mailu_email}, already exists" continue fi - echo "Syncing ${email}" + echo "Syncing ${mailu_email}" /usr/sbin/runuser -u www-data -- php occ mail:account:create \ - "${username}" "${username}" "${email}" \ - mail.bstein.dev 993 ssl "${email}" "${app_pw}" \ - mail.bstein.dev 587 tls "${email}" "${app_pw}" || true + "${username}" "${username}" "${mailu_email}" \ + mail.bstein.dev 993 ssl "${mailu_email}" "${app_pw}" \ + mail.bstein.dev 587 tls "${mailu_email}" "${app_pw}" || true done diff --git a/scripts/tests/test_mailu_sync.py b/scripts/tests/test_mailu_sync.py index 41616b2..9e5f383 100644 --- a/scripts/tests/test_mailu_sync.py +++ b/scripts/tests/test_mailu_sync.py @@ -102,7 +102,8 @@ def test_kc_get_users_paginates(monkeypatch): sync.SESSION = _PagedSession() users = sync.kc_get_users("tok") assert [u["id"] for u in users] == ["u1", "u2"] - assert sync.SESSION.calls == 2 + # Pagination stops when results < page size. + assert sync.SESSION.calls == 1 def test_ensure_mailu_user_skips_foreign_domain(monkeypatch): @@ -119,6 +120,7 @@ def test_ensure_mailu_user_skips_foreign_domain(monkeypatch): def test_ensure_mailu_user_upserts(monkeypatch): sync = load_sync_module(monkeypatch) + monkeypatch.setattr(sync.bcrypt_sha256, "hash", lambda password: f"hash:{password}") captured = {} class _Cursor: @@ -134,6 +136,7 @@ def test_ensure_mailu_user_upserts(monkeypatch): def test_main_generates_password_and_upserts(monkeypatch): sync = load_sync_module(monkeypatch) + monkeypatch.setattr(sync.bcrypt_sha256, "hash", lambda password: f"hash:{password}") users = [ {"id": "u1", "username": "user1", "email": "user1@example.com", "attributes": {}}, {"id": "u2", "username": "user2", "email": "user2@example.com", "attributes": {"mailu_app_password": ["keepme"]}}, @@ -176,6 +179,6 @@ def test_main_generates_password_and_upserts(monkeypatch): sync.main() - # Should attempt two inserts (third user skipped due to domain mismatch) - assert len(updated) == 1 # only one missing attr was backfilled - assert conns and len(conns[0]._cursor.executions) == 2 + # Always backfill mailu_email, even if Keycloak recovery email is external. + assert len(updated) == 3 + assert conns and len(conns[0]._cursor.executions) == 3 diff --git a/services/nextcloud/mail-sync-cronjob.yaml b/services/nextcloud/mail-sync-cronjob.yaml index 52dc3ea..809bc78 100644 --- a/services/nextcloud/mail-sync-cronjob.yaml +++ b/services/nextcloud/mail-sync-cronjob.yaml @@ -25,6 +25,8 @@ spec: value: https://sso.bstein.dev - name: KC_REALM value: atlas + - name: MAILU_DOMAIN + value: bstein.dev - name: KC_ADMIN_USER valueFrom: secretKeyRef: