Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions changelog.d/tenant-mail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
### Toegevoegd — 2026-10-14 (uitgaande mail per tenant, opt-in)

Een tenant kan nu mail versturen via SMTP met `tenant.mail` in zijn
tenantbestand (`enabled`, `fromAddress`, `domain`, optioneel `port` en
`secure`). Aanleiding: de demotenant `zuiddrecht-prod` heeft mail nodig voor
registratie en notificaties, en geen tenant op het platform had SMTP.

- De ApplicationSet zet bij `tenant.mail.enabled: true` de env vars
`MAIL_FROM_ADDRESS`, `MAIL_DOMAIN`, `SMTP_SECURE`, `SMTP_PORT` en, uit
`nextcloud-secrets`, `SMTP_HOST`, `SMTP_NAME`, `SMTP_PASSWORD`, en zet
`nextcloud.defaultConfigs.smtp.config.php` aan voor alleen die tenant.
- De secret-sleutels (`smtp-host`, `smtp-username`, `smtp-password`) zijn
`optional`. Bewust niet via `nextcloud.mail.enabled` van de chart: daar zijn
ze verplicht en blijft de pod in `CreateContainerConfigError` zolang ze
ontbreken.
- `create-tenant-secret.sh` schrijft de `smtp-*` sleutels mee als `SMTP_HOST`,
`SMTP_USERNAME` en `SMTP_PASSWORD` gezet zijn (alle drie of geen).
`env.example` noemde ze al, het script deed er niets mee.
- `validate-values.sh` controleert `tenant.mail`; elf nieuwe fixtures in
`tests/cases/tenant-mail-*`.

Blast radius: geen. Alle 89 tenants renderen byte voor byte gelijk (values en
manifests, vergeleken met `helm template` en de goTemplate-emulatie van de
ApplicationSet). Alleen een tenant die `tenant.mail` zet, krijgt nieuwe env vars
en een extra configbestand, en herstart dus.

Nog niet: SMTP-sleutels voor tenants met `tenant.secrets.managed: true` (ESO).
20 changes: 20 additions & 0 deletions docs/SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,29 @@ helm template nextcloud-secret charts/tenant-secret -n <tenant> \
| `postgres-password` | Postgres admin | ✅ | — |
| `redis-password` | per-tenant Redis | ✅ | — |
| `mariadb-password` / `mariadb-root-password` | MariaDB user/root | — | ✅ |
| `smtp-host` / `smtp-username` / `smtp-password` | uitgaande mail, alleen gelezen bij `tenant.mail.enabled: true` (optioneel, alle drie of geen) | ✅ | ✅ |

> The admin-password key is **`nextcloud-password`** (not `admin-password`).

### SMTP-sleutels toevoegen aan een bestaande tenant

`create-tenant-secret.sh` schrijft de `smtp-*` sleutels mee als `SMTP_HOST`,
`SMTP_USERNAME` en `SMTP_PASSWORD` gezet zijn. Draai dat script NIET opnieuw op
een tenant die al draait: het schrijft de hele Secret opnieuw, met nieuwe
wachtwoorden. Voeg de sleutels bij een bestaande tenant los toe:

```bash
kubectl -n <namespace> patch secret nextcloud-secrets --type merge -p "$(cat <<EOF
{"stringData":{"smtp-host":"<host>","smtp-username":"<gebruiker>","smtp-password":"<wachtwoord of token>"}}
EOF
)"
kubectl -n <namespace> rollout restart deployment/nextcloud
```

Env vars uit een Secret worden alleen bij de start gelezen, vandaar de restart.
Voor een tenant met `tenant.secrets.managed: true` (ESO) is dit nog niet
geregeld: de ExternalSecret is eigenaar van de Secret en kent geen `smtp-*` bron.

## Verify

```bash
Expand Down
37 changes: 37 additions & 0 deletions docs/TENANT-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,43 @@ Alleen de **hostname** wijzigen is wél een gewone operatie — zie
`docs/ADDING-TENANT.md` § Cutting Over from a Migration Hostname. Dat raakt de
namespace, de prefix en de database niet.

## Uitgaande mail (`tenant.mail`)

Standaard verstuurt een tenant geen mail: `common.yaml` zet `smtp.config.php`
fleet-breed uit en geen enkele tenant had SMTP. Een tenant die mail nodig heeft
(registratie, wachtwoord vergeten, notificaties) zet het aan in zijn
tenantbestand:

```yaml
tenant:
mail:
enabled: true
fromAddress: noreply # alleen het deel voor de @
domain: zuiddrecht.nl # afzender wordt noreply@zuiddrecht.nl
# port: 465 # standaard 465
# secure: ssl # standaard ssl (impliciete TLS); none vraagt een port
```

Host en inloggegevens staan nooit in Git maar in `nextcloud-secrets`:
`smtp-host`, `smtp-username`, `smtp-password` (zie `docs/SECRETS.md`). De
ApplicationSet zet dan `MAIL_FROM_ADDRESS`, `MAIL_DOMAIN`, `SMTP_*` en mount de
`smtp.config.php` van de chart. De secret-sleutels zijn `optional`: ontbreken ze,
dan start de pod gewoon en gaat er geen mail uit. Controleer daarom na het
aanzetten:

```bash
kubectl -n <namespace> exec deploy/nextcloud -c nextcloud -- \
su -s /bin/sh www-data -c "php occ config:list system --private" | grep -E 'mail_'
```

Daarna een testmail vanuit Beheer, Basisinstellingen, E-mailserver. De mail-instellingen
staan in een read-only configbestand; aanpassen gaat via het tenantbestand en de
Secret, niet via `occ config:system:set` of de beheerpagina.

`validate-values.sh` weigert een onbekende sleutel onder `tenant.mail`, een
`fromAddress` met een @, een ontbrekend domein en `secure: none` zonder poort.
Een tenant zonder `tenant.mail` rendert byte voor byte hetzelfde als voorheen.

## Tenant Reset (Data wissen)

Reset een tenant naar een schone staat **zonder** de configuratie te verwijderen.
Expand Down
42 changes: 42 additions & 0 deletions nextcloud-platform/argo/applicationsets/nextcloud-tenants.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,48 @@ spec:
# (currently canary-overrides only; graduates to env/prod.yaml).
- name: NEXTCLOUD_OBJECTSTORE_PREFIX
value: "{{ $name }}/"
{{- if eq (toString .tenant.mail.enabled) "true" }}
# Uitgaande mail, opt-in per tenant (tenant.mail, zie
# docs/TENANT-OPERATIONS.md "Uitgaande mail"). Dezelfde env
# vars als de chart bij nextcloud.mail.enabled zet, maar de
# secret-sleutels zijn optional: ontbreken smtp-host,
# smtp-username of smtp-password nog in nextcloud-secrets, dan
# start de pod gewoon en verstuurt Nextcloud niets
# (smtp.config.php doet niets zonder SMTP_HOST). Met de
# chart-route bleef de pod dan in CreateContainerConfigError.
- name: MAIL_FROM_ADDRESS
value: "{{ .tenant.mail.fromAddress }}"
- name: MAIL_DOMAIN
value: "{{ .tenant.mail.domain }}"
- name: SMTP_SECURE
value: '{{ if eq (toString .tenant.mail.secure) "none" }}{{ else }}ssl{{ end }}'
- name: SMTP_PORT
value: "{{ default 465 .tenant.mail.port }}"
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: nextcloud-secrets
key: smtp-host
optional: true
- name: SMTP_NAME
valueFrom:
secretKeyRef:
name: nextcloud-secrets
key: smtp-username
optional: true
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: nextcloud-secrets
key: smtp-password
optional: true
{{- end }}
{{- if eq (toString .tenant.mail.enabled) "true" }}
# common.yaml zet smtp.config.php fleet-breed uit; alleen een
# tenant met tenant.mail krijgt hem, read-only uit de chart.
defaultConfigs:
smtp.config.php: true
{{- end }}
ingress:
tls:
- secretName: "nextcloud-{{ $name }}-tls"
Expand Down
31 changes: 31 additions & 0 deletions nextcloud-platform/scripts/create-tenant-secret.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ usage() {
echo " DB_PASSWORD Nextcloud user password"
echo " REDIS_PASSWORD Redis password"
echo ""
echo "Optional, for outgoing mail (tenant.mail in the tenant file; all three or none):"
echo " SMTP_HOST SMTP server, e.g. smtp.mx.cloudflare.net"
echo " SMTP_USERNAME SMTP login"
echo " SMTP_PASSWORD SMTP password or API token"
echo ""
echo "Examples:"
echo " cp env.example .env && nano .env"
echo " $0 zuiddrecht-prod --mariadb --namespace zuiddrecht-prod"
Expand Down Expand Up @@ -251,6 +256,18 @@ else
NEXTCLOUD_SECRET="${NEXTCLOUD_SECRET:-$(generate_password 64)}"
fi

# Optional: SMTP for tenant.mail (all three or none). The tenant file switches
# mail on (tenant.mail.enabled); these keys carry host and login, never Git.
smtp_set=0
for v in SMTP_HOST SMTP_USERNAME SMTP_PASSWORD; do
[ -n "${!v:-}" ] && smtp_set=$((smtp_set + 1))
done
if [ "$smtp_set" -ne 0 ] && [ "$smtp_set" -ne 3 ]; then
for v in SMTP_HOST SMTP_USERNAME SMTP_PASSWORD; do
[ -z "${!v:-}" ] && missing_vars+=("$v (SMTP: set all three or none)")
done
fi

# Check for missing variables
if [ ${#missing_vars[@]} -gt 0 ]; then
echo -e "${RED}Error: Missing required variables:${NC}"
Expand All @@ -274,6 +291,11 @@ echo " DB Type: $DB_TYPE"
echo " Admin User: $ADMIN_USERNAME"
echo " DB Username: $DB_USERNAME"
echo " S3 Access Key: ${S3_ACCESS_KEY:0:8}..."
if [ "$smtp_set" -eq 3 ]; then
echo " SMTP: ${SMTP_USERNAME} @ ${SMTP_HOST} (smtp-* keys added)"
else
echo " SMTP: none (no smtp-* keys; tenant.mail sends nothing)"
fi
echo ""

# Create namespace if it doesn't exist
Expand Down Expand Up @@ -345,6 +367,15 @@ EOF
)
fi

# SMTP keys go last, under stringData (the final block in both variants above).
if [ "$smtp_set" -eq 3 ]; then
SECRET_YAML="${SECRET_YAML}
# SMTP (read when the tenant file sets tenant.mail.enabled: true)
smtp-host: \"${SMTP_HOST}\"
smtp-username: \"${SMTP_USERNAME}\"
smtp-password: \"${SMTP_PASSWORD}\""
fi

if [ "$DRY_RUN" = true ]; then
echo -e "${YELLOW}DRY RUN - Would create:${NC}"
echo ""
Expand Down
3 changes: 3 additions & 0 deletions nextcloud-platform/scripts/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ REDIS_PASSWORD=9iw4fIUbt1d3UNJvGkMNqEap
# =============================================================================
# SMTP (optional - for email notifications)
# =============================================================================
# All three or none. Only read when the tenant file sets tenant.mail.enabled: true
# (docs/TENANT-OPERATIONS.md). Cloudflare Email Sending: host smtp.mx.cloudflare.net,
# username api_token, password = API token with Email Sending: Edit.
# SMTP_HOST=smtp.example.com
# SMTP_USERNAME=nc-smtp-user
# SMTP_PASSWORD=Kp2mXvR8nQ4wYjL5zTsH7cBd
Expand Down
79 changes: 79 additions & 0 deletions nextcloud-platform/scripts/validate-values.sh
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,84 @@ validate_frontend_branding() {
return 0
}

# Validate tenant.mail (uitgaande mail, opt-in)
#
# De ApplicationSet zet bij tenant.mail.enabled: true de SMTP-env vars en
# smtp.config.php. Host, gebruiker en wachtwoord staan NOOIT hier maar in
# nextcloud-secrets (smtp-host, smtp-username, smtp-password).
# fromAddress is alleen het deel vóór de @ (Nextcloud plakt mail_domain erachter);
# `noreply@zuiddrecht.nl` hier zou `noreply@zuiddrecht.nl@zuiddrecht.nl` opleveren.
MAIL_KEYS=("enabled" "fromAddress" "domain" "port" "secure")
validate_mail() {
local file="$1"
local has_error=0
local key found a

while IFS= read -r key; do
[ -z "$key" ] && continue
found=0
for a in "${MAIL_KEYS[@]}"; do
if [ "$key" = "$a" ]; then
found=1
break
fi
done
if [ $found -eq 0 ]; then
log_error "$file: Onbekende sleutel '$key' onder tenant.mail. Niets leest hem, dus hij wordt stil genegeerd. Toegestaan: ${MAIL_KEYS[*]}. SMTP-host en -inloggegevens horen in nextcloud-secrets, niet hier."
has_error=1
fi
done < <(yq eval '.tenant.mail // {} | keys | .[]' "$file" 2>/dev/null)

local enabled from domain port secure
enabled=$(yq eval '.tenant.mail.enabled // ""' "$file" 2>/dev/null)
[ -z "$enabled" ] && return $has_error

if [ "$enabled" != "true" ] && [ "$enabled" != "false" ]; then
log_error "$file: tenant.mail.enabled moet true of false zijn (got '$enabled')"
return 1
fi
[ "$enabled" = "false" ] && return $has_error

from=$(yq eval '.tenant.mail.fromAddress // ""' "$file" 2>/dev/null)
domain=$(yq eval '.tenant.mail.domain // ""' "$file" 2>/dev/null)
port=$(yq eval '.tenant.mail.port // ""' "$file" 2>/dev/null)
secure=$(yq eval '.tenant.mail.secure // ""' "$file" 2>/dev/null)

if [ -z "$from" ]; then
log_error "$file: tenant.mail.fromAddress ontbreekt (het deel vóór de @, bv. 'noreply')"
has_error=1
elif [[ "$from" == *@* ]]; then
log_error "$file: tenant.mail.fromAddress '$from' bevat een @. Alleen het deel vóór de @; het domein hoort in tenant.mail.domain."
has_error=1
elif ! [[ "$from" =~ ^[a-zA-Z0-9]([a-zA-Z0-9._+-]{0,62}[a-zA-Z0-9])?$ ]]; then
log_error "$file: Invalid tenant.mail.fromAddress '$from' (letters, cijfers, . _ + -)"
has_error=1
fi

if [ -z "$domain" ]; then
log_error "$file: tenant.mail.domain ontbreekt (bv. 'zuiddrecht.nl')"
has_error=1
elif ! [[ "$domain" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$ ]]; then
log_error "$file: Invalid tenant.mail.domain '$domain' (kleine letters, bv. 'zuiddrecht.nl')"
has_error=1
fi

if [ -n "$port" ] && ! { [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; }; then
log_error "$file: Invalid tenant.mail.port '$port' (1-65535)"
has_error=1
fi

if [ -n "$secure" ] && [ "$secure" != "ssl" ] && [ "$secure" != "none" ]; then
log_error "$file: Invalid tenant.mail.secure '$secure'. Toegestaan: ssl (standaard, poort 465) of none."
has_error=1
elif [ "$secure" = "none" ] && [ -z "$port" ]; then
log_error "$file: tenant.mail.secure is none maar tenant.mail.port ontbreekt. De standaardpoort 465 is impliciete TLS; geef de poort expliciet."
has_error=1
fi

return $has_error
}

# Check for potential secrets in file content
check_for_secrets() {
local file="$1"
Expand Down Expand Up @@ -679,6 +757,7 @@ validate_tenant_file() {
validate_frontend_image "$file" || ((file_errors++))
validate_frontend_keys "$file" || ((file_errors++))
validate_frontend_branding "$file" || ((file_errors++))
validate_mail "$file" || ((file_errors++))
check_for_secrets "$file" || true # Warnings only

if [ $file_errors -eq 0 ]; then
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tenant.mail.fromAddress 'noreply@zuiddrecht.nl' bevat een @
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
tenant:
name: canary-accept
environment: accept
wave: "0"
dbType: postgres
apps:
enabled:
- opencatalogi
- openconnector
- openregister
mail:
enabled: true
fromAddress: noreply@zuiddrecht.nl
domain: zuiddrecht.nl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tenant.mail.enabled moet true of false zijn
15 changes: 15 additions & 0 deletions nextcloud-platform/tests/cases/tenant-mail-enabled-geen-bool.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
tenant:
name: canary-accept
environment: accept
wave: "0"
dbType: postgres
apps:
enabled:
- opencatalogi
- openconnector
- openregister
mail:
enabled: ja
fromAddress: noreply
domain: zuiddrecht.nl
1 change: 1 addition & 0 deletions nextcloud-platform/tests/cases/tenant-mail-geldig.expect
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PASS
15 changes: 15 additions & 0 deletions nextcloud-platform/tests/cases/tenant-mail-geldig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
tenant:
name: canary-accept
environment: accept
wave: "0"
dbType: postgres
apps:
enabled:
- opencatalogi
- openconnector
- openregister
mail:
enabled: true
fromAddress: noreply
domain: zuiddrecht.nl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PASS
17 changes: 17 additions & 0 deletions nextcloud-platform/tests/cases/tenant-mail-none-met-poort.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
tenant:
name: canary-accept
environment: accept
wave: "0"
dbType: postgres
apps:
enabled:
- opencatalogi
- openconnector
- openregister
mail:
enabled: true
fromAddress: noreply
domain: zuiddrecht.nl
secure: none
port: 587
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tenant.mail.secure is none maar tenant.mail.port ontbreekt
Loading
Loading