SMTP relay connection test hangs indefinitely on non-standard TLS ports (e.g. 2465)
Operating System
Linux
OS Version
Ubuntu 24.04.3 LTS
System Architecture
x86_64
Docker Version
Docker version 28.4.0, build d8eb465
Docker Compose Version
Docker Compose version v2.26.1
Reproducible in Latest Version?
Yes, occurs in the latest stable release
Reproduction Steps
- Add Custom SMTP Relay
- port 2465
- Click Test Connect
Observed Behavior
The loading state persists for many minutes.
Expected Behavior
SMTP relay connection test hangs indefinitely on non-standard TLS ports (e.g. 2465)
Summary
Testing a custom SMTP relay on an implicit-TLS port other than 465 (for example 2465) causes the "Test connection" request to hang indefinitely. The UI spinner never resolves and no error is ever returned.
The root cause is that TLS mode is decided by a hardcoded port-number comparison, and the plaintext dial path has no read timeout.
Environment
- Commit:
fc36c76c050c3775c5e899faf7403cf0262d2744 - Relay host tested:
next-smtp.useplunk.com - Port:
2465(implicit TLS)
Steps to reproduce
- Go to the SMTP relay settings page and open the add/edit relay form.
- Fill in host
next-smtp.useplunk.com, port2465, and valid credentials. - Click Test connection.
- The request to
POST /relay/test_connectionnever returns. The loading state persists for many minutes.
Expected: the connection succeeds, or fails with a clear error within a few seconds.
Actual: the request hangs until the OS-level TCP timeout, with no feedback to the user.
Root cause
In core/internal/service/mail_service/sending.go, isSecure() (line 446) decides between implicit TLS and plaintext purely by string-comparing the port:
func (e *EmailSender) isSecure() bool {
if e.Port == "465" {
return true
} else if e.Port == "587" {
return false
}
return false
}Any port that is not literally 465 falls through to false, so Connect() (line 190) routes port 2465 to connectPlain() (line 235) instead of connectWithSSL() (line 207).
connectPlain() then calls smtp.Dial(), which opens a plaintext TCP connection and blocks waiting for the server's 220 ... ESMTP greeting. But 2465 is an implicit-TLS port — the server is waiting for a TLS ClientHello before it says anything. Neither side speaks first, and the connection deadlocks.
Two things make this a hang rather than an error:
smtp.Dial()usesnet.Dialwith no read deadline, so the block persists until TCP keepalive gives up, which can take many minutes.- There is no timeout or context deadline anywhere in
TestSmtpConnection(core/internal/service/relay/smtp_connection_helper.go, line 45) or in the controller (core/internal/controller/relay/relay_v1_test_smtp_connection.go).
The STARTTLS branch has the same hardcoded-port problem — connectPlain() only attempts STARTTLS when the port is exactly 587 (line 242), so a relay offering STARTTLS on 2525 or 1587 would connect unencrypted and then fail at auth.
Note that 2465 is a common alternative to 465, since many cloud providers block the standard submission ports. Restricting implicit TLS to 465 alone rules out a fair number of real-world relays.
Suggested fixes
1. Add timeouts so a misconfiguration surfaces as an error instead of a hang. This is the most important part — whatever the port logic ends up being, the test should never block indefinitely.
// connectPlain
d := &net.Dialer{Timeout: 10 * time.Second}
conn, err := d.Dial("tcp", net.JoinHostPort(e.Host, e.Port))
if err != nil {
return fmt.Errorf("SMTP dial: %w", err)
}
conn.SetDeadline(time.Now().Add(30 * time.Second))
client, err := smtp.NewClient(conn, e.Host)// connectWithSSL
d := &net.Dialer{Timeout: 10 * time.Second}
conn, err := tls.DialWithDialer(d, "tcp", net.JoinHostPort(e.Host, e.Port), tlsCfg)2. Stop deriving TLS mode from the port number. Ideally the relay config should carry an explicit encryption mode (none / starttls / tls) chosen by the user in the form, with the port used only as a default. Failing that, at minimum:
- treat the common implicit-TLS ports as secure (
465,2465,8465), and - in
connectPlain(), attempt STARTTLS whenever the server advertises it (client.Extension("STARTTLS")) rather than checking for port587.
3. Surface the timeout in the UI. Even with a server-side deadline, the frontend testSmtp call in core/frontend/src/api/modules/smtp.ts has no client-side timeout, so it would be worth adding one as a backstop.
Workaround
Use port 587 or 465 instead, which are the only two ports the current logic handles correctly.
Relevant files
core/internal/service/mail_service/sending.go—isSecure(),connectPlain(),connectWithSSL()core/internal/service/relay/smtp_connection_helper.go—TestSmtpConnection()core/internal/controller/relay/relay_v1_test_smtp_connection.gocore/frontend/src/views/smtp/components/SmtpForm.vue—onTest()core/frontend/src/api/modules/smtp.ts—testSmtp()
Supplemental Information
Source: Billionmail/BillionMail