Add outbound relay (smarthost) admin UI#4
Open
cyberb wants to merge 14 commits into
Open
Conversation
Self-hosted mail sent directly from home/residential IPs is widely rejected by Gmail/Outlook (IP reputation, Spamhaus PBL) and many ISPs block port 25, so direct delivery fails regardless of SPF/DKIM/DMARC. The industry-standard fix, as shipped by mailcow/mailu/docker-mailserver, is to route outbound mail through an authenticated relay (smarthost). Productizes the manual workaround from platform issue #553 so it also survives upgrades: - Persist relay settings (enabled/host/port/user/password) in the [relay] section of user_mail.cfg under $SNAP_COMMON. - Postfix main.cf template renders a relayhost + SASL block when relay is enabled; RegenerateConfigs writes and postmaps sasl_passwd, so the relay is reapplied on every install/refresh. - New webui daemon (cmd/webui, rest.Server) serves GET/POST /api/relay over a unix socket, reusing the installer to apply changes and restart postfix via the platform. - nginx serves a Vue 3 + Element Plus admin page at /admin/ and proxies /api/ to the webui socket, alongside Roundcube webmail (mailcow-style split of webmail vs server admin). - Playwright spec covers the relay form end to end. Also bump Roundcube 1.6.1 -> 1.6.15 (LTS security releases).
$SNAP_DATA is snapshotted and rolled back transactionally on a failed refresh; $SNAP_COMMON is shared across revisions and never rolls back. The app kept all mutable state (Postgres DB, DKIM keys, maildirs, spool, relay credentials, generated configs) in $SNAP_COMMON, so a rolled-back binary could face newer-format data. Move it all to $SNAP_DATA, keeping only web.socket in $SNAP_COMMON (the Syncloud platform routes app traffic to that hardcoded path). - installer data root common -> current; add AppCommonDir so nginx still binds web.socket in $SNAP_COMMON. - MigrateCommonToData() renames every $SNAP_COMMON entry (except sockets) into $SNAP_DATA once, guarded by a marker; runs at the top of InitConfig so both install and post-refresh migrate existing devices. - Service scripts use $SNAP_DATA (except the web.socket line); php-fpm.conf pid/listen paths templated via AppDataDir.
After moving app state to $SNAP_DATA, InitConfig only chowned the data dir, leaving $SNAP_COMMON root-owned. The nginx service runs as mail and must create web.socket under $SNAP_COMMON (platform contract), so bind failed with EACCES and nginx crash-looped (platform served 503). Ensure $SNAP_COMMON exists and is owned by mail, as it was before the migration.
- Element Plus forwards data-testid onto the inner <input>, so target it
directly instead of .locator('input') (which searched for an input
inside the input and timed out).
- The relay save path (ApplyRelay -> RegenerateConfigs + postmap + platform
RestartService) needs privilege; run the webui daemon as root like the
hooks that already call the platform. The socket is chmod 0666 so nginx
(mail) can still connect.
Roundcube (the default web UI at /) had no way to reach the new relay admin page. Add a small mailadmin plugin that registers an "Admin" button in the Elastic taskbar (with a gear icon via CSS mask so it matches the skin), linking to /admin/. Packaged into roundcubemail/plugins and enabled in config.inc.php. The e2e now logs into Roundcube and clicks the button to reach the relay form.
Roundcube rewrote the plugin button href '/admin/' into a skin-relative 'skins/elastic/admin/' (404). Set the correct absolute href and force navigation on click in the footer script, so the taskbar Admin button reliably opens /admin/ regardless of Roundcube's taskbar click handling.
On upgrade the stable DB's postgresql.conf (with unix_socket_directories baked to /var/snap/mail/common/database) is carried to $SNAP_DATA by the migration but still targets the now-gone common path, so postgres fails to create its lock file and the refresh aborts. Rewrite the data dir's postgresql.conf from the template on every InitConfig (extracted as Database.UpdateConfig, reused by Init), so it always matches the current data-dir location.
…ilpit Drop root from the webui daemon. Relay is now fully lookup-table driven: main.cf statically references sender_dependent_relayhost_maps + sasl_passwd, and the webui (as mail) writes and postmaps those two maps on save — no main.cf regeneration, no postfix reload, no platform RestartService. The mail-owned config dir makes all of this doable without privilege. To make map changes take effect immediately (postfix caches lookup maps for a daemon's lifetime), the trivial-rewrite service recycles per request (master.cf: rewrite -o max_use=1), so a relay toggle applies on the next message without a reload. E2e now covers both modes end to end, purely through the UI: - 02-send: plain local send arrives in the inbox. - 03-relay: log in, open the admin page via the Roundcube button, save relay credentials in the form, return to webmail via a new "Back to mail" link, compose an external message, then poll the relay's REST API until it arrives. The relay target is a Mailpit service (authenticated SMTP + REST), added to the amd64 pipeline.
The Configure hook (root) creates user_mail.cfg during activation, after InitConfig's chown, so it stayed root-owned and the mail-user webui got permission denied writing relay settings. Chown it to mail in setActivated.
linux.Chown runs chown -R on dir/ with a trailing slash, which fails on a file (Not a directory). Chown the file directly instead.
The postfix build linked libsasl2 but never installed libsasl2-modules, so the SMTP client had no PLAIN/LOGIN mechanism plugin and authenticating to a relay failed with 'No worthy mechs found' (breaks relay to Gmail/any authenticated smarthost, caught by the relay e2e). Install libsasl2-modules and copy the sasl2 plugins into postfix/lib/sasl2, where SASL_PATH points.
The sasl2 plugins are bundled at postfix/lib/sasl2 and service.postfix.sh exports SASL_PATH, but postfix's master strips env vars not listed in import_environment, so the smtp client never saw it and still reported 'No worthy mechs found'. Set SASL_PATH via import_environment in main.cf.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Self-hosted mail sent directly from home/residential IPs is widely rejected by Gmail/Outlook (IP reputation, Spamhaus PBL, Nov-2025 hard 5.7.26 rejections) and many ISPs block port 25 (platform issue #567). SPF/DKIM/DMARC/PTR are necessary but not sufficient. The industry-standard fix — shipped by mailcow/mailu/docker-mailserver — is an authenticated relay (smarthost). This productizes the manual workaround from platform issue #553 and makes it survive upgrades.
What
Outbound relay (smarthost), non-root, map-driven
enabled/host/port/user/password) persist in the[relay]section ofuser_mail.cfg(chowned tomail).main.cfis static and referencessender_dependent_relayhost_maps+sasl_passwd. Thewebuidaemon runs asmailand, on save, writes andpostmaps those two maps — nomain.cfregeneration, nopostfix reload, no platformRestartService.trivial-rewriterecycles per request (master.cf: -o max_use=1), so postfix re-reads the maps on the next message.libsasl2-modules(PLAIN/LOGIN client mechs) intopostfix/lib/sasl2, andmain.cfpassesSASL_PATHviaimport_environmentso thesmtpclient finds them.Admin UI
/admin/(nginx proxies/api/to the webui socket), with a "Back to mail" link.mailadminplugin) linking to/admin/.1.6.1 → 1.6.15(LTS security).Migrate app state
$SNAP_COMMON→$SNAP_DATAState (Postgres DB, DKIM keys, maildirs, spool, relay creds, configs) never rolled back on a failed refresh. Moved it all to
$SNAP_DATA, keeping onlyweb.socketin$SNAP_COMMON(platform contract; keptmail-owned so nginx can bind it).MigrateCommonToData()relocates existing installs once (marker-guarded, at the top ofInitConfig), and the DB'spostgresql.confis refreshed from the template so upgrades repoint it tocurrent.E2e (both modes, pure UI)
02-send: plain local send arrives in the inbox.03-relay: log in → Admin button → save relay credentials in the form → "Back to mail" → compose an external message → poll the relay's REST API until it arrives. Relay target is a Mailpit service (authenticated SMTP + REST) on the amd64 pipeline.Validation — CI build #369, amd64 all green
test bookworm/buster,e2e(relay send authenticates to the relay and is delivered),e2e-mobile, andtest-upgrade(published-stable → this build) all pass.Closes syncloud/platform#553 · relates to syncloud/platform#567