#Requires -Version 5.1 <# .SYNOPSIS One-time setup for the disposable Stalwart test server from docker-compose.yml: completes the bootstrap wizard, creates a real "devadmin" account, and sets the default OAuth access token lifetime. .DESCRIPTION Local development only. Requires the dev container to be running ('npm run dev:server'). The STALWART_RECOVERY_ADMIN account is break-glass only and always issues fixed 1h OAuth tokens regardless of server config, so dev-token.ps1 authenticates as "devadmin" instead, created here. Idempotent: safe to re-run; does nothing if the server already left bootstrap mode. #> param( [string]$ApiBaseUrl = "http://localhost:8080" ) $ErrorActionPreference = 'Stop' $RecoveryAccount = "admin@example.org" $RecoverySecret = "c8321iEscHDy0GWV" $DevDomain = "example.org" $DevHostname = "mail.example.org" $DevAdminName = "devadmin" $DevAdminSecret = "DevAdminPass123!" $DefaultTokenExpiryMs = 10800000 # 3 hours $recoveryCreds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($RecoveryAccount):$($RecoverySecret)")) $authHeader = @{ Authorization = "Basic $recoveryCreds" } function Invoke-Jmap($body) { Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15 } Write-Host "Waiting for $ApiBaseUrl to be reachable..." $ready = $false for ($i = 0; $i -lt 30; $i++) { try { Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null $ready = $true break } catch { Start-Sleep -Seconds 1 } } if (-not $ready) { throw "Server did not become reachable at $ApiBaseUrl" } $session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15 $accountId = $session.primaryAccounts.'urn:stalwart:jmap' $queryResult = Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0")) } $alreadyBootstrapped = -not ($queryResult.methodResponses[0][1].type -eq "forbidden") if ($alreadyBootstrapped) { Write-Host "Server already bootstrapped, skipping setup. (Use 'docker compose down -v; npm run dev:server' to start fresh.)" return } Write-Host "Completing server bootstrap (domain: $DevDomain, no TLS certificate request)..." Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Bootstrap/set", @{ accountId = $accountId update = @{ singleton = @{ defaultDomain = $DevDomain; serverHostname = $DevHostname; requestTlsCertificate = $false } } }, "0")) } | Out-Null Write-Host "Restarting the container to apply bootstrap config (one-time only)..." docker compose restart stalwart | Out-Null $ready = $false for ($i = 0; $i -lt 30; $i++) { try { Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null $ready = $true break } catch { Start-Sleep -Seconds 1 } } if (-not $ready) { throw "Server did not come back up after restart" } $domainResult = Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0")) } $domainId = $domainResult.methodResponses[0][1].ids[0] Write-Host "Creating devadmin account ($DevAdminName@$DevDomain)..." $createResult = Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Account/set", @{ accountId = $accountId create = @{ u1 = @{ "@type" = "User"; name = $DevAdminName; domainId = $domainId; roles = @{ "@type" = "Admin" } } } }, "0")) } $devAdminId = $createResult.methodResponses[0][1].created.u1.id Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Account/set", @{ accountId = $accountId update = @{ $devAdminId = @{ credentials = @{ "0" = @{ "@type" = "Password"; secret = $DevAdminSecret } } } } }, "0")) } | Out-Null Write-Host "Setting default OAuth access token lifetime to 3 hours..." Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:OidcProvider/set", @{ accountId = $accountId update = @{ singleton = @{ accessTokenExpiry = $DefaultTokenExpiryMs } } }, "0")) } | Out-Null try { Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Action/set", @{ accountId = $accountId; create = @{ a1 = @{ "@type" = "ReloadSettings" } } }, "0")) } | Out-Null } catch {} # Cosmetic only: Stalwart seeds a default "Stalwart Web Interface" / # stalwartlabs/webui entry for the x:Application record serving /admin and # /account (see Settings > Web Applications' "Active WebUI" card). Point it # at this fork so that card isn't misleading in local dev too. Write-Host "Pointing the active WebUI's description at this fork..." try { $appResult = Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Application/query", @{ accountId = $accountId }, "0")) } $appId = $appResult.methodResponses[0][1].ids[0] if ($appId) { Invoke-Jmap @{ using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") methodCalls = @(, @("x:Application/set", @{ accountId = $accountId update = @{ $appId = @{ description = "Stalwart WebUI Fork" resourceUrl = "https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip" } } }, "0")) } | Out-Null } } catch {} Write-Host "Done. $DevAdminName@$DevDomain / $DevAdminSecret is ready - run scripts/dev-token.ps1 to get a token."