2025-12-14 14:05:01 -03:00
|
|
|
#!/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:?}"
|
2026-01-03 02:35:47 -03:00
|
|
|
MAILU_DOMAIN="${MAILU_DOMAIN:?}"
|
2026-01-03 12:18:29 -03:00
|
|
|
ONLY_USERNAME="${ONLY_USERNAME:-}"
|
2026-01-06 10:02:50 -03:00
|
|
|
POSTGRES_HOST="${POSTGRES_HOST:-}"
|
|
|
|
|
POSTGRES_DB="${POSTGRES_DB:-}"
|
|
|
|
|
POSTGRES_USER="${POSTGRES_USER:-}"
|
|
|
|
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
2025-12-14 14:05:01 -03:00
|
|
|
|
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
|
|
|
apt-get update && apt-get install -y jq curl >/dev/null
|
|
|
|
|
fi
|
|
|
|
|
|
2026-01-06 10:02:50 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 06:52:53 -03:00
|
|
|
list_mail_accounts() {
|
2026-01-01 17:36:23 -03:00
|
|
|
local user_id="${1}"
|
2026-01-03 06:52:53 -03:00
|
|
|
local export_out
|
2026-01-01 17:36:23 -03:00
|
|
|
|
|
|
|
|
# Nextcloud Mail does not provide a list command; export is safe (does not print passwords).
|
2026-01-03 07:13:58 -03:00
|
|
|
# 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
|
2026-01-01 23:24:11 -03:00
|
|
|
echo "WARN: unable to export mail accounts for ${user_id}; skipping sync for safety" >&2
|
2026-01-03 06:52:53 -03:00
|
|
|
return 1
|
2026-01-01 23:24:11 -03:00
|
|
|
fi
|
|
|
|
|
|
2026-01-03 06:52:53 -03:00
|
|
|
# The export output is human-readable and includes blocks like:
|
|
|
|
|
# Account 10:
|
|
|
|
|
# - E-Mail: user@example.com
|
|
|
|
|
# Extract "account-id <tab> email" pairs.
|
|
|
|
|
awk '
|
|
|
|
|
/^Account[[:space:]]+[0-9]+:/ {
|
|
|
|
|
id=$2;
|
|
|
|
|
sub(/:$/, "", id);
|
|
|
|
|
next;
|
|
|
|
|
}
|
|
|
|
|
id != "" && /@/ {
|
2026-01-03 07:39:45 -03:00
|
|
|
# Keep the regex simple (mawk does not support interval expressions like {2,}).
|
2026-01-03 07:18:50 -03:00
|
|
|
if (match($0, /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/)) {
|
2026-01-03 07:06:30 -03:00
|
|
|
printf("%s\t%s\n", id, substr($0, RSTART, RLENGTH));
|
2026-01-03 06:52:53 -03:00
|
|
|
id="";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
' <<<"${export_out}" | sort -u
|
2025-12-14 14:15:19 -03:00
|
|
|
}
|
|
|
|
|
|
2025-12-14 14:05:01 -03:00
|
|
|
token=$(
|
|
|
|
|
curl -s -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
|
|
|
|
|
|
2026-01-01 17:36:23 -03:00
|
|
|
cd /var/www/html
|
|
|
|
|
|
2026-01-03 12:18:29 -03:00
|
|
|
kc_users_url="${KC_BASE}/admin/realms/${KC_REALM}/users?max=2000"
|
|
|
|
|
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"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
users=$(curl -s -H "Authorization: Bearer ${token}" "${kc_users_url}")
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2025-12-14 14:05:01 -03:00
|
|
|
|
2026-01-03 12:18:29 -03:00
|
|
|
while read -r user; do
|
|
|
|
|
user_id=$(jq -r '.id' <<<"${user}")
|
|
|
|
|
username=$(jq -r '.username' <<<"${user}")
|
2026-01-03 02:35:47 -03:00
|
|
|
keycloak_email=$(echo "${user}" | jq -r '.email // empty')
|
|
|
|
|
mailu_email=$(echo "${user}" | jq -r '(.attributes.mailu_email[0] // .attributes.mailu_email // empty)')
|
2026-01-02 03:09:26 -03:00
|
|
|
app_pw=$(echo "${user}" | jq -r '(.attributes.mailu_app_password[0] // .attributes.mailu_app_password // empty)')
|
2026-01-03 02:35:47 -03:00
|
|
|
|
|
|
|
|
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
|
2026-01-03 06:52:53 -03:00
|
|
|
|
|
|
|
|
if ! accounts=$(list_mail_accounts "${username}"); then
|
2025-12-14 14:15:19 -03:00
|
|
|
continue
|
|
|
|
|
fi
|
2026-01-03 06:52:53 -03:00
|
|
|
|
|
|
|
|
# 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
|
2026-01-03 12:18:29 -03:00
|
|
|
|
|
|
|
|
# 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=""
|
2026-01-06 10:02:50 -03:00
|
|
|
editor_mode_ids=()
|
2026-01-03 12:18:29 -03:00
|
|
|
if [[ -n "${mailu_accounts_after}" ]]; then
|
|
|
|
|
while IFS=$'\t' read -r _account_id account_email; do
|
2026-01-06 10:02:50 -03:00
|
|
|
editor_mode_ids+=("${_account_id}")
|
2026-01-03 12:18:29 -03:00
|
|
|
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
|
2026-01-06 10:02:50 -03:00
|
|
|
set_editor_mode_richtext "${editor_mode_ids[@]}"
|
2026-01-03 12:18:29 -03:00
|
|
|
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}")
|