Repository navigation
Setup Password Change
This page sets up Settings > Password, so that users can change their mailbox password from ALPS. ALPS hands each change to one of two backends: an HTTP request to your mail server's admin API, or a program that ALPS runs on its own host. Under the multi provider, each group of domains can have its own password settings. Every configuration on this page was run in a lab first. The full key reference is in Config-Plugins, and the full behavior is in PASSWORD_CHANGE.md.
- A Password tab in Settings, with the fields Current Password, New Password and Confirm New Password, and an Update Password button. The page checks that the two new passwords match before it sends anything.
- ALPS checks the current password against the one the session signed in with. This check contacts no server. It counts against the login rate limits the same way a sign-in does.
- ALPS then passes the change to your backend. When the backend reports success, ALPS signs in to the mail server with the new password, and the user stays signed in.
- With
[server] login_keyset, ALPS also re-issues the login cookie with the new password, keeping the user's "Stay signed in after closing the browser" choice. It also updates the copy of the password that each linked account stores (see Sessions-and-Sign-in). - Under
[provider] type = "multi", a domain entry, a route or the default backend can name its own password block. That block replaces the global one for the logins it covers.
- An ALPS that already signs users in. See Setup-Local-Try-Out and Running-in-Production.
- A way to set a mailbox password. You need either an admin HTTP API that ALPS can reach, or a command line tool on the ALPS host. The examples use sora's admin API. It changes a password with
PUT /admin/accounts/<address>, the JSON body{"password": "<new password>"}andAuthorization: Bearer <admin API key>, and the old password stops working at once. For another server, change the endpoint, method and payload mapping to match its API. - For the command backend: a Unix host. The example helper uses
curlandjq. -
[server] login_key, so that a change keeps the login cookie and the linked accounts up to date. Generate one withopenssl rand -base64 32. Keep the key secret, and give every node the same one. - Every plugin you use, each in its own section with
enabled = true. Setting[plugin.password] enabled = trueturns on ALPS's enabled list if no other[plugin.*]section had, and from then on onlybase, which always loads, and the plugins whose own sections setenabled = trueload. A section that leavesenabledout is then left out too (see Config-Plugins). - An
endpointor acommandfor the password plugin. Withenabled = trueand neither, in the global block or in anymultibackend's block, the plugin stays off: Settings has no Password tab, and startup logspassword: enabled without an endpoint, command or routed backend; plugin disabled, then the warningEnabled plugin "password" did not load; check its name and backend configuration.
The files below are complete. Each one replaces /etc/alps/config.toml. They hold the admin API's key, so make the file readable only by root and the user ALPS runs as, for example with chown root:alps and chmod 0640. Behind a reverse proxy, also add trusted_proxies and the rest of Setup-Behind-a-Reverse-Proxy.
The files enable only base and password, so every other plugin is left out, without a line in the log. Add a section with enabled = true for each other plugin you use, such as gpg, caldav, carddav and managesieve, as in config.example.toml. managesieve needs a server. caldav and carddav take one too, or load without it, and users then enter servers of their own in Settings.
ALPS sends one request per change. Here it sends sora's PUT with the new password:
[server]
# Behind a reverse proxy, also set trusted_proxies (see Setup-Behind-a-Reverse-Proxy).
addr = "127.0.0.1:1323"
login_key = "<your Fernet key>"
[provider]
type = "imap"
[provider.imap]
server = "imaps://imap.example.com:993"
[smtp]
server = "smtps://smtp.example.com:465"
[plugin.base]
enabled = true
[plugin.password]
enabled = true
[plugin.password.options]
endpoint = "https://mailadmin.example.com/admin/accounts/{email}"
method = "PUT"
auth_type = "bearer"
token = "<your sora admin API key>"
payload = "json"
[plugin.password.options.payload_mapping]
password = "new_password"For ada@example.com changing her password to n3w-secret, the endpoint receives:
PUT /admin/accounts/ada@example.com
Content-Type: application/json
Authorization: Bearer <your sora admin API key>
{"password":"n3w-secret"}
-
{email}is the login exactly as the user typed it.{local}and{domain}are the parts before and after the first@. ALPS escapes each value as a URL path segment, which leaves@,&and=as they are, so put the placeholders in the path, not in a query string. -
payload_mappinglists the fields the body carries. Each entry is<field sent> = "<value>", and the value is one ofusername,local,domain,old_passwordandnew_password. Without a mapping, the body is{}, or empty for a form. - Use an
https://endpoint, since the body carries the password.
An API that takes a form, wants Basic authentication, or needs the login in the body takes a block like this one:
[plugin.password.options]
endpoint = "https://mailadmin.example.com/api/domains/{domain}/mailboxes/{local}/password"
method = "PATCH"
auth_type = "basic"
username = "webmail"
password = "<the API's password for ALPS>"
payload = "form"
[plugin.password.options.payload_mapping]
email = "username"
current_password = "old_password"
new_password = "new_password"For ada@example.org, the endpoint receives this request. The fields are sorted by name:
PATCH /api/domains/example.org/mailboxes/ada/password
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64 of "webmail:<the API's password for ALPS>">
current_password=old-secret&email=ada%40example.org&new_password=n3w-secret
A mapping value that is not one of the five names above is dropped without a message, so a mapping cannot send a fixed value.
ALPS goes by the endpoint's final status. It follows up to 10 redirects, so point endpoint at the URL that makes the change, not at one that redirects.
| Endpoint answers | ALPS answers the browser | Settings shows |
|---|---|---|
| Any status below 400 | 200 password_changed
|
"Your password has been changed." |
| Any status below 400, but the new password does not open the mailbox | 200 reauthenticate (see Troubleshooting) |
"Your password has been changed. Please sign in again." |
| 401, 403 or 5xx | 502 password_service_unavailable
|
"Password changes are temporarily unavailable. Please try again later." |
| Any other 4xx | 400 password_change_rejected
|
"Could not change your password. Please try again." |
| Nothing: no connection, a TLS error, more than 10 redirects, or no answer within 15 seconds | 502 password_service_unavailable
|
"Password changes are temporarily unavailable. Please try again later." |
For a server that changes passwords with a command line tool, ALPS runs a program in place of the HTTP request. The helper below sets the password through sora's admin API, so it works wherever curl reaches that API. Save it as set-password:
#!/bin/sh
# Sets a mailbox password through sora's admin API.
# ALPS runs: set-password <email>, with the new password on standard input.
set -eu
API=https://mailadmin.example.com
email=$1
IFS= read -r new_password
# The key is read from a file as a header, so it never appears in the
# arguments of a running program.
jq -n --arg p "$new_password" '{password: $p}' |
curl --silent --show-error --fail-with-body --max-time 10 \
--request PUT \
--header @/etc/alps/sora-admin-header \
--header 'Content-Type: application/json' \
--data-binary @- \
--output /dev/stderr \
"$API/admin/accounts/$(jq -rn --arg e "$email" '$e | @uri')"Save the key as sora-admin-header, one line long:
Authorization: Bearer <your sora admin API key>
Install both as root, with the ALPS user able to read the key but not change anything:
useradd --system --home-dir /var/lib/alps --create-home --shell /usr/sbin/nologin alps # if ALPS has no user yet
install -d -m 0755 /usr/local/libexec/alps /etc/alps
install -m 0755 -o root -g root set-password /usr/local/libexec/alps/set-password
install -m 0640 -o root -g alps sora-admin-header /etc/alps/sora-admin-header
install -m 0640 -o root -g alps config.toml /etc/alps/config.tomlThe configuration is the HTTP one with command in place of the endpoint keys:
[server]
# Behind a reverse proxy, also set trusted_proxies (see Setup-Behind-a-Reverse-Proxy).
addr = "127.0.0.1:1323"
login_key = "<your Fernet key>"
[provider]
type = "imap"
[provider.imap]
server = "imaps://imap.example.com:993"
[smtp]
server = "smtps://smtp.example.com:465"
[plugin.base]
enabled = true
[plugin.password]
enabled = true
[plugin.password.options]
command = ["/usr/local/libexec/alps/set-password", "{email}"]
# stdin = "{new_password}\n" (the default)How ALPS runs the program:
-
Directly, with no shell. Each list entry reaches the program as one argument. The arguments may carry
{email},{local}and{domain}, but never the passwords. -
Passwords on standard input. By default the program reads the new password on a line of its own. A tool that asks for both passwords can be given
stdin = "{old_password}\n{new_password}\n". -
As the ALPS user, with only
PATHin its environment. ALPS passes no other environment variable, so a helper keeps its key in a file. This one reads a file that thealpsgroup can read. -
Exit status. 0 means the password was changed. Exit status 1 or above is a refused change (400
password_change_rejected), whatever the reason, including an admin API that cannot be reached. Settings then shows "Could not change your password. Please try again." A program that cannot be started, or is ended by a signal, gives 502password_service_unavailable. When a run fails, ALPS logs the first 4 KiB of the program's standard error with both passwords masked, and it discards standard output. The helper sendscurl's messages and the API's answer to standard error, so they reach the log. -
15 seconds. A program still running after 15 seconds is killed together with its process group, and the browser gets 504
password_service_unavailable. The helper's--max-time 10makescurlgive up first, so a slow admin API gives a refused change (400) and the log carriescurl's own message.
The other rules (what is refused before the program runs, what is checked at startup) are in Config-Plugins.
Under the multi provider, a backend can carry a password sub-table with the same keys as [plugin.password.options]. Here example.com changes passwords with the helper above, example.org has an admin API of its own, and every other domain uses the global block:
[server]
# Behind a reverse proxy, also set trusted_proxies (see Setup-Behind-a-Reverse-Proxy).
addr = "127.0.0.1:1323"
login_key = "<your Fernet key>"
[provider]
type = "multi"
[[provider.multi.routes]]
domains = ["example.com"]
type = "imap"
server = "imaps://imap.example.com:993"
# Belongs to the route declared just above it.
[provider.multi.routes.password]
command = ["/usr/local/libexec/alps/set-password", "{email}"]
[provider.multi.domains."example.org"]
type = "imap"
server = "imaps://imap.example.org:993"
[provider.multi.domains."example.org".password]
endpoint = "https://mailadmin.example.org/admin/accounts/{email}"
method = "PUT"
auth_type = "bearer"
token = "<example.org's admin API key>"
payload = "json"
[provider.multi.domains."example.org".password.payload_mapping]
password = "new_password"
# Every other domain. It names no password block, so the global one applies.
[provider.multi.default]
type = "imap"
server = "imaps://imap.example.net:993"
[smtp]
server = "smtps://smtp.example.com:465"
[plugin.base]
enabled = true
[plugin.password]
enabled = true
[plugin.password.options]
endpoint = "https://mailadmin.example.net/api/domains/{domain}/mailboxes/{local}/password"
method = "PATCH"
auth_type = "basic"
username = "webmail"
password = "<the API's password for ALPS>"
payload = "form"
[plugin.password.options.payload_mapping]
email = "username"
new_password = "new_password"- A backend's block replaces
[plugin.password.options]whole. Nothing is merged in from the global block: not the credentials, notpayload_mapping. Give each block its own credentials and its own mapping, or acommand. - In TOML,
[provider.multi.routes.password]belongs to the[[provider.multi.routes]]declared just above it. Keep each one directly under its own route. A domain entry takes[provider.multi.domains."<domain>".password], and the default backend takes[provider.multi.default.password]. - A backend with no block uses the global one. Logins that the template or autodiscovery route use the default backend's block, or the global one when the default names none.
-
[plugin.password] enabled = trueis still required. A backend's block with anendpointorcommandis enough for the plugin to load without a global one; logins that then fall to the global block get 502password_service_unavailable. - ALPS checks only the global block at startup. A backend's block that cannot run is found when a user of that backend changes a password (see Troubleshooting), so try one change per backend after you deploy.
More on routes and domain entries is in Config-Providers-and-SMTP and Setup-Many-Domains.
Run ALPS as its own user. Under a service manager, use the same user and command line:
runuser -u alps -- /usr/local/bin/alps -config /etc/alps/config.tomlWith no [logging] section, ALPS logs JSON lines to standard output. Among them, the plugins load before the server starts:
{"time":"...","level":"INFO","msg":"Loaded plugin \"base\""}
{"time":"...","level":"INFO","msg":"Loaded plugin \"password\""}
{"time":"...","level":"INFO","msg":"Starting server on 127.0.0.1:1323"}
These files leave out [webauthn], so ALPS also warns that rpid is unset. Set it for your hostname as described in Config-Rate-Limiting-and-WebAuthn.
A key ALPS does not know is named in a warning, such as Unknown configuration key: plugin.password.option (ignored). The keys inside options and inside a backend's password table are not checked, so compare those with the keys on this page.
If the global block cannot run, ALPS logs failed to load plugins: plugin.password: <reason> and exits with status 1. The reasons are listed under Troubleshooting.
- Sign in as a test user.
- Open Settings and choose Password.
- Fill in Current Password, New Password and Confirm New Password, then click Update Password.
- The message "Your password has been changed." appears, and the form empties. The user stays signed in.
- Sign out, then sign in with the new password. The old password is refused.
These commands talk to ALPS the way the Settings page does. Like a browser, they send POST /session and POST /password/change with an Origin header equal to the address you reach ALPS at; ALPS answers 403 to a request whose Origin names another address. Passwords typed on a command line can be seen by other users of the machine and end up in your shell history, so use a test account.
U=https://webmail.example.com
curl -s -c jar -H "Origin: $U" -H 'Content-Type: application/json' \
-d '{"username":"ada@example.com","password":"old-secret"}' "$U/session"
# {"ok":true}
curl -s -b jar "$U/session" | jq .PasswordChangeEnabled
# true
curl -s -b jar -H "Origin: $U" -H 'Content-Type: application/json' \
-d '{"old_password":"old-secret","password":"n3w-secret"}' "$U/password/change"
# {"code":"password_changed","message":"Password successfully changed"}
curl -s -o /dev/null -w '%{http_code}\n' -H "Origin: $U" -H 'Content-Type: application/json' \
-d '{"username":"ada@example.com","password":"n3w-secret"}' "$U/session"
# 200The same sign-in with the old password answers 401 {"error":"Failed to login"}. PasswordChangeEnabled is false when the password plugin did not load, and Settings then has no Password tab.
The sign-ins and the change each count against the login limits. By default one address may make 5 attempts a minute, successful ones included, so running these checks a few times in a row ends in 429 (see Config-Rate-Limiting-and-WebAuthn).
Each of these came up in the lab. The second column is the log message, the msg of a JSON log line.
| What you see | In the log | Cause and fix |
|---|---|---|
Settings has no Password tab; PasswordChangeEnabled is false
|
password: enabled without an endpoint, command or routed backend; plugin disabled and Enabled plugin "password" did not load; ..., or no line about the plugin at all |
With those lines, no block names an endpoint or command: add one. With no line, [plugin.password] does not set enabled = true. |
Sign-in or change answers 429 Too many login attempts, please wait a minute; Settings shows "Too many attempts. Please try again later." |
You reached the per-address login limit while testing. Wait 60 seconds. | |
403 current_password_incorrect, "That password is not right." |
The current password typed is not the session's password. Nothing was sent to the backend. | |
200 reauthenticate, "Your password has been changed. Please sign in again." |
Failed to update session password: failed to verify new password with provider: ... |
The backend reported success, but the new password does not open the mailbox. In the lab, an endpoint that answered 204 without changing anything gave this. Check that the endpoint changes the password the mail server checks. |
400 password_change_rejected
|
Password change failed, status 400: {"error":"Either password or password_hash is required"} |
The block has no payload_mapping, so the body was {}. Add the mapping. |
400 password_change_rejected
|
Password change failed, status 422: |
The endpoint refused the change. Its status and body are in the log. |
502 password_service_unavailable
|
Password change failed, status 401: (or 403) |
The endpoint did not accept the block's token, or its username and password. |
502 password_service_unavailable
|
Password change HTTP request failed: ... connect: connection refused |
ALPS cannot reach the endpoint. Check the URL and the network path from the ALPS host. |
400 password_change_rejected
|
Password change command failed for <login>: /usr/local/libexec/alps/set-password: exit status 26 (stderr "curl: option --header: error encountered when reading a file ...") |
The ALPS user cannot read /etc/alps/sora-admin-header. Set it to root:alps, mode 0640. |
400 password_change_rejected
|
... exit status 22 (stderr "curl: (22) The requested URL returned error: 403\n{\"error\":\"Invalid API key\"}") |
The admin API key in the header file is wrong. |
400 password_change_rejected
|
... exit status 7 (stderr "curl: (7) Failed to connect to mailadmin.example.com port 443 after ... ms: Could not connect to server") |
The helper cannot reach the admin API. Check API in the helper and the network path from the ALPS host. |
400 password_change_rejected, after 10 seconds |
... exit status 28 (stderr "curl: (28) Operation timed out after 10009 milliseconds with 0 bytes received") |
The admin API did not answer within the helper's --max-time 10. |
504 password_service_unavailable, after 15 seconds |
... timed out after 15s: /usr/local/libexec/alps/set-password: signal: killed (stderr "") |
The program did not finish in 15 seconds. ALPS killed it and its child processes. Give the tool its own shorter timeout, as --max-time 10 does here. |
500 password_service_unavailable for one domain only |
Password change is misconfigured: the program "set-password" must be an absolute path |
That backend's own password block cannot run. Blocks under provider.multi are checked only when used. Fix the block and restart ALPS. |
ALPS does not start, and logs failed to load plugins: plugin.password: followed by one of these:
| Global block | Message |
|---|---|
command = ["set-password", "{email}"] |
the program "set-password" must be an absolute path |
{new_password} in an argument |
command arguments cannot carry {new_password}: passwords go on stdin |
| A program that does not exist | the program: stat /usr/local/libexec/alps/missing: no such file or directory |
command = "/usr/local/libexec/alps/set-password {email}", a string |
command must be a list: the program, then each of its arguments |
Both endpoint and command
|
set either endpoint or command, not both |
stdin without command
|
stdin is used only with command |
ALPS does not check the HTTP keys at startup. A mistyped endpoint shows up only at the first change.
- Config-Plugins: every password key, the startup checks, and what ALPS refuses before a program runs.
-
Config-Providers-and-SMTP:
[provider.multi]and the endpoints each backend can name. -
Sessions-and-Sign-in:
login_key, login cookies and linked accounts. - Config-Rate-Limiting-and-WebAuthn: the login limits that the current-password check counts against.
- PASSWORD_CHANGE.md: every answer and error code.
ALPS main a5443047 and sora 22ce317e, on 2026-11-12. The HTTP backend ran on a macOS 26.4 host. The command backend and the multi configuration ran in a Debian 13.7 (trixie) container under Docker 29.9.2, as the user alps, with curl 8.14.1 and jq 1.7. The requests were recorded with a Python 3.9.6 capture server. The Settings page was driven in Chromium 151.0.7922.34. A second run then followed the page from the start in a fresh Debian 13.7 container: the install commands, all three files, runuser, the curl checks and the browser steps. The lab used http:// and imap+insecure:// addresses on one machine. On this page they are replaced with production names.
Updated from the code at main 92ec0e11 without a rerun: the startup lines, which no longer name the plugins the enabled list leaves out but warn about an enabled plugin that did not load and about unknown keys; CalDAV and CardDAV without a server; and the linked-accounts line that a change logged without login_key, which ALPS now writes only for an account that has linked accounts, so its troubleshooting row is gone. The files on this page, and the first with the form block, were started once with a 92ec0e11 build on macOS, the command file with a stand-in program, to check that ALPS knows every key and to read the startup lines shown.
These pages describe ALPS as it is on the main branch; the reference documents live in the repository's docs/ directory.
Architecture
- Architecture Overview
- Mail Server Requirements
- Sessions and Sign-in
- Storage in IMAP METADATA
- Mail Providers
- Plugins
- Features
- Security Features
- Clustering
Tests
Configuration
- Configuration Overview
- Server
- Providers and SMTP
- TLS
- Cluster
- Rate Limiting and WebAuthn
- Logging and Cache
- Plugins
- Branding
Common Setups