mailu: sync via mailu_email attribute

This commit is contained in:
Brad Stein 2026-01-03 02:35:47 -03:00
parent 10e322e853
commit e6eff8165a
5 changed files with 63 additions and 26 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
*.md
!README.md
__pycache__/
*.py[cod]

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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: