Skip to content

Add outbound relay (smarthost) admin UI#4

Open
cyberb wants to merge 14 commits into
masterfrom
relay-outbound-admin
Open

Add outbound relay (smarthost) admin UI#4
cyberb wants to merge 14 commits into
masterfrom
relay-outbound-admin

Conversation

@cyberb

@cyberb cyberb commented Jul 15, 2026

Copy link
Copy Markdown
Member

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

  • Relay settings (enabled/host/port/user/password) persist in the [relay] section of user_mail.cfg (chowned to mail).
  • main.cf is static and references sender_dependent_relayhost_maps + sasl_passwd. The webui daemon runs as mail and, on save, writes and postmaps those two maps — no main.cf regeneration, no postfix reload, no platform RestartService.
  • Changes apply immediately: trivial-rewrite recycles per request (master.cf: -o max_use=1), so postfix re-reads the maps on the next message.
  • Outbound SMTP AUTH: the postfix build bundles libsasl2-modules (PLAIN/LOGIN client mechs) into postfix/lib/sasl2, and main.cf passes SASL_PATH via import_environment so the smtp client finds them.

Admin UI

  • Vue 3 + Element Plus page at /admin/ (nginx proxies /api/ to the webui socket), with a "Back to mail" link.
  • Admin button in the Roundcube Elastic taskbar (mailadmin plugin) linking to /admin/.
  • Roundcube bumped 1.6.1 → 1.6.15 (LTS security).

Migrate app state $SNAP_COMMON$SNAP_DATA

State (Postgres DB, DKIM keys, maildirs, spool, relay creds, configs) never rolled back on a failed refresh. Moved it all to $SNAP_DATA, keeping only web.socket in $SNAP_COMMON (platform contract; kept mail-owned so nginx can bind it). MigrateCommonToData() relocates existing installs once (marker-guarded, at the top of InitConfig), and the DB's postgresql.conf is refreshed from the template so upgrades repoint it to current.

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, and test-upgrade (published-stable → this build) all pass.

Closes syncloud/platform#553 · relates to syncloud/platform#567

cyberb added 14 commits July 15, 2026 15:31
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.
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.

[mail] implement ISP relay (indirect) delivery feature

1 participant