From 0527f139de4bdbdab9384f0014bbaeffe6b70ced Mon Sep 17 00:00:00 2001 From: santanoce Date: Thu, 1 Jan 2026 21:50:09 +0100 Subject: [PATCH 1/2] feat: added support for LOGIN AUTH for the SMTP sender --- docs/config.md | 2 +- server/smtp_sender.go | 73 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index b04438e7..351c8848 100644 --- a/docs/config.md +++ b/docs/config.md @@ -590,7 +590,7 @@ To allow forwarding messages via e-mail, you can configure an **SMTP server for you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. `curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`). -As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the +As of today, SMTP servers with PLAIN and LOGIN auth and STARTLS are supported. To enable e-mail sending, you must set the following settings: * `base-url` is the root URL for the ntfy server; this is needed for e-mail footer diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 0f798030..f22f3570 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -1,12 +1,15 @@ package server import ( + "bytes" _ "embed" // required by go:embed "encoding/json" + "errors" "fmt" "mime" "net" "net/smtp" + "slices" "strings" "sync" "time" @@ -15,11 +18,79 @@ import ( "heckel.io/ntfy/v2/util" ) +var ( + errUnencryptedConnection = errors.New("unencrypted connection") + errWrongHostname = errors.New("wrong host name") + errNoSupportedAuth = errors.New("no supported auth mechanisms found") + errUnexpectedServerChallenge = errors.New("unexpected server challenge") +) + type mailer interface { Send(v *visitor, m *message, to string) error Counts() (total int64, success int64, failure int64) } +type plainOrLoginAuth struct { + identity string + username string + password string + host string + authMethod string +} + +func PlainOrLoginAuth(identity, username, password, host string) smtp.Auth { + return &plainOrLoginAuth{identity: identity, username: username, password: password, host: host} +} + +func isLocalhost(name string) bool { + return name == "localhost" || name == "127.0.0.1" || name == "::1" +} + +func (a *plainOrLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + // Must have TLS, or else localhost server. + // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. + // In particular, it doesn't matter if the server advertises PLAIN auth. + // That might just be the attacker saying + // "it's ok, you can trust me with your password." + if !server.TLS && !isLocalhost(server.Name) { + return "", nil, errUnencryptedConnection + } + if server.Name != a.host { + return "", nil, errWrongHostname + } + + if slices.Contains(server.Auth, "PLAIN") { + a.authMethod = "PLAIN" + resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password) + return a.authMethod, resp, nil + } else if slices.Contains(server.Auth, "LOGIN") { + a.authMethod = "LOGIN" + return a.authMethod, nil, nil + } else { + return "", nil, errNoSupportedAuth + } +} + +func (a *plainOrLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + + if a.authMethod == "PLAIN" { + // We've already sent everything. + return nil, errUnexpectedServerChallenge + } + + switch { + case bytes.Equal(fromServer, []byte("Username:")): + return []byte(a.username), nil + case bytes.Equal(fromServer, []byte("Password:")): + return []byte(a.password), nil + default: + return nil, fmt.Errorf("%w: %s", errUnexpectedServerChallenge, fromServer) + } +} + type smtpSender struct { config *Config success int64 @@ -39,7 +110,7 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error { } var auth smtp.Auth if s.config.SMTPSenderUser != "" { - auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) + auth = PlainOrLoginAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) } ev := logvm(v, m). Tag(tagEmail). From 8e6c2a2087dda829b6b1e67107029bd3989cd948 Mon Sep 17 00:00:00 2001 From: santanoce Date: Thu, 1 Jan 2026 22:26:22 +0100 Subject: [PATCH 2/2] refactor: improved log from byte to string --- server/smtp_sender.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/smtp_sender.go b/server/smtp_sender.go index f22f3570..27946109 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -87,7 +87,7 @@ func (a *plainOrLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) { case bytes.Equal(fromServer, []byte("Password:")): return []byte(a.password), nil default: - return nil, fmt.Errorf("%w: %s", errUnexpectedServerChallenge, fromServer) + return nil, fmt.Errorf("%w: %s", errUnexpectedServerChallenge, string(fromServer)) } }