#!/bin/bash set -euo pipefail KC_BASE="${KC_BASE:?}" KC_REALM="${KC_REALM:?}" KC_ADMIN_USER="${KC_ADMIN_USER:?}" KC_ADMIN_PASS="${KC_ADMIN_PASS:?}" MAILU_DOMAIN="${MAILU_DOMAIN:?}" ONLY_USERNAME="${ONLY_USERNAME:-}" POSTGRES_HOST="${POSTGRES_HOST:-}" POSTGRES_DB="${POSTGRES_DB:-}" POSTGRES_USER="${POSTGRES_USER:-}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" if ! command -v jq >/dev/null 2>&1; then apt-get update && apt-get install -y jq curl >/dev/null fi ensure_psql() { if command -v psql >/dev/null 2>&1; then return 0 fi apt-get update && apt-get install -y postgresql-client >/dev/null } set_editor_mode_richtext() { local ids=("$@") if [[ ${#ids[@]} -eq 0 ]]; then return 0 fi if [[ -z "${POSTGRES_HOST}" || -z "${POSTGRES_DB}" || -z "${POSTGRES_USER}" || -z "${POSTGRES_PASSWORD}" ]]; then echo "WARN: missing postgres env; cannot update mail editor_mode" >&2 return 0 fi ensure_psql local ids_csv ids_csv=$(IFS=,; echo "${ids[*]}") PGPASSWORD="${POSTGRES_PASSWORD}" psql \ -h "${POSTGRES_HOST}" \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ -v ON_ERROR_STOP=1 \ -c "UPDATE oc_mail_accounts SET editor_mode='richtext' WHERE id IN (${ids_csv}) AND editor_mode <> 'richtext';" \ >/dev/null } list_mail_accounts() { local user_id="${1}" local export_out # Nextcloud Mail does not provide a list command; export is safe (does not print passwords). # Some occ commands emit to stderr; capture both streams so we don't mis-detect "no accounts". if ! export_out=$(/usr/sbin/runuser -u www-data -- php occ mail:account:export "${user_id}" 2>&1); then echo "WARN: unable to export mail accounts for ${user_id}; skipping sync for safety" >&2 return 1 fi # The export output is human-readable and includes blocks like: # Account 10: # - E-Mail: user@example.com # Extract "account-id email" pairs. awk ' /^Account[[:space:]]+[0-9]+:/ { id=$2; sub(/:$/, "", id); next; } id != "" && /@/ { # Keep the regex simple (mawk does not support interval expressions like {2,}). if (match($0, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/)) { printf("%s\t%s\n", id, substr($0, RSTART, RLENGTH)); id=""; } } ' <<<"${export_out}" | sort -u } token=$( curl -fsS -d "grant_type=password" \ -d "client_id=admin-cli" \ -d "username=${KC_ADMIN_USER}" \ -d "password=${KC_ADMIN_PASS}" \ "${KC_BASE}/realms/master/protocol/openid-connect/token" | jq -r '.access_token' ) if [[ -z "${token}" || "${token}" == "null" ]]; then echo "Failed to obtain admin token" exit 1 fi cd /var/www/html kc_users_url="${KC_BASE}/admin/realms/${KC_REALM}/users?max=2000&briefRepresentation=false" if [[ -n "${ONLY_USERNAME}" ]]; then username_q=$(jq -nr --arg v "${ONLY_USERNAME}" '$v|@uri') kc_users_url="${KC_BASE}/admin/realms/${KC_REALM}/users?username=${username_q}&exact=true&max=1&briefRepresentation=false" fi users=$(curl -fsS -H "Authorization: Bearer ${token}" "${kc_users_url}") if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${users}"; then echo "ERROR: Keycloak user list is not an array; aborting sync" >&2 exit 1 fi kc_set_user_mail_meta() { local user_id="${1}" local primary_email="${2}" local mailu_account_count="${3}" local synced_at="${4}" # Fetch the full user representation so we don't accidentally clobber attributes. local user_json updated_json if ! user_json=$(curl -fsS -H "Authorization: Bearer ${token}" \ "${KC_BASE}/admin/realms/${KC_REALM}/users/${user_id}"); then echo "WARN: unable to fetch Keycloak user ${user_id} for metadata writeback" >&2 return 1 fi updated_json=$( jq -c \ --arg primary_email "${primary_email}" \ --arg mailu_account_count "${mailu_account_count}" \ --arg synced_at "${synced_at}" \ ' .attributes = (.attributes // {}) | .attributes.nextcloud_mail_primary_email = [$primary_email] | .attributes.nextcloud_mail_account_count = [$mailu_account_count] | .attributes.nextcloud_mail_synced_at = [$synced_at] | del(.access) ' <<<"${user_json}" ) curl -fsS -X PUT \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "${updated_json}" \ "${KC_BASE}/admin/realms/${KC_REALM}/users/${user_id}" >/dev/null } while read -r user; do user_id=$(jq -r '.id' <<<"${user}") username=$(jq -r '.username' <<<"${user}") 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)') 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 ! accounts=$(list_mail_accounts "${username}"); then continue fi # Manage only internal Mailu-domain accounts; leave any external accounts untouched. mailu_accounts=$(awk -v d="${MAILU_DOMAIN,,}" 'tolower($2) ~ ("@" d "$") {print}' <<<"${accounts}" || true) desired_email="${mailu_email}" primary_id="" primary_email="" if [[ -n "${mailu_accounts}" ]]; then while IFS=$'\t' read -r account_id account_email; do if [[ -z "${primary_id}" ]]; then primary_id="${account_id}" primary_email="${account_email}" fi if [[ "${account_email,,}" == "${desired_email,,}" ]]; then primary_id="${account_id}" primary_email="${account_email}" break fi done <<<"${mailu_accounts}" echo "Updating ${username} mail account ${primary_id} (${primary_email})" /usr/sbin/runuser -u www-data -- php occ mail:account:update -q "${primary_id}" \ --name "${username}" \ --email "${desired_email}" \ --imap-host mail.bstein.dev \ --imap-port 993 \ --imap-ssl-mode ssl \ --imap-user "${desired_email}" \ --imap-password "${app_pw}" \ --smtp-host mail.bstein.dev \ --smtp-port 587 \ --smtp-ssl-mode tls \ --smtp-user "${desired_email}" \ --smtp-password "${app_pw}" \ --auth-method password >/dev/null 2>&1 || true # Remove any extra Mailu-domain accounts for this user to prevent duplicates. while IFS=$'\t' read -r account_id account_email; do if [[ "${account_id}" == "${primary_id}" ]]; then continue fi echo "Deleting extra mail account ${account_id} (${account_email})" /usr/sbin/runuser -u www-data -- php occ mail:account:delete -q "${account_id}" >/dev/null 2>&1 || true done <<<"${mailu_accounts}" else echo "Creating mail account for ${username} (${desired_email})" /usr/sbin/runuser -u www-data -- php occ mail:account:create -q \ "${username}" "${username}" "${desired_email}" \ mail.bstein.dev 993 ssl "${desired_email}" "${app_pw}" \ mail.bstein.dev 587 tls "${desired_email}" "${app_pw}" password >/dev/null 2>&1 || true fi # Write non-secret metadata back to Keycloak for UI introspection and onboarding gating. synced_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ") if accounts_after=$(list_mail_accounts "${username}"); then mailu_accounts_after=$(awk -v d="${MAILU_DOMAIN,,}" 'tolower($2) ~ ("@" d "$") {print}' <<<"${accounts_after}" || true) if [[ -n "${mailu_accounts_after}" ]]; then mailu_account_count=$(printf '%s\n' "${mailu_accounts_after}" | wc -l | tr -d ' ') else mailu_account_count="0" fi primary_email_after="" editor_mode_ids=() if [[ -n "${mailu_accounts_after}" ]]; then while IFS=$'\t' read -r _account_id account_email; do editor_mode_ids+=("${_account_id}") if [[ "${account_email,,}" == "${desired_email,,}" ]]; then primary_email_after="${account_email}" break fi if [[ -z "${primary_email_after}" ]]; then primary_email_after="${account_email}" fi done <<<"${mailu_accounts_after}" fi set_editor_mode_richtext "${editor_mode_ids[@]}" else mailu_account_count="0" primary_email_after="" fi kc_set_user_mail_meta "${user_id}" "${primary_email_after}" "${mailu_account_count}" "${synced_at}" || true done < <(jq -c '.[]' <<<"${users}")