Repository navigation
Setup Many Domains
This page sets up one ALPS for several mail domains that live on different mail servers, using the multi provider. The example has one domain on a server of its own whose users sign in with bare names, two domains that share a second server, and a default backend for every other domain. Each backend names its own submission, contacts, calendar, filter and password-change services. A last section adds domains whose server ALPS finds from a name template or from DNS SRV records. Every key is described on Config-Providers-and-SMTP and in CONFIGURATION.md, and the routing rules on Mail-Providers.
- One address,
https://webmail.example.com, where the users of every domain sign in. - At sign-in, ALPS takes the domain from the login, picks the backend for that domain, and signs in to the backend's IMAP server. The session stays with that backend until it ends.
- Sending, contacts, calendars, filters, the auto-reply and password changes go to the services named by the same backend. ALPS signs in to the submission, DAV and ManageSieve servers with the login and password the user typed. Users can also set up a CalDAV or CardDAV connection of their own in Settings; a separate DAV password there needs
[server] login_key, which this file leaves out (CDAV.md).
| User signs in as | Backend | Sending | Contacts and calendars | Filters | Password change | |
|---|---|---|---|---|---|---|
jdoe |
[provider.multi.domains."example.org"] |
imap.example.org |
smtp.example.org |
dav.example.org |
imap.example.org |
a program on the ALPS host |
ann@example.net, bo@brand.example
|
the [[provider.multi.routes]] entry |
mail.example.net |
mail.example.net |
dav.example.net |
mail.example.net |
the example.net admin API |
kim@example.com, or any other domain |
[provider.multi.default] |
mail.example.com |
mail.example.com |
dav.example.com |
mail.example.com |
the example.com admin API |
ALPS tries its rules in this order, and the first one that matches picks the backend:
- a
domainsentry for exactly that domain; - the first
routesentry whosedomainslist holds it; -
template, for domains intemplate_domains; - autodiscovery, for domains in
autodiscover_domains; -
default.
A login without @ is given default_domain before the rules are tried.
- ALPS installed, behind a reverse proxy on the same host that serves
https://webmail.example.com. The file below has the two[server]lines that setup needs; the proxy itself is on Setup-Behind-a-Reverse-Proxy, and the rest of a production setup on Running-in-Production. - For each backend: an IMAP server with METADATA, where ALPS keeps each account's settings (see Storage-in-IMAP-METADATA), and a submission server. Add a CalDAV/CardDAV server, a ManageSieve server and a way to change passwords if you want those features. What ALPS needs from the servers is on Mail-Server-Requirements.
- Every service of a backend must accept the login exactly as the user types it, with the same password. ALPS does not rewrite the login: a user who signs in as
jdoereaches IMAP, SMTP AUTH, the DAV server's Basic auth and ManageSieve asjdoe. In the lab, the bare-name backend's submission server saw the AUTH userdave, and the message's From wasdave@corp.test. - One test account on each backend, to check the setup with.
The whole configuration file, for example /etc/alps/config.toml. It holds the admin API tokens, so make it readable only by root and the user ALPS runs as, for example with chown root:alps and chmod 0640.
[server]
# ALPS behind a reverse proxy on this host that serves https://webmail.example.com.
addr = "127.0.0.1:1323"
trusted_proxies = ["127.0.0.1/32", "::1/128"]
[webauthn]
rpid = "webmail.example.com"
origins = ["https://webmail.example.com"]
[provider]
type = "multi"
[provider.multi]
# A login without "@" belongs to example.org.
default_domain = "example.org"
# example.org has a server of its own. Its users sign in with bare names.
[provider.multi.domains."example.org"]
server = "imaps://imap.example.org:993"
authserv_ids = ["mx.example.org"]
smtp = "smtps://smtp.example.org:465"
caldav = "https://dav.example.org/"
carddav = "https://dav.example.org/"
managesieve = "managesieve://imap.example.org:4190"
[provider.multi.domains."example.org".password]
command = ["/usr/local/sbin/set-mail-password", "{local}"]
# example.net and brand.example share a second server.
[[provider.multi.routes]]
domains = ["example.net", "brand.example"]
server = "imaps://mail.example.net:993"
authserv_ids = ["mx.example.net"]
smtp = "smtps://mail.example.net:465"
caldav = "https://dav.example.net/"
carddav = "https://dav.example.net/"
managesieve = "managesieve://mail.example.net:4190"
# Belongs to the route above.
[provider.multi.routes.password]
endpoint = "https://admin.example.net/admin/accounts/{email}"
method = "PUT"
auth_type = "bearer"
token = "REPLACE-WITH-THE-EXAMPLE-NET-ADMIN-TOKEN"
payload = "json"
[provider.multi.routes.password.payload_mapping]
password = "new_password"
# example.com and every other domain hosted on the main server.
[provider.multi.default]
server = "imaps://mail.example.com:993"
authserv_ids = ["mx.example.com"]
smtp = "smtps://mail.example.com:465"
caldav = "https://dav.example.com/"
carddav = "https://dav.example.com/"
managesieve = "managesieve://mail.example.com:4190"
# Required even though every backend names its own smtp.
[smtp]
server = "smtps://mail.example.com:465"
[plugin.base]
enabled = true
[plugin.caldav]
enabled = true
[plugin.carddav]
enabled = true
[plugin.managesieve]
enabled = true
[plugin.gpg]
enabled = true
[plugin.password]
enabled = true
# The main server's password API, for every backend without a block of its own:
# here, the default.
[plugin.password.options]
endpoint = "https://admin.example.com/admin/accounts/{email}"
method = "PUT"
auth_type = "bearer"
token = "REPLACE-WITH-THE-EXAMPLE-COM-ADMIN-TOKEN"
payload = "json"
[plugin.password.options.payload_mapping]
password = "new_password"What the file does:
-
trusted_proxieslists the proxy on this host, so that ALPS reads the browser's scheme and address from the proxy's forwarded headers. Without it, the origin check expectshttp://and every sign-in fromhttps://webmail.example.comis refused. List only your own proxies; see Setup-Behind-a-Reverse-Proxy. -
Bare names. With
default_domain = "example.org", the loginjdoeis routed as if it werejdoe@example.org, andjdoe@example.orgis the address its mail is sent from. The IMAP server still receivesjdoe. In the lab,GET /sessionfor a bare login answered"Username":"dave"and"Address":"dave@corp.test", and the account menu showeddave. -
Domain names in
domainskeys androuteslists are matched in any case. A backend'stypedefaults to"imap", which needsserver. A backend can also be a"maildir"one (see Setup-Maildir). -
Endpoints. Each backend names its
smtp,caldav,carddavandmanagesieveservers, with a scheme. A backend that leaves one out uses the global setting ([smtp] server, or the plugin's ownserver), never another backend's.managesieve://connects on port 4190 and requires STARTTLS before the password is sent. Write each DAV URL the way your DAV server publishes it: this file uses the server's root, and a server that serves DAV under a path needs that path in the URL. The per-backend DAV and ManageSieve keys are in CDAV.md and FILTERS.md. ALPS reads these URLs at startup without connecting to them: one it cannot use, such as a host and port without a scheme or a scheme of another service, stops startup (see Start it). A wrong host name, or a server that is down, shows only when a user of that backend uses the service. -
[smtp] serveris required even when every backend names its ownsmtp. Here it is the default's server. -
authserv_idsnames the receiving servers whoseAuthentication-Resultsfield ALPS trusts for the sender badge, the BIMI logo and the warning above a failed message, for that backend's mail. Each backend has its own MX, so each has its own list. -
Password blocks. A backend's
passwordsub-table replaces[plugin.password.options]as a whole,payload_mappingincluded. The default backend has no block here, so its users get[plugin.password.options].[plugin.password] enabled = trueswitches password change on for every backend.[provider.multi.routes.password]belongs to the[[provider.multi.routes]]written just above it, so a second route needs its own[[provider.multi.routes]]header with its ownpasswordsub-table under it. See PASSWORD_CHANGE.md. -
The password program for example.org gets
{local}, the part of the login before@(the whole login when there is none), and reads the new password on standard input.{email}is the login as typed, which isjdoe@example.orgwhen a user types the full address. The program path must be absolute. ALPS checks a backend's password block only when a user changes a password, not at startup: if the program is missing or not executable, Settings says "Password changes are temporarily unavailable. Please try again later." and the log saysPassword change is misconfigured. More on both kinds of password change: Setup-Password-Change. -
Plugins. The sections that set
enabled = trueform a list, and onlybase, which always loads, and the plugins on it load. A section that leavesenabledout is left out under such a list, so every plugin wanted hasenabled = true. Here no plugin has a globalserver.caldavandcarddavload all the same, because their sections setenabled = true.managesieveloads because the backends namemanagesieveendpoints: withenabled = trueand no backend naming one, it stays off, and startup logsManageSieve enabled without a server or routed endpoint; plugin disabled, thenEnabled plugin "managesieve" did not load; check its name and backend configuration.passwordloads because[plugin.password.options]names an endpoint; a backend'spasswordblock with anendpointorcommandwould be enough too. Give every backend its DAV and ManageSieve servers, or set a globalserverin[plugin.caldav],[plugin.carddav]and[plugin.managesieve]for the backends that name none. A backend with no DAV server of its own and no global one gives its users no Contacts, Calendar or Tasks in the top bar, only Settings > Calendar and Settings > Contacts, where each user can enter a server of their own. Until they do, requests to/contactsand/calendar/...answer 409carddav_not_configuredandcaldav_not_configured, and ALPS logs no error for them. A route that named no ManageSieve server got its filters from the global[plugin.managesieve] server.
Users of the bare-name domain sometimes type jdoe@example.org. ALPS routes that to the same backend and passes jdoe@example.org on as typed, so the servers must accept both forms. In the lab, Dovecot refused the full address (401) until its conf.d/auth.conf dropped the domain from the login:
# Dovecot 2.4
auth_username_format = %{user | username | lower}
After that, both dave and dave@corp.test signed in. The full-address session sent mail as dave@corp.test through the backend's submission server (AUTH user dave@corp.test, which that server accepted), and opened contacts, calendars and filters. Because the password program gets {local}, it names the same account for both forms. Dovecot itself is on Setup-Dovecot-and-Postfix.
For domains you do not want to list one by one, add a template, autodiscovery, or both. The keys go directly under [provider.multi], before the backend tables, so that section of the file then reads:
[provider.multi]
default_domain = "example.org"
template = "imaps://imap.%d:993"
template_domains = ["customer1.example", "customer2.example"]
autodiscover = true
autodiscover_domains = ["partner.example"]-
templatebuilds the IMAP server's URL from the login:%dis the domain and%uthe part before@.%n, the whole login, cannot form a host name: a template with it stops startup withmulti: template does not support %n (full login); use %u and %d. The checks these values must pass are on Config-Providers-and-SMTP. Forann@customer1.examplethe URL isimaps://imap.customer1.example:993. Only domains intemplate_domainsuse it; other domains go on to the next rule. -
autodiscoverlooks up the SRV records_imaps._tcp.<domain>, then_imap._tcp.<domain>, for the domains inautodiscover_domains. Each ALPS process keeps a server it found for 15 minutes, and a failed lookup for 30 seconds. - Both rules connect only to servers at public addresses. Put a server on a private network in a
domainsorroutesentry. - When one of these rules matches a domain and no usable server is found or reached, the sign-in ends with "The service could not sign you in just now. Please try again in a moment." (HTTP 503). ALPS does not fall back to
default: in the lab, a user of a template domain who also had an account on the default server still got 503. - These domains have no backend entry of their own, so their users send, use contacts, calendars and filters, and change passwords through the
defaultbackend's services, or the global settings wheredefaultnames none. Those servers must accept their logins. The servers these rules find also take noauthserv_ids. Where either matters, give the domain adomainsentry. -
"*"in either list matches every domain, sodefaultis then reached only by a bare login when nodefault_domainis set. In the lab, withtemplate_domains = ["*"], users ofexample.testandfourth.testwere sent to the template's server and got 503, while thedomainsentry and the route still matched first. The domain a user types then decides which server ALPS connects to, so use"*"only where no one but users you know can reach the sign-in page. - Set each list together with its rule. With
templateorautodiscoverset and its list empty, ALPS does not start:multi: template requires non-empty template_domains, ormulti: autodiscover requires non-empty autodiscover_domains. A template that does not make an IMAP URL with a scheme stops startup too, withmulti: invalid template: ....
alps -config /etc/alps/config.tomlALPS connects to no backend at startup, so hosts that are down do not stop it. The startup log names the global SMTP server, the sign-in limits, each loaded plugin and the listen address. The file above has no [logging] section, so the log is JSON on stdout. Started with ALPS at main 92ec0e11, the messages were these, apart from the listen address, which names your addr:
Configured SMTP server: smtps://mail.example.com:465
Rate limiting enabled: IP=5/min, 20/hour | User=5/15min, 10/hour | Global=100/sec
Loaded plugin "base"
Loaded plugin "gpg"
Loaded plugin "managesieve"
Loaded plugin "password"
Loaded plugin "caldav"
Loaded plugin "carddav"
Starting server on 127.0.0.1:1323
A key ALPS does not know shows up here as a warning, such as Unknown configuration key: provider.multi.default.smpt (ignored); the keys inside a backend's password table are not checked. Several mistakes stop startup before the log opens, with a line on standard error that starts with Error generating options: multi::
- a
multisection with nodomains,routes,template,autodiscoverordefault, aroutesentry withoutdomains, or a backend withoutserver; - a value of the wrong type in a backend, such as
smtp = 465, or apasswordthat is not a table; - a backend's
smtp,caldav,carddavormanagesieveURL that cannot be used, for examplemulti: invalid service for domain "example.org": caldav: unknown scheme for CalDAV server: "dav.example.org" ...forcaldav = "dav.example.org:8443"; - a template with
%n, a template that is not an IMAP URL, or a rule with an empty allowlist (see Template and autodiscovery).
The message names the domain, or the route by its index from 0, for example multi: route at index 0 has no domains. The full list, with the messages, is on Config-Providers-and-SMTP.
Do these steps with the test account of each backend, and one bare-name login:
-
Sign in at
https://webmail.example.com. On the backend's IMAP server, the log shows the login; Dovecot writesimap-login: Info: Logged in: user=<jdoe>. -
Send a message to the test account of another backend. It should arrive there, and a copy should be in the sender's Sent folder. The submission server's log shows the AUTH user and the sender address.
-
Open Contacts, Calendar and Tasks in the top bar, and Filters and Auto-reply under Settings (the menu at the top right). Each should open without an error, and the DAV and ManageSieve servers' logs should show the user.
-
Under Settings, Password, change the password. Sign out, sign in with the new password, and change it back.
-
Search the ALPS log for problems. With the file above the log is JSON on stdout: in the terminal when you start ALPS as above, or in the journal under the shipped systemd unit:
journalctl -u alps | grep -E 'sign-in failed|not configured|unusable server|Password change|session password'
With
[logging] outputset to a file (see Config-Logging-and-Cache), run the samegrepon that file.
The same checks without a browser, for one test account:
U=https://webmail.example.com
curl -s -c jar.txt -X POST "$U/session" -H "Origin: $U" -H 'Content-Type: application/json' \
-d '{"username":"jdoe","password":"the-test-password"}'
curl -s -b jar.txt "$U/session"
for p in /contacts /calendar/calendars /managesieve/script; do
curl -s -o /dev/null -w "$p %{http_code}\n" -b jar.txt "$U$p"
done
curl -s -b jar.txt -X DELETE "$U/session" -H "Origin: $U"
rm jar.txtThe Origin header must be the address you reach ALPS at. In the lab, for the bare-name user, the sign-in answered {"ok":true}, GET /session answered "Username":"dave" and "Address":"dave@corp.test", the three services answered 200, and the sign-out answered {"ok":true}.
Each sign-in, successful or not, and each password change counts against the limit of 5 a minute from one client address (ip_requests_per_minute, see Config-Rate-Limiting-and-WebAuthn). Checking many test accounts from one machine can reach it, so space the checks out. Over the limit, the sign-in answers 429 with "retry_after":60.
What the lab showed, one row per backend:
| Lab login | Backend | Sign-in | Sent through (AUTH user) | Contacts, calendars, filters | Password change |
|---|---|---|---|---|---|
dave and dave@corp.test
|
the domains entry, on Dovecot |
200 | its own submission server (dave, dave@corp.test) |
its own DAV server and Dovecot's ManageSieve, 200 | the program, then sign-in with the new password |
msd-bob@other.test, msd-erin@third.test
|
the route | 200 | the route's server (msd-bob@other.test) |
the route's servers, 200 | the route's API, then sign-in with the new password |
msd-anna@example.test, msd-zoe@fourth.test
|
default |
200 | the default's server | the default's servers, 200 | the global block, then sign-in with the new password |
msd-tom@tmpl.test |
template (host not in DNS) |
503 | |||
msd-al@auto.test |
autodiscovery (no SRV record) | 503 | |||
msd-anna (bare, but not on the bare-name server) |
the domains entry |
401 |
These are the problems met while building the lab.
| What you see | What the ALPS log says | What to do |
|---|---|---|
| A bare-name user who types the full address gets "Login failed. Please check your credentials." (401); the bare name works. |
sign-in failed with reason="bad credentials", and the server's answer in error
|
The server knows the account only by its bare name. Make it accept both forms, as in Users who type the full address. |
| A user gets "Login failed. Please check your credentials." with the right password. |
sign-in failed with reason="bad credentials", and the backend server's answer in error
|
ALPS routed the login to a backend that does not know it. In the lab, msd-anna typed without its domain was given default_domain and sent to the bare-name server. Check which rule the login's domain matches, and look for the login in that backend's own log. A login that no rule matches, with no default, gets the same answer, and its error reads authentication failed: no mail provider configured for domain "…". |
| Users of a template domain get "The service could not sign you in just now." (503). |
cannot resolve server address "imap.customer1.example": lookup imap.customer1.example, ending in no such host
|
The host name the template built has no DNS record. Add the record, or give the domain a domains entry. A name that resolves to a private or other restricted address is logged as access to restricted server address "…" is forbidden instead. |
| Users of an autodiscovery domain get the same 503. | autodiscovery failed for domain "partner.example": autodiscover: no valid external IMAP SRV records found for domain "partner.example" |
The domain has no _imaps._tcp or _imap._tcp record with a target at a public address. After you add one, the failed lookup stays cached for up to 30 seconds. |
| A password change is refused; the page says "Could not change your password. Please try again." | Password change command failed for jdoe@example.org: ... exit status 1 (stderr "no such user") |
The program was given {email}, the full login as typed, for a server that knows bare names. Pass {local}. |
| A password change fails; Settings says "Password changes are temporarily unavailable. Please try again later." | Password change is misconfigured: the program: stat /usr/local/sbin/set-mail-password: no such file or directory |
The backend's password block names a program that is not there or not executable. ALPS checks a backend's block only when it is used, so startup did not report it. Install the program or correct the path. |
| After a password change the page says "Your password has been changed. Please sign in again." |
Failed to update session password: failed to verify new password with provider:, then the reason, such as authentication failed: ...
|
The server accepted the change, but refused the new password, or could not be reached, when ALPS signed in with it right afterwards. In the lab, Dovecot re-read its passwd file at most once a second, and the check came within the same second. ALPS keeps the session open on the old password (PASSWORD_CHANGE.md); the user signs out and in again with the new one. A program that returns only once the server accepts the new password avoids this; the lab's waits one second. |
| One backend's users have no Contacts, Calendar or Tasks in the top bar, and Settings > Contacts and Settings > Calendar ask them for a server. | No error. The curl check under Check it works answers 409 for /contacts and /calendar/calendars, with carddav_not_configured and caldav_not_configured. |
That backend names no carddav and caldav, and there is no global server. Add them to the backend, or let the users enter servers of their own. |
Tested on 2026-11-12 with ALPS main a5443047 (the macOS arm64 build of the test lab), on macOS 26.4 (arm64):
- sora, main branch (the lab's build of 2026-11-12, reporting
sora version dev), as the default backend and, through logging TCP forwarders on other ports, as the route's server, with ALPS's own test stack (internal/e2estack) as its submission and CalDAV/CardDAV server; - Dovecot 2.4.5 (0cbb641) with Pigeonhole 2.4.5, image
dovecot/dovecot:latest, on Docker Engine 29.9.2, as the bare-name backend, with a second copy of the test stack as its submission and CalDAV/CardDAV server; - Python 3.13.7 for the forwarders, curl 8.7.1, Playwright 1.62.0 with HeadlessChrome 151.0.7922.34, and Go 1.26.5 for
go test ./provider/multi/.
The lab used corp.test for example.org, other.test and third.test for the route, and example.test and fourth.test for the default. It connected in plaintext on 127.0.0.1 (imap+insecure://, smtp+insecure://, http+insecure://, managesieve+insecure://); this page shows the same structure with TLS URLs. The file above was also started as written (with another listen address and a temp_dir) and routed a login at example.net to mail.example.net.
The page was then followed a second time, in a separate lab with other names: org.test for example.org on the same Dovecot image with a third copy of the test stack, net.test and brand.test for the route on sora through an nginx 1.31.8 (nginx:alpine) TCP and HTTP forwarder, and example.test and any.test for the default. The file was used as written with only addresses, the DAV paths of the lab's DAV server and credentials changed, and with the default sign-in limits. Bare-name and full-address sign-in, sending through each backend's submission server, contacts, calendars, filters and password change through each kind of block, the 503s for a template and an autodiscovery domain, the 401 for a bare login that is not on the bare-name server, the 429 after the fifth sign-in in a minute, and the startup errors above all behaved as this page describes. The file exactly as written (only addr changed) started with the messages shown under Start it and routed jdoe, ann@example.net and kim@example.com to imap.example.org, mail.example.net and mail.example.com.
A successful sign-in through template or autodiscovery needs a public server and was not run; those rules were checked against the code and the provider/multi tests.
Updated from the code at main 92ec0e11 without a rerun: the startup checks of each backend's URLs and value types, of %n and of the template and autodiscovery allowlists; what users of a backend without a DAV server get, now that they can enter a server of their own in Settings; the cause that a refused sign-in now logs; the message for a template host name that does not resolve; and the order of the startup lines. The file above, alone and with the template and autodiscovery keys, was started once with a 92ec0e11 build on macOS, 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