Skip to content

feat(mail): make mailcow a selectable mail backend, installable from the panel - #12

Open
redstonerthebest wants to merge 12 commits into
MythicalLTD:masterfrom
redstonerthebest:pr/mailcow-mail-backend
Open

redstonerthebest wants to merge 12 commits into
MythicalLTD:masterfrom
redstonerthebest:pr/mailcow-mail-backend

Conversation

@redstonerthebest

Copy link
Copy Markdown
Contributor

FeatherQuilld: selectable mail backend (mailcow: dockerized)

What this adds

system.mail.backend selects the stack a node manages:

Value Stack
docker-mailserver (default, aliases docker, docker-mail-server, dms) unchanged historic behaviour
mailcow (aliases mailcow-dockerized, mailcowdockerized) full mailcow stack, driven through its REST API

/api/mail/* stays identical for both backends (domains, mailboxes, passwords, enable/disable,
aliases, spam filter, autoresponders, lists), so the panel and the WebSpace DNS provisioning do
not need to know which stack answers. An unknown value is rejected instead of silently managing
the other stack.

mailcow can either be installed by FeatherQuilld on the node itself (host package mailcow,
like mailserver/webmail today) or already run on another host (system.mail.mailcow.url +
api_key).

Why

Nodes that already run mailcow had to be managed outside the panel, and the docker-mailserver
stack is not the right answer for every operator. The switch keeps existing installations
byte-identical (same default, same data paths, same API payloads) while making mailcow a
first-class option.

What the last commit improves (self-service)

Reviewing the branch from an operator's point of view showed three things that forced people to
read the code:

  1. docs/mail-backends.md described what the backend does, not how to get a mailcow running
    (requirements, config, DNS, verification, failure modes). It is now a setup guide for both
    paths, with the complete system.mail.* reference incl. defaults.
  2. A stack that was not running produced a bare … mail server is not running. Now
    IMailBackend.NotRunningHint() (a default interface member, so third-party backends and test
    fakes keep compiling) supplies the actionable text: which config keys to set, or which host
    package to install. MailManager appends it, MailBackendFactory lists the valid values and
    points at the doc, and the startup self-test tells a local from a remote mailcow.
  3. MailController.RequireManager gated on MailProbe.ContainerRunning(), i.e. on the
    docker-mailserver container name. With a mailcow on another host every /api/mail/* call
    failed with "not running on this node" while the API answered. It now uses
    MailProbe.StackRunning(), the predicate that already knows about remote stacks.

Verification

  • git am of the eight patches on top of master (9d8c72a) in a fresh clone: clean.
  • dotnet build: succeeded, 0 errors.
  • dotnet test --filter "FullyQualifiedName~Mail": 96/96 passed (new tests cover the factory
    message, both NotRunningHint() variants - local and remote mailcow - and the guard message).
  • mailcow payloads were verified against a live stack (Version 2026-09a) in the earlier
    commits of this branch: add/* flat, edit|delete with {items, attr} (the plain array for
    delete/*), add/domain needing a real mailbox limit (0 rejects every mailbox), DKIM read
    through get/dkim/<domain> because current mailcow keeps keys in redis, and the API key coming
    from mailcow.conf (API_KEY, API_ALLOW_FROM, SKIP_IP_CHECK=y when the API is only
    published on loopback).

Notes for the reviewer

  • No behaviour change for existing installations: the default stays docker-mailserver, no
    config key is required, and nothing under /var/lib moves.
  • The mailcow installer runs mailcow's own generate_config.sh non-interactively (upstream
    removed mailcow.conf.example; .env is a symlink to mailcow.conf) and only then overrides
    the keys the panel owns.
  • Ports are policy, not a technical detail: the default keeps 80/443 with the panel's proxy and
    publishes the mailcow UI/API on loopback (8080/8443). Operators can change both.
  • Autoresponders remain local-only for mailcow (installed with doveadm sieve inside the
    dovecot container); everything else works against a remote stack.

Lennard Wilmer and others added 11 commits October 10, 2026 12:50
system.mail.backend now selects the stack the panel manages:
docker-mailserver (default, behaviour unchanged) or mailcow: dockerized.

- IMailBackend + MailBackendFactory: MailManager keeps payload parsing, event
  hooks and bookkeeping (domains file, autoresponder state, lists) and delegates
  the stack specific work, so /api/mail/* stays identical for both backends.
- DockerMailserverBackend: the previous MailManager logic, unchanged.
- MailcowBackend: domains, mailboxes, passwords, enable/disable, aliases, DKIM
  and the per-mailbox spam score via mailcow's REST API; autoresponder via
  doveadm sieve inside the dovecot container (no mailbox password required).
  delete/alias resolves the alias id first, because mailcow deletes by id.
- HostPackageManager: new "mailcow" package next to "mailserver" (compose fetch,
  mailcow.conf generation for hostname/ports/ACME, up/down on purge).
- MailProbe, StartupSelfTest and MailDnsHelper are backend aware; mailcow DKIM
  keys are read from data/dkim/<domain>/<selector>.txt.
- Unknown backend values are rejected instead of silently managing the other stack.
- README "Mail backends" section plus tests for backend selection, mailcow
  payloads, alias id resolution and the autoresponder script.
…ailcow

Upstream removed mailcow.conf.example and moved DKIM keys into redis; the
mailcow backend of this branch was written against the older layout.

- mailcow.conf is now created by mailcow's own generate_config.sh, run
  non-interactively (hostname/timezone/ClamAV answer passed in, --dev keeps it
  from checking out a branch, stdin feeds the overwrite prompt) and afterwards
  only the keys the panel owns are overridden. A checkout without the script
  gets a documented fallback file instead of an empty config.
- API_KEY/API_KEY_READ_ONLY/API_ALLOW_FROM are generated into mailcow.conf and
  the same key is written to feather-api-key, so nothing has to be pasted into
  the mailcow UI; the UI/API is published on loopback only (the panel's proxy
  owns 80/443) and SKIP_IP_CHECK is set because docker rewrites the source IP.
- ClamAV is disabled below 2.5 GiB RAM, mirroring the installer's own
  recommendation, so the unattended install cannot hang on that prompt.
- DKIM hints read the public record through GET /api/v1/get/dkim/<domain>
  (redis-backed, chunks joined); the data/dkim/<domain>/<selector>.txt
  candidates stay as a legacy fallback, and a non-object API answer (mailcow
  returns [] for a domain without a key) no longer throws.
- docs/mail-backends.md documents the backend, the conf rules and the DKIM
  path; tests cover the conf rules, the key material and both DKIM sources.
The mailcow backend assumed the stack runs on the same node and gated every
operation on local docker containers, so a mailcow on a dedicated mail host
(mail.mailcow.url + API key) was reported as "not running".

- IMailcowApi.PingAsync (default false) + MailcowApiClient implementation of
  GET /api/v1/get/status/version: the API answering is what "running" means for
  a remote stack.
- MailcowDocker.StackReachable() = local containers OR remote API answering;
  RemoteHost() labels the target ("mail.allo.bet (remote)") for diagnostics.
- MailProbe.StackRunning/StackIdentifier and MailcowBackend.IsRunning use it, so
  /api/mail/* works against a remote mailcow; MailcowBackend.ProbeStatus reports
  mode (local|remote), the remote host and only requires local ports for local
  stacks.
- MailController: probe and the best-effort DKIM generation use the backend-aware
  probe instead of looking for the docker-mailserver container.
- tests: PingAsync (version document, missing key, error body) -> 456 tests.
Live test against a mailcow that runs on its own host found two payload bugs:

- add/domain sent "mailboxes":"0". mailcow's mailbox add rejects while
  count >= domain.mailboxes (functions.mailbox.inc.php), so 0 means "no mailboxes
  at all" and every mailbox on an auto-created domain failed with
  max_mailbox_exceeded; 0; 0. Now driven by mail.mailcow.domain_mailbox_limit (default 10).
- delete/{domain,mailbox,alias} sent {"items":[..]}. For action=delete json_api.php sets
  $_POST['items'] = <whole request body>, and the handler json_decodes it again, so the
  wrapped object arrived as items = {"items":[..]} and mailcow answered access_denied.
  The delete endpoints take the bare array body.

Verified live: auto-added domain, mailbox creation, IMAP/SMTP login, delete cycle.
Tests 460 pass.
MailDnsHelper.ResolveMailHostname() fell back to "mail.<domain>" whenever
mail.hostname was empty, so a node with a remote mailcow published MX/SPF records
pointing at a host that does not exist (seen live: MX mail.lennyplugins.dev for a
mailcow reachable at mail.allo.bet). Prefer the configured hostname, then the
mailcow host/url, and only invent mail.<domain> when nothing is configured.

Tests: 463 pass (3 new).
A mail domain that only has MX/SPF/DKIM/DMARC works, but mail clients cannot
configure themselves. The hints now also carry autodiscover/autoconfig (CNAME to
the mail host) and the SRV set for IMAP/submission/POP3, marked `optional` so a
panel or provider that cannot write them never fails the run. Ports come from the
mail config (imap_port/smtp_port), POP3s uses the standard 995.

Tests: 465 pass (2 new).
The deliverability checks probed 127.0.0.1 for the mail ports and measured the reverse DNS of the
web node's IP, so a domain whose mail runs on a remote mailcow reported "not listening" and a
failing PTR while the mail server was healthy. Ports are now probed on the configured mail host
(loopback still accepted for a local stack) and the PTR check resolves the MX target's address
instead of the node's; the node's own reverse DNS is reported separately because it only matters
when that node sends mail itself.
Operators who wanted mailcow had to read the code: the doc described what the
backend does, not how to get a stack running, the config keys were only visible
in the source, and a mailcow on another host failed every /api/mail call with
"not running on this node" although its API answered.

- docs/mail-backends.md is now a setup guide: requirements, both paths
  (FeatherQuilld installs the stack / use a mailcow you already run), the full
  system.mail.* reference with defaults, DNS records, verification commands and
  a troubleshooting table.
- NotRunningHint() as a default member on IMailBackend: the guard message now
  names the config keys to set or the host package to install. MailManager
  appends it, MailBackendFactory lists the valid values and points at the doc.
- MailController gates on MailProbe.StackRunning() instead of the
  docker-mailserver container name, so the mail API also serves a remote mailcow.
- Startup self-test: a configured remote mailcow reports api_key/API_ALLOW_FROM
  and ports instead of "install the package".
BuildHints_IncludesMxAndSpfWithoutDkimFile asserted that DKIM is not ready for
example.com, but the config used the default root directory, so the assertion
read /var/lib/featherquilld/mail on the machine running the tests. On a node
that has ever provisioned example.com (or kept a key from a live test) the test
fails while nothing is wrong with the code.

Point the config at a throwaway root, like the other mail tests do.
The panel's package manager renders whatever GET /api/system/packages returns,
so an operator installing mailcow from the panel saw a name and an Install
button but no word about the backend switch or where the guide lives.

- HostPackageStatus gains description and docs_url; the mail packages (and
  docker, which they depend on) fill them. The mailcow text names the config key
  to set (system.mail.backend: mailcow) and points at docs/mail-backends.md.
- Tests assert both, so the panel keeps having something to show.
@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8275be92-dc3a-4fd0-ae50-05298c36cb86

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…pushed yet, test/code unchanged vs upstream)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant