Skip to content

Setup Maildir

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

This page sets up ALPS on the server that holds the mail, so that ALPS reads and writes each user's Maildir directly and no IMAP server runs. It covers the passwd file that users sign in against, the maildir path pattern, the user ALPS runs as, what works and what does not with Maildir, and sending through an SMTP submission server. The provider's reference is MAILDIR.md, and Mail-Providers compares it with the imap and multi providers.

What you get

  • Users sign in with a username and password that ALPS checks against a Dovecot-style passwd file, /etc/dovecot/users on this page. ALPS reads the file again on every sign-in, so a new or changed entry works without a restart.
  • Each user's mail is a Maildir at /var/vmail/<domain>/<user>/Maildir. INBOX is that directory itself. Other folders are Maildir++ dot-directories: Sent is .Sent, and Archive.2024 is .Archive.2024.
  • When ALPS reads a folder, it moves new deliveries from new/ into cur/. Flags are the letters after :2, in the file name (S read, R answered, F starred, T deleted, D draft). Sent copies and drafts are written into the maildir. Renaming a folder renames its subfolders with it.
  • The message list is ordered by each message's Date header with the default sort (Received Date), and by the delivery time in the file name for a message without a valid Date.
  • What ALPS would keep in IMAP METADATA with an IMAP server goes into alps_store.json at the root of each user's maildir, with mode 0600. That covers the user's ALPS settings, OpenPGP keyring, linked accounts and security keys, and the CalDAV and CardDAV connections the user saves in Settings when those plugins are loaded (Storage-in-IMAP-METADATA). Back it up with the maildir.
  • Mail goes out through an SMTP submission server. ALPS signs in to it with the username and password the user signed in with.

What does not work with Maildir

These come from the provider's limitations.

  • New mail is not pushed (push_new_mail). Pages check for new mail at each user's check-mail interval instead.
  • There is no storage quota display, no folder sharing, and no server-side threading of the message list.
  • Search covers only the current folder, and there is no Search All. Within the folder, a query reads as it does on an IMAP account (SEARCH.md): prefixes such as from: and subject:, quoted phrases, several terms together, and the toolbar's Unread and Starred filters, which match the messages' flags. Encoded headers and MIME parts are decoded before they are compared. ALPS opens every message file of the folder for each search.
  • Sender authentication badges and BIMI logos are not shown.
  • Every folder is listed as subscribed, and subscribing or unsubscribing has no effect.
  • Tags ($label1 to $label5) and other keywords cannot be stored. A tag change is refused, and no message is changed.
  • Folder names are shown exactly as they are on disk, with no modified UTF-7 decoding.

Before you start

  • A Linux host where mail is delivered into Maildirs as one delivery user. This page uses user and group vmail (uid and gid 5000), maildirs under /var/vmail/<domain>/<user>/Maildir/, and Postfix's virtual delivery agent as the delivery agent. Any other delivery agent needs to write the same layout as the same user.
  • An SMTP submission server that accepts each user's ALPS username and password with AUTH PLAIN, and lets the user send as their address. ALPS needs [smtp] server whatever the provider (Mail-Server-Requirements, MAIL_SERVER.md). If that server authenticates through Dovecot's passwd-file, it can read the same /etc/dovecot/users. The scheme table below shows which entries both accept. A submission server on this host shows how to set up Postfix for it.
  • The ALPS binary at /usr/local/bin/alps and the systemd unit dist/alps.service from the repository (Running-in-Production).
  • A TLS reverse proxy on the same host that publishes ALPS at https://mail.example.com and forwards to 127.0.0.1:1323 (Setup-Behind-a-Reverse-Proxy).
  • One ALPS instance for these maildirs. ALPS serializes changes to alps_store.json only within one process, so do not let two instances serve the same maildirs. With [cluster] enabled, maildir accounts get no recovery codes for security keys (Clustering).
  • htpasswd (Debian package apache2-utils) to make bcrypt password hashes.

Configuration

The delivery user and directories

Run these as root:

groupadd -g 5000 vmail
useradd -u 5000 -g vmail -d /var/vmail -M -s /usr/sbin/nologin vmail
install -d -o vmail -g vmail -m 0750 /var/vmail
install -d -m 0755 /etc/dovecot /etc/alps

/etc/alps/config.toml

[server]
# Reached through the TLS reverse proxy on this host.
addr = "127.0.0.1:1323"
trusted_proxies = ["127.0.0.1/32", "::1/128"]
# Keeps sign-ins across restarts. Generate with: openssl rand -base64 32
login_key = "REPLACE-WITH-YOUR-KEY"

[webauthn]
rpid = "mail.example.com"
origins = ["https://mail.example.com"]

[provider]
type = "maildir"

[provider.maildir]
auth_passwd_file = "/etc/dovecot/users"
path = "/var/vmail/%d/%u/Maildir"

[smtp]
server = "smtps://mail.example.com:465"

It holds login_key, so install it readable only by root and the vmail group:

install -o root -g vmail -m 0640 config.toml /etc/alps/config.toml

What each part does:

  • auth_passwd_file is required. Without it ALPS stops at startup with no password file specified in config file for maildir provider ([provider.maildir] auth_passwd_file). Without the [provider.maildir] section it stops with missing TOML section [provider.maildir]. ALPS does not check at startup that the file exists or can be read. That shows up at the first sign-in (see Troubleshooting).
  • path is a pattern for each user's own maildir: the directory that holds cur, new, tmp, the dot-folders and alps_store.json. ALPS adds nothing to it. In the pattern, %u is the local part of the login, %d is the domain and %n is the whole login. For a login without @, %u and %n are the whole login and %d is empty. Use %u together with %d, or use %n, so that every account gets its own directory. A pattern without placeholders makes all accounts share one maildir and one settings file. When path is set, some logins cannot sign in: one with an empty local part, and one whose local part or domain contains / or \ or is . or ...
  • Without path, each user's maildir is <home>/Maildir, where <home> is field 6 of their passwd entry. Every entry then needs a home field. With the entries below, both forms give the same directory.
  • [smtp] server is required at startup, with the maildir provider too. Without it ALPS stops with no SMTP server specified in config file ([smtp] server). smtps:// uses implicit TLS, on port 465 when the URL names none. smtp:// requires STARTTLS, on port 587 when the URL names none. Certificates are checked against the host's system roots. Config-Providers-and-SMTP lists the [smtp] keys.
  • trusted_proxies lists only your own proxy, so that the rate limits and logs see each client's real address (Config-Server).
  • login_key is optional. Without it no session survives a restart and accounts cannot be linked (Sessions-and-Sign-in). Keep it secret.
  • [webauthn] names the host users reach ALPS at, so that security keys work. Without rpid, ALPS logs a warning at startup (Config-Rate-Limiting-and-WebAuthn).
  • [provider] timeout_sec has no effect on the maildir provider.
  • With no [plugin.*] section, the plugins that need nothing configured load: base and gpg (OpenPGP).
  • ALPS warns at startup about every key it does not know, for example Unknown configuration key: provider.maildir.paht (ignored), and keeps that key's default.

/etc/dovecot/users

# <user>:<password>:<uid>:<gid>:<gecos>:<home>:<shell>:<extra fields>
john@example.com:{CRYPT}$2y$12$puyzuBw7zxJnwO7Lm.rjVeWms/SWepzFi8B8oawdVD8LKMLmcyPiO:5000:5000::/var/vmail/example.com/john::

The hash above is of the password example-password. Make your own for every account (see Adding an account).

install -o root -g vmail -m 0640 users /etc/dovecot/users

ALPS only reads this file, so vmail needs read access and nothing more. Of each line ALPS uses the user (field 1), the password (field 2) and the home directory (field 6). It ignores the uid, the gid and any extra fields, and does every file operation as its own user. Blank lines and lines starting with # are skipped. The first field must match the login exactly as the user types it, including letter case: John@example.com does not sign in as john@example.com. Only the first line whose user matches is tried. The full format is in MAILDIR.md.

The password field starts with a scheme tag. Tags are case-insensitive. A field without a tag is compared as plain text, so give every entry its tag. These entries were tried in the lab, against ALPS and against Dovecot 2.4.5's passwd-file:

Password field ALPS Dovecot 2.4.5
{CRYPT} + bcrypt ($2y$; $2a$, $2b$, $2x$ also accepted) Yes. Recommended. Yes
{SHA256} or {SHA512} + hex digest (upper or lower case) Yes Yes
{MD5} + hex digest Yes No: refused as a weak scheme. Dovecot's name for this digest is {PLAIN-MD5}, a tag ALPS does not accept.
{PLAIN} + the password (cannot contain :) Yes Yes
{CRYPT} + SHA512-crypt ($6$) No Not tried
{BLF-CRYPT} + bcrypt No (works once the tag is {CRYPT}) Not tried
{SHA256} + base64 digest (what doveadm pw -s SHA256 prints) No Not tried
{SHA256.HEX} No Not tried

ALPS also refuses $5$, $1$ and DES hashes under {CRYPT}, the {SHA256-CRYPT} and {SHA512-CRYPT} tags, and salted and other schemes such as {SSHA*}, {SMD5}, {SHA}, {PLAIN-MD5}, {PBKDF2}, {ARGON2*} and {CRAM-MD5} (supported schemes). Unsalted digests are weak, so use bcrypt. An entry in a scheme ALPS refuses can never sign in. The user sees the same message as for a wrong password, and ALPS logs a warning that names the user and the scheme, such as alps/provider/maildir: warning: user "john@example.com" has unsupported password scheme "{CRYPT}" for a $6$ hash. The warning leaves out the hash and the password.

The systemd drop-in

The shipped unit runs ALPS as user alps, with the whole file system read-only (ProtectSystem=strict). ALPS creates folders and their tmp, new and cur with mode 0700, so it has to run as the user that owns the mail. It also needs write access to the maildirs. Create /etc/systemd/system/alps.service.d/maildir.conf:

[Service]
User=vmail
Group=vmail
ReadWritePaths=/var/vmail
install -m 0644 dist/alps.service /etc/systemd/system/alps.service
install -d -m 0755 /etc/systemd/system/alps.service.d
install -m 0644 maildir.conf /etc/systemd/system/alps.service.d/maildir.conf

ProtectHome=true in the unit hides /home. For maildirs under /home, set ProtectHome=false in the same drop-in (OPERATIONS.md).

Delivery with Postfix

This page used Postfix's virtual delivery agent, writing as uid and gid 5000 into the same paths:

postconf -e 'virtual_mailbox_domains = example.com' \
  'virtual_mailbox_base = /var/vmail' \
  'virtual_mailbox_maps = texthash:/etc/postfix/vmailbox' \
  'virtual_uid_maps = static:5000' \
  'virtual_gid_maps = static:5000'
systemctl restart postfix

/etc/postfix/vmailbox holds one line per account. The trailing / makes Postfix deliver in Maildir format:

john@example.com    example.com/john/Maildir/

Keep example.com out of Postfix's mydestination. On its first delivery, Postfix creates the user's maildir as vmail with mode 0700.

A submission server on this host

ALPS needs a submission server that takes the same usernames and passwords; the lab used one on another host. With Postfix on this host, the submission service from Setup-Dovecot-and-Postfix works with this page's accounts, with these differences:

  • Take from that page the submission entry in master.cf, smtpd_sender_login_maps with /etc/postfix/sender_login, and the myhostname and TLS lines of main.cf.
  • Postfix checks the passwords through Dovecot's authentication service. Install dovecot-core only, without dovecot-imapd, dovecot-lmtpd or dovecot-managesieved. In Dovecot's configuration, name this page's /etc/dovecot/users as the passwd-file passdb, and add the service auth listener for Postfix from that page. Dovecot's authentication runs as the dovecot user, which must be able to read the file as well as vmail; for example, add dovecot to the vmail group (usermod -aG vmail dovecot) and restart Dovecot.
  • Leave out the rest of that page: the IMAP, LMTP and Sieve parts, and virtual_transport = lmtp:unix:private/dovecot-lmtp, which would replace the virtual delivery agent this page uses.
  • In ALPS, set [smtp] server = "smtp://mail.example.com:587". smtp:// always upgrades with STARTTLS, which that submission entry requires.

This combination was not part of the lab run.

Adding an account

Run these as root in bash. They add the passwd entry and the Postfix line, and create the maildir with its usual folders as vmail:

read -rs -p 'Password: ' PW; echo
HASH=$(printf '%s\n' "$PW" | htpasswd -niBC 12 "" | tr -d ':\n'); unset PW
echo "john@example.com:{CRYPT}$HASH:5000:5000::/var/vmail/example.com/john::" >> /etc/dovecot/users
echo "john@example.com    example.com/john/Maildir/" >> /etc/postfix/vmailbox
postfix reload
runuser -u vmail -- sh -c 'umask 077; d=/var/vmail/example.com/john/Maildir
  for f in "" .Drafts .Sent .Trash .Junk .Archive; do mkdir -p "$d/$f/cur" "$d/$f/new" "$d/$f/tmp"; done'

Then give the account the same username and password on the submission server.

The runuser line creates the maildir and the usual folders, so that they show from the first sign-in. Without it, ALPS creates the maildir, mode 0700, the first time it saves the user's settings, and creates Sent or Drafts the first time it stores a sent copy or a draft. It files sent copies only into a folder named Sent, and drafts into one named Drafts, or Draft when there is no Drafts, in any letter case. A folder whose name only contains the word, such as Presentations, is never used.

Start it

systemctl daemon-reload
systemctl enable --now alps
systemctl status alps
journalctl -u alps -n 20

systemctl status alps should show active (running) and list the drop-in under Drop-In:. The journal should end with the line "msg":"Starting server on 127.0.0.1:1323", and should have no rpid is unset warning. A configuration error makes ALPS exit, and systemd starts it again every 5 seconds. The journal then shows the same startup error over and over.

Check it works

The checks below run on the server. They sign in to ALPS directly at 127.0.0.1:1323, and send the Origin header that ALPS's request check expects for that address.

  1. Deliver a message and see it land as vmail:

    printf 'From: postmaster@example.com\nTo: john@example.com\nSubject: First message\n\nHello.\n' \
      | /usr/sbin/sendmail -f postmaster@example.com john@example.com
    ls -l /var/vmail/example.com/john/Maildir/new

    Postfix delivers in the background, so run ls again if new/ is still empty. The file belongs to vmail and has mode 0600.

  2. Sign in and list the inbox:

    A=http://127.0.0.1:1323; J=$(mktemp)
    curl -s -c "$J" -b "$J" -H "Origin: $A" -H 'Content-Type: application/json' \
      -d '{"username":"john@example.com","password":"the password"}' "$A/session"
    curl -s -b "$J" "$A/mailboxes/INBOX/status"
    curl -s -b "$J" "$A/mailboxes/INBOX" | grep -o '"Subject":"[^"]*"'

    Expect {"ok":true}, then {"total":1,"unseen":1,...}, then "Subject":"First message". The file has now moved from new/ to cur/, with :2, added to its name.

  3. Save a setting:

    curl -s -b "$J" -X PUT -H "Origin: $A" -H 'Content-Type: application/json' \
      -d '{"signature":"John"}' "$A/settings"
    ls -l /var/vmail/example.com/john/Maildir/alps_store.json

    Expect {"ok":true} and a file with -rw------- vmail vmail.

  4. Send to an address you can read:

    curl -s -b "$J" -X POST -H "Origin: $A" -F to=you@example.org \
      -F subject='Hello from ALPS' -F text='Sent through the submission server.' "$A/messages"
    ls /var/vmail/example.com/john/Maildir/.Sent/cur
    rm -f "$J"

    Expect {"ok":true}, the message at the recipient, and a copy in .Sent/cur whose name ends in :2,S.

  5. In a browser, open https://mail.example.com, sign in, open the message in the Inbox, then click Compose, write a message and send it. The copy appears in Sent.

Troubleshooting

These are the cases met while building this setup. "The service could not sign you in" below is the login form's message for an answer of 503. In the journal, those sign-in failures appear as "msg":"sign-in failed" with "reason":"error", and the cause in the "error" field.

What you see Cause and fix
systemctl status alps shows status=217/USER. The journal says Failed at step USER spawning /usr/local/bin/alps. The unit runs as alps and that user does not exist. Install the drop-in with User=vmail.
"The service could not sign you in just now." Journal: failed to open passwd file: open /etc/dovecot/users: permission denied ALPS's user cannot read the passwd file. Make it root:vmail 0640 and run ALPS as vmail.
The same message. Journal: failed to check whether john@example.com signs in with 2FA: open /var/vmail/example.com/john/Maildir: permission denied ALPS runs as a user that cannot open the maildir. Run it as the user that owns the mail.
"Login failed. Please check your credentials." for a password you know is right. Journal: "reason":"bad credentials" When the journal also has has unsupported password scheme for that user, the entry uses a scheme ALPS refuses (see the table). Otherwise the login is typed in a different case from field 1, or there is no entry.
Saving settings fails with "An internal error occurred". Journal: failed to save settings: openat alps_store.json.tmp: read-only file system ReadWritePaths=/var/vmail is missing. In the same state, a send goes out but its copy cannot be stored: the composer says "Message sent, but no copy could be saved in Sent.", and the journal has Sent message cleanup: filing=… with the same read-only file system.
"The service could not sign you in just now." Journal: has no home directory in passwd file and no path pattern is configured path is unset and the entry has no field 6. Add the home field, or set path.
An account that signed in before now gets "The service could not sign you in just now." The journal has an ERROR line that ends in a JSON error, such as invalid character 'b' looking for beginning of object key string. alps_store.json is no longer valid JSON, for example after a hand edit. Restore it from a backup, or fix the JSON.
Sending fails with "Failed to send message: Forbidden" (the request log shows POST /messages with status 403). The submission server refused the user's username and password. Create the account there with the same password.
ALPS exits at start with no password file specified ..., missing TOML section [provider.maildir] or no SMTP server specified .... Add the key or section named.

Tested with

ALPS main a5443047, on 2026-11-12. Debian 13.7 (arm64) in a container, with systemd 257.13-1deb13u1 as PID 1 running the shipped unit and the drop-in above. Postfix 3.10.13 handled virtual delivery, apache2-utils 2.4.71-1deb13u1 made the hashes (htpasswd), and curl 8.14.1 ran the checks. The container ran under Docker Desktop 29.9.2 on macOS 26.4. Dovecot 2.4.5 (official container image) ran doveadm pw and the passwd-file check. The submission server was sora's, from the ALPS browser-test lab. The browser check used Chromium from Playwright 1.62.0. The lab used the domain example.test, and sent through that submission server over smtp+insecure:// on the lab network. Apart from those values, its files match the ones on this page. The page was then followed again, step by step, in a fresh container with the same package versions, with the browser check made through an nginx proxy in front of ALPS.

Updated from the code at main 92ec0e11 without a rerun: the CalDAV and CardDAV connections kept in alps_store.json, and the startup warning for unknown keys. The configuration file above was started once with a 92ec0e11 build on macOS, to check that ALPS knows every key and loads base and gpg.

Clone this wiki locally