#!/usr/bin/env bash # provision-stalwart.sh — bring a fresh Stalwart up *fully configured from .env*. # # Stalwart v0.16 keeps ALL of its config in Postgres (not files), reachable # through the `x:` JMAP management objects. config/config.json only tells the # image where Postgres lives; this script writes everything else the setup # wizard would — stores, listeners, the primary domain (+DKIM), the admin and # relay/catch-all accounts, TLS/DNS, and (optionally) SSO via Authelia — so the # operator never has to touch the wizard for a working mail server. # # Idempotent: every object is keyed by a stable name/singleton and created only # if missing, so re-running is safe. Run AFTER `docker compose up -d` (the # stalwart sidecar must be reachable on the tailnet). # # ./provision-stalwart.sh # provision from ./.env # ./provision-stalwart.sh --print-dns # also dump the zone records to publish # # Designed to drop into federatedSocial's bootstrap.sh as `cmd_provision_stalwart`. # # TIERS (the DNS/TLS automation boundary): # * Default (trustless): domain in MANUAL dkim/dns mode; certs via the existing # wildcard / HTTP-01; the script PRINTS the exact DNS records to publish. # * Opt-in: if STALWART_DNS_API_KEY is set AND the provider probe succeeds, the # domain flips to AUTOMATIC — Stalwart auto-publishes DNS + does DNS-01. # # NOTE: the Authelia-SSO step (provision_oidc) writes the Stalwart side via API # but only PRINTS the Authelia client block to paste — editing Authelia's # hand-maintained YAML from a script is deliberately avoided. The admin and # relay accounts ALWAYS keep password auth as break-glass, so enabling SSO can # never lock you out of Stalwart. The SSO login flow could not be validated # against a throwaway instance here — verify it on a test deploy before trusting. set -euo pipefail # --------------------------------------------------------------------------- # Setup: load .env, derive endpoint + admin auth # --------------------------------------------------------------------------- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${REPO_ROOT}/.env" [[ -f "$ENV_FILE" ]] || { echo "Error: ${ENV_FILE} not found (cp .env.example .env)" >&2; exit 1; } set -a; source "$ENV_FILE"; set +a PRINT_DNS=0; [[ "${1:-}" == "--print-dns" ]] && PRINT_DNS=1 for v in STALWART_MAGIC_NAME TS_TAILNET STALWART_DOMAIN STALWART_HOSTNAME \ STALWART_FALLBACK_ADMIN_SECRET REDIS_MAGIC_NAME STALWART_REDIS_DB \ GARAGE_MAGIC_NAME GARAGE_REGION GARAGE_ACCESS_KEY_ID GARAGE_SECRET_ACCESS_KEY \ STALWART_S3_BUCKET SMTP_USER SMTP_PASSWORD; do [[ -n "${!v:-}" ]] || { echo "Error: required \$$v is empty in .env" >&2; exit 1; } done SW_HOST="${STALWART_MAGIC_NAME}.${TS_TAILNET}" SW_BASE="http://${SW_HOST}:8080" SW_JMAP="${SW_BASE}/jmap" NET_TRUST='{"100.64.0.0/10":true,"fd7a:115c:a1e0::/48":true}' # tailnet CGNAT + ULA RELAY_LOCAL="${SMTP_USER%@*}" # zaphod RELAY_DOMAIN="${SMTP_USER#*@}" # infinidim.net ACCT_ID="b" # principals primary account log(){ printf ' %s\n' "$*"; } die(){ echo "Error: $*" >&2; exit 1; } # --------------------------------------------------------------------------- # JMAP helpers (HTTP Basic — no PKCE/Bearer needed for scripting) # --------------------------------------------------------------------------- SW_AUTH="" # set by sw_auth() # sw_call -> raw response on stdout sw_call(){ curl -s -m 25 -u "$SW_AUTH" -H 'Content-Type: application/json' "$SW_JMAP" -X POST \ --data "$(jq -nc --argjson mc "$1" '{using:["urn:ietf:params:jmap:core","urn:stalwart:jmap"],methodCalls:$mc}')" } # sw_ok — die if any error/notCreated/notUpdated/notDestroyed present sw_ok(){ local r="$1" if echo "$r" | jq -e '.methodResponses[0][0]=="error"' >/dev/null 2>&1; then die "JMAP error: $(echo "$r" | jq -c '.methodResponses[0][1]')" fi if echo "$r" | jq -e '.methodResponses[0][1] | (.notCreated//{}|length>0) or (.notUpdated//{}|length>0) or (.notDestroyed//[]|length>0)' >/dev/null 2>&1; then die "JMAP set rejected: $(echo "$r" | jq -c '.methodResponses[0][1]|{notCreated,notUpdated,notDestroyed}')" fi } # find id of object of TYPE whose `name` == NAME (empty if none) sw_find_id(){ # $1 type $2 name local r; r=$(sw_call "$(jq -nc --arg t "$1" '[[($t+"/query"),{accountId:"'"$ACCT_ID"'"},"0"],[($t+"/get"),{accountId:"'"$ACCT_ID"'","#ids":{resultOf:"0",name:($t+"/query"),path:"/ids"},properties:["name"]},"1"]]')") echo "$r" | jq -r --arg n "$2" '.methodResponses[1][1].list[]? | select(.name==$n) | .id' | head -1 } # authenticate: prefer a real admin account; fall back to the first-boot virtual admin sw_auth(){ local secret="$STALWART_FALLBACK_ADMIN_SECRET" u for u in "admin@${STALWART_DOMAIN}" "admin"; do if curl -s -m 8 -u "${u}:${secret}" -o /dev/null -w '%{http_code}' "${SW_JMAP}/session" 2>/dev/null | grep -q 200; then SW_AUTH="${u}:${secret}"; log "authenticated as ${u}"; return 0 fi done die "cannot authenticate to ${SW_JMAP} as admin (check STALWART_FALLBACK_ADMIN_SECRET / first-boot state)" } # =========================================================================== # Provisioning steps # =========================================================================== ensure_admin(){ # persistent superuser (survives once config is written; fallback then goes inert) [[ -n "${DOMAIN_ID:-}" ]] || die "ensure_admin needs the domain first" local id; id=$(sw_find_id x:Account admin) if [[ -z "$id" ]]; then log "creating persistent admin account" sw_ok "$(sw_call "$(jq -nc --arg pw "$STALWART_FALLBACK_ADMIN_SECRET" --arg dom "$DOMAIN_ID" \ '[["x:Account/set",{accountId:"'"$ACCT_ID"'",create:{a:{"@type":"User",name:"admin",domainId:$dom,description:"System administrator",roles:{"@type":"Admin"},credentials:{"0":{"@type":"Password",secret:$pw}}}}},"0"]]')")" else log "admin account present"; fi } re_auth(){ # switch to the persistent admin so the rest of the run is immune to the # first-boot fallback going inert the moment a real admin exists. local u="admin@${STALWART_DOMAIN}" if curl -s -m 8 -u "${u}:${STALWART_FALLBACK_ADMIN_SECRET}" -o /dev/null -w '%{http_code}' "${SW_JMAP}/session" 2>/dev/null | grep -q 200; then SW_AUTH="${u}:${STALWART_FALLBACK_ADMIN_SECRET}"; log "re-authenticated as ${u}" fi } ensure_stores(){ log "blob store -> Garage S3" sw_ok "$(sw_call "$(jq -nc \ --arg ep "http://${GARAGE_MAGIC_NAME}.${TS_TAILNET}:3900" --arg region "$GARAGE_REGION" \ --arg bucket "$STALWART_S3_BUCKET" --arg ak "$GARAGE_ACCESS_KEY_ID" --arg sk "$GARAGE_SECRET_ACCESS_KEY" \ '[["x:BlobStore/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{"@type":"S3",region:{"@type":"Custom",customEndpoint:$ep,customRegion:$region},bucket:$bucket,accessKey:$ak,secretKey:{"@type":"Value",secret:$sk},verifyAfterWrite:true}}},"0"]]')")" log "in-memory store -> Redis db ${STALWART_REDIS_DB}" sw_ok "$(sw_call "$(jq -nc --arg url "redis://${REDIS_MAGIC_NAME}.${TS_TAILNET}:6379/${STALWART_REDIS_DB}" \ '[["x:InMemoryStore/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{"@type":"Redis",url:$url}}},"0"]]')")" } ensure_listeners(){ # name port protocol implicitTls proxyTrust(0/1) local rows=( "smtp 25 smtp 0 1" "submission 587 smtp 0 1" "submissions 465 smtp 1 1" "imap 143 imap 0 1" "imaps 993 imap 1 1" "http 8080 http 0 0" "https 443 http 1 0" "sieve 4190 manageSieve 0 0" ) local row name port proto impl trust id for row in "${rows[@]}"; do read -r name port proto impl trust <<<"$row" id=$(sw_find_id x:NetworkListener "$name"); [[ -n "$id" ]] && { log "listener ${name} present"; continue; } log "creating listener ${name} (:${port})" local pt='{}'; [[ "$trust" == 1 ]] && pt="$NET_TRUST" sw_ok "$(sw_call "$(jq -nc --arg n "$name" --arg bind "[::]:${port}" --arg p "$proto" \ --argjson impl "$([[ $impl == 1 ]] && echo true || echo false)" --argjson pt "$pt" \ '[["x:NetworkListener/set",{accountId:"'"$ACCT_ID"'",create:{l:{name:$n,bind:{($bind):true},protocol:$p,useTls:true,tlsImplicit:$impl,overrideProxyTrustedNetworks:$pt}}},"0"]]')")" done } ensure_domain(){ # primary mail domain; DKIM auto-gen; dns/cert mode set later by tier DOMAIN_ID=$(sw_find_id x:Domain "$STALWART_DOMAIN") if [[ -z "$DOMAIN_ID" ]]; then log "creating domain ${STALWART_DOMAIN} (auto DKIM)" local r; r=$(sw_call "$(jq -nc --arg d "$STALWART_DOMAIN" --arg ca "$SMTP_USER" \ '[["x:Domain/set",{accountId:"'"$ACCT_ID"'",create:{d:{name:$d,isEnabled:true,description:"Primary mail domain",catchAllAddress:$ca,subAddressing:{"@type":"Enabled"},dkimManagement:{"@type":"Automatic",algorithms:{Dkim1Ed25519Sha256:true,Dkim1RsaSha256:true},selectorTemplate:"v{version}-{algorithm}-{date-%Y%m%d}",rotateAfter:7776000000,retireAfter:604800000,deleteAfter:2592000000}}}},"0"]]')") sw_ok "$r"; DOMAIN_ID=$(echo "$r" | jq -r '.methodResponses[0][1].created.d.id') else log "domain ${STALWART_DOMAIN} present"; fi } ensure_accounts(){ # relay / catch-all account from SMTP_USER (admin handled separately) local id; id=$(sw_find_id x:Account "$RELAY_LOCAL") if [[ -z "$id" ]]; then log "creating relay account ${SMTP_USER}" sw_ok "$(sw_call "$(jq -nc --arg n "$RELAY_LOCAL" --arg dom "$DOMAIN_ID" --arg pw "$SMTP_PASSWORD" \ '[["x:Account/set",{accountId:"'"$ACCT_ID"'",create:{a:{"@type":"User",name:$n,domainId:$dom,description:"Relay / catch-all",credentials:{"0":{"@type":"Password",secret:$pw}}}}},"0"]]')")" else log "relay account ${SMTP_USER} present"; fi } ensure_system(){ log "system settings: hostname ${STALWART_HOSTNAME}" sw_ok "$(sw_call "$(jq -nc --arg h "$STALWART_HOSTNAME" --arg d "$DOMAIN_ID" \ '[["x:SystemSettings/set",{accountId:"'"$ACCT_ID"'",update:{singleton:{defaultHostname:$h,defaultDomainId:$d}}},"0"]]')")" } # ---- TLS / DNS tier ------------------------------------------------------- ensure_dns_tier(){ if [[ -n "${STALWART_DNS_API_KEY:-}" && "${STALWART_DNS_PROVIDER:-spaceship}" == "spaceship" ]]; then log "DNS provider key present -> tier 2 (automatic publish via Spaceship)" local sid; sid=$(sw_find_id x:DnsServer "${STALWART_DNS_DESC:-managed}") if [[ -z "$sid" ]]; then local r; r=$(sw_call "$(jq -nc --arg k "$STALWART_DNS_API_KEY" --arg s "${STALWART_DNS_API_SECRET:-}" --arg n "${STALWART_DNS_DESC:-managed}" \ '[["x:DnsServer/set",{accountId:"'"$ACCT_ID"'",create:{s:{"@type":"Spaceship",description:$n,apiKey:$k,secret:$s,ttl:300000,pollingInterval:15000,propagationTimeout:60000}}},"0"]]')") sw_ok "$r"; sid=$(echo "$r" | jq -r '.methodResponses[0][1].created.s.id') fi log "domain -> Automatic DNS (origin ${STALWART_DOMAIN}) + ACME DNS-01" sw_ok "$(sw_call "$(jq -nc --arg sid "$sid" --arg origin "$STALWART_DOMAIN" \ '[["x:Domain/set",{accountId:"'"$ACCT_ID"'",update:{"'"$DOMAIN_ID"'":{dnsManagement:{"@type":"Automatic",dnsServerId:$sid,origin:$origin,publishRecords:{autoConfig:true,autoConfigLegacy:true,autoDiscover:true,caa:true,dkim:true,dmarc:true,mtaSts:true,mx:true,spf:true,srv:true,tlsRpt:true}}}}},"0"]]')")" ensure_acme_dns01 PRINT_DNS=0 # records publish themselves else log "no DNS API key -> tier 1 (trustless): manual DNS, records printed below" PRINT_DNS=1 fi } ensure_acme_dns01(){ [[ -z "${STALWART_ACME_CONTACT:-}" ]] && { log " (set STALWART_ACME_CONTACT to enable DNS-01 ACME)"; return; } local id; id=$(sw_find_id x:AcmeProvider letsencrypt) [[ -n "$id" ]] && { log " ACME provider present"; return; } log " creating ACME provider (Let's Encrypt, DNS-01)" sw_ok "$(sw_call "$(jq -nc --arg c "$STALWART_ACME_CONTACT" \ '[["x:AcmeProvider/set",{accountId:"'"$ACCT_ID"'",create:{p:{name:"letsencrypt",directory:"https://acme-v02.api.letsencrypt.org/directory",challengeType:"Dns01",contact:$c,renewBefore:"R23",maxRetries:10}}},"0"]]')")" } print_dns_records(){ [[ "$PRINT_DNS" == 1 ]] || return 0 echo; echo "=== Publish these DNS records for ${STALWART_DOMAIN} (tier 1 / manual) ===" sw_call "$(jq -nc --arg id "$DOMAIN_ID" '[["x:Domain/get",{accountId:"'"$ACCT_ID"'",ids:[$id],properties:["dnsZoneFile"]},"0"]]')" \ | jq -r '.methodResponses[0][1].list[0].dnsZoneFile // "(zone file unavailable)"' echo "=== then re-run with STALWART_DNS_API_KEY set for automatic publishing ===" } # ---- Authelia SSO (opt-in; admin/relay keep password auth as break-glass) -- provision_oidc(){ [[ "${STALWART_SSO_ENABLE:-false}" == "true" ]] || { log "SSO disabled (set STALWART_SSO_ENABLE=true to wire Authelia)"; return; } [[ -n "${AUTHELIA_PORTAL_URL:-}" ]] || die "STALWART_SSO_ENABLE=true but AUTHELIA_PORTAL_URL is empty" local issuer="$AUTHELIA_PORTAL_URL" id id=$(sw_find_id x:Directory "authelia") if [[ -z "$id" ]]; then log "creating Stalwart OIDC directory -> ${issuer}" sw_ok "$(sw_call "$(jq -nc --arg iss "$issuer" --arg ud "$STALWART_DOMAIN" \ '[["x:Directory/set",{accountId:"'"$ACCT_ID"'",create:{d:{"@type":"Oidc",description:"authelia",issuerUrl:$iss,claimUsername:"preferred_username",claimName:"name",claimGroups:"groups",usernameDomain:$ud}}},"0"]]')")" else log "OIDC directory present"; fi cat <>> Add this client to authelia/configuration.yml under >>> identity_providers.oidc.clients (hash the secret with: >>> docker run --rm authelia/authelia authelia crypto hash generate pbkdf2 --password '') - client_id: stalwart client_name: Stalwart Mail client_secret: '}>' public: false authorization_policy: two_factor redirect_uris: - https://${STALWART_HOSTNAME}/auth/oauth scopes: [openid, profile, email, groups] >>> admin + ${SMTP_USER} keep password auth as break-glass — SSO can't lock you out. EOF } # =========================================================================== main(){ echo "Provisioning Stalwart at ${SW_BASE} from ${ENV_FILE}" command -v jq >/dev/null || die "jq is required" curl -s -m 8 -o /dev/null "${SW_BASE}/jmap/session" || die "cannot reach ${SW_BASE} (is the stack up?)" sw_auth ensure_stores ensure_domain ensure_admin re_auth ensure_accounts ensure_listeners ensure_system ensure_dns_tier provision_oidc print_dns_records echo "Done. Listeners rebind on container restart if newly created: docker compose restart stalwart" } # Run only when executed directly — so the file can be sourced for testing. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@"; fi