From 669bb7578e41323774f05851f307ae630b7dd6ae Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:30:42 +0000 Subject: [PATCH 1/9] Migrate the Dovecot configuration to 2.4 --- Makefile | 40 +++-- README.md | 167 ++++++++++++++++-- rootfs/etc/dovecot/conf.d/10-auth.conf | 2 +- rootfs/etc/dovecot/conf.d/10-logging.conf | 3 +- rootfs/etc/dovecot/conf.d/10-mail.conf | 21 ++- rootfs/etc/dovecot/conf.d/10-master.conf | 13 +- rootfs/etc/dovecot/conf.d/10-ssl.conf | 9 +- rootfs/etc/dovecot/conf.d/15-lda.conf | 1 - rootfs/etc/dovecot/conf.d/20-imap.conf | 19 +- rootfs/etc/dovecot/conf.d/20-lmtp.conf | 12 +- rootfs/etc/dovecot/conf.d/20-managesieve.conf | 21 +++ rootfs/etc/dovecot/conf.d/20-pop3.conf | 9 +- rootfs/etc/dovecot/conf.d/90-quota.conf | 113 ++++++++++-- rootfs/etc/dovecot/conf.d/90-sieve.conf | 116 +++++++----- rootfs/etc/dovecot/conf.d/auth-ldap.conf.ext | 90 +++++++++- rootfs/etc/dovecot/conf.d/auth-sql.conf.ext | 32 +++- rootfs/etc/dovecot/dovecot-dict-sql.conf.ext | 15 -- .../etc/dovecot/dovecot-ldap-master.conf.ext | 21 --- rootfs/etc/dovecot/dovecot-ldap.conf.ext | 31 ---- rootfs/etc/dovecot/dovecot-sql.conf.ext | 6 - rootfs/etc/dovecot/dovecot.conf | 3 + rootfs/usr/local/bin/quota-warning.sh | 4 +- rootfs/usr/local/bin/run.sh | 8 + rootfs/usr/local/bin/setup.sh | 23 ++- test/config/mariadb/struct.sql | 4 +- test/default.bats | 71 +++++++- test/ldap.bats | 4 +- test/reverse.bats | 15 +- .../external-to-tiny-quota-rejected.txt | 12 ++ test/traefik_acmev1.bats | 4 +- test/traefik_acmev2.bats | 4 +- 31 files changed, 676 insertions(+), 217 deletions(-) create mode 100644 rootfs/etc/dovecot/conf.d/20-managesieve.conf delete mode 100644 rootfs/etc/dovecot/dovecot-dict-sql.conf.ext delete mode 100644 rootfs/etc/dovecot/dovecot-ldap-master.conf.ext delete mode 100644 rootfs/etc/dovecot/dovecot-ldap.conf.ext delete mode 100644 rootfs/etc/dovecot/dovecot-sql.conf.ext create mode 100644 test/share/tests/email-templates/external-to-tiny-quota-rejected.txt diff --git a/Makefile b/Makefile index a71610f5..14b92032 100644 --- a/Makefile +++ b/Makefile @@ -129,11 +129,11 @@ init_ldap: init_openldap init_redis -e LDAP_GROUP_RESULT_MEMBER="member" \ -e LDAP_SENDER_FILTER="(&(|(mail=%s)(mailalias=%s))(objectClass=mailAccount))" \ -e LDAP_SENDER_ATTRIBUTE="mail" \ - -e LDAP_DOVECOT_USER_ATTRS="=home=/var/mail/vhosts/%d/%n/,=mail=maildir:/var/mail/vhosts/%d/%n/mail/,mailuserquota=quota_rule=*:bytes=%\$$" \ - -e LDAP_DOVECOT_USER_FILTER="(&(mail=%u)(objectClass=mailAccount))" \ - -e LDAP_DOVECOT_PASS_ATTRS="mail=user,userPassword=password" \ - -e LDAP_DOVECOT_PASS_FILTER="(&(mail=%u)(objectClass=mailAccount))" \ - -e LDAP_DOVECOT_ITERATE_ATTRS="mail=user" \ + -e LDAP_DOVECOT_USER_ATTRS="home=/var/mail/vhosts/%{user|domain}/%{user|username}/,mail_driver=maildir,mail_path=/var/mail/vhosts/%{user|domain}/%{user|username}/mail/,quota_storage_size=%{ldap:mailuserquota}" \ + -e LDAP_DOVECOT_USER_FILTER="(&(mail=%{user})(objectClass=mailAccount))" \ + -e LDAP_DOVECOT_PASS_ATTRS="user=%{ldap:mail}" \ + -e LDAP_DOVECOT_PASS_FILTER="(&(mail=%{user})(objectClass=mailAccount))" \ + -e LDAP_DOVECOT_ITERATE_ATTRS="user=%{ldap:mail}" \ -e LDAP_DOVECOT_ITERATE_FILTER="(objectClass=mailAccount)" \ -e DKIM_SELECTOR="mail20190101" \ -e VMAILUID=`id -u` \ @@ -206,15 +206,15 @@ init_ldap2: init_openldap init_redis -e LDAP_ALIAS_ATTRIBUTE="mail" \ -e LDAP_SENDER_FILTER="(&(|(mail=%s)(mailalias=%s))(objectClass=mailAccount))" \ -e LDAP_SENDER_ATTRIBUTE="mail" \ - -e LDAP_DOVECOT_USER_ATTRS="=home=/var/mail/vhosts/%d/%n/,=mail=maildir:/var/mail/vhosts/%d/%n/mail/,mailuserquota=quota_rule=*:bytes=%\$$" \ - -e LDAP_DOVECOT_USER_FILTER="(&(mail=%u)(objectClass=mailAccount))" \ - -e LDAP_DOVECOT_PASS_ATTRS="mail=user,userPassword=password" \ - -e LDAP_DOVECOT_PASS_FILTER="(&(mail=%u)(objectClass=mailAccount))" \ - -e LDAP_DOVECOT_ITERATE_ATTRS="mail=user" \ + -e LDAP_DOVECOT_USER_ATTRS="home=/var/mail/vhosts/%{user|domain}/%{user|username}/,mail_driver=maildir,mail_path=/var/mail/vhosts/%{user|domain}/%{user|username}/mail/,quota_storage_size=%{ldap:mailuserquota}" \ + -e LDAP_DOVECOT_USER_FILTER="(&(mail=%{user})(objectClass=mailAccount))" \ + -e LDAP_DOVECOT_PASS_ATTRS="user=%{ldap:mail}" \ + -e LDAP_DOVECOT_PASS_FILTER="(&(mail=%{user})(objectClass=mailAccount))" \ + -e LDAP_DOVECOT_ITERATE_ATTRS="user=%{ldap:mail}" \ -e LDAP_DOVECOT_ITERATE_FILTER="(objectClass=mailAccount)" \ -e LDAP_MASTER_USER_ENABLED=true \ - -e LDAP_DOVECOT_MASTER_PASS_ATTRS="mail=user,userPassword=password" \ - -e LDAP_DOVECOT_MASTER_PASS_FILTER="(&(mail=%u)(st=%{login_user})(objectClass=mailAccount))" \ + -e LDAP_DOVECOT_MASTER_PASS_ATTRS="user=%{ldap:mail}" \ + -e LDAP_DOVECOT_MASTER_PASS_FILTER="(&(mail=%{user})(st=%{login_user})(objectClass=mailAccount))" \ -e DISABLE_CLAMAV=true \ -e DISABLE_SIEVE=true \ -e DISABLE_SIGNING=true \ @@ -453,6 +453,22 @@ fixtures_default: @echo "Waiting for the postfix queue to drain (mailserver_default) ..." @timeout $(WAIT_TIMEOUT) sh -c 'until [ -z "$$(docker exec mailserver_default find /var/spool/postfix/incoming /var/spool/postfix/active /var/spool/postfix/maildrop -type f 2>/dev/null)" ]; do sleep 2; done' \ || { echo "TIMEOUT: postfix queue did not drain"; docker exec mailserver_default postqueue -p; exit 1; } + + # Push tiny.quota@domain.tld (100 KB) over its limit; the body is generated + # because it only has to be big. Grace admits it and fires the quota warnings. + docker exec mailserver_default /bin/sh -c "{ echo 'HELO mx.gmail.com'; echo 'MAIL FROM: user@gmail.com'; echo 'RCPT TO: tiny.quota@domain.tld'; echo 'DATA'; echo 'From: Docker Mail Server '; echo 'To: Tiny Quota '; echo 'Date: Sat, 28 Nov 2016 12:00:00 +0200'; echo 'Subject: Quota Filler'; echo 'Test:external-to-tiny-quota-filler'; echo ''; yes AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | head -4000; echo '.'; echo 'QUIT'; } > /tmp/quota-filler.txt" + docker exec mailserver_default /bin/sh -c "python3 /tmp/tests/smtp-send.py 0.0.0.0 25 /tmp/quota-filler.txt" + # Wait until postfix has delivered everything it accepted + @echo "Waiting for the postfix queue to drain (mailserver_default) ..." + @timeout $(WAIT_TIMEOUT) sh -c 'until [ -z "$$(docker exec mailserver_default find /var/spool/postfix/incoming /var/spool/postfix/active /var/spool/postfix/maildrop -type f 2>/dev/null)" ]; do sleep 2; done' \ + || { echo "TIMEOUT: postfix queue did not drain"; docker exec mailserver_default postqueue -p; exit 1; } + + # Now that the mailbox is over quota, the next message has to be refused. + docker exec mailserver_default /bin/sh -c "python3 /tmp/tests/smtp-send.py 0.0.0.0 25 /tmp/tests/email-templates/external-to-tiny-quota-rejected.txt" + # Wait until postfix has delivered everything it accepted + @echo "Waiting for the postfix queue to drain (mailserver_default) ..." + @timeout $(WAIT_TIMEOUT) sh -c 'until [ -z "$$(docker exec mailserver_default find /var/spool/postfix/incoming /var/spool/postfix/active /var/spool/postfix/maildrop -type f 2>/dev/null)" ]; do sleep 2; done' \ + || { echo "TIMEOUT: postfix queue did not drain"; docker exec mailserver_default postqueue -p; exit 1; } run_default: ./test/bats/bin/bats test/default.bats stop_default: diff --git a/README.md b/README.md index 868951b2..981d1b67 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,11 @@ Thank you very much. - [Postfix blacklist](#postfix-blacklist) - [Email client settings](#email-client-settings) - [Components](#components) + - [Migration to Dovecot 2.4 (LDAP lookups)](#migration-to-dovecot-24-ldap-lookups) + - [The mapping runs the other way round](#the-mapping-runs-the-other-way-round) + - [Password mapping and authentication binds](#password-mapping-and-authentication-binds) + - [A converted example](#a-converted-example) + - [Checking the result](#checking-the-result) - [Migration from Traefik 1 to 2](#migration-from-traefik-1-to-2) - [Migration from 1.0 to 1.1](#migration-from-10-to-11) - [Migration from hardware/mailserver to mailserver2/mailserver](#migration-from-hardwaremailserver-to-mailserver2mailserver) @@ -118,6 +123,8 @@ Please check, if your system meets the following minimum requirements: **NOTE**: If you are having problems with clamd using too much memory on startup, set `CLAMD_MEMORY_FIX` to `true`. See [the ClamAV documentation](https://docs.clamav.net/manual/Installing/Docker.html#what-can-i-do-to-minimize-ram-usage) for details. +**NOTE**: Dovecot needs more open files than the soft limit of 1024 that containers commonly inherit. The image raises the soft limit to the hard limit at startup, so nothing is needed unless the hard limit is low as well, in which case Dovecot logs `fd limit (ulimit -n) is lower than required under max. load` at startup: set `ulimits: nofile: 65536` (or higher) on the mailserver service. +

Back to table of contents :arrow_up_small:

### Prerequisites @@ -845,23 +852,31 @@ The **LDAP_XXX_ATTRIBUTE** specifies which attribute of the found LDAP objects w The optional **FORWARD** and **GROUP** lookups are technically identical to the **ALIAS** lookup and could be used interchangeably but are intended for additional alias/group/forward lookups. So you can use aliases using an alias field in your user objects. Forwards as source and destination mapping fields in forwarding objects and group address and group member emails in group objects. But you can also use them in different ways to suit your system. -Then you also have to provide the lookups for Dovecot. These will probably be similar to your postfix lookups but may and will differ in some cases. The variables necessary are **LDAP_DOVECOT_USER_ATTRS**, **LDAP_DOVECOT_USER_FILTER**, **LDAP_DOVECOT_PASS_ATTRS**, **LDAP_DOVECOT_PASS_FILTER**, **LDAP_DOVECOT_ITERATE_ATTRS**, **LDAP_DOVECOT_ITERATE_FILTER**. They correspond directly to the Dovecot variables of the same name. While the user and pass attributes and filters are required, the iterate attributes and filters are not. For more detailed information please look at https://wiki.dovecot.org/AuthDatabase/LDAP/Userdb. Note that multiple attributes may be required per query and must be provided in a different form than for postfix! Here are some examples: +Then you also have to provide the lookups for Dovecot. These will probably be similar to your postfix lookups but may and will differ in some cases. The variables necessary are **LDAP_DOVECOT_USER_ATTRS**, **LDAP_DOVECOT_USER_FILTER**, **LDAP_DOVECOT_PASS_ATTRS**, **LDAP_DOVECOT_PASS_FILTER**, **LDAP_DOVECOT_ITERATE_ATTRS**, **LDAP_DOVECOT_ITERATE_FILTER**. They correspond directly to the Dovecot variables of the same name. While the user and pass attributes and filters are required, the iterate attributes and filters are not. For more detailed information please look at https://doc.dovecot.org/2.4.1/core/config/auth/databases/ldap.html. Note that multiple attributes may be required per query and must be provided in a different form than for postfix! Here are some examples: ``` -LDAP_DOVECOT_USER_ATTRS="=home=/var/mail/vhosts/%d/%n/,=mail=maildir:/var/mail/vhosts/%d/%n/mail/,mailuserquota=quota=quota_rule=*:bytes=%\$$" -LDAP_DOVECOT_USER_FILTER="(&(mail=%u)(objectClass=mailAccount))" -LDAP_DOVECOT_PASS_ATTRS="mail=user,userPassword=password" -LDAP_DOVECOT_PASS_FILTER="(&(mail=%u)(objectClass=mailAccount))" -LDAP_DOVECOT_ITERATE_ATTRS="mail=user" +LDAP_DOVECOT_USER_ATTRS="home=/var/mail/vhosts/%{user|domain}/%{user|username}/,mail_driver=maildir,mail_path=/var/mail/vhosts/%{user|domain}/%{user|username}/mail/,quota_storage_size=%{ldap:mailuserquota}" +LDAP_DOVECOT_USER_FILTER="(&(mail=%{user})(objectClass=mailAccount))" +LDAP_DOVECOT_PASS_ATTRS="user=%{ldap:mail}" +LDAP_DOVECOT_PASS_FILTER="(&(mail=%{user})(objectClass=mailAccount))" +LDAP_DOVECOT_ITERATE_ATTRS="user=%{ldap:mail}" LDAP_DOVECOT_ITERATE_FILTER="(objectClass=mailAccount)" ``` -This mailserver also supports the user of master users that are allowed to log into other users mailboxes using their own password. This can be used e.g. for shared mailboxes or external IMAP services that should be able to connect to all inboxes via IMAP while not knowing the users passwords. To enable the use of master users set **LDAP_MASTER_USER_ENABLED** to *true*. With **LDAP_MASTER_USER_SEPARATOR** the separator can be specified (default is \*). So you can log in with the username `normaluser@yoursystem.com*masteruser@yoursystem.com` or `normaluser*masteruser` if you only use usernames as logins. The password then has to be the password of the master user. **LDAP_MASTER_USER_SEARCH_BASE**, **LDAP_MASTER_USER_SEARCH_SCOPE**, **LDAP_DOVECOT_MASTER_USER_ATTRS** and **LDAP_DOVECOT_MASTER_USER_FILTER** work analogous to the Dovecot user lookups. For more detailed documentation please look at https://wiki.dovecot.org/Authentication/MasterUsers . Note that `%u` is the master user name in this case and `%{login_user}` can be used to get the user name of the user to be logged in. +:warning: These values are Dovecot 2.4 syntax, which the image has shipped since it +moved to Debian 13. If you are coming from an earlier version your existing values +need converting first, and nothing will tell you that you forgot - see +[Migration to Dovecot 2.4 (LDAP lookups)](#migration-to-dovecot-24-ldap-lookups). +The fields that can be returned are listed under +https://doc.dovecot.org/2.4.1/core/config/auth/userdb.html and +https://doc.dovecot.org/2.4.1/core/config/auth/passdb.html + +This mailserver also supports the user of master users that are allowed to log into other users mailboxes using their own password. This can be used e.g. for shared mailboxes or external IMAP services that should be able to connect to all inboxes via IMAP while not knowing the users passwords. To enable the use of master users set **LDAP_MASTER_USER_ENABLED** to *true*. With **LDAP_MASTER_USER_SEPARATOR** the separator can be specified (default is \*). So you can log in with the username `normaluser@yoursystem.com*masteruser@yoursystem.com` or `normaluser*masteruser` if you only use usernames as logins. The password then has to be the password of the master user. **LDAP_MASTER_USER_SEARCH_BASE**, **LDAP_MASTER_USER_SEARCH_SCOPE**, **LDAP_DOVECOT_MASTER_USER_ATTRS** and **LDAP_DOVECOT_MASTER_USER_FILTER** work analogous to the Dovecot user lookups. For more detailed documentation please look at https://doc.dovecot.org/2.4.1/core/config/auth/master_users.html . Note that `%{user}` is the master user name in this case and `%{login_user}` can be used to get the user name of the user to be logged in. ``` LDAP_MASTER_USER_ENABLED=true -LDAP_DOVECOT_MASTER_PASS_ATTRS="mail=user,userPassword=password" -LDAP_DOVECOT_MASTER_PASS_FILTER="(&(mail=%u)(st=%{login_user})(objectClass=mailAccount))" +LDAP_DOVECOT_MASTER_PASS_ATTRS="user=%{ldap:mail}" +LDAP_DOVECOT_MASTER_PASS_FILTER="(&(mail=%{user})(st=%{login_user})(objectClass=mailAccount))" ```

Back to table of contents :arrow_up_small:

@@ -1080,14 +1095,24 @@ protocol imap { # /mnt/docker/mail/dovecot/conf.d/90-quota.conf -plugin { - - quota_rule2 = Trash:storage=+200M - quota_exceeded_message = You have exceeded your mailbox quota. - +namespace inbox { + mailbox Trash { + quota_storage_extra = 200M + } } + +quota_exceeded_message = You have exceeded your mailbox quota. ``` +:warning: Dovecot 2.4 removed the `plugin { }` block: plugin settings are now +written at the top level, and many were renamed (`quota_rule` is split into +`quota_storage_size` / `quota_storage_extra`, `mail_location` into +`mail_driver` / `mail_path`, and `quota_grace` into `quota_storage_grace`, which +no longer accepts a percentage). Existing overrides in +`/mnt/docker/mail/dovecot/conf.d` need converting, as Dovecot 2.4 refuses to +start on an unknown setting. See +https://doc.dovecot.org/2.4.1/installation/upgrade/2.3-to-2.4.html +

Back to table of contents :arrow_up_small:

### Postfix blacklist @@ -1138,6 +1163,120 @@ NOQUEUE: reject: 554 5.7.1 : Sender address rejected: Acces

Back to table of contents :arrow_up_small:

+### Migration to Dovecot 2.4 (LDAP lookups) + +Dovecot 2.4 came in with the move to Debian 13, and it changed how the LDAP +lookups are written. The contents of **LDAP_DOVECOT_USER_ATTRS**, +**LDAP_DOVECOT_PASS_ATTRS**, **LDAP_DOVECOT_ITERATE_ATTRS**, +**LDAP_DOVECOT_MASTER_PASS_ATTRS** and their matching **_FILTER** variables are +passed straight through to Dovecot, so a directory-backed setup has to convert +them before upgrading. Setups using MySQL or PostgreSQL are not affected: those +queries are built inside the image. + +Convert them *before* you upgrade, because nothing reports the problem. Dovecot +2.4 does not reject a 2.3 style entry - it accepts it as a field whose name it +does not recognise - so the container starts, `doveconf` is happy, and the only +symptom is that logins and deliveries fail because the lookup no longer returns a +home directory, a mail location or a quota. + +#### The mapping runs the other way round + +A 2.3 entry read `ldapAttribute=dovecotField`, with a leading `=` marking a fixed +value and `%$` standing for the value of the attribute. A 2.4 entry reads +`dovecotField=value`, and an LDAP attribute is referenced inside the value as +`%{ldap:attribute}`: + +| 2.3 | 2.4 | +| --- | --- | +| `mail=user` | `user=%{ldap:mail}` | +| `=home=/var/mail/vhosts/%d/%n/` | `home=/var/mail/vhosts/%{user\|domain}/%{user\|username}/` | +| `=mail=maildir:/var/mail/vhosts/%d/%n/mail/` | `mail_driver=maildir,mail_path=/var/mail/vhosts/%{user\|domain}/%{user\|username}/mail/` | +| `mailuserquota=quota_rule=*:bytes=%$` | `quota_storage_size=%{ldap:mailuserquota}` | + +Three things to watch for: + +* The `%` variables changed everywhere, in filters as well as values: `%u` becomes + `%{user}`, `%d` becomes `%{user|domain}` and `%n` becomes `%{user|username}`. + `%{login_user}` in a master user filter is unchanged. +* `mail` is gone as a field and is replaced by the pair `mail_driver` and + `mail_path`, so one 2.3 entry becomes two. +* The list is comma separated - **ATTRS** is expanded into Dovecot's `fields { }` + block (`iterate_fields { }` for the iterate variant) - so an individual value + cannot contain a comma. + +The LDAP filters themselves keep the same syntax; only the `%` variables in them +change. + +#### Password mapping and authentication binds + +With **LDAP_BIND** set to *true* (the default) Dovecot authenticates by binding to +the directory as the user and never needs the password hash. Most servers do not +return `userPassword` to a search, and where 2.3 quietly ignored that, 2.4 logs an +error for every single login: + +``` +Error: ldap: auth_passdb_post settings: Failed to parse configuration: Failed to +expand passdb_fields/password setting variables: ldap: No such attribute 'userpassword' +``` + +Authentication still succeeds, but the log fills up. Drop the password entry from +**LDAP_DOVECOT_PASS_ATTRS** and **LDAP_DOVECOT_MASTER_PASS_ATTRS** unless you run +with **LDAP_BIND** set to *false*, where the hash is fetched and compared locally +and the mapping is still required. + +#### A converted example + +Before, on Dovecot 2.3: + +``` +LDAP_DOVECOT_USER_ATTRS="=home=/var/mail/vhosts/%d/%n/,=mail=maildir:/var/mail/vhosts/%d/%n/mail/,mailuserquota=quota_rule=*:bytes=%$" +LDAP_DOVECOT_USER_FILTER="(&(mail=%u)(objectClass=mailAccount))" +LDAP_DOVECOT_PASS_ATTRS="mail=user,userPassword=password" +LDAP_DOVECOT_PASS_FILTER="(&(mail=%u)(objectClass=mailAccount))" +LDAP_DOVECOT_ITERATE_ATTRS="mail=user" +LDAP_DOVECOT_ITERATE_FILTER="(objectClass=mailAccount)" +``` + +After, on Dovecot 2.4: + +``` +LDAP_DOVECOT_USER_ATTRS="home=/var/mail/vhosts/%{user|domain}/%{user|username}/,mail_driver=maildir,mail_path=/var/mail/vhosts/%{user|domain}/%{user|username}/mail/,quota_storage_size=%{ldap:mailuserquota}" +LDAP_DOVECOT_USER_FILTER="(&(mail=%{user})(objectClass=mailAccount))" +LDAP_DOVECOT_PASS_ATTRS="user=%{ldap:mail}" +LDAP_DOVECOT_PASS_FILTER="(&(mail=%{user})(objectClass=mailAccount))" +LDAP_DOVECOT_ITERATE_ATTRS="user=%{ldap:mail}" +LDAP_DOVECOT_ITERATE_FILTER="(objectClass=mailAccount)" +``` + +And for master users: + +``` +LDAP_DOVECOT_MASTER_PASS_ATTRS="user=%{ldap:mail}" +LDAP_DOVECOT_MASTER_PASS_FILTER="(&(mail=%{user})(st=%{login_user})(objectClass=mailAccount))" +``` + +#### Checking the result + +Since a half-converted value fails silently, ask Dovecot what it actually gets +back for one of your accounts once the new container is up: + +``` +docker exec -ti mailserver doveadm user john.doe@domain.tld +``` + +The output has to list `home`, `mail_driver` and `mail_path`, plus +`quota_storage_size` if you map a quota. Anything missing is an entry in +**LDAP_DOVECOT_USER_ATTRS** that has not been converted - and note that a field +with an unrecognised name is simply absent here rather than reported. Then confirm +that a login works and leaves nothing behind in the error log: + +``` +docker exec -ti mailserver doveadm auth test john.doe@domain.tld yourpassword +docker exec -ti mailserver cat /var/log/mail.err +``` + +

Back to table of contents :arrow_up_small:

+ ### Migration from Traefik 1 to 2 Migrating from traefik 1 to 2 does not change any mailserver images that are used, only the traefik image version. You do need to update traefik labels on all containers as per [docker-compose.sample.yml](docker-compose.sample.yml). When you do this, either delete your acme.json and let traefik request the new certificate, or use the [tool](https://github.com/traefik/traefik-migration-tool) to convert from v1 to v2. diff --git a/rootfs/etc/dovecot/conf.d/10-auth.conf b/rootfs/etc/dovecot/conf.d/10-auth.conf index 80b7faf2..a72ee5a8 100644 --- a/rootfs/etc/dovecot/conf.d/10-auth.conf +++ b/rootfs/etc/dovecot/conf.d/10-auth.conf @@ -1,4 +1,4 @@ -disable_plaintext_auth = yes +auth_allow_cleartext = no auth_mechanisms = plain login {{ if eq .DBDRIVER "ldap" }} diff --git a/rootfs/etc/dovecot/conf.d/10-logging.conf b/rootfs/etc/dovecot/conf.d/10-logging.conf index 6a7c37b0..73c7271c 100644 --- a/rootfs/etc/dovecot/conf.d/10-logging.conf +++ b/rootfs/etc/dovecot/conf.d/10-logging.conf @@ -6,4 +6,5 @@ syslog_facility = mail #auth_debug = yes #auth_debug_passwords = yes #mail_debug = yes -#verbose_ssl = yes +# 2.3 had verbose_ssl for this +#log_debug = category=ssl diff --git a/rootfs/etc/dovecot/conf.d/10-mail.conf b/rootfs/etc/dovecot/conf.d/10-mail.conf index f1d8eb82..5758d261 100644 --- a/rootfs/etc/dovecot/conf.d/10-mail.conf +++ b/rootfs/etc/dovecot/conf.d/10-mail.conf @@ -1,6 +1,6 @@ -mail_plugins = $mail_plugins quota zlib -mail_location = maildir:/var/mail/vhosts/%d/%n/{{ .VMAIL_SUBDIR }} -maildir_stat_dirs=yes +mail_driver = maildir +mail_path = /var/mail/vhosts/%{user | domain}/%{user | username}/{{ .VMAIL_SUBDIR }} +maildir_stat_dirs = yes namespace inbox { inbox = yes @@ -14,7 +14,14 @@ last_valid_uid = {{ .VMAILUID }} mail_privileged_group = vmail -plugin { - zlib_save_level = 6 # 1..9 - zlib_save = gz # or bz2, If this config entry missing, compression is disabled. -} \ No newline at end of file +mail_plugins { + quota = yes + mail_compress = yes +{{ if ne .DBDRIVER "ldap" }} + quota_clone = yes +{{ end }} +} + +# Compression of saved mails (replaces the 2.3 zlib plugin) +mail_compress_write_method = gz +compress_gz_level = 6 diff --git a/rootfs/etc/dovecot/conf.d/10-master.conf b/rootfs/etc/dovecot/conf.d/10-master.conf index 23cd6249..c5d76b0a 100644 --- a/rootfs/etc/dovecot/conf.d/10-master.conf +++ b/rootfs/etc/dovecot/conf.d/10-master.conf @@ -9,7 +9,7 @@ service imap-login { ssl = yes } - service_count = 1 + service_restart_request_count = 1 process_min_avail = DOVECOT_MIN_PROCESS process_limit = DOVECOT_MAX_PROCESS @@ -34,7 +34,7 @@ service pop3-login { ssl = yes } - service_count = 1 + service_restart_request_count = 1 process_min_avail = DOVECOT_MIN_PROCESS process_limit = DOVECOT_MAX_PROCESS @@ -74,6 +74,15 @@ service auth { user = dovecot + # See DOVECOT_AUTH_CLIENT_LIMIT in setup.sh + client_limit = DOVECOT_AUTH_CLIENT_LIMIT + +} + +service anvil { + + client_limit = DOVECOT_AUTH_CLIENT_LIMIT + } service auth-worker { diff --git a/rootfs/etc/dovecot/conf.d/10-ssl.conf b/rootfs/etc/dovecot/conf.d/10-ssl.conf index e1e6e5ec..c7e086f6 100644 --- a/rootfs/etc/dovecot/conf.d/10-ssl.conf +++ b/rootfs/etc/dovecot/conf.d/10-ssl.conf @@ -1,6 +1,7 @@ ssl = required -ssl_cert = <{{ .FULLCHAIN }} -ssl_key = <{{ .KEYFILE }} +ssl_server_cert_file = {{ .FULLCHAIN }} +ssl_server_key_file = {{ .KEYFILE }} ssl_min_protocol = TLSv1.2 -ssl_cipher_list = EECDH+AES:EDH+AES+aRSA:!DH # PFS only and disallow non-ECC based DH algorithms -ssl_prefer_server_ciphers = yes +# PFS only and disallow non-ECC based DH algorithms +ssl_cipher_list = EECDH+AES:EDH+AES+aRSA:!DH +ssl_server_prefer_ciphers = server diff --git a/rootfs/etc/dovecot/conf.d/15-lda.conf b/rootfs/etc/dovecot/conf.d/15-lda.conf index 85ac7d68..2dcd059c 100644 --- a/rootfs/etc/dovecot/conf.d/15-lda.conf +++ b/rootfs/etc/dovecot/conf.d/15-lda.conf @@ -3,6 +3,5 @@ recipient_delimiter = {{ .RECIPIENT_DELIMITER }} protocol lda { postmaster_address = postmaster@{{ .DOMAIN }} - mail_plugins = $mail_plugins } diff --git a/rootfs/etc/dovecot/conf.d/20-imap.conf b/rootfs/etc/dovecot/conf.d/20-imap.conf index c389e97e..b2ecc7cd 100644 --- a/rootfs/etc/dovecot/conf.d/20-imap.conf +++ b/rootfs/etc/dovecot/conf.d/20-imap.conf @@ -2,8 +2,23 @@ imap_idle_notify_interval = 4 mins protocol imap { - mail_plugins = $mail_plugins imap_quota imap_sieve imap_zlib - imap_client_workarounds = tb-extra-mailbox-sep + # A protocol scoped mail_plugins block replaces the global one instead of + # adding to it (2.3 wrote "mail_plugins = $mail_plugins imap_quota ..."), + # so the global plugins have to be repeated here. + mail_plugins { + quota = yes + mail_compress = yes +{{ if ne .DBDRIVER "ldap" }} + quota_clone = yes +{{ end }} + imap_quota = yes + imap_sieve = yes + } + + imap_client_workarounds { + tb-extra-mailbox-sep = yes + } + mail_max_userip_connections = 20 } diff --git a/rootfs/etc/dovecot/conf.d/20-lmtp.conf b/rootfs/etc/dovecot/conf.d/20-lmtp.conf index fa6360c0..981c031a 100644 --- a/rootfs/etc/dovecot/conf.d/20-lmtp.conf +++ b/rootfs/etc/dovecot/conf.d/20-lmtp.conf @@ -1,6 +1,16 @@ protocol lmtp { postmaster_address = postmaster@{{ .DOMAIN }} - mail_plugins = $mail_plugins sieve + + # See 20-imap.conf: this block replaces the global mail_plugins rather than + # extending it. + mail_plugins { + quota = yes + mail_compress = yes +{{ if ne .DBDRIVER "ldap" }} + quota_clone = yes +{{ end }} + sieve = yes + } } diff --git a/rootfs/etc/dovecot/conf.d/20-managesieve.conf b/rootfs/etc/dovecot/conf.d/20-managesieve.conf new file mode 100644 index 00000000..0b21740d --- /dev/null +++ b/rootfs/etc/dovecot/conf.d/20-managesieve.conf @@ -0,0 +1,21 @@ +# Debian ships this file with "protocols { sieve = yes }" enabled, which would +# make DISABLE_SIEVE ineffective: conf.d is included after the protocols line +# in dovecot.conf, so it would switch ManageSieve back on. Ship our own copy +# with only the service definitions and let setup.sh add sieve to the +# protocols line when ManageSieve is enabled. + +service managesieve-login { + + inet_listener sieve { + port = 4190 + } + +} + +service managesieve { + +} + +protocol sieve { + +} diff --git a/rootfs/etc/dovecot/conf.d/20-pop3.conf b/rootfs/etc/dovecot/conf.d/20-pop3.conf index aa0c868b..5b3617d9 100644 --- a/rootfs/etc/dovecot/conf.d/20-pop3.conf +++ b/rootfs/etc/dovecot/conf.d/20-pop3.conf @@ -1,8 +1,11 @@ protocol pop3 { - mail_plugins = $mail_plugins - pop3_client_workarounds = outlook-no-nuls oe-ns-eoh - pop3_uidl_format = %08Xu%08Xv + pop3_client_workarounds { + outlook-no-nuls = yes + oe-ns-eoh = yes + } + + pop3_uidl_format = %{uid | hex(8)}%{uidvalidity | hex(8)} mail_max_userip_connections = 20 } diff --git a/rootfs/etc/dovecot/conf.d/90-quota.conf b/rootfs/etc/dovecot/conf.d/90-quota.conf index 1cefc47e..709a1c0e 100644 --- a/rootfs/etc/dovecot/conf.d/90-quota.conf +++ b/rootfs/etc/dovecot/conf.d/90-quota.conf @@ -1,23 +1,108 @@ -plugin { -{{ if ne .DBDRIVER "ldap" }} - quota = dict:User quota::proxy::sqlquota +quota "User quota" { + +{{ if eq .DBDRIVER "ldap" }} + driver = maildir {{ else }} - quota = maildir + driver = count {{ end }} - quota_rule = *:storage=5GB - quota_rule2 = Trash:storage=+100M - quota_grace = 10%% - quota_exceeded_message = The quota of this mailbox is exhausted. Contact your system administrator. - quota_warning = storage=90%% quota-warning 90 %u - quota_warning2 = storage=80%% quota-warning 80 %u - quota_warning3 = storage=70%% quota-warning 70 %u - quota_warning4 = storage=60%% quota-warning 60 %u + + # 2.3 expressed this as a percentage (quota_grace = 10%%); 2.4 only accepts + # an absolute size, so this is dovecot's own default rather than 10% of the + # quota. + quota_storage_grace = 10 M + + warning warn-90 { + quota_storage_percentage = 90 + execute quota-warning { + args = 90 %{user} + } + } + + warning warn-80 { + quota_storage_percentage = 80 + execute quota-warning { + args = 80 %{user} + } + } + + warning warn-70 { + quota_storage_percentage = 70 + execute quota-warning { + args = 70 %{user} + } + } + + warning warn-60 { + quota_storage_percentage = 60 + execute quota-warning { + args = 60 %{user} + } + } } +# Default limit for users the userdb gives no quota for (2.3: quota_rule = +# *:storage=5GB). This has to sit outside the quota filter above: a limit set +# inside the filter outranks the per-user value coming from the userdb, which +# would pin every user to this default. +quota_storage_size = 5G + +quota_exceeded_message = The quota of this mailbox is exhausted. Contact your system administrator. + +# Was: quota_rule2 = Trash:storage=+100M +namespace inbox { + mailbox Trash { + quota_storage_extra = 100M + } +} + {{ if ne .DBDRIVER "ldap" }} -dict { - sqlquota = {{ .DBDRIVER }}:/etc/dovecot/dovecot-dict-sql.conf.ext +# The 2.3 configuration used the dict quota driver so that usage was stored in +# the postfixadmin quota2 table. 2.4 removed that driver, so usage is now +# tracked by the count driver and mirrored into quota2 by quota_clone. +quota_clone { + dict proxy { + name = sqlquota + } +} + +dict_server { + dict sqlquota { + driver = sql + sql_driver = {{ .DBDRIVER }} +{{ if eq .DBDRIVER "pgsql" }} + pgsql {{ .DBHOST }} { + pgsql_parameters { + port = {{ .DBPORT }} + dbname = {{ .DBNAME }} + user = {{ .DBUSER }} + password = {{ .DBPASS }} + } + } +{{ else }} + mysql {{ .DBHOST }} { + mysql_port = {{ .DBPORT }} + mysql_dbname = {{ .DBNAME }} + mysql_user = {{ .DBUSER }} + mysql_password = {{ .DBPASS }} + } +{{ end }} + dict_map priv/quota/storage { + sql_table = quota2 + username_field = username + value_field bytes { + type = uint + } + } + + dict_map priv/quota/messages { + sql_table = quota2 + username_field = username + value_field messages { + type = uint + } + } + } } {{ end }} diff --git a/rootfs/etc/dovecot/conf.d/90-sieve.conf b/rootfs/etc/dovecot/conf.d/90-sieve.conf index dedb4aa5..4fc4606a 100644 --- a/rootfs/etc/dovecot/conf.d/90-sieve.conf +++ b/rootfs/etc/dovecot/conf.d/90-sieve.conf @@ -1,42 +1,78 @@ -plugin { - - sieve_plugins = sieve_imapsieve sieve_extprograms - - sieve = /var/mail/vhosts/%d/%n/.dovecot.sieve - sieve_default = /var/mail/sieve/default.sieve - sieve_after = /var/mail/sieve/default.sieve - sieve_dir = /var/mail/vhosts/%d/%n/sieve - sieve_global = /var/mail/sieve - sieve_max_script_size = 1M - sieve_quota_max_scripts = 0 - sieve_quota_max_storage = 0 - sieve_pipe_bin_dir = /etc/dovecot/sieve - sieve_global_extensions = +vnd.dovecot.pipe - - # Pigeonhole Sieve Vacation Extension fix - # -------------------------------------------------------------- - # More info : - # https://github.com/hardware/mailserver/issues/227 - # http://wiki2.dovecot.org/Pigeonhole/Sieve/Extensions/Vacation - # https://tools.ietf.org/html/rfc5230#section-4.5 - - # Set the enveloppe address to allow rspamd to guess which - # domain key the message should be signed with - sieve_vacation_send_from_recipient = yes - - # Send vacation replies even for aliases - sieve_vacation_dont_check_recipient = yes - # -------------------------------------------------------------- - - # From elsewhere to Spam folder - imapsieve_mailbox1_name = Spam - imapsieve_mailbox1_causes = COPY - imapsieve_mailbox1_before = file:/etc/dovecot/sieve/report-spam.sieve - - # From Spam folder to elsewhere - imapsieve_mailbox2_name = * - imapsieve_mailbox2_from = Spam - imapsieve_mailbox2_causes = COPY - imapsieve_mailbox2_before = file:/etc/dovecot/sieve/report-ham.sieve +sieve_plugins { + sieve_imapsieve = yes + sieve_extprograms = yes +} + +# Personal scripts (was: sieve = ... / sieve_dir = ...) +sieve_script personal { + type = personal + driver = file + path = /var/mail/vhosts/%{user | domain}/%{user | username}/sieve + active_path = /var/mail/vhosts/%{user | domain}/%{user | username}/.dovecot.sieve +} + +# Used when the user has no script of their own (was: sieve_default) +sieve_script default { + type = default + driver = file + path = /var/mail/sieve/default.sieve +} + +# Run after the user's own script (was: sieve_after) +sieve_script after { + type = after + driver = file + path = /var/mail/sieve/default.sieve +} + +# Location of scripts included with :global (was: sieve_global) +sieve_script global { + type = global + driver = file + path = /var/mail/sieve +} + +sieve_max_script_size = 1M +sieve_quota_script_count = 0 +sieve_quota_storage_size = 0 +sieve_pipe_bin_dir = /etc/dovecot/sieve + +sieve_global_extensions { + vnd.dovecot.pipe = yes +} + +# Pigeonhole Sieve Vacation Extension fix +# -------------------------------------------------------------- +# More info : +# https://github.com/hardware/mailserver/issues/227 +# http://wiki2.dovecot.org/Pigeonhole/Sieve/Extensions/Vacation +# https://tools.ietf.org/html/rfc5230#section-4.5 + +# Set the enveloppe address to allow rspamd to guess which +# domain key the message should be signed with +sieve_vacation_send_from_recipient = yes + +# Send vacation replies even for aliases +# (was: sieve_vacation_dont_check_recipient = yes) +sieve_vacation_check_recipient = no +# -------------------------------------------------------------- + +# From elsewhere to Spam folder +namespace inbox { + mailbox Spam { + sieve_script report-spam { + type = before + cause = copy + path = /etc/dovecot/sieve/report-spam.sieve + } + } +} +# From Spam folder to elsewhere +imapsieve_from Spam { + sieve_script report-ham { + type = before + cause = copy + path = /etc/dovecot/sieve/report-ham.sieve + } } diff --git a/rootfs/etc/dovecot/conf.d/auth-ldap.conf.ext b/rootfs/etc/dovecot/conf.d/auth-ldap.conf.ext index 94affd23..1afc926e 100644 --- a/rootfs/etc/dovecot/conf.d/auth-ldap.conf.ext +++ b/rootfs/etc/dovecot/conf.d/auth-ldap.conf.ext @@ -1,18 +1,90 @@ +ldap_uris = ldap://{{ .DBHOST }}:{{ .DBPORT }} +ldap_version = 3 +ldap_deref = never + +{{ if eq .LDAP_BIND "true" }} +ldap_auth_dn = {{ .LDAP_BIND_DN }} +ldap_auth_dn_password = {{ .LDAP_BIND_PW }} +{{ end }} + {{ if eq .LDAP_MASTER_USER_ENABLED "true" }} auth_master_user_separator = {{ .LDAP_MASTER_USER_SEPARATOR }} -passdb { + +passdb ldap_master { driver = ldap - args = /etc/dovecot/dovecot-ldap-master.conf.ext master = yes - pass = yes + # 2.3 spelled this "pass = yes" + result_success = continue + + ldap_base = {{ .LDAP_MASTER_USER_SEARCH_BASE }} +{{ if eq .LDAP_MASTER_USER_SEARCH_SCOPE "sub" }} + ldap_scope = subtree +{{ else if eq .LDAP_MASTER_USER_SEARCH_SCOPE "base" }} + ldap_scope = base +{{ else if eq .LDAP_MASTER_USER_SEARCH_SCOPE "one" }} + ldap_scope = onelevel +{{ end }} +{{ if eq .LDAP_BIND "true" }} + ldap_bind = yes +{{ else }} + ldap_bind = no +{{ end }} + ldap_filter = {{ .LDAP_DOVECOT_MASTER_PASS_FILTER }} +{{ if ne .LDAP_DOVECOT_MASTER_PASS_ATTRS "" }} + + fields { + {{ .LDAP_DOVECOT_MASTER_PASS_ATTRS | replace "," "\n " }} + } +{{ end }} } {{ end }} -passdb { - driver = ldap - args = /etc/dovecot/dovecot-ldap.conf.ext + +passdb ldap { + ldap_base = {{ .LDAP_MAILBOX_SEARCH_BASE }} +{{ if eq .LDAP_MAILBOX_SEARCH_SCOPE "sub" }} + ldap_scope = subtree +{{ else if eq .LDAP_MAILBOX_SEARCH_SCOPE "base" }} + ldap_scope = base +{{ else if eq .LDAP_MAILBOX_SEARCH_SCOPE "one" }} + ldap_scope = onelevel +{{ end }} +{{ if eq .LDAP_BIND "true" }} + ldap_bind = yes +{{ else }} + ldap_bind = no +{{ end }} + ldap_filter = {{ .LDAP_DOVECOT_PASS_FILTER }} +{{ if ne .LDAP_DOVECOT_PASS_ATTRS "" }} + + fields { + {{ .LDAP_DOVECOT_PASS_ATTRS | replace "," "\n " }} + } +{{ end }} } -userdb { - driver = ldap - args = /etc/dovecot/dovecot-ldap.conf.ext +userdb ldap { + ldap_base = {{ .LDAP_MAILBOX_SEARCH_BASE }} +{{ if eq .LDAP_MAILBOX_SEARCH_SCOPE "sub" }} + ldap_scope = subtree +{{ else if eq .LDAP_MAILBOX_SEARCH_SCOPE "base" }} + ldap_scope = base +{{ else if eq .LDAP_MAILBOX_SEARCH_SCOPE "one" }} + ldap_scope = onelevel +{{ end }} + ldap_filter = {{ .LDAP_DOVECOT_USER_FILTER }} +{{ if ne .LDAP_DOVECOT_ITERATE_FILTER "" }} + ldap_iterate_filter = {{ .LDAP_DOVECOT_ITERATE_FILTER }} +{{ end }} +{{ if ne .LDAP_DOVECOT_USER_ATTRS "" }} + + fields { + {{ .LDAP_DOVECOT_USER_ATTRS | replace "," "\n " }} + } +{{ end }} +{{ if ne .LDAP_DOVECOT_ITERATE_ATTRS "" }} + + iterate_fields { + {{ .LDAP_DOVECOT_ITERATE_ATTRS | replace "," "\n " }} + } +{{ end }} } diff --git a/rootfs/etc/dovecot/conf.d/auth-sql.conf.ext b/rootfs/etc/dovecot/conf.d/auth-sql.conf.ext index 2aeaac63..407f576a 100644 --- a/rootfs/etc/dovecot/conf.d/auth-sql.conf.ext +++ b/rootfs/etc/dovecot/conf.d/auth-sql.conf.ext @@ -1,9 +1,29 @@ -passdb { - driver = sql - args = /etc/dovecot/dovecot-sql.conf.ext +sql_driver = {{ .DBDRIVER }} + +{{ if eq .DBDRIVER "pgsql" }} +pgsql {{ .DBHOST }} { + pgsql_parameters { + port = {{ .DBPORT }} + dbname = {{ .DBNAME }} + user = {{ .DBUSER }} + password = {{ .DBPASS }} + } +} +{{ else }} +mysql {{ .DBHOST }} { + mysql_port = {{ .DBPORT }} + mysql_dbname = {{ .DBNAME }} + mysql_user = {{ .DBUSER }} + mysql_password = {{ .DBPASS }} +} +{{ end }} + +passdb sql { + default_password_scheme = {{ .PASSWORD_SCHEME }} + query = SELECT password, quota AS userdb_quota_storage_size FROM mailbox WHERE username = '%{user}' AND active = TRUE } -userdb { - driver = sql - args = /etc/dovecot/dovecot-sql.conf.ext +userdb sql { + query = SELECT CONCAT('/var/mail/vhosts/',maildir) AS home, 'maildir' AS mail_driver, CONCAT('/var/mail/vhosts/',maildir,'{{ .VMAIL_SUBDIR }}') AS mail_path, {{ .VMAILUID }} AS uid, {{ .VMAILGID }} AS gid, quota AS quota_storage_size FROM mailbox WHERE username = '%{user}' AND active = TRUE + iterate_query = SELECT username AS user FROM mailbox } diff --git a/rootfs/etc/dovecot/dovecot-dict-sql.conf.ext b/rootfs/etc/dovecot/dovecot-dict-sql.conf.ext deleted file mode 100644 index 685fd39a..00000000 --- a/rootfs/etc/dovecot/dovecot-dict-sql.conf.ext +++ /dev/null @@ -1,15 +0,0 @@ -connect = "host={{ .DBHOST }} port={{ .DBPORT }} dbname={{ .DBNAME }} user={{ .DBUSER }} password={{ .DBPASS }}" - -map { - pattern = priv/quota/storage - table = quota2 - username_field = username - value_field = bytes -} - -map { - pattern = priv/quota/messages - table = quota2 - username_field = username - value_field = messages -} diff --git a/rootfs/etc/dovecot/dovecot-ldap-master.conf.ext b/rootfs/etc/dovecot/dovecot-ldap-master.conf.ext deleted file mode 100644 index e3d6f454..00000000 --- a/rootfs/etc/dovecot/dovecot-ldap-master.conf.ext +++ /dev/null @@ -1,21 +0,0 @@ -hosts = {{ .DBHOST }}:{{ .DBPORT }} -ldap_version = 3 -{{ if eq .LDAP_BIND "true" }} -auth_bind = yes -dn = {{ .LDAP_BIND_DN }} -dnpass = {{ .LDAP_BIND_PW }} -{{ else }} -auth_bind = no -{{ end }} -base = {{ .LDAP_MASTER_USER_SEARCH_BASE }} -{{ if eq .LDAP_MASTER_USER_SEARCH_SCOPE "sub" }} -scope = subtree -{{ else if eq .LDAP_MASTER_USER_SEARCH_SCOPE "base" }} -scope = base -{{ else if eq .LDAP_MASTER_USER_SEARCH_SCOPE "one" }} -scope = onelevel -{{ end }} -deref = never - -pass_attrs = {{ .LDAP_DOVECOT_MASTER_PASS_ATTRS }} -pass_filter = {{ .LDAP_DOVECOT_MASTER_PASS_FILTER }} diff --git a/rootfs/etc/dovecot/dovecot-ldap.conf.ext b/rootfs/etc/dovecot/dovecot-ldap.conf.ext deleted file mode 100644 index ed49460e..00000000 --- a/rootfs/etc/dovecot/dovecot-ldap.conf.ext +++ /dev/null @@ -1,31 +0,0 @@ -hosts = {{ .DBHOST }}:{{ .DBPORT }} -ldap_version = 3 -{{ if eq .LDAP_BIND "true" }} -auth_bind = yes -dn = {{ .LDAP_BIND_DN }} -dnpass = {{ .LDAP_BIND_PW }} -{{ else }} -auth_bind = no -{{ end }} -base = {{ .LDAP_MAILBOX_SEARCH_BASE }} -{{ if eq .LDAP_MAILBOX_SEARCH_SCOPE "sub" }} -scope = subtree -{{ else if eq .LDAP_MAILBOX_SEARCH_SCOPE "base" }} -scope = base -{{ else if eq .LDAP_MAILBOX_SEARCH_SCOPE "one" }} -scope = onelevel -{{ end }} -deref = never - -user_attrs = {{ .LDAP_DOVECOT_USER_ATTRS }} -user_filter = {{ .LDAP_DOVECOT_USER_FILTER }} - -pass_attrs = {{ .LDAP_DOVECOT_PASS_ATTRS }} -pass_filter = {{ .LDAP_DOVECOT_PASS_FILTER }} - -{{ if ne .LDAP_DOVECOT_ITERATE_ATTRS "" }} -iterate_attrs = {{ .LDAP_DOVECOT_ITERATE_ATTRS }} -{{ end }} -{{ if ne .LDAP_DOVECOT_ITERATE_FILTER "" }} -iterate_filter = {{ .LDAP_DOVECOT_ITERATE_FILTER }} -{{ end }} diff --git a/rootfs/etc/dovecot/dovecot-sql.conf.ext b/rootfs/etc/dovecot/dovecot-sql.conf.ext deleted file mode 100644 index 9b5a3029..00000000 --- a/rootfs/etc/dovecot/dovecot-sql.conf.ext +++ /dev/null @@ -1,6 +0,0 @@ -driver = {{ .DBDRIVER }} -connect = "host={{ .DBHOST }} port={{ .DBPORT }} dbname={{ .DBNAME }} user={{ .DBUSER }} password={{ .DBPASS }}" -default_pass_scheme = {{ .PASSWORD_SCHEME }} -iterate_query = SELECT username AS user FROM mailbox -user_query = SELECT CONCAT('/var/mail/vhosts/',maildir) AS home, CONCAT('maildir:/var/mail/vhosts/',maildir,'{{ .VMAIL_SUBDIR }}') AS mail, {{ .VMAILUID }} AS uid, {{ .VMAILGID }} AS gid, CONCAT('*:bytes=',quota) AS quota_rule FROM mailbox WHERE username = '%u' AND active = TRUE -password_query = SELECT password, CONCAT('*:bytes=',quota) AS userdb_quota_rule FROM mailbox WHERE username = '%u' AND active = TRUE diff --git a/rootfs/etc/dovecot/dovecot.conf b/rootfs/etc/dovecot/dovecot.conf index 0d70d5a8..18001c24 100644 --- a/rootfs/etc/dovecot/dovecot.conf +++ b/rootfs/etc/dovecot/dovecot.conf @@ -1,3 +1,6 @@ +dovecot_config_version = 2.4.0 +dovecot_storage_version = 2.4.0 + !include_try /usr/share/dovecot/protocols.d/*.protocol protocols = imap lmtp listen = *, [::] diff --git a/rootfs/usr/local/bin/quota-warning.sh b/rootfs/usr/local/bin/quota-warning.sh index b4f33442..3faae28f 100755 --- a/rootfs/usr/local/bin/quota-warning.sh +++ b/rootfs/usr/local/bin/quota-warning.sh @@ -3,7 +3,9 @@ PERCENT=$1 USER=$2 -cat << EOF | /usr/lib/dovecot/dovecot-lda -d $USER -o "plugin/quota=dict:User quota::noenforcing:proxy::sqlquota" +# The mailbox is at or over its limit by definition, so quota is not enforced +# for this delivery. +cat << EOF | /usr/lib/dovecot/dovecot-lda -d $USER -o "quota_enforce=no" From: postmaster@{{ .DOMAIN }} Subject: Mailbox quota warning diff --git a/rootfs/usr/local/bin/run.sh b/rootfs/usr/local/bin/run.sh index 91d228a3..2fb7195f 100755 --- a/rootfs/usr/local/bin/run.sh +++ b/rootfs/usr/local/bin/run.sh @@ -151,5 +151,13 @@ fi # LAUNCH ALL SERVICES # --------------------------------------------------------------------------------------------- +# Dovecot sizes service auth { client_limit } from the core count and needs +# that many open files. Containers commonly inherit a soft limit of 1024; +# raising it up to the hard limit needs no privilege and every service +# inherits it from here. +hard=$(ulimit -Hn) +[ "$hard" = unlimited ] && hard=1048576 +ulimit -Sn "$hard" || echo "[WARN] Could not raise the open files soft limit to $hard" + echo "[INFO] Starting services" exec s6-svscan /services diff --git a/rootfs/usr/local/bin/setup.sh b/rootfs/usr/local/bin/setup.sh index 1e55801a..6880a394 100644 --- a/rootfs/usr/local/bin/setup.sh +++ b/rootfs/usr/local/bin/setup.sh @@ -237,13 +237,11 @@ _envtpl /etc/postfix/sql/virtual-alias-domain-catchall-maps.cf _envtpl /etc/postfixadmin/fetchmail.conf -_envtpl /etc/dovecot/dovecot-sql.conf.ext -_envtpl /etc/dovecot/dovecot-dict-sql.conf.ext - _envtpl /etc/dovecot/conf.d/10-auth.conf _envtpl /etc/dovecot/conf.d/10-mail.conf _envtpl /etc/dovecot/conf.d/10-ssl.conf _envtpl /etc/dovecot/conf.d/15-lda.conf +_envtpl /etc/dovecot/conf.d/20-imap.conf _envtpl /etc/dovecot/conf.d/20-lmtp.conf _envtpl /etc/dovecot/conf.d/90-quota.conf @@ -267,11 +265,10 @@ if [ "$DBDRIVER" = "ldap" ]; then _envtpl /etc/postfix/ldap/virtual-forward-maps.cf _envtpl /etc/postfix/ldap/virtual-group-maps.cf - _envtpl /etc/dovecot/dovecot-ldap.conf.ext - _envtpl /etc/dovecot/dovecot-ldap-master.conf.ext - _envtpl /etc/dovecot/conf.d/auth-ldap.conf.ext + rm -f /etc/dovecot/conf.d/auth-sql.conf.ext + else rm -f /etc/postfix/ldap/sender-login-maps.cf \ @@ -280,10 +277,10 @@ else /etc/postfix/ldap/virtual-alias-maps.cf \ /etc/postfix/ldap/virtual-forward-maps.cf \ /etc/postfix/ldap/virtual-group-maps.cf \ - /etc/dovecot/dovecot-ldap.conf.ext \ - /etc/dovecot/dovecot-ldap-master.conf.ext \ /etc/dovecot/conf.d/auth-ldap.conf.ext + _envtpl /etc/dovecot/conf.d/auth-sql.conf.ext + fi # POSTFIX CUSTOM CONFIG @@ -344,7 +341,13 @@ DOVECOT_MIN_PROCESS=$(nproc) # with ~5 open connections per user DOVECOT_MAX_PROCESS=$(($(nproc) * 500)) +# client_limit for auth and anvil: one connection per login process, so it has +# to cover imap-login and pop3-login (DOVECOT_MAX_PROCESS each) plus +# managesieve-login, imap-urlauth-login and lmtp at 100 apiece, with headroom. +DOVECOT_AUTH_CLIENT_LIMIT=$((DOVECOT_MAX_PROCESS * 2 + 500)) + sed -i -e "s/DOVECOT_MIN_PROCESS/${DOVECOT_MIN_PROCESS}/" \ + -e "s/DOVECOT_AUTH_CLIENT_LIMIT/${DOVECOT_AUTH_CLIENT_LIMIT}/" \ -e "s/DOVECOT_MAX_PROCESS/${DOVECOT_MAX_PROCESS}/" /etc/dovecot/conf.d/10-master.conf # ENABLE / DISABLE MAIL SERVER FEATURES @@ -358,7 +361,9 @@ if [ "$DEBUG_MODE" != false ]; then fi if [[ "$DEBUG_MODE" = *"dovecot"* || "$DEBUG_MODE" = true ]]; then echo "[INFO] Dovecot debug mode is enabled" - sed -i 's/^#//g' /etc/dovecot/conf.d/10-logging.conf + # only uncomment actual settings, so explanatory comments in the file + # do not turn into invalid configuration lines + sed -i -E 's/^#([a-z_]+ =)/\1/' /etc/dovecot/conf.d/10-logging.conf fi if [[ "$DEBUG_MODE" = *"rspamd"* || "$DEBUG_MODE" = true ]]; then echo "[INFO] Rspamd debug mode is enabled" diff --git a/test/config/mariadb/struct.sql b/test/config/mariadb/struct.sql index f23d36fb..f84e75a2 100644 --- a/test/config/mariadb/struct.sql +++ b/test/config/mariadb/struct.sql @@ -73,6 +73,7 @@ CREATE TABLE `alias` ( LOCK TABLES `alias` WRITE; /*!40000 ALTER TABLE `alias` DISABLE KEYS */; INSERT INTO `alias` VALUES ('postmaster@domain.tld','john.doe@domain.tld','domain.tld','2016-11-28 08:54:26','2016-11-28 08:58:19',1),('hostmaster@domain.tld','john.doe@domain.tld','domain.tld','2016-11-28 08:54:26','2016-11-28 08:58:19',1),('john.doe@domain.tld','john.doe@domain.tld','domain.tld','2016-11-28 08:56:47','2016-11-28 08:56:47',1),('sarah.connor@domain.tld','sarah.connor@domain.tld','domain.tld','2016-11-28 08:57:51','2016-11-28 08:57:51',1); +INSERT INTO `alias` VALUES ('tiny.quota@domain.tld','tiny.quota@domain.tld','domain.tld','2016-11-28 08:58:00','2016-11-28 08:58:00',1); /*!40000 ALTER TABLE `alias` ENABLE KEYS */; UNLOCK TABLES; @@ -297,6 +298,7 @@ CREATE TABLE `mailbox` ( LOCK TABLES `mailbox` WRITE; /*!40000 ALTER TABLE `mailbox` DISABLE KEYS */; INSERT INTO `mailbox` VALUES ('john.doe@domain.tld','{SHA512-CRYPT}$6$v1LkarodHyGGmfoy$ZszVBzfEZ0CaVnYaBasgvaHJUCNfxwD/E0eNy3iuix56Vl1ZcuDvG9PVr9JRZx5k.7wp1nMb5M1V4aZXo2yfn0','John DOE','domain.tld/john.doe/',1024000,'john.doe','domain.tld','2016-11-28 08:56:47','2016-11-28 08:56:47',1,'','','','2000-01-01 00:00:00'),('sarah.connor@domain.tld','{SHA512-CRYPT}$6$ub.zCcyeaM7Mhs6S$rL4Yj2.Zsk8aFoF5l1mAddVrPo.UZ/1UrNwBC7UTBrX47cViSHo5eepEes6jMqC21P3cBm82adqJZvo91Ekme0','Sarah CONNOR','domain.tld/sarah.connor/',1024000,'sarah.connor','domain.tld','2016-11-28 08:57:51','2016-11-28 08:57:51',1,'','','','2000-01-01 00:00:00'); +INSERT INTO `mailbox` VALUES ('tiny.quota@domain.tld','{SHA512-CRYPT}$6$v1LkarodHyGGmfoy$ZszVBzfEZ0CaVnYaBasgvaHJUCNfxwD/E0eNy3iuix56Vl1ZcuDvG9PVr9JRZx5k.7wp1nMb5M1V4aZXo2yfn0','Tiny QUOTA','domain.tld/tiny.quota/',102400,'tiny.quota','domain.tld','2016-11-28 08:58:00','2016-11-28 08:58:00',1,'','','','2000-01-01 00:00:00'); /*!40000 ALTER TABLE `mailbox` ENABLE KEYS */; UNLOCK TABLES; @@ -345,7 +347,7 @@ CREATE TABLE `quota2` ( LOCK TABLES `quota2` WRITE; /*!40000 ALTER TABLE `quota2` DISABLE KEYS */; -INSERT INTO `quota2` VALUES ('john.doe@domain.tld',0,0),('sarah.connor@domain.tld',0,0); +INSERT INTO `quota2` VALUES ('john.doe@domain.tld',0,0),('sarah.connor@domain.tld',0,0),('tiny.quota@domain.tld',0,0); /*!40000 ALTER TABLE `quota2` ENABLE KEYS */; UNLOCK TABLES; diff --git a/test/default.bats b/test/default.bats index 9dd6f14c..c2b12ff5 100644 --- a/test/default.bats +++ b/test/default.bats @@ -379,13 +379,19 @@ load 'test_helper/bats-assert/load' assert_success } -@test "checking rspamd: 7 messages scanned" { - run docker exec mailserver_default /bin/sh -c "rspamc stat | grep -i 'Messages scanned: 7'" +# Derivable from fixtures_default: 11 SMTP conversations, 2 refused before DATA +# (ghost@ at RCPT, the john.doe sender via sender_access), 9 scanned. GTUBE and +# EICAR are rejected; the rest are "no action", including both tiny.quota +# messages, which are refused at LMTP after rspamd has passed them. Not counted: +# postfix's own bounces (not miltered), quota warnings (dovecot-lda, never enter +# postfix) and the IMAP learn calls (counted as learned). +@test "checking rspamd: 9 messages scanned" { + run docker exec mailserver_default /bin/sh -c "rspamc stat | grep -i 'Messages scanned: 9'" assert_success } -@test "checking rspamd: 5 messages with action no action" { - run docker exec mailserver_default /bin/sh -c "rspamc stat | grep -i 'Messages with action no action: 5'" +@test "checking rspamd: 7 messages with action no action" { + run docker exec mailserver_default /bin/sh -c "rspamc stat | grep -i 'Messages with action no action: 7'" assert_success } @@ -427,6 +433,48 @@ load 'test_helper/bats-assert/load' assert_output 2 } +# quota_clone mirrors usage into the postfixadmin quota2 table; the seed rows +# start at 0. +@test "checking accounts: quota usage is mirrored into quota2 (default configuration)" { + run docker exec mariadb /bin/sh -c "mysql -upostfix -ptestpasswd -N -B -e \"SELECT bytes FROM quota2 WHERE username = 'john.doe@domain.tld'\" postfix 2>/dev/null" + assert_success + [ "$output" -gt 0 ] + + run docker exec mariadb /bin/sh -c "mysql -upostfix -ptestpasswd -N -B -e \"SELECT messages FROM quota2 WHERE username = 'john.doe@domain.tld'\" postfix 2>/dev/null" + assert_success + [ "$output" -gt 0 ] +} + +# mail_compress writes messages gzipped; a silent loss of compression would only +# show up as mailboxes growing on disk. +# quota_storage_grace (10M) admits the message that crosses the limit, so the +# filler lands over 100% rather than being refused. +@test "checking accounts: quota grace admits the message that crosses the limit (default configuration)" { + run docker exec mailserver_default /bin/sh -c "doveadm quota get -u tiny.quota@domain.tld 2>/dev/null | awk '/STORAGE/ {print \$(NF)}'" + assert_success + [ "$output" -gt 100 ] +} + +# quota-warning.sh delivers with enforcement off, so the warning reaches a +# mailbox that is already over its limit. doveadm rather than grep because +# mail is gzipped on disk. +@test "checking accounts: quota warning reaches an over-quota mailbox (default configuration)" { + run docker exec mailserver_default /bin/sh -c "doveadm search -u tiny.quota@domain.tld subject 'Mailbox quota warning' 2>/dev/null | wc -l" + assert_success + [ "$output" -ge 1 ] +} + +@test "checking accounts: mail to an over-quota mailbox is rejected (default configuration)" { + run docker exec mailserver_default /bin/sh -c "grep 'to=' /var/log/mail.log | grep -c 'The quota of this mailbox is exhausted'" + assert_success + [ "$output" -ge 1 ] +} + +@test "checking accounts: delivered mail is compressed on disk (default configuration)" { + run docker exec mailserver_default /bin/sh -c "total=\$(ls -A /var/mail/vhosts/domain.tld/john.doe/mail/new/ | wc -l); gz=\$(file /var/mail/vhosts/domain.tld/john.doe/mail/new/* | grep -c 'gzip compressed data'); [ \"\$total\" -gt 0 ] && [ \"\$total\" = \"\$gz\" ]" + assert_success +} + @test "checking accounts: user mail folders for john.doe" { run docker exec mailserver_default /bin/bash -c "ls -A /var/mail/vhosts/domain.tld/john.doe/mail/ | grep -E 'cur|new|tmp' | wc -l" assert_success @@ -572,7 +620,7 @@ load 'test_helper/bats-assert/load' } @test "checking dovecot: password scheme is correct" { - run docker exec mailserver_default /bin/sh -c "grep 'SHA512-CRYPT' /etc/dovecot/dovecot-sql.conf.ext | wc -l" + run docker exec mailserver_default /bin/sh -c "grep 'SHA512-CRYPT' /etc/dovecot/conf.d/auth-sql.conf.ext | wc -l" assert_success assert_output 1 } @@ -596,7 +644,7 @@ load 'test_helper/bats-assert/load' } @test "checking dovecot: quota dict mysql (default configuration)" { - run docker exec mailserver_default /bin/sh -c "doveconf dict sqlquota 2>/dev/null | grep 'mysql'" + run docker exec mailserver_default /bin/sh -c "doveconf dict_server 2>/dev/null | grep 'mysql'" assert_success } @@ -616,9 +664,9 @@ load 'test_helper/bats-assert/load' run docker exec mailserver_default /bin/sh -c "doveconf -h mail_debug 2>/dev/null" assert_success assert_output "no" - run docker exec mailserver_default /bin/sh -c "doveconf -h verbose_ssl 2>/dev/null" + run docker exec mailserver_default /bin/sh -c "doveconf -h log_debug 2>/dev/null" assert_success - assert_output "no" + assert_output "" } # @@ -848,3 +896,10 @@ load 'test_helper/bats-assert/load' assert_failure assert_output --partial 'No such file or directory' } + +# run.sh raises the soft open-files limit to the hard limit, which is what +# dovecot's service auth { client_limit } requires. +@test "checking logs: dovecot does not warn about the open files limit (default configuration)" { + run docker exec mailserver_default grep -h "fd limit" /var/log/mail.warn /var/log/mail.log + assert_failure +} diff --git a/test/ldap.bats b/test/ldap.bats index 89254892..af19af83 100644 --- a/test/ldap.bats +++ b/test/ldap.bats @@ -430,9 +430,9 @@ load 'test_helper/bats-assert/load' run docker exec mailserver_ldap /bin/sh -c "doveconf -h mail_debug 2>/dev/null" assert_success assert_output "no" - run docker exec mailserver_ldap /bin/sh -c "doveconf -h verbose_ssl 2>/dev/null" + run docker exec mailserver_ldap /bin/sh -c "doveconf -h log_debug 2>/dev/null" assert_success - assert_output "no" + assert_output "" } # diff --git a/test/reverse.bats b/test/reverse.bats index 0d5c4908..c58651b9 100644 --- a/test/reverse.bats +++ b/test/reverse.bats @@ -355,6 +355,17 @@ load 'test_helper/bats-assert/load' assert_output 2 } +# Same quota_clone mirroring as the default suite, over the pgsql dict backend. +@test "checking accounts: quota usage is mirrored into quota2 (reverse configuration)" { + run docker exec postgres /bin/sh -c "psql -U postfix -d postfix -t -A -c \"SELECT bytes FROM quota2 WHERE username = 'john.doe@domain.tld'\" 2>/dev/null" + assert_success + [ "$output" -gt 0 ] + + run docker exec postgres /bin/sh -c "psql -U postfix -d postfix -t -A -c \"SELECT messages FROM quota2 WHERE username = 'john.doe@domain.tld'\" 2>/dev/null" + assert_success + [ "$output" -gt 0 ] +} + # # dkim # @@ -416,11 +427,11 @@ load 'test_helper/bats-assert/load' @test "checking dovecot: login_greeting value (reverse configuration)" { run docker exec mailserver_reverse /bin/sh -c "doveconf -h login_greeting 2>/dev/null" assert_success - assert_output "Dovecot (Debian) ready." + assert_output "Dovecot ready." } @test "checking dovecot: quota dict pgsql (reverse configuration)" { - run docker exec mailserver_reverse /bin/sh -c "doveconf dict sqlquota 2>/dev/null | grep 'pgsql'" + run docker exec mailserver_reverse /bin/sh -c "doveconf dict_server 2>/dev/null | grep 'pgsql'" assert_success } diff --git a/test/share/tests/email-templates/external-to-tiny-quota-rejected.txt b/test/share/tests/email-templates/external-to-tiny-quota-rejected.txt new file mode 100644 index 00000000..8788ca93 --- /dev/null +++ b/test/share/tests/email-templates/external-to-tiny-quota-rejected.txt @@ -0,0 +1,12 @@ +HELO mx.gmail.com +MAIL FROM: user@gmail.com +RCPT TO: tiny.quota@domain.tld +DATA +From: Docker Mail Server +To: Tiny Quota +Date: Sat, 28 Nov 2016 12:00:00 +0200 +Subject: Test Message +Test:external-to-tiny-quota-rejected + +. +QUIT diff --git a/test/traefik_acmev1.bats b/test/traefik_acmev1.bats index c82e709d..c114074c 100644 --- a/test/traefik_acmev1.bats +++ b/test/traefik_acmev1.bats @@ -38,9 +38,9 @@ load 'test_helper/bats-assert/load' run docker exec mailserver_traefik_acmev1 /bin/sh -c "doveconf -h mail_debug 2>/dev/null" assert_success assert_output "yes" - run docker exec mailserver_traefik_acmev1 /bin/sh -c "doveconf -h verbose_ssl 2>/dev/null" + run docker exec mailserver_traefik_acmev1 /bin/sh -c "doveconf -h log_debug 2>/dev/null" assert_success - assert_output "yes" + assert_output "category=ssl" } # diff --git a/test/traefik_acmev2.bats b/test/traefik_acmev2.bats index 28805876..ad58b381 100644 --- a/test/traefik_acmev2.bats +++ b/test/traefik_acmev2.bats @@ -38,9 +38,9 @@ load 'test_helper/bats-assert/load' run docker exec mailserver_traefik_acmev2 /bin/sh -c "doveconf -h mail_debug 2>/dev/null" assert_success assert_output "yes" - run docker exec mailserver_traefik_acmev2 /bin/sh -c "doveconf -h verbose_ssl 2>/dev/null" + run docker exec mailserver_traefik_acmev2 /bin/sh -c "doveconf -h log_debug 2>/dev/null" assert_success - assert_output "yes" + assert_output "category=ssl" } # From a9819227de79f837556d8c3dca6afa9f441143b9 Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:18:44 +1200 Subject: [PATCH 2/9] Report a failure to create the vmail user instead of hiding it --- rootfs/usr/local/bin/setup.sh | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/rootfs/usr/local/bin/setup.sh b/rootfs/usr/local/bin/setup.sh index 6880a394..c465a3a9 100644 --- a/rootfs/usr/local/bin/setup.sh +++ b/rootfs/usr/local/bin/setup.sh @@ -511,8 +511,23 @@ ln -s /var/mail/dovecot /var/lib/dovecot # --------------------------------------------------------------------------------------------- # Create vmail user -groupadd -g "$VMAILGID" vmail &> /dev/null -useradd -g vmail -u "$VMAILUID" vmail -d /var/mail &> /dev/null +# The output used to be discarded, which hid a collision on VMAILUID/VMAILGID: +# the container then started without a vmail user and dovecot 2.4 died with +# "service(auth) User doesn't exist: vmail". Note Debian 13 occupies UID/GID +# 999 (systemd-journal), which Debian 12 left free. +if ! getent group vmail > /dev/null; then + if ! groupadd -g "$VMAILGID" vmail; then + echo "[ERROR] Could not create the vmail group with GID $VMAILGID. That GID is probably already taken inside the image, set VMAILGID to a free one." + touch /etc/setup-error + fi +fi + +if ! getent passwd vmail > /dev/null; then + if ! useradd -g vmail -u "$VMAILUID" vmail -d /var/mail; then + echo "[ERROR] Could not create the vmail user with UID $VMAILUID. That UID is probably already taken inside the image, set VMAILUID to a free one." + touch /etc/setup-error + fi +fi # Create all needed folders in queue directory for subdir in "" etc dev usr usr/lib usr/lib/sasl2 usr/lib/zoneinfo public maildrop; do From 4211f0d8ee89006b776ce03c2dd4d70883a282df Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:25:43 +1200 Subject: [PATCH 3/9] Adapt the postfix configuration to postfix 3.10 --- rootfs/etc/postfix/ffdhe2048.pem | 8 -------- rootfs/etc/postfix/main.cf | 1 - rootfs/usr/local/bin/setup.sh | 4 +++- test/default.bats | 2 -- 4 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 rootfs/etc/postfix/ffdhe2048.pem diff --git a/rootfs/etc/postfix/ffdhe2048.pem b/rootfs/etc/postfix/ffdhe2048.pem deleted file mode 100644 index 9b182b72..00000000 --- a/rootfs/etc/postfix/ffdhe2048.pem +++ /dev/null @@ -1,8 +0,0 @@ ------BEGIN DH PARAMETERS----- -MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz -+8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a -87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 -YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi -7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD -ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg== ------END DH PARAMETERS----- diff --git a/rootfs/etc/postfix/main.cf b/rootfs/etc/postfix/main.cf index e50a0f14..905152af 100644 --- a/rootfs/etc/postfix/main.cf +++ b/rootfs/etc/postfix/main.cf @@ -88,7 +88,6 @@ smtpd_tls_exclude_ciphers = aNULL,eNULL,EXPORT,DES,3DES,RC2,RC4,MD5,PSK,SRP, smtpd_tls_CApath = /etc/ssl/certs smtpd_tls_cert_file = {{ .FULLCHAIN }} smtpd_tls_key_file = {{ .KEYFILE }} -smtpd_tls_dh1024_param_file = /etc/postfix/ffdhe2048.pem tls_preempt_cipherlist = yes tls_random_source = dev:/dev/urandom diff --git a/rootfs/usr/local/bin/setup.sh b/rootfs/usr/local/bin/setup.sh index c465a3a9..6f184992 100644 --- a/rootfs/usr/local/bin/setup.sh +++ b/rootfs/usr/local/bin/setup.sh @@ -530,7 +530,9 @@ if ! getent passwd vmail > /dev/null; then fi # Create all needed folders in queue directory -for subdir in "" etc dev usr usr/lib usr/lib/sasl2 usr/lib/zoneinfo public maildrop; do +# usr/lib/zoneinfo is not listed: Debian's postfix (>= 3.9.1-4) deletes it +# from the chroot on startup as unused +for subdir in "" etc dev usr usr/lib usr/lib/sasl2 public maildrop; do mkdir -p /var/mail/postfix/spool/$subdir chmod 755 /var/mail/postfix/spool/$subdir done diff --git a/test/default.bats b/test/default.bats index c2b12ff5..118fa499 100644 --- a/test/default.bats +++ b/test/default.bats @@ -551,8 +551,6 @@ load 'test_helper/bats-assert/load' @test "checking postfix: check some folders in queue directory" { run docker exec mailserver_default [ -d /var/mail/postfix/spool/usr/lib/sasl2 ] assert_success - run docker exec mailserver_default [ -d /var/mail/postfix/spool/usr/lib/zoneinfo ] - assert_success } @test "checking postfix: check dovecot unix sockets in queue directory" { From e81b6e863bf519a5f4573ded5628c64aa5820880 Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:31:06 +0000 Subject: [PATCH 4/9] Fix zeyple on Debian 13 --- rootfs/usr/local/bin/setup.sh | 8 ++++---- rootfs/usr/local/bin/zeyple.py | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/rootfs/usr/local/bin/setup.sh b/rootfs/usr/local/bin/setup.sh index 6f184992..566d7bac 100644 --- a/rootfs/usr/local/bin/setup.sh +++ b/rootfs/usr/local/bin/setup.sh @@ -588,10 +588,10 @@ if [ "$ENABLE_ENCRYPTION" = true ]; then # Generating John Doe GPG key s6-setuidgid zeyple gpg --homedir "/var/mail/zeyple/keys" --batch --generate-key < Date: Sun, 13 Sep 2026 01:29:05 +0000 Subject: [PATCH 5/9] Deny File/Path/Shm message inputs on the rspamd workers --- rootfs/etc/rspamd/local.d/worker-controller.inc | 7 +++++++ rootfs/etc/rspamd/local.d/worker-proxy.inc | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/rootfs/etc/rspamd/local.d/worker-controller.inc b/rootfs/etc/rspamd/local.d/worker-controller.inc index 8ca99177..87540eef 100644 --- a/rootfs/etc/rspamd/local.d/worker-controller.inc +++ b/rootfs/etc/rspamd/local.d/worker-controller.inc @@ -3,3 +3,10 @@ password = ""; enable_password = ""; secure_ip = "127.0.0.1"; secure_ip = "::1"; + +# Deny privileged File/Path/Shm message inputs: the worker binds a +# non-loopback TCP socket, so any client reaching it could otherwise make +# rspamd read arbitrary files readable by the rspamd user. Nothing here +# uses those inputs (postfix talks milter, rspamc streams on stdin). +# Defaults to true in rspamd 4.1.x and to false in the next major release. +allow_file_and_shm_inputs = false; diff --git a/rootfs/etc/rspamd/local.d/worker-proxy.inc b/rootfs/etc/rspamd/local.d/worker-proxy.inc index 0cdbc771..d6351f7d 100644 --- a/rootfs/etc/rspamd/local.d/worker-proxy.inc +++ b/rootfs/etc/rspamd/local.d/worker-proxy.inc @@ -1,5 +1,12 @@ bind_socket = "*:11332"; +# Deny privileged File/Path/Shm message inputs: the worker binds a +# non-loopback TCP socket, so any client reaching it could otherwise make +# rspamd read arbitrary files readable by the rspamd user. Nothing here +# uses those inputs (postfix talks milter, rspamc streams on stdin). +# Defaults to true in rspamd 4.1.x and to false in the next major release. +allow_file_and_shm_inputs = false; + upstream "local" { self_scan = yes; } From d593677d60684cc3e63ada3e7f09409343a44987 Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:37:29 +0000 Subject: [PATCH 6/9] Build against the Debian 13 overlay --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index baf6bbc9..87000b37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM mailserver2/debian-mail-overlay:1.0.20 +FROM mailserver2/debian-mail-overlay:1.1.0 LABEL description="Simple and full-featured mail server using Docker" From 3c725dc5401428c01b99ba5d4c0d5295216a3bed Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:53:44 +0000 Subject: [PATCH 7/9] Replace the retired default keyserver in encryption.sh --- README.md | 4 ++-- rootfs/usr/local/bin/encryption.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 981d1b67..8c4b6979 100644 --- a/README.md +++ b/README.md @@ -460,8 +460,8 @@ docker exec -ti mailserver encryption.sh import-all-keys #### Specify another GPG keyserver ``` -docker exec -ti mailserver encryption.sh import-key YOUR_KEY_ID hkp://pgp.mit.edu -docker exec -ti mailserver encryption.sh import-all-keys hkp://keys.gnupg.net +docker exec -ti mailserver encryption.sh import-key YOUR_KEY_ID hkps://keyserver.ubuntu.com +docker exec -ti mailserver encryption.sh import-all-keys hkps://keyserver.ubuntu.com ``` #### Run other GPG options diff --git a/rootfs/usr/local/bin/encryption.sh b/rootfs/usr/local/bin/encryption.sh index aa38bb2e..e5a3a3e4 100644 --- a/rootfs/usr/local/bin/encryption.sh +++ b/rootfs/usr/local/bin/encryption.sh @@ -6,7 +6,7 @@ OPTIONS=$1 KEYID=$2 -KEYSERVER=${3:-"hkp://keys.gnupg.net"} +KEYSERVER=${3:-"hkps://keys.openpgp.org"} # Zeyple home directory ZEYPLE_DIR="/var/mail/zeyple" From 5d31992cde09e2a445c32871e19ade4ad75f8667 Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:28:31 +0000 Subject: [PATCH 8/9] Ignore reads in the certificate watcher --- Makefile | 44 +++++++++++++++++-- rootfs/usr/local/bin/watcher.py | 9 +++- test/reverse.bats | 10 +++++ .../{acme.v1.json => acme.v1/acme.json} | 0 .../{acme.v2.json => acme.v2/acme.json} | 0 test/traefik_acmev1.bats | 10 +++++ test/traefik_acmev2.bats | 10 +++++ 7 files changed, 77 insertions(+), 6 deletions(-) rename test/share/traefik/{acme.v1.json => acme.v1/acme.json} (100%) rename test/share/traefik/{acme.v2.json => acme.v2/acme.json} (100%) diff --git a/Makefile b/Makefile index 14b92032..d8dd0e4f 100644 --- a/Makefile +++ b/Makefile @@ -31,8 +31,8 @@ ldap: init_ldap fixtures_ldap run_ldap stop_ldap ldap2: init_ldap2 fixtures_ldap2 run_ldap2 stop_ldap2 sieve: init_sieve fixtures_sieve run_sieve stop_sieve ecdsa: init_ecdsa run_ecdsa stop_ecdsa -traefik_acmev1: init_traefik_acmev1 run_traefik_acmev1 stop_traefik_acmev1 -traefik_acmev2: init_traefik_acmev2 run_traefik_acmev2 stop_traefik_acmev2 +traefik_acmev1: init_traefik_acmev1 fixtures_traefik_acmev1 run_traefik_acmev1 stop_traefik_acmev1 +traefik_acmev2: init_traefik_acmev2 fixtures_traefik_acmev2 run_traefik_acmev2 stop_traefik_acmev2 build-no-cache: docker build --no-cache -t $(NAME) . @@ -342,6 +342,16 @@ fixtures_reverse: @echo "Waiting for the postfix queue to drain (mailserver_reverse) ..." @timeout $(WAIT_TIMEOUT) sh -c 'until [ -z "$$(docker exec mailserver_reverse find /var/spool/postfix/incoming /var/spool/postfix/active /var/spool/postfix/maildrop -type f 2>/dev/null)" ]; do sleep 2; done' \ || { echo "TIMEOUT: postfix queue did not drain"; docker exec mailserver_reverse postqueue -p; exit 1; } + # One in-place rewrite of a watched certificate (same bytes) has to produce + # exactly one reload; the tests count them. Last, so no delivery above is + # racing a postfix/dovecot reload. + @timeout $(WAIT_TIMEOUT) sh -c 'until docker logs mailserver_reverse 2>&1 | grep -q "Watching /etc/letsencrypt/live"; do sleep 2; done' \ + || { echo "TIMEOUT: cert watcher did not start"; docker logs --tail 20 mailserver_reverse; exit 1; } + docker exec mailserver_reverse python3 -c "p='/etc/letsencrypt/live/mail.domain.tld/fullchain.pem'; d=open(p,'rb').read(); f=open(p,'r+b'); f.write(d); f.close()" + @timeout $(WAIT_TIMEOUT) sh -c 'until docker logs mailserver_reverse 2>&1 | grep -q "Updating SSL certificates and reloading"; do sleep 2; done' \ + || { echo "TIMEOUT: watcher did not react to writing the certificate"; docker logs --tail 20 mailserver_reverse; exit 1; } + # Settle time in which a watcher reacting to its own reads would reload again + sleep 15 run_reverse: ./test/bats/bin/bats test/reverse.bats stop_reverse: @@ -390,9 +400,22 @@ init_traefik_acmev1: init_redis init_mariadb -e VMAILGID=`id -g` \ -e DISABLE_CLAMAV=true \ -e TESTING=true \ - -v "`pwd`/test/share/traefik/acme.v1.json":/etc/letsencrypt/acme/acme.json \ + -v "`pwd`/test/share/traefik/acme.v1":/etc/letsencrypt/acme \ -h mail.domain.tld \ -t $(NAME) +fixtures_traefik_acmev1: + # One in-place rewrite of acme.json (same bytes) has to produce exactly one + # reload; the tests count them. Postfix and dovecot must be up so the + # reload has something to signal. + @timeout $(WAIT_TIMEOUT) sh -c 'until docker exec mailserver_traefik_acmev1 nc -z 0.0.0.0 25 && docker exec mailserver_traefik_acmev1 nc -z 0.0.0.0 143; do sleep 2; done' \ + || { echo "TIMEOUT: postfix/dovecot not listening"; docker logs --tail 20 mailserver_traefik_acmev1; exit 1; } + @timeout $(WAIT_TIMEOUT) sh -c 'until docker logs mailserver_traefik_acmev1 2>&1 | grep -q "Watching /etc/letsencrypt/acme"; do sleep 2; done' \ + || { echo "TIMEOUT: cert watcher did not start"; docker logs --tail 20 mailserver_traefik_acmev1; exit 1; } + docker exec mailserver_traefik_acmev1 python3 -c "p='/etc/letsencrypt/acme/acme.json'; d=open(p,'rb').read(); f=open(p,'r+b'); f.write(d); f.close()" + @timeout $(WAIT_TIMEOUT) sh -c 'until docker logs mailserver_traefik_acmev1 2>&1 | grep -q "Updating SSL certificates and reloading"; do sleep 2; done' \ + || { echo "TIMEOUT: watcher did not react to writing acme.json"; docker logs --tail 20 mailserver_traefik_acmev1; exit 1; } + # Settle time in which a watcher reacting to its own reads would reload again + sleep 15 run_traefik_acmev1: docker exec mailserver_traefik_acmev1 /bin/sh -c "while ! echo PING | nc -z 0.0.0.0 587 ; do sleep 1 ; done" ./test/bats/bin/bats test/traefik_acmev1.bats @@ -415,9 +438,22 @@ init_traefik_acmev2: init_redis init_mariadb -e VMAILGID=`id -g` \ -e DISABLE_CLAMAV=true \ -e TESTING=true \ - -v "`pwd`/test/share/traefik/acme.v2.json":/etc/letsencrypt/acme/acme.json \ + -v "`pwd`/test/share/traefik/acme.v2":/etc/letsencrypt/acme \ -h mail.domain.tld \ -t $(NAME) +fixtures_traefik_acmev2: + # One in-place rewrite of acme.json (same bytes) has to produce exactly one + # reload; the tests count them. Postfix and dovecot must be up so the + # reload has something to signal. + @timeout $(WAIT_TIMEOUT) sh -c 'until docker exec mailserver_traefik_acmev2 nc -z 0.0.0.0 25 && docker exec mailserver_traefik_acmev2 nc -z 0.0.0.0 143; do sleep 2; done' \ + || { echo "TIMEOUT: postfix/dovecot not listening"; docker logs --tail 20 mailserver_traefik_acmev2; exit 1; } + @timeout $(WAIT_TIMEOUT) sh -c 'until docker logs mailserver_traefik_acmev2 2>&1 | grep -q "Watching /etc/letsencrypt/acme"; do sleep 2; done' \ + || { echo "TIMEOUT: cert watcher did not start"; docker logs --tail 20 mailserver_traefik_acmev2; exit 1; } + docker exec mailserver_traefik_acmev2 python3 -c "p='/etc/letsencrypt/acme/acme.json'; d=open(p,'rb').read(); f=open(p,'r+b'); f.write(d); f.close()" + @timeout $(WAIT_TIMEOUT) sh -c 'until docker logs mailserver_traefik_acmev2 2>&1 | grep -q "Updating SSL certificates and reloading"; do sleep 2; done' \ + || { echo "TIMEOUT: watcher did not react to writing acme.json"; docker logs --tail 20 mailserver_traefik_acmev2; exit 1; } + # Settle time in which a watcher reacting to its own reads would reload again + sleep 15 run_traefik_acmev2: docker exec mailserver_traefik_acmev2 /bin/sh -c "while ! echo PING | nc -z 0.0.0.0 587 ; do sleep 1 ; done" ./test/bats/bin/bats test/traefik_acmev2.bats diff --git a/rootfs/usr/local/bin/watcher.py b/rootfs/usr/local/bin/watcher.py index 449cf0e1..6b5106eb 100644 --- a/rootfs/usr/local/bin/watcher.py +++ b/rootfs/usr/local/bin/watcher.py @@ -6,7 +6,7 @@ import subprocess from threading import Timer from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler, RegexMatchingEventHandler +from watchdog.events import FileSystemEventHandler def debounce(wait): @@ -35,12 +35,17 @@ def watch(self, file_path): print("[INFO] Watching %s" % file_path) self.observer.schedule(self, file_path) - def on_any_event(self, event): + # Only events that change a file. Reads (opened, closed without write) are + # ignored: the reload itself reads the watched files, and reacting to that + # would retrigger it indefinitely. + def on_changed(self, event): if event.is_directory: return print("[INFO] Watched Event %s" % repr(event)) self.reload_certificates() + on_created = on_modified = on_moved = on_deleted = on_closed = on_changed + @debounce(3) def reload_certificates(self): status = subprocess.call(['certs_helper.sh', 'reload']) diff --git a/test/reverse.bats b/test/reverse.bats index c58651b9..b62a046d 100644 --- a/test/reverse.bats +++ b/test/reverse.bats @@ -366,6 +366,16 @@ load 'test_helper/bats-assert/load' [ "$output" -gt 0 ] } +# fixtures_reverse rewrites one watched certificate once. A watcher that +# reacted to its own reads would start the cycle again and again. The rewritten bytes +# are identical, so the cycle ends at "Live Certificates match". +@test "checking ssl: one write to a watched certificate causes exactly one reload (reverse configuration)" { + run docker logs mailserver_reverse + assert_success + [ "$(echo "$output" | grep -c 'Updating SSL certificates and reloading')" -eq 1 ] + [ "$(echo "$output" | grep -c 'Live Certificates match')" -eq 1 ] +} + # # dkim # diff --git a/test/share/traefik/acme.v1.json b/test/share/traefik/acme.v1/acme.json similarity index 100% rename from test/share/traefik/acme.v1.json rename to test/share/traefik/acme.v1/acme.json diff --git a/test/share/traefik/acme.v2.json b/test/share/traefik/acme.v2/acme.json similarity index 100% rename from test/share/traefik/acme.v2.json rename to test/share/traefik/acme.v2/acme.json diff --git a/test/traefik_acmev1.bats b/test/traefik_acmev1.bats index c114074c..b049e385 100644 --- a/test/traefik_acmev1.bats +++ b/test/traefik_acmev1.bats @@ -61,6 +61,16 @@ load 'test_helper/bats-assert/load' assert_success } +# fixtures_traefik_acmev1 rewrites acme.json once. A watcher that reacted to +# its own reads would start the cycle again and again. The rewritten bytes +# are identical, so the cycle ends at "Live Certificates match". +@test "checking traefik acme v1: one write to acme.json causes exactly one reload" { + run docker logs mailserver_traefik_acmev1 + assert_success + [ "$(echo "$output" | grep -c 'Updating SSL certificates and reloading')" -eq 1 ] + [ "$(echo "$output" | grep -c 'Live Certificates match')" -eq 1 ] +} + @test "checking traefik acme v1: dump.log doesn't exist" { run docker exec mailserver_traefik_acmev1 [ -f /etc/letsencrypt/acme/dump.log ] assert_failure diff --git a/test/traefik_acmev2.bats b/test/traefik_acmev2.bats index ad58b381..d3637361 100644 --- a/test/traefik_acmev2.bats +++ b/test/traefik_acmev2.bats @@ -70,6 +70,16 @@ load 'test_helper/bats-assert/load' assert_success } +# fixtures_traefik_acmev2 rewrites acme.json once. A watcher that reacted to +# its own reads would start the cycle again and again. The rewritten bytes +# are identical, so the cycle ends at "Live Certificates match". +@test "checking traefik acme v2: one write to acme.json causes exactly one reload" { + run docker logs mailserver_traefik_acmev2 + assert_success + [ "$(echo "$output" | grep -c 'Updating SSL certificates and reloading')" -eq 1 ] + [ "$(echo "$output" | grep -c 'Live Certificates match')" -eq 1 ] +} + @test "checking traefik acme v2: dump.log doesn't exist" { run docker exec mailserver_traefik_acmev2 [ -f /etc/letsencrypt/acme/dump.log ] assert_failure From 62b5200f99ac9a1057fe89f17cdf4daa736a9993 Mon Sep 17 00:00:00 2001 From: Andrew Savinykh <658865+AndrewSav@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:25:44 +0000 Subject: [PATCH 9/9] Document clearing the rspamd hyperscan cache after an rspamd upgrade --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 8c4b6979..d179839b 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Thank you very much. - [Password mapping and authentication binds](#password-mapping-and-authentication-binds) - [A converted example](#a-converted-example) - [Checking the result](#checking-the-result) + - [Clearing the rspamd hyperscan cache after an rspamd upgrade](#clearing-the-rspamd-hyperscan-cache-after-an-rspamd-upgrade) - [Migration from Traefik 1 to 2](#migration-from-traefik-1-to-2) - [Migration from 1.0 to 1.1](#migration-from-10-to-11) - [Migration from hardware/mailserver to mailserver2/mailserver](#migration-from-hardwaremailserver-to-mailserver2mailserver) @@ -1277,6 +1278,53 @@ docker exec -ti mailserver cat /var/log/mail.err

Back to table of contents :arrow_up_small:

+### Clearing the rspamd hyperscan cache after an rspamd upgrade + +Do this once, on an existing installation, when the image you are upgrading to +ships a different version of rspamd than the one you were running. That is the +case when moving to the Debian 13 image (rspamd 4.1.5) from `1.1.24` or earlier +(rspamd 4.1.0), and it will be the case again at the next rspamd version change. +A new installation does not need it. You can check which version an image has +before and after the upgrade with: + +``` +docker compose exec mailserver rspamd --version +``` + +Rspamd compiles its regular expressions into hyperscan databases and caches them +under `/var/mail/rspamd`, which is a persistent volume. The cache files are named +after the patterns they hold and carry no engine version, so the new rspamd finds +the files the old one wrote, cannot deserialise them, and falls back to its +slower matcher. It does not replace them either: a failed load stops the +multipattern from being recompiled, so the stale files survive every restart. + +Mail filtering is unaffected, but rspamd logs this at error level on every start, +which also creates `/var/log/mail.err` where there would otherwise be none: + +``` +rspamd[…]: <…>; hyperscan; add_cached_file: invalid path: "/var/mail/rspamd/.hs.zst", error message: No such file or directory +rspamd[…]: <…>; re_cache; rspamd_re_cache_apply_hyperscan_blob: cannot load hyperscan class : deserialize failed +rspamd[…]: <…>; rspamd_worker_multipattern_async_loaded: failed to hot-swap multipattern 'tld' to hyperscan using 'file' cache backend, continuing with ACISM fallback +``` + +Delete the cached databases and restart. Only the hyperscan databases are +removed; rspamd statistics (`*.rrd`), downloaded maps (`*.map`) and the control +socket are left alone, and the databases are rebuilt on the next start. + +``` +docker compose exec mailserver sh -c 'rm -f /var/mail/rspamd/*.hs.zst /var/mail/rspamd/*.hs /var/mail/rspamd/*.unser /var/mail/rspamd/*.hsmp /var/mail/rspamd/*.hsmc' +docker compose restart mailserver +``` + +A minute or so later, both of these should come back empty: + +``` +docker compose logs --since 5m mailserver | grep -E 'hot-swap multipattern|cannot read hyperscan cache|add_cached_file: invalid path' +docker compose exec mailserver ls /var/log/mail.err +``` + +

Back to table of contents :arrow_up_small:

+ ### Migration from Traefik 1 to 2 Migrating from traefik 1 to 2 does not change any mailserver images that are used, only the traefik image version. You do need to update traefik labels on all containers as per [docker-compose.sample.yml](docker-compose.sample.yml). When you do this, either delete your acme.json and let traefik request the new certificate, or use the [tool](https://github.com/traefik/traefik-migration-tool) to convert from v1 to v2.