336 lines
15 KiB
YAML

# services/nextcloud-mail-sync/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: nextcloud-mail-sync
namespace: nextcloud
labels:
atlas.bstein.dev/glue: "true"
spec:
schedule: "0 5 * * *"
suspend: true
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/agent-pre-populate-only: "true"
vault.hashicorp.com/role: "nextcloud"
vault.hashicorp.com/agent-inject-secret-nextcloud-env.sh: "kv/data/atlas/nextcloud/nextcloud-db"
vault.hashicorp.com/agent-inject-template-nextcloud-env.sh: |
{{ with secret "kv/data/atlas/nextcloud/nextcloud-db" }}
export POSTGRES_DB="{{ .Data.data.database }}"
export POSTGRES_USER="{{ index .Data.data "db-username" }}"
export POSTGRES_PASSWORD="{{ index .Data.data "db-password" }}"
{{ end }}
{{ with secret "kv/data/atlas/nextcloud/nextcloud-admin" }}
export NEXTCLOUD_ADMIN_USER="{{ index .Data.data "admin-user" }}"
export NEXTCLOUD_ADMIN_PASSWORD="{{ index .Data.data "admin-password" }}"
{{ end }}
export ADMIN_USER="${NEXTCLOUD_ADMIN_USER}"
export ADMIN_PASS="${NEXTCLOUD_ADMIN_PASSWORD}"
{{ with secret "kv/data/atlas/nextcloud/nextcloud-oidc" }}
export OIDC_CLIENT_ID="{{ index .Data.data "client-id" }}"
export OIDC_CLIENT_SECRET="{{ index .Data.data "client-secret" }}"
{{ end }}
{{ with secret "kv/data/atlas/shared/postmark-relay" }}
export SMTP_NAME="{{ index .Data.data "apikey" }}"
export SMTP_PASSWORD="{{ index .Data.data "apikey" }}"
{{ end }}
{{ with secret "kv/data/atlas/shared/keycloak-admin" }}
export KC_ADMIN_USER="{{ .Data.data.username }}"
export KC_ADMIN_PASS="{{ .Data.data.password }}"
{{ end }}
spec:
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
restartPolicy: OnFailure
securityContext:
runAsUser: 0
runAsGroup: 0
serviceAccountName: nextcloud-vault
containers:
- name: mail-sync
image: nextcloud:29-apache
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
env:
- name: KC_BASE
value: http://keycloak.sso.svc.cluster.local
- name: KC_REALM
value: atlas
- name: MAILU_DOMAIN
value: bstein.dev
- name: POSTGRES_HOST
value: postgres-service.postgres.svc.cluster.local
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: nextcloud-web
mountPath: /var/www/html
- name: nextcloud-config-pvc
mountPath: /var/www/html/config
- name: nextcloud-custom-apps
mountPath: /var/www/html/custom_apps
- name: nextcloud-user-data
mountPath: /var/www/html/data
args:
- |
set -eu
. /vault/secrets/nextcloud-env.sh
cat <<'SCRIPT' > /tmp/nextcloud-mail-sync.sh
#!/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).
if ! export_out=$(/usr/sbin/runuser -u www-data -- php occ mail:account:export "${user_id}"); then
echo "WARN: unable to export mail accounts for ${user_id}; skipping sync for safety" >&2
return 1
fi
awk -v OFS='\t' '
BEGIN { IGNORECASE=1; id="" }
$1 == "Account" { id=$2; sub(":", "", id); next }
$1 == "-" && tolower($2) ~ /^e-?mail:$/ { if (id) print id, $3 }
' <<<"${export_out}" | sort -u
}
token=$(
curl -fsS \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=admin-cli" \
--data-urlencode "username=${KC_ADMIN_USER}" \
--data-urlencode "password=${KC_ADMIN_PASS}" \
"${KC_BASE}/realms/master/protocol/openid-connect/token" | jq -r '.access_token // empty'
)
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}" \
--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
primary_id=$(list_mail_accounts "${username}" | awk -v d="${desired_email,,}" 'tolower($2) == d {print $1; exit}')
primary_email="${desired_email}"
fi
if [[ -n "${primary_id}" ]]; then
set_editor_mode_richtext "${primary_id}"
fi
mailu_account_count=$(wc -l <<<"${mailu_accounts}" | tr -d ' ')
if [[ -z "${mailu_account_count}" ]]; then
mailu_account_count="0"
fi
synced_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
kc_set_user_mail_meta "${user_id}" "${primary_email}" "${mailu_account_count}" "${synced_at}" || true
done < <(jq -c '.[]' <<<"${users}")
SCRIPT
exec /bin/bash /tmp/nextcloud-mail-sync.sh
volumes:
- name: nextcloud-config-pvc
persistentVolumeClaim:
claimName: nextcloud-config-v2
- name: nextcloud-custom-apps
persistentVolumeClaim:
claimName: nextcloud-custom-apps-v2
- name: nextcloud-user-data
persistentVolumeClaim:
claimName: nextcloud-user-data-v2
- name: nextcloud-web
persistentVolumeClaim:
claimName: nextcloud-web-v2