Headless install (answers file)
Purpose. Complete setup without a browser — MSPs, scripted provisioning, CI fresh-VPS tests — through the same API state machine the Setup Wizard uses. The answers file is a versioned public contract (SetupAnswers in packages/core).
Behaviour. mailctl install --answers setup.yaml validates the file locally, installs the stack, then POST /api/v1/setup/answers performs hostname → TLS → first domain → owner → optional AI provider → render + apply, and prints the DNS records. Re-running against a completed server fails with a clear message; setup cannot be re-run (change settings through the admin API instead).
Schema (version 1)
version: 1hostname: mail.example.com # FQDN of this server (A + PTR should point here)tls: mode: acme # acme | byo | none acmeEmail: ops@example.com # required for acme (Let's Encrypt account / expiry notices) # mode: byo → certificatePem: | … and privateKeyPem: | … # mode: none → no TLS at all: passwords in clear text on 143 and 587 (and 110 while POP3 is on). Refused unless the mail # ports are published on loopback (PUBLISH_BIND) or allowPlaintextAuth: true is given — # `mailctl install --allow-plaintext-auth` sets it for you.domain: name: example.com # first mail domain defaultQuotaBytes: 0 # 0 = unlimitedowner: email: admin@example.com # control-plane owner login password: change-me-please-now # ≥10 characters createMailbox: true # also create the admin@example.com mailbox when it is in the first domainprotocols: # OPTIONAL — omit it and POP3 stays on, which is how the server ships pop3: false # false closes 110 and 995 at Dovecot; Autoconfig/Autodiscover never advertised POP3ai: # OPTIONAL — omit the whole block and AI stays off, which is how the server ships provider: ollama # any id from GET /ai/providers (anthropic, openai, ollama, vllm, …) model: llama3.1 # a model id from your provider's documentation, not from us endpoint: http://gpu.internal:11434 # required only where the provider is self-hosted apiKey: sk-… # required where the provider needs one; sealed at rest localOnly: true # refuse any endpoint that is not on a private networkJSON is accepted too. Validation errors list each offending path.
The protocols block is the answers-file half of the POP3 switch on the wizard’s TLS step (PUT /setup/protocols); it is applied after TLS and before the first domain, and a version-1 file without it keeps POP3 on.
tls.mode: none on a public address is refused, by mailctl install before it touches the host and by POST /setup/answers itself. With no TLS the only way a client can sign in is by sending its password in the clear, and a server doing that on the internet is what CERT-Bund scans for and reports to the hosting provider — which is how it was found. The rule is one function, plaintextAuthRefusal in packages/core, and both readers apply it: mode: none is allowed when PUBLISH_BIND is loopback (the dev stack, CI), or when the file says allowPlaintextAuth: true (or the command line says --allow-plaintext-auth, which sets the same key). With the escape, install prints a warning and mailctl doctor reports a ✖ on its plaintext auth row for as long as the mode is in use.
The ai block is the answers-file half of the wizard’s optional AI step — every wizard step is expressible as an API call and as an answers-file key. It is the same AiProviderConfigInput schema the API and the admin picker use, so it is checked by the same rules — a self-hosted provider without an endpoint, or a hosted vendor under localOnly: true, is rejected in the file rather than at the first AI call. It is applied after the owner and before render + apply, so a provider the server refuses fails the install instead of leaving a server that applied its configuration and silently ignored half the file. Adding an optional key does not break a version-1 file, so version stays at 1. See AI providers.
Configuration. None on the host beyond .env written by mailctl.
Limits & edge cases. The owner mailbox is only created when the owner email belongs to the first domain. The file contains secrets — the owner password and, if you use it, the AI provider key — so keep it 0600 and delete it after install. Additional domains/mailboxes are created afterwards via the API or an API token.