Skip to content

Setup Many Domains

Dejan Strbac edited this page Nov 12, 2026 · 1 revision

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.

What you get

  • 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 Mail 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:

  1. a domains entry for exactly that domain;
  2. the first routes entry whose domains list holds it;
  3. template, for domains in template_domains;
  4. autodiscovery, for domains in autodiscover_domains;
  5. default.

A login without @ is given default_domain before the rules are tried.

Before you start

  • 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 jdoe reaches IMAP, SMTP AUTH, the DAV server's Basic auth and ManageSieve as jdoe. In the lab, the bare-name backend's submission server saw the AUTH user dave, and the message's From was dave@corp.test.
  • One test account on each backend, to check the setup with.

Configuration

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_proxies lists 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 expects http:// and every sign-in from https://webmail.example.com is refused. List only your own proxies; see Setup-Behind-a-Reverse-Proxy.
  • Bare names. With default_domain = "example.org", the login jdoe is routed as if it were jdoe@example.org, and jdoe@example.org is the address its mail is sent from. The IMAP server still receives jdoe. In the lab, GET /session for a bare login answered "Username":"dave" and "Address":"dave@corp.test", and the account menu showed dave.
  • Domain names in domains keys and routes lists are matched in any case. A backend's type defaults to "imap", which needs server. A backend can also be a "maildir" one (see Setup-Maildir).
  • Endpoints. Each backend names its smtp, caldav, carddav and managesieve servers, with a scheme. A backend that leaves one out uses the global setting ([smtp] server, or the plugin's own server), 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] server is required even when every backend names its own smtp. Here it is the default's server.
  • authserv_ids names the receiving servers whose Authentication-Results field 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 password sub-table replaces [plugin.password.options] as a whole, payload_mapping included. The default backend has no block here, so its users get [plugin.password.options]. [plugin.password] enabled = true switches 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 own password sub-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 is jdoe@example.org when 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 says Password change is misconfigured. More on both kinds of password change: Setup-Password-Change.
  • Plugins. The sections that set enabled = true form a list, and only base, which always loads, and the plugins on it load. A section that leaves enabled out is left out under such a list, so every plugin wanted has enabled = true. Here no plugin has a global server. caldav and carddav load all the same, because their sections set enabled = true. managesieve loads because the backends name managesieve endpoints: with enabled = true and no backend naming one, it stays off, and startup logs ManageSieve enabled without a server or routed endpoint; plugin disabled, then Enabled plugin "managesieve" did not load; check its name and backend configuration. password loads because [plugin.password.options] names an endpoint; a backend's password block with an endpoint or command would be enough too. Give every backend its DAV and ManageSieve servers, or set a global server in [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 /contacts and /calendar/... answer 409 carddav_not_configured and caldav_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 who type the full address

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.

Template and autodiscovery

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"]
  • template builds the IMAP server's URL from the login: %d is the domain and %u the part before @. %n, the whole login, cannot form a host name: a template with it stops startup with multi: template does not support %n (full login); use %u and %d. The checks these values must pass are on Config-Providers-and-SMTP. For ann@customer1.example the URL is imaps://imap.customer1.example:993. Only domains in template_domains use it; other domains go on to the next rule.
  • autodiscover looks up the SRV records _imaps._tcp.<domain>, then _imap._tcp.<domain>, for the domains in autodiscover_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 domains or routes entry.
  • 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 default backend's services, or the global settings where default names none. Those servers must accept their logins. The servers these rules find also take no authserv_ids. Where either matters, give the domain a domains entry.
  • "*" in either list matches every domain, so default is then reached only by a bare login when no default_domain is set. In the lab, with template_domains = ["*"], users of example.test and fourth.test were sent to the template's server and got 503, while the domains entry 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 template or autodiscover set and its list empty, ALPS does not start: multi: template requires non-empty template_domains, or multi: autodiscover requires non-empty autodiscover_domains. A template that does not make an IMAP URL with a scheme stops startup too, with multi: invalid template: ....

Start it

alps -config /etc/alps/config.toml

ALPS 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 multi section with no domains, routes, template, autodiscover or default, a routes entry without domains, or a backend without server;
  • a value of the wrong type in a backend, such as smtp = 465, or a password that is not a table;
  • a backend's smtp, caldav, carddav or managesieve URL that cannot be used, for example multi: invalid service for domain "example.org": caldav: unknown scheme for CalDAV server: "dav.example.org" ... for caldav = "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.

Check it works

Do these steps with the test account of each backend, and one bare-name login:

  1. Sign in at https://webmail.example.com. On the backend's IMAP server, the log shows the login; Dovecot writes imap-login: Info: Logged in: user=<jdoe>.

  2. 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.

  3. 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.

  4. Under Settings, Password, change the password. Sign out, sign in with the new password, and change it back.

  5. 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] output set to a file (see Config-Logging-and-Cache), run the same grep on 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.txt

The 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

Troubleshooting

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 with

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.

Clone this wiki locally