Nixos/stalwart: How to write expression?

In stalwart you can have expressions which looks like this:

[component.section]
key = [ 
    { if = "condition", then = "value_if_true" },
    { if = "condition_2", then = "value_if_true" },
    { else = "value_if_false" }
]

But how do you create this in services.stalwart-mail.settings?

I tried this with

        auth.dkim.sign = [
          { "if" = "listener != 'smtp'"; "then" = "['rsa', 'elliptic']"; }
          { "else" = false; }
        ];

but it gets parsed as

[[auth.dkim.sign]]
if = "listener != 'smtp'"
then = "['rsa', 'elliptic']"

[[auth.dkim.sign]]
else = false

which is wrong.

(Maintainer ping)
@happysalada @euxane @onny @norpol

What’s wrong with this exactly?
(TOML double brackets are equivalent to array elements)

What’s wrong with this exactly?

The generated config of

        auth.dkim.sign = [
          { "if" = "listener != 'smtp'"; "then" = "['rsa', 'elliptic']"; }
          { "else" = false; }
        ];

doesn’t look like an expression as shown in the stalwart config but I’m clueless how so I thought it would be fine to ask it here (and ping you :sweat_smile: ).


Due to the “wrong” expression I’m getting this (according from journalctl -eu stalwart-mail):

Jan 24 21:26:50 server stalwart[148624]: 2026-01-24T20:26:50Z ERROR Configuration parse error (config.parse-error) details = "Failed to parse setting "auth.dkim.sign": Multiple 'else' found"

The full config

I took it from systemctl cat stalwart-mail.service in the ExecStart line after --config=:

[[auth.dkim.sign]]
if = "listener != 'smtp'"
then = "['rsa', 'elliptic']"

[[auth.dkim.sign]]
else = false

[authentication.fallback-admin]
secret = "%{file:/run/credentials/stalwart-mail.service/fallback-admin}%"
user = "admin"

[certificate.traefik]
cert = "%{file:/var/lib/stalwart-mail/tls/cert}%"
default = true
private-key = "%{file:/var/lib/stalwart-mail/tls/key}%"

[resolver]
public-suffix = ["file:///nix/store/81cd3pck3chqgancpb90vf63d5njr1w9-publicsuffix-list-0-unstable-2025-10-08/share/publicsuffix/public_suffix_list.dat"]
type = "system"

[server]
allowed-ips = ["10.0.0.0/24"]
hostname = "mail.redacted.redacted"

[server.auto-ban.abuse]
rate = "3/1d"

[server.auto-ban.auth]
rate = "3/1d"

[server.auto-ban.loiter]
rate = "150/1d"

[server.auto-ban.scan]
paths = [
    "*.php*",
    "*.cgi*",
    "*.asp*",
    "*/wp-*",
    "*/php*",
    "*/cgi-bin*",
    "*xmlrpc*",
    "*../*",
    "*/..*",
    "*joomla*",
    "*wordpress*",
    "*drupal*",
]
rate = "50/1d"

[server.listener.http]
bind = ["127.0.0.1:49200"]
protocol = "http"

[server.listener.smtp]
bind = ["[::]:25"]
protocol = "smtp"

[server.listener.smtp.proxy]
override = true
trusted-networks = ["127.0.0.1"]

[server.listener.smtp-starttls]
bind = ["[::]:587"]
protocol = "smtp"

[server.listener.smtp-starttls.proxy]
override = true
trusted-networks = ["127.0.0.1"]

[server.listener.smtps]
bind = ["[::]:465"]
protocol = "smtp"

[server.listener.smtps.proxy]
override = true
trusted-networks = ["127.0.0.1"]

[server.listener.smtps.tls]
implicit = true

[signature.elliptic]
algorithm = "ed25519-sha256"
canonicalization = "relaxed/relaxed"
domain = "redacted"
headers = [
    "From",
    "To",
    "Cc",
    "Date",
    "Subject",
    "Message-ID",
    "Organization",
    "MIME-Version",
    "Content-Type",
    "In-Reply-To",
    "References",
    "List-Id",
    "User-Agent",
    "Thread-Topic",
    "Thread-Index",
]
private-key = "%{file:/run/credentials/stalwart-mail.service/dkim-elliptic}%"
report = true
selector = "ed-default"

[signature.rsa]
algorithm = "rsa-sha256"
canonicalization = "relaxed/relaxed"
domain = "redacted"
headers = [
    "From",
    "To",
    "Cc",
    "Date",
    "Subject",
    "Message-ID",
    "Organization",
    "MIME-Version",
    "Content-Type",
    "In-Reply-To",
    "References",
    "List-Id",
    "User-Agent",
    "Thread-Topic",
    "Thread-Index",
]
private-key = "%{file:/run/credentials/stalwart-mail.service/dkim-rsa}%"
report = true
selector = "rsa-default"

[spam-filter]
resource = "file:///nix/store/yik2jfnvidbyjgdwszznwfr89x567igb-spam-filter-2.0.4/spam-filter.toml"

[storage]
blob = "fs"
data = "db"
directory = "internal"
fts = "db"
lookup = "db"

[store.db]
path = "/var/lib/stalwart-mail/data/index.sqlite3"
type = "sqlite"

[store.fs]
path = "/var/lib/stalwart-mail/data/blobs"
type = "fs"

[tracer.stdout]
ansi = false
enable = true
level = "info"
type = "stdout"

[webadmin]
path = "/var/cache/stalwart-mail"
resource = "file:///nix/store/v05dxwda840a7ik2q1ap7yxwzp5xyr1a-webadmin-0.1.32/webadmin.zip"

I think that I don’t understand what you are getting at :sweat_smile:

Quoting TornaxO7 via NixOS Discourse (2026-01-24 21:41 CET):

The generated config of

auth.dkim.sign = [
{ "if" = "listener != 'smtp'"; "then" = "['rsa', 'elliptic']"; }
{ "else" = false; }
];

doesn’t look like an expression as shown in the stalwart config but I’m
clueless how so I thought it would be fine to ask it here (and ping you
:sweat_smile: ).

[…]

I think that I don’t understand what you are getting at :sweat_smile:

In TOML, the two following snippets should be equivalent, just written with
two different notations (TOML: English v1.0.0-rc.2):

TOML:

[[auth.dkim.sign]]
if = "listener != 'smtp'"
then = "['rsa', 'elliptic']"

[[auth.dkim.sign]]
else = false

TOML:

[auth.dkim]
sign = [
  { if = "listener != 'smtp'", then = "['rsa', elliptic']" },
  { else = false }
]

So your Nix expression and the generated TOML configuration file look
valid to me.


Due to the “wrong” expression I’m getting this (according from
journalctl -eu stalwart-mail):

Jan 24 21:26:50 server stalwart[148624]: 2026-01-24T20:26:50Z ERROR
Configuration parse error (config.parse-error) details = "Failed to
parse setting "auth.dkim.sign": Multiple 'else' found"

I’m not sure where this extra “else” clause is coming from.
It might be a default value from the program which got merged with the
nix-generated config instead of being overwritten :confused:

1 Like

Yes, but I’d like to get this in toml:

[component.section]
key = [ 
    { if = "condition", then = "value_if_true" },
    { if = "condition_2", then = "value_if_true" },
    { else = "value_if_false" }
]

But I don’t see how that’s possible in nix.

It’s not possible with the default builder. It’s also purely cosmetic, like wanting nix to add more spaces for indentation.

How is this purely cosmetic?

[auth.dkim]
sign = [
  { if = "listener != 'smtp'", then = "['rsa', elliptic']" },
  { else = false }
]

or do you mean that I should simply reduce this to

[auth.dkim]
sign = [ "rsa", "elliptic"]

?

No no, it’s like how in the Nix language the difference between

{
  foo.bar = 1;
  foo.qux = 2;
}

and

{
  foo = { bar = 1; qux = 2; };
}

is cosmetic. In the TOML language (forgive the redundancy),

[[auth.dkim.sign]]
foo = bar

(note the double brackets!) means the same thing as this:

[auth.dkim]
sign = [
  { foo = bar }
]
1 Like

Okay since no one answered your actual question:

You have something else (unrelated) wrong in your config.

    services.stalwart-mail = {
      enable = true;
      settings = {
        auth.dkim.sign = [
          {
            "if" = "listener != 'smtp'";
            "then" = "['rsa', 'elliptic']";
          }
          { "else" = false; }
        ];
      };
    };

Works perfectly fine for me.

TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Starting Stalwart Server v0.15.4 (server.startup) version = "0.15.4"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Spam classifier model not found (spam.model-not-found)
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Webadmin resource unpacked (resource.webadmin-unpacked) path = "/var/cache/stalwart-mail/STALWART_WEBADMIN"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Housekeeper process started (housekeeper.start)
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "webadmin.path": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "auth.dkim.sign.0000.if": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "auth.dkim.sign.0001.else": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "auth.dkim.sign.0000.then": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "resolver.public-suffix.0000": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "resolver.type": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP WARN Configuration build warning (config.build-warning) details = "WARNING for "spam-filter.resource": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Downloading external resource (resource.download-external) details = "Downloaded ASN/Geo data", url = "https://cdn.jsdelivr.net/npm/@ip-location-db/asn/asn-ipv4.csv", elapsed = 431ms
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Downloading external resource (resource.download-external) details = "Downloaded ASN/Geo data", url = "https://cdn.jsdelivr.net/npm/@ip-location-db/asn/asn-ipv6.csv", elapsed = 172ms
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Downloading external resource (resource.download-external) details = "Downloaded ASN/Geo data", url = "https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country/geolite2-geo-whois-asn-country-ipv4.csv", elapsed = 198ms
TIMESTAMP HOST stalwart-mail[845856]: TIMESTAMP INFO Downloading external resource (resource.download-external) details = "Downloaded ASN/Geo data", url = "https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-geo-whois-asn-country/geolite2-geo-whois-asn-country-ipv6.csv", elapsed = 317ms

So forget about this TOML conversion stuff, post your actual full config if you want help debugging.

2 Likes

I am quite certain that I just ran into the same issue.

The error:

ERROR Configuration parse error (config.parse-error) details = "Failed to parse setting "auth.dkim.sign": Multiple 'else' found"

The relevant part of my Nix config looks like this:

services.stalwart = let
    ifthen = field: data: {
        "if" = field;
        "then" = data;
    };
    otherwise = value: { "else" = value; };
in {

    # ...

    settings.auth.dkim = {
        verify = "strict";
        strict = true;
        sign = [
            (ifthen "is_local_domain('', sender_domain)" "['rsa-' + sender_domain, 'ed25519-' + sender_domain]")
            (otherwise false)
        ];
    };
};

And the (correctly) generated part of the TOML config file looks like this:

[auth.dkim]
strict = true
verify = "strict"

[[auth.dkim.sign]]
if = "is_local_domain('', sender_domain)"
then = "['rsa-' + sender_domain, 'ed25519-' + sender_domain]"

[[auth.dkim.sign]]
else = false

From what I understand, Stalwart parses the TOML config once at startup, only to copy the settings into a database.
When you make changes in the web interface (which I assume you really, really should not do), Stalwart updates the database but fails to update the config file because it is read-only.

If you then make changes via NixOS to the config file, Stalwart appears to mix things up between the config file and the database. At some point, my if, then, and else statements had magically duplicated themselves in the database entry.

The admittedly somewhat brutal workaround I found was:

  • Back up your email using a mail client
  • Disable Stalwart in the Nix config and switch to the new configuration
  • Remove the entire /var/lib/stalwart-mail directory
  • Re-enable Stalwart via Nix

Stalwart then rebuilds its database using the correct config values.

There must be a better way to resolve this issue, but the moral of the story is:

Never make any changes via Stalwart’s web interface when the configuration is managed by Nix.

1 Like

Probably hand-updating the database entry for the configuration. Possibly a bug report to stalwart because they shouldn’t duplicate configuration and sync it, especially ignoring write errors.

1 Like

A Nix user has already created an issue about read-only configuration files here:

https://github.com/stalwartlabs/stalwart/issues/531

Unfortunately, it was locked due to…

“…the unproductive, uninformed and snarky comments from @andreymal”

The Stalwart maintainer was quite adamant that the web admin interface requires a writable configuration file.

The available workarounds seem to be either not using the web interface at all or marking all relevant configuration parameters as so-called “local-keys”.
This ensures they are stored only in the configuration file and not in the database.
Any changes made to these parameters via the web interface are rejected, thereby avoiding data inconsistencies.

These are the local-keys I added to my configuration:

config.local-keys = [
    "store.*"
    "directory.*"
    "tracer.*"
    "server.*"
    "!server.blocked-ip.*"
    "!server.allowed-ip.*"
    "authentication.fallback-admin.*"
    "cluster.*"
    "storage.data"
    "storage.blob"
    "storage.lookup"
    "storage.fts"
    "storage.directory"
    "spam-filter.resource"
    "webadmin.resource"
    "webadmin.path"
    "config.local-keys.*"
    "lookup.default.hostname"
    "lookup.default.domain"
    "certificate.*"
    "auth.dkim.*"
    "signature.*"
    "imap.*"
    "session.*"
    "resolver.*"
    "queue.*"
];

(The configuration parameter names seem to change quite often, so it’s best to verify them against your own configuration.)

Perhaps these local-keys should be set by default in the Nix package to avoid this issue.
However, I am not sure whether this would introduce new problems for “cluster deployments”:

“Stalwart has this ‘weird’ configuration design because it needs to be able to work in distributed environments. Some settings are local to the node (stored in a TOML file), while others apply to the entire cluster (stored in the database). […]”

P.S.

I should also mention that Stalwart does emit warnings about this issue on startup:

WARN Configuration build warning (config.build-warning) details = "WARNING for 'queue.strategy.route.0000.if': Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings"

I… Still think it’s stupid to persist configuration in this half-state. I’d just write a patch for stalwart that only commits the database transaction if writing to the config file was successful. This should even be possible in a distributed environment (or rather, in that kind of environment you’d probably want to disable the config file entirely and just use the database - the comments are needlessly snarky, but I do think that person has a point; the UX of mixing the source of truth for configuration doesn’t seem to be considered, the least you can do if you insist on distributed transactions for configuration management is make sure the transactions are actually successful). NixOS does show the issue, but other distros will also be affected; any file write failure will result in broken state.

Either way, doesn’t sound like a difficult patch, if upstream doesn’t want to carry it maintaining it downstream probably wouldn’t be too much of a pain; I’d even check if it’s an acceptable addition to the nixpkgs package.

2 Likes

While I agree that the inconsistency between the configuration file and the database should be addressed, I also think that local-keys should be added to the Nix package as a default option.


Before opening an issue for Stalwart, I wanted to document the data duplication I encountered:

Steps to Reproduce

  • Ensure dkim.verify.* is not explicitly marked as "local-key".
  • Add the following condition to the Stalwart configuration:
[[auth.dkim.sign]]
if = "is_local_domain('', sender_domain)"
then = "['rsa-' + sender_domain, 'ed25519-' + sender_domain]"

[[auth.dkim.sign]]
else = false
  • Stalwart should display similar local-key warnings:
WARNING for "auth.dkim.sign.0001.else": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings
WARNING for "auth.dkim.sign.0000.then": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings
WARNING for "auth.dkim.sign.0000.if": Database key defined in local configuration, this might cause issues. See https://stalw.art/docs/configuration/overview/#local-and-database-settings
ERROR Configuration parse error (config.parse-error) details = "Failed to parse setting \"auth.dkim.sign\": Found 'then' in 'else' block"

Possible Root Cause

I had a look at Stalwarts source, and if I understand correctly, the issue may originate from the ConfigManager::set function:

https://github.com/stalwartlabs/stalwart/blob/9595d86ca813d1ecdf531fd37a49715bee27e2cf/crates/common/src/manager/config.rs#L176

It appears to first update the data stores and only afterward call ConfigManager::update_local, which fails if the configuration file is read-only.

I assume that ConfigManager::clear and ConfigManager::clear_prefix might suffer from the same issue:

Adding a file permission check before updating the data stores would likely prevent this inconsistency.

I attempted to write a patch, but given my limited knowledge of Stalwart and Rust, I had to concede defeat.


Should I open an issue regardless of not having a patch ready?