Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a99d005
Add outbound relay (smarthost) admin UI
cyberb Jul 15, 2026
451e31d
Migrate app state from $SNAP_COMMON to $SNAP_DATA
cyberb Jul 15, 2026
77df7e2
Make www/build.sh executable
cyberb Jul 15, 2026
dcb864f
test: point data_dir fixture at $SNAP_DATA after migration
cyberb Jul 15, 2026
a666ebb
Keep $SNAP_COMMON owned by mail so nginx can bind web.socket
cyberb Jul 16, 2026
9efb66c
Fix relay e2e: fill inputs by testid; run webui as root
cyberb Jul 16, 2026
9484625
Add Admin button to Roundcube taskbar linking to /admin/
cyberb Jul 16, 2026
087b66a
Fix admin button navigation to /admin/
cyberb Jul 16, 2026
cca6df5
Refresh DB postgresql.conf on upgrade so it points at $SNAP_DATA
cyberb Jul 16, 2026
accf52c
Run webui as mail (non-root), map-driven relay, e2e relay send via Ma…
cyberb Jul 16, 2026
37156fd
Chown user_mail.cfg to mail so the non-root webui can write it
cyberb Jul 16, 2026
9660366
Chown user_mail.cfg via executor (golib Chown is directory-only)
cyberb Jul 16, 2026
e38b154
Bundle Cyrus SASL client mechanisms for outbound relay auth
cyberb Jul 16, 2026
e304fe7
Pass SASL_PATH to postfix daemons so smtp client finds sasl mechs
cyberb Jul 16, 2026
d37767f
Drop UpdateConfig existence guard; call it only where the DB dir exists
cyberb Jul 24, 2026
e7d50d9
Mark MigrateCommonToData as removable after the upgrade window
cyberb Jul 24, 2026
d760f48
Take relay settings from the platform instead of a local admin UI
cyberb Jul 29, 2026
f6bb152
Relay over implicit tls, and upgrade the end of life components
cyberb Jul 29, 2026
ec3e1f0
Drop mcrypt from the php build
cyberb Jul 29, 2026
048ca16
Match the linker name glibc uses since 2.34
cyberb Jul 29, 2026
193a6ac
Tolerate a platform without the mail relay endpoint
cyberb Jul 29, 2026
02ecd10
Initialise the cluster with the libc locale provider
cyberb Jul 29, 2026
3f2d582
Build postgres the way the other apps do
cyberb Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .drone.jsonnet
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
local name = 'mail';
local roundcube = '1.6.1';
local dovecot = '2.3.16';
local nginx = '1.24.0';
local postfix = '3.4.28';
local roundcube = '1.6.15';
local dovecot = '2.3.21';
local nginx = '1.30.4';
local postfix = '3.11.5';
local python = '3.12-slim-bookworm';
local golang = '1.24.0';
local debian = 'bookworm-slim';
local bullseye = 'bullseye-slim';
local php = 'php:8.0.30-fpm-bullseye';
local postgres = 'postgres:9.4-alpine';
local php = 'php:8.3-fpm-bookworm';
local postgres = 'postgres:17-bookworm';
local platform = '26.04.10';
local playwright = 'mcr.microsoft.com/playwright:v1.48.2-jammy';
local store_publisher = 'stable-303';
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ log
lib
src/version
.drone.yml
.amd64watch.py
4 changes: 2 additions & 2 deletions bin/service.dovecot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ fi
export DOVECOT_BINDIR=${DIR}/dovecot/bin
case $1 in
start)
/bin/rm -rf ${SNAP_COMMON}/dovecot/master.pid
exec $DIR/dovecot/bin/dovecot.sh -F -c ${SNAP_COMMON}/config/dovecot/dovecot.conf
/bin/rm -rf ${SNAP_DATA}/dovecot/master.pid
exec $DIR/dovecot/bin/dovecot.sh -F -c ${SNAP_DATA}/config/dovecot/dovecot.conf
;;
*)
echo "not valid command"
Expand Down
2 changes: 1 addition & 1 deletion bin/service.nginx.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )

/bin/rm -f ${SNAP_COMMON}/web.socket
exec ${DIR}/nginx/bin/nginx.sh -c ${SNAP_COMMON}/config/nginx/nginx.conf -p ${DIR}/nginx -e stderr
exec ${DIR}/nginx/bin/nginx.sh -c ${SNAP_DATA}/config/nginx/nginx.conf -p ${DIR}/nginx -e stderr
2 changes: 1 addition & 1 deletion bin/service.opendkim.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fi

case $1 in
start)
exec $DIR/opendkim/bin/opendkim.sh -x ${SNAP_COMMON}/config/opendkim/opendkim.conf
exec $DIR/opendkim/bin/opendkim.sh -x ${SNAP_DATA}/config/opendkim/opendkim.conf
;;
*)
echo "not valid command"
Expand Down
4 changes: 2 additions & 2 deletions bin/service.php-fpm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ fi

case $1 in
start)
exec $DIR/php/bin/php-fpm.sh -y ${SNAP_COMMON}/config/php/php-fpm.conf -c ${SNAP_COMMON}/config/php/php.ini
exec $DIR/php/bin/php-fpm.sh -y ${SNAP_DATA}/config/php/php-fpm.conf -c ${SNAP_DATA}/config/php/php.ini
;;
post-start)
timeout 5 /bin/bash -c 'until [ -S '${SNAP_COMMON}'/log/php5-fpm.sock ]; do echo "waiting for ${SNAP_COMMON}/log/php5-fpm.sock"; sleep 1; done'
timeout 5 /bin/bash -c 'until [ -S '${SNAP_DATA}'/log/php5-fpm.sock ]; do echo "waiting for ${SNAP_DATA}/log/php5-fpm.sock"; sleep 1; done'
;;
*)
echo "not valid command"
Expand Down
4 changes: 2 additions & 2 deletions bin/service.postfix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ fi
case $1 in
start)
export SASL_PATH=$DIR/postfix/lib/sasl2
exec $DIR/postfix/bin/postfix.sh -c ${SNAP_COMMON}/config/postfix start
exec $DIR/postfix/bin/postfix.sh -c ${SNAP_DATA}/config/postfix start
;;
stop)
exec $DIR/postfix/bin/postfix.sh -c ${SNAP_COMMON}/config/postfix stop
exec $DIR/postfix/bin/postfix.sh -c ${SNAP_DATA}/config/postfix stop
;;
*)
echo "not valid command"
Expand Down
4 changes: 2 additions & 2 deletions bin/service.postgresql.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ fi

case $1 in
start)
exec ${DIR}/postgresql/bin/pg_ctl.sh -w -s -D ${SNAP_COMMON}/database start
exec ${DIR}/postgresql/bin/pg_ctl.sh -w -s -D ${SNAP_DATA}/database start
;;
stop)
exec ${DIR}/postgresql/bin/pg_ctl.sh -s -D ${SNAP_COMMON}/database stop -m fast
exec ${DIR}/postgresql/bin/pg_ctl.sh -s -D ${SNAP_DATA}/database stop -m fast
;;
*)
echo "not valid command"
Expand Down
8 changes: 8 additions & 0 deletions cli/cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ func main() {
},
})

cmd.AddCommand(&cobra.Command{
Use: "mail-relay-change",
RunE: func(cmd *cobra.Command, args []string) error {
logger.Info("mail-relay-change")
return installer.New(logger).MailRelayChange()
},
})

cmd.AddCommand(&cobra.Command{
Use: "certificate-change",
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down
2 changes: 1 addition & 1 deletion cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module hooks

require (
github.com/spf13/cobra v1.8.0
github.com/syncloud/golib v1.1.20
github.com/syncloud/golib v1.1.21-0.20260729184907-cdecff5b87eb
go.uber.org/zap v1.26.0
gopkg.in/ini.v1 v1.67.3
)
Expand Down
4 changes: 4 additions & 0 deletions cli/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/syncloud/golib v1.1.20 h1:dNekQRY+X0YwRtn9JtlvtKMbxcxQldSydTJdClvVOl0=
github.com/syncloud/golib v1.1.20/go.mod h1:XmmoqNuegLnl27FbmzLj60dTR/tN7c1X7Qp3uUPRC88=
github.com/syncloud/golib v1.1.21-0.20260729105707-df6b7ca9d901 h1:3m8gVms1fQhUU6ChC+Qmn3BUL5j3rMWbyQe7QRr0sBc=
github.com/syncloud/golib v1.1.21-0.20260729105707-df6b7ca9d901/go.mod h1:XmmoqNuegLnl27FbmzLj60dTR/tN7c1X7Qp3uUPRC88=
github.com/syncloud/golib v1.1.21-0.20260729184907-cdecff5b87eb h1:s7vgP+f39QkABsqCj2B6aImE1OhFIT1l8qh7XNJel2Y=
github.com/syncloud/golib v1.1.21-0.20260729184907-cdecff5b87eb/go.mod h1:XmmoqNuegLnl27FbmzLj60dTR/tN7c1X7Qp3uUPRC88=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
Expand Down
49 changes: 49 additions & 0 deletions cli/installer/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Database struct {
password string
port int
initFile string
backupFile string
executor *Executor
logger *zap.Logger
}
Expand All @@ -31,6 +32,7 @@ func NewDatabase(appDir, dataDir, configDir, name, user, password string, port i
password: password,
port: port,
initFile: path.Join(appDir, "roundcubemail", "SQL", "postgres.initial.sql"),
backupFile: path.Join(dataDir, "database.dump.sql"),
executor: executor,
logger: logger,
}
Expand All @@ -44,6 +46,49 @@ func (d *Database) Port() int {
return d.port
}

func (d *Database) pgDumpAll() string {
return path.Join(d.appDir, "postgresql", "bin", "pg_dumpall.sh")
}

// Backup, Remove and Restore are always run around a refresh rather than only
// when the postgres version changes, so that a major version bump needs no
// detection and no in place upgrade: the cluster is simply rebuilt from a dump
// the previous version produced.
func (d *Database) Backup() error {
if _, err := os.Stat(d.databaseDir); os.IsNotExist(err) {
return nil
}
d.logger.Info("dumping database")
_, err := d.executor.RunDir("", d.pgDumpAll(), "-U", d.user, "-h", d.databaseDir, "-f", d.backupFile)
return err
}

// Rebuild throws away the cluster the previous revision left behind and starts
// an empty one of the current version, ready for the dump to be loaded once
// postgres is running. It is a no op without a dump to restore from.
func (d *Database) Rebuild() error {
if _, err := os.Stat(d.backupFile); os.IsNotExist(err) {
return d.UpdateConfig()
}
d.logger.Info("removing old cluster")
if err := os.RemoveAll(d.databaseDir); err != nil {
return err
}
return d.Init()
}

func (d *Database) Restore() error {
if _, err := os.Stat(d.backupFile); os.IsNotExist(err) {
return nil
}
d.logger.Info("restoring database")
if _, err := d.executor.RunDir("", d.psql(), "-U", d.user, "-h", d.databaseDir,
"-d", "postgres", "-f", d.backupFile); err != nil {
return err
}
return os.Remove(d.backupFile)
}

func (d *Database) psql() string {
return path.Join(d.appDir, "postgresql", "bin", "psql.sh")
}
Expand All @@ -54,6 +99,10 @@ func (d *Database) Init() error {
if _, err := d.executor.RunDir("", "sudo", "-H", "-u", d.user, initdb, d.databaseDir); err != nil {
return err
}
return d.UpdateConfig()
}

func (d *Database) UpdateConfig() error {
src := path.Join(d.configDir, "postgresql", "postgresql.conf")
dst := path.Join(d.databaseDir, "postgresql.conf")
content, err := os.ReadFile(src)
Expand Down
76 changes: 72 additions & 4 deletions cli/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var dkimKeyPattern = regexp.MustCompile(`(?s).*p=(.*?)".*`)
type Variables struct {
AppDir string
AppDataDir string
AppCommonDir string
DbPsqlPath string
DbPsqlPort int
DbName string
Expand All @@ -44,6 +45,7 @@ type Variables struct {
type Installer struct {
appDir string
dataDir string
commonDir string
configPath string
logDir string
opendkimDir string
Expand All @@ -57,12 +59,14 @@ type Installer struct {

func New(logger *zap.Logger) *Installer {
appDir := fmt.Sprintf("/snap/%s/current", App)
dataDir := fmt.Sprintf("/var/snap/%s/common", App)
dataDir := fmt.Sprintf("/var/snap/%s/current", App)
commonDir := fmt.Sprintf("/var/snap/%s/common", App)
configPath := path.Join(dataDir, "config")
executor := NewExecutor(logger)
return &Installer{
appDir: appDir,
dataDir: dataDir,
commonDir: commonDir,
configPath: configPath,
logDir: path.Join(dataDir, "log"),
opendkimDir: path.Join(dataDir, "opendkim"),
Expand Down Expand Up @@ -100,10 +104,15 @@ func (i *Installer) RegenerateConfigs() error {
if err != nil {
return err
}
relay, err := i.platformClient.GetMailRelay()
if err != nil {
return err
}

variables := Variables{
AppDir: i.appDir,
AppDataDir: i.dataDir,
AppCommonDir: i.commonDir,
DbPsqlPath: i.database.Dir(),
DbPsqlPort: i.database.Port(),
DbName: DbName,
Expand All @@ -120,10 +129,52 @@ func (i *Installer) RegenerateConfigs() error {
return err
}

if err := i.writeRelayMaps(relay, deviceDomainName); err != nil {
return err
}

return linux.Chown(i.configPath, UserName)
}

// MigrateCommonToData is a one-time shim relocating pre-2026-07 state from
// $SNAP_COMMON to $SNAP_DATA. Safe to delete (with its InitConfig call) once
// all devices have auto-refreshed past this release, after 2026-08-08.
func (i *Installer) MigrateCommonToData() error {
marker := path.Join(i.dataDir, ".migrated_from_common")
if _, err := os.Stat(marker); err == nil {
return nil
}
if err := linux.CreateMissingDirs(i.dataDir); err != nil {
return err
}
entries, err := os.ReadDir(i.commonDir)
if err != nil {
if os.IsNotExist(err) {
return os.WriteFile(marker, []byte{}, 0644)
}
return err
}
for _, entry := range entries {
name := entry.Name()
if name == "web.socket" || strings.HasSuffix(name, ".socket") {
continue
}
dst := path.Join(i.dataDir, name)
if _, err := os.Stat(dst); err == nil {
continue
}
i.logger.Info("migrating to data", zap.String("name", name))
if err := os.Rename(path.Join(i.commonDir, name), dst); err != nil {
return err
}
}
return os.WriteFile(marker, []byte{}, 0644)
}

func (i *Installer) InitConfig() error {
if err := i.MigrateCommonToData(); err != nil {
return err
}
if err := linux.CreateUser("maildrop"); err != nil {
return err
}
Expand All @@ -142,6 +193,7 @@ func (i *Installer) InitConfig() error {
boxDataDir := path.Join(i.dataDir, "box")

if err := linux.CreateMissingDirs(
i.commonDir,
path.Join(i.dataDir, "nginx"),
path.Join(i.dataDir, "config"),
i.logDir,
Expand Down Expand Up @@ -172,6 +224,9 @@ func (i *Installer) InitConfig() error {
if err := linux.Chown(i.dataDir, UserName); err != nil {
return err
}
if err := linux.Chown(i.commonDir, UserName); err != nil {
return err
}
if _, err := i.executor.RunDir("", "chown", "-R", "dovecot:dovecot", boxDataDir); err != nil {
return err
}
Expand Down Expand Up @@ -206,7 +261,10 @@ func (i *Installer) Install() error {
}

func (i *Installer) PostRefresh() error {
return i.InitConfig()
if err := i.InitConfig(); err != nil {
return err
}
return i.database.Rebuild()
}

func (i *Installer) Configure() error {
Expand All @@ -218,6 +276,8 @@ func (i *Installer) Configure() error {
if err := i.Initialize(); err != nil {
return err
}
} else if err := i.database.Restore(); err != nil {
return err
}
return i.PrepareStorage()
}
Expand All @@ -244,7 +304,11 @@ func (i *Installer) setActivated(activated bool) error {
return err
}
cfg.Section("mail").Key("activated").SetValue(strconv.FormatBool(activated))
return cfg.SaveTo(i.userConfigFile)
if err := cfg.SaveTo(i.userConfigFile); err != nil {
return err
}
_, err = i.executor.RunDir("", "chown", fmt.Sprintf("%s:%s", UserName, UserName), i.userConfigFile)
return err
}

func (i *Installer) PrepareStorage() error {
Expand Down Expand Up @@ -278,8 +342,12 @@ func (i *Installer) AccessChange() error {
return i.UpdateDomain()
}

func (i *Installer) MailRelayChange() error {
return i.ApplyRelay()
}

func (i *Installer) PreRefresh() error {
return nil
return i.database.Backup()
}

func (i *Installer) BackupPreStop() error {
Expand Down
Loading