[MM-67979] [MM-67980] Add SMTP and push proxy connectivity status to support packet diagnostics (#35837)

* MM-67979 MM-67980: Add SMTP and push proxy connectivity to support packet

Adds a `notifications` section to `diagnostics.yaml` in the support
packet with SMTP email and push proxy connectivity probe results.

- `notifications.email.status`: ok/fail/disabled based on whether
  SendEmailNotifications is enabled and an SMTP connection can be
  established using mail.TestConnection()
- `notifications.push.status`: ok/fail/disabled based on whether
  SendPushNotifications is enabled and an HTTP GET to the configured
  PushNotificationServer URL succeeds
- Error messages are included in the `error` field on failure
- No email or push notification is sent during the probe

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: handle errcheck lint violations in support_packet_test.go

Suppress unhandled error return values from rw.WriteString calls in
the mock SMTP server used in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use 127.0.0.1 directly in SMTP reachability test

Replace localhost:0 with 127.0.0.1:0 for the mock SMTP listener so
that it always binds to the loopback interface. In CI Docker containers
localhost may resolve to the container IP rather than 127.0.0.1, causing
the SMTP dial to fail with connection refused. Also switch from string
manipulation to net.TCPAddr type assertion for reliable host/port
extraction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: override MM_EMAILSETTINGS_SMTPSERVER env var in SMTP reachability test

The CI environment sets MM_EMAILSETTINGS_SMTPSERVER=inbucket via
test.env. Mattermost's config Store.Set() calls GetEnvironment()
(os.Environ()) on every UpdateConfig, so env vars silently override
any programmatic config change. Use t.Setenv before UpdateConfig so
the env var points to 127.0.0.1 for the duration of the subtest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add model.StatusDisabled constant and use it in support_packet.go

Replace "disabled" string literals with model.StatusDisabled for
consistency with model.StatusOk and model.StatusFail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: use utils.GetHostnameFromSiteURL, extract testPushProxyConnection helper, set LDAP StatusDisabled

- Replace manual url.Parse with utils.GetHostnameFromSiteURL (consistent with app/config.go)
- Extract push proxy HTTP check into testPushProxyConnection with TODO to move to its own package
- Set d.LDAP.Status = model.StatusDisabled when LDAP is not configured
- Replace "disabled" string literals in tests with model.StatusDisabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add status field to ElasticSearch diagnostics with ok/fail/disabled

When indexing is enabled, reports ok or fail based on TestConfig result.
When indexing is disabled or the engine is unavailable, reports disabled.
Backend/ServerVersion/ServerPlugins are still collected when the engine
exists regardless of indexing status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: update Happy path test for LDAP and ES StatusDisabled assertions

Both are disabled in the test environment so they now report StatusDisabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: use GET /version endpoint for push proxy connectivity check

Use url.JoinPath to construct the /version path safely, replacing
raw root URL access. Also validate the HTTP status code so non-2xx/3xx
responses are treated as failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Ben Schumacher
2026-04-28 16:11:43 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Mattermost Build
parent 5c43e4b15f
commit c85601dc7f
4 changed files with 266 additions and 3 deletions
+83 -2
View File
@@ -5,7 +5,11 @@ package platform
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
rpprof "runtime/pprof"
@@ -19,6 +23,8 @@ import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
const (
@@ -221,6 +227,8 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
}
d.LDAP.ServerName = severName
d.LDAP.ServerVersion = serverVersion
} else {
d.LDAP.Status = model.StatusDisabled
}
/* SAML */
@@ -231,14 +239,64 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
/* Elastic Search */
if se := ps.SearchEngine.ElasticsearchEngine; se != nil {
d.ElasticSearch.Backend = *ps.Config().ElasticsearchSettings.Backend
d.ElasticSearch.ServerVersion = se.GetFullVersion()
d.ElasticSearch.ServerPlugins = se.GetPlugins()
if *ps.Config().ElasticsearchSettings.EnableIndexing {
appErr := se.TestConfig(rctx, ps.Config())
if appErr != nil {
d.ElasticSearch.Status = model.StatusFail
d.ElasticSearch.Error = appErr.Error()
} else {
d.ElasticSearch.Status = model.StatusOk
}
} else {
d.ElasticSearch.Status = model.StatusDisabled
}
d.ElasticSearch.ServerVersion = se.GetFullVersion()
d.ElasticSearch.ServerPlugins = se.GetPlugins()
} else {
d.ElasticSearch.Status = model.StatusDisabled
}
/* Email Notifications */
if model.SafeDereference(ps.Config().EmailSettings.SendEmailNotifications) {
emailSettings := ps.Config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(model.SafeDereference(ps.Config().ServiceSettings.SiteURL))
mailCfg := &mail.SMTPConfig{
Hostname: hostname,
ConnectionSecurity: model.SafeDereference(emailSettings.ConnectionSecurity),
SkipServerCertificateVerification: model.SafeDereference(emailSettings.SkipServerCertificateVerification),
ServerName: model.SafeDereference(emailSettings.SMTPServer),
Server: model.SafeDereference(emailSettings.SMTPServer),
Port: model.SafeDereference(emailSettings.SMTPPort),
ServerTimeout: model.SafeDereference(emailSettings.SMTPServerTimeout),
Username: model.SafeDereference(emailSettings.SMTPUsername),
Password: model.SafeDereference(emailSettings.SMTPPassword),
EnableSMTPAuth: model.SafeDereference(emailSettings.EnableSMTPAuth),
SendEmailNotifications: true,
FeedbackName: model.SafeDereference(emailSettings.FeedbackName),
FeedbackEmail: model.SafeDereference(emailSettings.FeedbackEmail),
ReplyToAddress: model.SafeDereference(emailSettings.ReplyToAddress),
}
if smtpErr := mail.TestConnection(mailCfg); smtpErr != nil {
d.Notifications.Email.Status = model.StatusFail
d.Notifications.Email.Error = smtpErr.Error()
} else {
d.Notifications.Email.Status = model.StatusOk
}
} else {
d.Notifications.Email.Status = model.StatusDisabled
}
/* Push Notifications */
if model.SafeDereference(ps.Config().EmailSettings.SendPushNotifications) {
pushServerURL := model.SafeDereference(ps.Config().EmailSettings.PushNotificationServer)
if pushErr := testPushProxyConnection(rctx.Context(), pushServerURL); pushErr != nil {
d.Notifications.Push.Status = model.StatusFail
d.Notifications.Push.Error = pushErr.Error()
} else {
d.Notifications.Push.Status = model.StatusOk
}
} else {
d.Notifications.Push.Status = model.StatusDisabled
}
b, err := yaml.Marshal(&d)
@@ -253,6 +311,29 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model
return fileData, rErr.ErrorOrNil()
}
// TODO: move this into its own push proxy package once one exists (see also pushNotificationClient in server.go)
func testPushProxyConnection(ctx context.Context, serverURL string) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
versionURL, err := url.JoinPath(serverURL, "version")
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("push proxy returned unexpected status %d", resp.StatusCode)
}
return nil
}
func (ps *PlatformService) getSanitizedConfigFile(rctx request.CTX) (*model.FileData, error) {
config := ps.getSanitizedConfig(rctx, &model.SanitizeOptions{PartiallyRedactDataSources: true})
spConfig := model.SupportPacketConfig{
@@ -4,12 +4,18 @@
package platform
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path"
"runtime"
"strconv"
"strings"
"testing"
"github.com/goccy/go-yaml"
@@ -255,7 +261,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
assert.Zero(t, d.Cluster.NumberOfNodes)
/* LDAP */
assert.Empty(t, d.LDAP.Status)
assert.Equal(t, model.StatusDisabled, d.LDAP.Status)
assert.Empty(t, d.LDAP.Error)
assert.Empty(t, d.LDAP.ServerName)
assert.Empty(t, d.LDAP.ServerVersion)
@@ -264,6 +270,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
assert.Empty(t, d.SAML.ProviderType)
/* Elastic Search */
assert.Equal(t, model.StatusDisabled, d.ElasticSearch.Status)
assert.Empty(t, d.ElasticSearch.ServerVersion)
assert.Empty(t, d.ElasticSearch.ServerPlugins)
})
@@ -314,6 +321,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
packet := getDiagnostics(t)
assert.Equal(t, model.StatusDisabled, packet.LDAP.Status)
assert.Equal(t, "", packet.LDAP.ServerName)
assert.Equal(t, "", packet.LDAP.ServerVersion)
})
@@ -475,6 +483,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
packet := getDiagnostics(t)
assert.Equal(t, model.StatusDisabled, packet.ElasticSearch.Status)
assert.Equal(t, model.ElasticsearchSettingsESBackend, packet.ElasticSearch.Backend)
assert.Equal(t, "7.10.0", packet.ElasticSearch.ServerVersion)
assert.Equal(t, []string{"plugin1", "plugin2"}, packet.ElasticSearch.ServerPlugins)
@@ -499,6 +508,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
packet := getDiagnostics(t)
assert.Equal(t, model.StatusOk, packet.ElasticSearch.Status)
assert.Equal(t, model.ElasticsearchSettingsOSBackend, packet.ElasticSearch.Backend)
assert.Equal(t, "2.5.0", packet.ElasticSearch.ServerVersion)
assert.Equal(t, []string{"opensearch-plugin"}, packet.ElasticSearch.ServerPlugins)
@@ -524,11 +534,170 @@ func TestGetSupportPacketDiagnostics(t *testing.T) {
packet := getDiagnostics(t)
assert.Equal(t, model.StatusFail, packet.ElasticSearch.Status)
assert.Equal(t, model.ElasticsearchSettingsESBackend, packet.ElasticSearch.Backend)
assert.Equal(t, "7.10.0", packet.ElasticSearch.ServerVersion)
assert.Equal(t, []string{"plugin1", "plugin2"}, packet.ElasticSearch.ServerPlugins)
assert.Equal(t, "TestConfig: ent.elasticsearch.test_config.connection_failed, connection refused", packet.ElasticSearch.Error)
})
t.Run("push notifications disabled", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = model.NewPointer(false)
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusDisabled, packet.Notifications.Push.Status)
assert.Empty(t, packet.Notifications.Push.Error)
})
t.Run("push notifications reachable", func(t *testing.T) {
pushServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/version", r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer pushServer.Close()
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
cfg.EmailSettings.PushNotificationServer = model.NewPointer(pushServer.URL)
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
cfg.EmailSettings.PushNotificationServer = model.NewPointer(model.GenericNotificationServer)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusOk, packet.Notifications.Push.Status)
assert.Empty(t, packet.Notifications.Push.Error)
})
t.Run("push notifications unreachable", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
cfg.EmailSettings.PushNotificationServer = model.NewPointer("http://localhost:1")
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendPushNotifications = model.NewPointer(true)
cfg.EmailSettings.PushNotificationServer = model.NewPointer(model.GenericNotificationServer)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusFail, packet.Notifications.Push.Status)
assert.NotEmpty(t, packet.Notifications.Push.Error)
})
t.Run("email notifications disabled", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(false)
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusDisabled, packet.Notifications.Email.Status)
assert.Empty(t, packet.Notifications.Email.Error)
})
t.Run("email notifications reachable", func(t *testing.T) {
l, listenErr := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, listenErr)
defer l.Close()
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
rw := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c))
_, _ = rw.WriteString("220 localhost ESMTP Test\r\n")
rw.Flush()
for {
line, err := rw.ReadString('\n')
if err != nil {
return
}
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "QUIT") {
_, _ = rw.WriteString("221 Bye\r\n")
rw.Flush()
return
}
_, _ = rw.WriteString("250 OK\r\n")
rw.Flush()
}
}(conn)
}
}()
tcpAddr := l.Addr().(*net.TCPAddr)
smtpPort := strconv.Itoa(tcpAddr.Port)
// MM_EMAILSETTINGS_SMTPSERVER may be set in CI and would override UpdateConfig.
// Use t.Setenv so the env var is updated before UpdateConfig calls Store.Set(),
// which re-reads GetEnvironment() (os.Environ()) and applies overrides.
t.Setenv("MM_EMAILSETTINGS_SMTPSERVER", "127.0.0.1")
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true)
cfg.EmailSettings.SMTPServer = model.NewPointer("127.0.0.1")
cfg.EmailSettings.SMTPPort = model.NewPointer(smtpPort)
cfg.EmailSettings.EnableSMTPAuth = model.NewPointer(false)
cfg.EmailSettings.ConnectionSecurity = model.NewPointer("")
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true)
cfg.EmailSettings.SMTPServer = model.NewPointer(model.EmailSMTPDefaultServer)
cfg.EmailSettings.SMTPPort = model.NewPointer(model.EmailSMTPDefaultPort)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusOk, packet.Notifications.Email.Status)
assert.Empty(t, packet.Notifications.Email.Error)
})
t.Run("email notifications unreachable", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true)
cfg.EmailSettings.SMTPServer = model.NewPointer("localhost")
cfg.EmailSettings.SMTPPort = model.NewPointer("1")
cfg.EmailSettings.SMTPServerTimeout = model.NewPointer(1)
})
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true)
cfg.EmailSettings.SMTPServer = model.NewPointer(model.EmailSMTPDefaultServer)
cfg.EmailSettings.SMTPPort = model.NewPointer(model.EmailSMTPDefaultPort)
cfg.EmailSettings.SMTPServerTimeout = model.NewPointer(10)
})
})
packet := getDiagnostics(t)
assert.Equal(t, model.StatusFail, packet.Notifications.Email.Status)
assert.NotEmpty(t, packet.Notifications.Email.Error)
})
}
func TestGetSanitizedConfigFile(t *testing.T) {
+1
View File
@@ -45,6 +45,7 @@ const (
STATUS = "status"
StatusOk = "OK"
StatusFail = "FAIL"
StatusDisabled = "disabled"
StatusUnhealthy = "UNHEALTHY"
StatusRemove = "REMOVE"
ConnectionId = "Connection-Id"
+12
View File
@@ -75,6 +75,17 @@ type SupportPacketDiagnostics struct {
NumberOfNodes int `yaml:"number_of_nodes"`
} `yaml:"cluster"`
Notifications struct {
Email struct {
Status string `yaml:"status"`
Error string `yaml:"error,omitempty"`
} `yaml:"email,omitempty"`
Push struct {
Status string `yaml:"status"`
Error string `yaml:"error,omitempty"`
} `yaml:"push,omitempty"`
} `yaml:"notifications,omitempty"`
LDAP struct {
Status string `yaml:"status,omitempty"`
Error string `yaml:"error,omitempty"`
@@ -87,6 +98,7 @@ type SupportPacketDiagnostics struct {
} `yaml:"saml"`
ElasticSearch struct {
Status string `yaml:"status,omitempty"`
Backend string `yaml:"backend,omitempty"`
ServerVersion string `yaml:"server_version,omitempty"`
ServerPlugins []string `yaml:"server_plugins,omitempty"`