TLS certificates ignored + SASL mechanisms require port-hack behind reverse proxy

Hello Stalwart team!

First, thank you for building such a robust and modern mail server. I have been testing and deploying it recently, and I wanted to report a couple of interrelated issues I encountered when running Stalwart behind a reverse proxy. I’ve included detailed troubleshooting steps and some proposed solutions that might help improve the experience for users with similar architectural setups.

Environment

  • Stalwart version: v0.16.19 (Docker stalwartlabs/stalwart:latest, network_mode: host)
  • Deployment: Docker on Ubuntu 18.04, with an nginx reverse proxy handling TLS termination.
  • Store: SQLite
  • DNS: mail.gustavo.pro209.54.105.125

Problem 1: TLS certificates ignored on IMAPS / POP3S / SMTPS

Description

Currently, Stalwart serves an rcgen self signed cert on ports 993, 995, and 465. This happens even though valid Let’s Encrypt certificates are properly configured via the admin UI and explicitly set as the defaultCertificateId. Interestingly, only HTTPS (port 443, handled by the nginx reverse proxy) successfully serves the correct certificate. Because email clients strictly validate certificates, this self-signed fallback prevents users from connecting securely.

Steps to reproduce

  1. Upload a valid TLS certificate via JMAP (Certificate/set) with is_default: true.
  2. Confirm the certificate exists and is set as defaultCertificateId in SystemSettings.
  3. Connect to any of the affected ports to inspect the certificate. For example:
echo | openssl s_client -connect mail.gustavo.pro:993 2>/dev/null | openssl x509 -noout -subject
# Result: subject=CN = rcgen self signed cert  ← INCORRECT

echo | openssl s_client -connect mail.gustavo.pro:443 2>/dev/null | openssl x509 -noout -subject
# Result: subject=CN = mail.gustavo.pro  ← CORRECT

What we tried

  • Verified that the underlying certificate files exist on the filesystem and are readable (/etc/stalwart/certs/fullchain.pem, /etc/stalwart/certs/privkey.pem, with 644 permissions).
  • Called ReloadTlsCertificates via the CLI. This failed with a Registry deserialization error, pointing to old, corrupted certificate entries in the SQLite database.
  • Attempted to delete the old certificate entries via a JMAP Certificate/set destroy command. This resulted in a serverUnavailable error, as the DB corruption seems to block the operation entirely.
  • Created a brand-new certificate via the CLI and set it as defaultCertificateId, but it is still ignored.
  • Upgraded from v0.16.15 to v0.16.19 to see if a recent patch addressed this, but the issue persists.

Root cause hypothesis

It appears the SQLite key-value store might contain corrupted or unreadable older certificate entries (e.g., izt2uylwaaqb, izcwizbgaaqb). When the TLS layer attempts to load certificates, it likely encounters these corrupted entries first, fails silently, and automatically falls back to generating the rcgen self-signed certificates. Currently, there doesn’t seem to be a mechanism to force-delete these corrupted entries or recover the database from this state.

Suggested improvements

  • CLI Recovery Tool: It would be incredibly helpful to have a CLI flag like stalwart-mail --reset-certs or --repair-db to safely purge all certificate entries from the store, allowing administrators to start fresh and re-upload valid certs.
  • Improved Logging: Logging a clear warning or error when certificate loading fails (rather than failing silently) would greatly speed up debugging.
  • Graceful Fallback: If the database-stored certificates fail to load, perhaps the system could attempt to fall back to the raw filesystem paths defined in the Certificate configuration before resorting to a self-signed certificate.

Problem 2: SASL mechanisms have no way to detect upstream TLS

Description

In containerized environments, it is standard practice to terminate TLS at an external reverse proxy (like nginx, Caddy, or Traefik) to centralize certificate management. However, when TLS is terminated externally, Stalwart’s internal is_tls variable evaluates to false on all ports.

The default saslMechanisms configuration relies heavily on the is_tls variable to securely permit PLAIN/LOGIN authentications:

Condition 0: local_port != 25 && is_tls  → [plain, login, oauthbearer, xoauth2]
Condition 1: local_port != 25            → [oauthbearer, xoauth2]
Default: false

Consequently, PLAIN/LOGIN authentication is silently disabled on all ports when operating behind a reverse proxy. Clients like Thunderbird will inexplicably fail with the error: > “The Outgoing server (SMTP) does not support the selected authentication method.”

Current workaround

Administrators can override the SASL configuration by explicitly hardcoding port numbers:

{
   "saslMechanisms": {
     "match": {
       "0": {"if": "local_port == 587 || local_port == 465", "then": "[plain, login, oauthbearer, xoauth2]"},
       "1": {"if": "local_port != 25 && is_tls", "then": "[plain, login, oauthbearer, xoauth2]"},
       "2": {"if": "local_port != 25", "then": "[oauthbearer, xoauth2]"}
     },
     "else": "false"
   }
}

While this works, it requires undocumented, per-deployment knowledge and is quite fragile if port assignments ever change.

Suggested fix

It would be fantastic if we could introduce a tlsTerminated (or assumeTls) boolean field on the NetworkListener configuration.

When set to true:

  1. The is_tls variable would automatically evaluate to true for SASL matching, rate limiting, and other relevant logic.
  2. The cert-related features (IMAPS/POP3S/SMTPS) would know that TLS is being handled externally, preventing them from trying to serve their own certificates unnecessarily.

This approach aligns perfectly with how proxy protocols are currently handled (e.g., overrideProxyTrustedNetworks on NetworkListener).

Example conceptual config:

{
   "bind": {"0.0.0.0:587": true},
   "protocol": "smtp",
   "tlsTerminated": true
}

With this in place, the default SASL expression (local_port != 25 && is_tls) would work out of the box for reverse proxy setups.


Impact Summary

Combined, these two issues currently make Stalwart difficult to deploy behind standard nginx/Caddy reverse proxies without relying on undocumented workarounds (like setting up a separate nginx stream block to handle TLS on mail ports, and manually overriding SASL configs). Given how common the Docker network_mode: host + reverse proxy pattern is for mail servers, streamlining this would be a huge win for the community.

Thank you so much for your time and for considering these suggestions! I would be more than happy to help test any patches or provide further logs if needed.