From 50c4bbd3c298b4ce77b441c5a9d8a7cd5d7a2667 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Thu, 6 Aug 2026 21:24:41 +0100 Subject: [PATCH 1/8] Accept mail arriving through the relay tunnel redirect terminates port 25 and forwards each message down the frp tunnel, which arrives at the device on loopback. Postfix needs somewhere to put it that is not the port it already offers the internet, so this adds a smtpd on 127.0.0.1:10025, the port the platform's frpc proxy forwards to. Two things had to be turned off for that service rather than left at their defaults. mynetworks trusts loopback, and everything through the tunnel arrives from loopback, so without emptying it this port would relay to any destination for anyone who reached it. There is a test that it refuses. The milters had to go as well, and this is the one that would have been quiet. opendkim runs in sign and verify mode with 127.0.0.1 in TrustedHosts, so it classifies tunnel traffic as internal: it would have signed mail from the whole internet with this device's key instead of verifying it, and forged mail would have arrived looking DKIM authenticated as the user's own domain. rspamd does its own verification when it lands, so nothing is lost by dropping them here. Tests cover delivery to a local mailbox, the port staying on loopback, the relay refusal, and that a delivered message carries no signature of ours. --- config/postfix/master.cf | 19 ++++++++++ test/test.py | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/config/postfix/master.cf b/config/postfix/master.cf index 2d2601d..54ab01a 100644 --- a/config/postfix/master.cf +++ b/config/postfix/master.cf @@ -14,6 +14,25 @@ smtp inet n - n - - smtpd #smtpd pass - - n - - smtpd #dnsblog unix - - n - 0 dnsblog #tlsproxy unix - - n - 0 tlsproxy +# mail arriving from the internet through the relay tunnel. +# +# bound to loopback because frpc is the only thing that reaches it. mynetworks +# trusts loopback, so it is emptied here: otherwise anything reaching this port +# could relay to any destination. +# +# the milters are dropped because opendkim runs in sign and verify mode and +# treats 127.0.0.1 as an internal host, so it would sign mail from the whole +# internet with this device's key instead of verifying it. +127.0.0.1:10025 inet n - n - - smtpd + -o syslog_name=postfix/tunnel + -o mynetworks= + -o smtpd_relay_restrictions=reject_unauth_destination + -o smtpd_recipient_restrictions= + -o smtpd_sasl_auth_enable=no + -o smtpd_tls_security_level=none + -o smtpd_milters= + -o non_smtpd_milters= + submission inet n - n - - smtpd -o syslog_name=postfix/submission # -o smtpd_tls_security_level=encrypt diff --git a/test/test.py b/test/test.py index 5c35c83..e2d2024 100644 --- a/test/test.py +++ b/test/test.py @@ -279,3 +279,81 @@ def assert_relayed(domain): messages = faker_messages(domain) assert len(messages) > 0, 'relay received nothing' return messages + + +def tunnel_smtp(device, script): + return device.run_ssh( + "python3 - <<'TUNNEL'\n{0}\nTUNNEL".format(script), throw=False) + + +def test_tunnel_port_is_loopback_only(device): + listening = device.run_ssh("ss -lnt | grep ':10025' || true", throw=False) + assert '10025' in listening, listening + # the internet reaches this port only through frpc, never directly + assert '0.0.0.0:10025' not in listening, listening + assert '*:10025' not in listening, listening + + +def test_tunnel_delivers_to_a_local_user(device, domain, device_user, app_domain, + device_password): + before = get_message_count(app_domain, device_user, device_password) + out = tunnel_smtp(device, ''' +import smtplib +from email.mime.text import MIMEText +msg = MIMEText("through the tunnel") +msg["Subject"] = "tunnel-delivery" +msg["From"] = "outside@example.com" +msg["To"] = "{user}@{domain}" +s = smtplib.SMTP("127.0.0.1", 10025, timeout=20) +s.sendmail("outside@example.com", ["{user}@{domain}"], msg.as_string()) +s.quit() +print("SENT") +'''.format(user=device_user, domain=domain)) + assert 'SENT' in out, out + + after = retry_func( + lambda: assert_arrived(app_domain, device_user, device_password, before), + message='tunnel delivery', retries=20, sleep=3) + assert after > before + + +def assert_arrived(app_domain, device_user, device_password, before): + count = get_message_count(app_domain, device_user, device_password) + assert count > before, 'nothing arrived through the tunnel' + return count + + +def test_tunnel_refuses_to_relay_elsewhere(device): + # mynetworks is emptied for this service, so loopback is not trusted and + # the port cannot be used to send mail to the rest of the world + out = tunnel_smtp(device, ''' +import smtplib +s = smtplib.SMTP("127.0.0.1", 10025, timeout=20) +s.ehlo() +print("MAIL", s.mail("outside@example.com")) +print("RCPT", s.rcpt("victim@example.com")) +s.quit() +''') + assert 'RCPT' in out, out + assert '554' in out or '550' in out, out + + +def latest_message(app_domain, device_user, device_password): + server = imaplib.IMAP4_SSL(app_domain, ssl_context=(SSLContext(ssl.PROTOCOL_TLS))) + server.login(device_user, device_password) + server.select('inbox') + _, data = server.search(None, 'SUBJECT', '"tunnel-delivery"') + ids = data[0].split() + assert ids, 'the tunnel delivered message is not in the mailbox' + _, fetched = server.fetch(ids[-1], '(RFC822)') + server.logout() + return fetched[0][1].decode('utf-8', 'replace') + + +def test_tunnel_does_not_sign_incoming_mail(app_domain, domain, device_user, device_password): + # opendkim treats 127.0.0.1 as internal and runs in sign and verify mode, + # so leaving its milter on this service would sign mail from the whole + # internet with this device's key and make forgeries look authenticated + message = latest_message(app_domain, device_user, device_password) + signed_by_us = 'd={0}'.format(domain) + assert signed_by_us not in message, message[:2000] From 8244bd95bc10bee8b72eefb90506300090bc30c2 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sat, 8 Aug 2026 20:09:52 +0100 Subject: [PATCH 2/8] Quote the tunnel test scripts so the shell does not eat them run_ssh wraps the command in double quotes, so the double quotes inside the python sent over the heredoc were stripped before python ever saw it and the first tunnel test died on a syntax error. The run stops at the first failure, so the other two had never run. --- test/test.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/test/test.py b/test/test.py index e2d2024..e006fcd 100644 --- a/test/test.py +++ b/test/test.py @@ -289,7 +289,6 @@ def tunnel_smtp(device, script): def test_tunnel_port_is_loopback_only(device): listening = device.run_ssh("ss -lnt | grep ':10025' || true", throw=False) assert '10025' in listening, listening - # the internet reaches this port only through frpc, never directly assert '0.0.0.0:10025' not in listening, listening assert '*:10025' not in listening, listening @@ -300,14 +299,14 @@ def test_tunnel_delivers_to_a_local_user(device, domain, device_user, app_domain out = tunnel_smtp(device, ''' import smtplib from email.mime.text import MIMEText -msg = MIMEText("through the tunnel") -msg["Subject"] = "tunnel-delivery" -msg["From"] = "outside@example.com" -msg["To"] = "{user}@{domain}" -s = smtplib.SMTP("127.0.0.1", 10025, timeout=20) -s.sendmail("outside@example.com", ["{user}@{domain}"], msg.as_string()) +msg = MIMEText('through the tunnel') +msg['Subject'] = 'tunnel-delivery' +msg['From'] = 'outside@example.com' +msg['To'] = '{user}@{domain}' +s = smtplib.SMTP('127.0.0.1', 10025, timeout=20) +s.sendmail('outside@example.com', ['{user}@{domain}'], msg.as_string()) s.quit() -print("SENT") +print('SENT') '''.format(user=device_user, domain=domain)) assert 'SENT' in out, out @@ -324,14 +323,12 @@ def assert_arrived(app_domain, device_user, device_password, before): def test_tunnel_refuses_to_relay_elsewhere(device): - # mynetworks is emptied for this service, so loopback is not trusted and - # the port cannot be used to send mail to the rest of the world out = tunnel_smtp(device, ''' import smtplib -s = smtplib.SMTP("127.0.0.1", 10025, timeout=20) +s = smtplib.SMTP('127.0.0.1', 10025, timeout=20) s.ehlo() -print("MAIL", s.mail("outside@example.com")) -print("RCPT", s.rcpt("victim@example.com")) +print('MAIL', s.mail('outside@example.com')) +print('RCPT', s.rcpt('victim@example.com')) s.quit() ''') assert 'RCPT' in out, out @@ -351,9 +348,6 @@ def latest_message(app_domain, device_user, device_password): def test_tunnel_does_not_sign_incoming_mail(app_domain, domain, device_user, device_password): - # opendkim treats 127.0.0.1 as internal and runs in sign and verify mode, - # so leaving its milter on this service would sign mail from the whole - # internet with this device's key and make forgeries look authenticated message = latest_message(app_domain, device_user, device_password) signed_by_us = 'd={0}'.format(domain) assert signed_by_us not in message, message[:2000] From a334541606ba3ba2fdd1a70cf78f91bae4bb3827 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sat, 8 Aug 2026 21:24:23 +0100 Subject: [PATCH 3/8] Speak smtp over bash instead of python in the tunnel tests Buster has no python3, so driving the tunnel with smtplib only ever worked on bookworm. Bash opens the socket itself, which is available on both. --- test/test.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/test/test.py b/test/test.py index e006fcd..a07eecd 100644 --- a/test/test.py +++ b/test/test.py @@ -283,7 +283,8 @@ def assert_relayed(domain): def tunnel_smtp(device, script): return device.run_ssh( - "python3 - <<'TUNNEL'\n{0}\nTUNNEL".format(script), throw=False) + 'exec 3<>/dev/tcp/127.0.0.1/10025\n{0}timeout 15 cat <&3'.format(script), + throw=False) def test_tunnel_port_is_loopback_only(device): @@ -297,18 +298,11 @@ def test_tunnel_delivers_to_a_local_user(device, domain, device_user, app_domain device_password): before = get_message_count(app_domain, device_user, device_password) out = tunnel_smtp(device, ''' -import smtplib -from email.mime.text import MIMEText -msg = MIMEText('through the tunnel') -msg['Subject'] = 'tunnel-delivery' -msg['From'] = 'outside@example.com' -msg['To'] = '{user}@{domain}' -s = smtplib.SMTP('127.0.0.1', 10025, timeout=20) -s.sendmail('outside@example.com', ['{user}@{domain}'], msg.as_string()) -s.quit() -print('SENT') +printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:<{user}@{domain}>\\r\\nDATA\\r\\n' >&3 +sleep 2 +printf 'Subject: tunnel-delivery\\r\\nFrom: outside@example.com\\r\\nTo: {user}@{domain}\\r\\n\\r\\nthrough the tunnel\\r\\n.\\r\\nQUIT\\r\\n' >&3 '''.format(user=device_user, domain=domain)) - assert 'SENT' in out, out + assert 'queued' in out, out after = retry_func( lambda: assert_arrived(app_domain, device_user, device_password, before), @@ -324,14 +318,8 @@ def assert_arrived(app_domain, device_user, device_password, before): def test_tunnel_refuses_to_relay_elsewhere(device): out = tunnel_smtp(device, ''' -import smtplib -s = smtplib.SMTP('127.0.0.1', 10025, timeout=20) -s.ehlo() -print('MAIL', s.mail('outside@example.com')) -print('RCPT', s.rcpt('victim@example.com')) -s.quit() +printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:\\r\\nQUIT\\r\\n' >&3 ''') - assert 'RCPT' in out, out assert '554' in out or '550' in out, out From 3afa5d2dc217be01758dd2a1c0cf7d63dac5777c Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 00:17:28 +0100 Subject: [PATCH 4/8] Let the mail app tell platform where to deliver tunnel mail Platform held the port the mail app listens on, so it knew an internal detail of another snap and opened an smtp proxy on every relayed device whether or not mail was installed. The app registers its socket on install and refresh, and platform forwards there and only adds the proxy once something has registered. Postfix takes the tunnel on a unix socket rather than a loopback port, so the listener is reachable through the filesystem instead of by anything local that can open a connection. --- cli/installer/installer.go | 8 +++++++ cli/installer/mail_inbound.go | 45 +++++++++++++++++++++++++++++++++++ config/postfix/master.cf | 11 +-------- test/test.py | 35 ++++++++++++++++++--------- 4 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 cli/installer/mail_inbound.go diff --git a/cli/installer/installer.go b/cli/installer/installer.go index 91058a3..1542950 100644 --- a/cli/installer/installer.go +++ b/cli/installer/installer.go @@ -54,6 +54,7 @@ type Installer struct { platformClient *platform.Client database *Database relay *Relay + mailInbound *MailInbound executor *Executor logger *zap.Logger } @@ -77,6 +78,7 @@ func New(logger *zap.Logger) *Installer { platformClient: platformClient, database: NewDatabase(appDir, dataDir, configPath, DbName, DbUser, DbPass, PsqlPort, executor, logger), relay: NewRelay(appDir, configPath, executor, logger), + mailInbound: NewMailInbound(dataDir, platform.NewHttpClient(), logger), executor: executor, logger: logger, } @@ -257,6 +259,9 @@ func (i *Installer) Install() error { if err := i.InitConfig(); err != nil { return err } + if err := i.mailInbound.Register(); err != nil { + return err + } return i.database.Init() } @@ -264,6 +269,9 @@ func (i *Installer) PostRefresh() error { if err := i.InitConfig(); err != nil { return err } + if err := i.mailInbound.Register(); err != nil { + return err + } return i.database.Rebuild() } diff --git a/cli/installer/mail_inbound.go b/cli/installer/mail_inbound.go new file mode 100644 index 0000000..5806cb2 --- /dev/null +++ b/cli/installer/mail_inbound.go @@ -0,0 +1,45 @@ +package installer + +import ( + "fmt" + "io" + "net/http" + "net/url" + "path" + + "github.com/syncloud/golib/platform" + "go.uber.org/zap" +) + +const TunnelSocket = "spool/public/tunnel" + +type MailInbound struct { + dataDir string + client platform.HttpClient + logger *zap.Logger +} + +func NewMailInbound(dataDir string, client platform.HttpClient, logger *zap.Logger) *MailInbound { + return &MailInbound{dataDir: dataDir, client: client, logger: logger} +} + +func (m *MailInbound) SocketPath() string { + return path.Join(m.dataDir, TunnelSocket) +} + +func (m *MailInbound) Register() error { + socket := m.SocketPath() + m.logger.Info("registering the inbound mail socket", zap.String("socket", socket)) + resp, err := m.client.Post("http://unix/mail/inbound/register", + url.Values{"socket": {socket}}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unable to register the inbound mail socket: %s %s", + resp.Status, string(body)) + } + return nil +} diff --git a/config/postfix/master.cf b/config/postfix/master.cf index 54ab01a..2fb4279 100644 --- a/config/postfix/master.cf +++ b/config/postfix/master.cf @@ -14,16 +14,7 @@ smtp inet n - n - - smtpd #smtpd pass - - n - - smtpd #dnsblog unix - - n - 0 dnsblog #tlsproxy unix - - n - 0 tlsproxy -# mail arriving from the internet through the relay tunnel. -# -# bound to loopback because frpc is the only thing that reaches it. mynetworks -# trusts loopback, so it is emptied here: otherwise anything reaching this port -# could relay to any destination. -# -# the milters are dropped because opendkim runs in sign and verify mode and -# treats 127.0.0.1 as an internal host, so it would sign mail from the whole -# internet with this device's key instead of verifying it. -127.0.0.1:10025 inet n - n - - smtpd +tunnel unix n - n - - smtpd -o syslog_name=postfix/tunnel -o mynetworks= -o smtpd_relay_restrictions=reject_unauth_destination diff --git a/test/test.py b/test/test.py index a07eecd..ac334f8 100644 --- a/test/test.py +++ b/test/test.py @@ -281,26 +281,39 @@ def assert_relayed(domain): return messages +TUNNEL_SOCKET = '/var/snap/mail/current/spool/public/tunnel' + + +@pytest.fixture(scope="session") +def socat(device): + device.run_ssh( + 'command -v socat || (apt-get update -qq && apt-get install -y -qq socat)', + throw=False) + installed = device.run_ssh('command -v socat', throw=False) + assert 'socat' in installed, installed + + def tunnel_smtp(device, script): return device.run_ssh( - 'exec 3<>/dev/tcp/127.0.0.1/10025\n{0}timeout 15 cat <&3'.format(script), + '{{ {0} }} | timeout 20 socat - UNIX-CONNECT:{1}'.format(script, TUNNEL_SOCKET), throw=False) -def test_tunnel_port_is_loopback_only(device): - listening = device.run_ssh("ss -lnt | grep ':10025' || true", throw=False) - assert '10025' in listening, listening - assert '0.0.0.0:10025' not in listening, listening - assert '*:10025' not in listening, listening +def test_tunnel_listens_on_a_socket_not_a_port(device): + listening = device.run_ssh("ss -lnt || true", throw=False) + assert ':10025' not in listening, listening + + socket = device.run_ssh("ls -l {0}".format(TUNNEL_SOCKET), throw=False) + assert 'srw' in socket, socket -def test_tunnel_delivers_to_a_local_user(device, domain, device_user, app_domain, +def test_tunnel_delivers_to_a_local_user(socat, device, domain, device_user, app_domain, device_password): before = get_message_count(app_domain, device_user, device_password) out = tunnel_smtp(device, ''' -printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:<{user}@{domain}>\\r\\nDATA\\r\\n' >&3 +printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:<{user}@{domain}>\\r\\nDATA\\r\\n' sleep 2 -printf 'Subject: tunnel-delivery\\r\\nFrom: outside@example.com\\r\\nTo: {user}@{domain}\\r\\n\\r\\nthrough the tunnel\\r\\n.\\r\\nQUIT\\r\\n' >&3 +printf 'Subject: tunnel-delivery\\r\\nFrom: outside@example.com\\r\\nTo: {user}@{domain}\\r\\n\\r\\nthrough the tunnel\\r\\n.\\r\\nQUIT\\r\\n' '''.format(user=device_user, domain=domain)) assert 'queued' in out, out @@ -316,9 +329,9 @@ def assert_arrived(app_domain, device_user, device_password, before): return count -def test_tunnel_refuses_to_relay_elsewhere(device): +def test_tunnel_refuses_to_relay_elsewhere(socat, device): out = tunnel_smtp(device, ''' -printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:\\r\\nQUIT\\r\\n' >&3 +printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:\\r\\nQUIT\\r\\n' ''') assert '554' in out or '550' in out, out From 5f7916c94411e33c7ba880e2dd33a9073a44cfa6 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 01:56:14 +0100 Subject: [PATCH 5/8] Drive the tunnel tests with the php the app already ships The tunnel listens on a unix socket now, which the shell cannot open on its own and buster has no python for, so the tests were reaching for socat and an apt install. The mail snap ships php, which speaks unix sockets and is there on both distros by definition. run_ssh wraps the command in double quotes, so the script is escaped once and passed through a heredoc rather than fighting nested quoting. --- test/test.py | 56 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/test/test.py b/test/test.py index ac334f8..899d9b6 100644 --- a/test/test.py +++ b/test/test.py @@ -284,19 +284,25 @@ def assert_relayed(domain): TUNNEL_SOCKET = '/var/snap/mail/current/spool/public/tunnel' -@pytest.fixture(scope="session") -def socat(device): - device.run_ssh( - 'command -v socat || (apt-get update -qq && apt-get install -y -qq socat)', - throw=False) - installed = device.run_ssh('command -v socat', throw=False) - assert 'socat' in installed, installed +PHP = '/snap/mail/current/bin/php' + + +def tunnel_smtp(device, conversation): + code = ( + '\\r\\nRCPT TO:<{user}@{domain}>\\r\\nDATA\\r\\n' -sleep 2 -printf 'Subject: tunnel-delivery\\r\\nFrom: outside@example.com\\r\\nTo: {user}@{domain}\\r\\n\\r\\nthrough the tunnel\\r\\n.\\r\\nQUIT\\r\\n' -'''.format(user=device_user, domain=domain)) + out = tunnel_smtp(device, ( + send('EHLO tunnel.test') + + send('MAIL FROM:') + + send('RCPT TO:<{0}@{1}>'.format(device_user, domain)) + + send('DATA') + + 'sleep(2);' + + send('Subject: tunnel-delivery') + + send('') + + send('through the tunnel') + + send('.') + + send('QUIT'))) assert 'queued' in out, out after = retry_func( @@ -329,10 +341,12 @@ def assert_arrived(app_domain, device_user, device_password, before): return count -def test_tunnel_refuses_to_relay_elsewhere(socat, device): - out = tunnel_smtp(device, ''' -printf 'EHLO tunnel.test\\r\\nMAIL FROM:\\r\\nRCPT TO:\\r\\nQUIT\\r\\n' -''') +def test_tunnel_refuses_to_relay_elsewhere(device): + out = tunnel_smtp(device, ( + send('EHLO tunnel.test') + + send('MAIL FROM:') + + send('RCPT TO:') + + send('QUIT'))) assert '554' in out or '550' in out, out From 06c698f0bbb80310a1c1149710894defd4073b58 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 09:45:36 +0100 Subject: [PATCH 6/8] Install on a platform that has never heard of inbound mail The app registers its socket on install, but the platform it installs onto is whatever is released, so a 404 means that platform predates the endpoint rather than that anything is wrong. The snap installs and simply has no tunnel. The cli build runs its tests now, which is where the three cases live. --- cli/build.sh | 2 ++ cli/go.mod | 4 +++ cli/installer/mail_inbound.go | 4 +++ cli/installer/mail_inbound_test.go | 57 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 cli/installer/mail_inbound_test.go diff --git a/cli/build.sh b/cli/build.sh index c072ac1..c68b65f 100755 --- a/cli/build.sh +++ b/cli/build.sh @@ -2,6 +2,8 @@ DIR=$(cd "$(dirname "$0")" && pwd) cd "$DIR" +go test ./... + OUT_HOOKS=../build/snap/meta/hooks OUT_BIN=../build/snap/bin mkdir -p "$OUT_HOOKS" "$OUT_BIN" diff --git a/cli/go.mod b/cli/go.mod index b583ab1..4aa304e 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -2,15 +2,19 @@ module hooks require ( github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.11.1 github.com/syncloud/golib v1.1.21 go.uber.org/zap v1.26.0 gopkg.in/ini.v1 v1.67.3 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.uber.org/multierr v1.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) go 1.21 diff --git a/cli/installer/mail_inbound.go b/cli/installer/mail_inbound.go index 5806cb2..ee44d38 100644 --- a/cli/installer/mail_inbound.go +++ b/cli/installer/mail_inbound.go @@ -36,6 +36,10 @@ func (m *MailInbound) Register() error { return err } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + m.logger.Info("this platform does not take inbound mail registrations") + return nil + } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("unable to register the inbound mail socket: %s %s", diff --git a/cli/installer/mail_inbound_test.go b/cli/installer/mail_inbound_test.go new file mode 100644 index 0000000..c80625b --- /dev/null +++ b/cli/installer/mail_inbound_test.go @@ -0,0 +1,57 @@ +package installer + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +type fakePlatform struct { + status int + posted url.Values + path string +} + +func (f *fakePlatform) Post(path string, values url.Values) (*http.Response, error) { + f.path = path + f.posted = values + return &http.Response{ + StatusCode: f.status, + Status: http.StatusText(f.status), + Body: io.NopCloser(strings.NewReader("")), + }, nil +} + +func (f *fakePlatform) Get(_ string) (*http.Response, error) { + return nil, nil +} + +func TestMailInbound_RegistersTheSocket(t *testing.T) { + platform := &fakePlatform{status: http.StatusOK} + inbound := NewMailInbound("/var/snap/mail/current", platform, zap.NewNop()) + + assert.NoError(t, inbound.Register()) + + assert.Equal(t, "http://unix/mail/inbound/register", platform.path) + assert.Equal(t, "/var/snap/mail/current/spool/public/tunnel", + platform.posted.Get("socket")) +} + +func TestMailInbound_OlderPlatformWithoutTheEndpointIsNotAFailure(t *testing.T) { + platform := &fakePlatform{status: http.StatusNotFound} + inbound := NewMailInbound("/var/snap/mail/current", platform, zap.NewNop()) + + assert.NoError(t, inbound.Register()) +} + +func TestMailInbound_ReportsOtherFailures(t *testing.T) { + platform := &fakePlatform{status: http.StatusInternalServerError} + inbound := NewMailInbound("/var/snap/mail/current", platform, zap.NewNop()) + + assert.Error(t, inbound.Register()) +} From 1f4a242b94a58ddf573500ba69cfe695ab858046 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 10:54:30 +0100 Subject: [PATCH 7/8] Wait for each smtp reply before sending the next command Postfix refuses commands that arrive before it has answered unless it has advertised pipelining and the client is authorised to use it, so firing the whole conversation at once got 554 protocol synchronization. The exchange is lock step now, with the message body the one part written without a read in between. --- test/test.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/test/test.py b/test/test.py index 899d9b6..04ec0bf 100644 --- a/test/test.py +++ b/test/test.py @@ -290,11 +290,19 @@ def assert_relayed(domain): def tunnel_smtp(device, conversation): code = ( '') + send('RCPT TO:<{0}@{1}>'.format(device_user, domain)) + send('DATA') - + 'sleep(2);' - + send('Subject: tunnel-delivery') - + send('') - + send('through the tunnel') + + write('Subject: tunnel-delivery') + + write('') + + write('through the tunnel') + send('.') + send('QUIT'))) assert 'queued' in out, out From a7352149a4cdb37c5fa74622f394ec1f7ab2a7ef Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 11:10:11 +0100 Subject: [PATCH 8/8] Point the php wrapper at the rendered ini, not the template It read the ini straight out of the snap, where date.timezone is still {{ .Timezone }}, so every cli run warned and fell back to utc. The rendered one lives beside the php-fpm config the service already uses; the path is derived so the wrapper works outside a snap environment too, which is how the tests reach it. Arguments are quoted now so ones with spaces survive. --- bin/php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/php b/bin/php index 189a66e..ea66671 100755 --- a/bin/php +++ b/bin/php @@ -1,3 +1,4 @@ #!/usr/bin/env bash APP_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) -${APP_DIR}/php/bin/php.sh -c ${APP_DIR}/config/php/php.ini $@ +DATA_DIR=${SNAP_DATA:-/var${APP_DIR}} +${APP_DIR}/php/bin/php.sh -c ${DATA_DIR}/config/php/php.ini "$@"