diff --git a/frontend/src/lib/dialogs/ChannelConfigModal.svelte b/frontend/src/lib/dialogs/ChannelConfigModal.svelte
index 01aa0d5a6..4471ec3f1 100644
--- a/frontend/src/lib/dialogs/ChannelConfigModal.svelte
+++ b/frontend/src/lib/dialogs/ChannelConfigModal.svelte
@@ -109,6 +109,7 @@
from_name: '',
encryption: 'tls',
skip_tls_verify: false,
+ auth_method: 'auto',
enabled: false
});
@@ -266,6 +267,7 @@
from_name: config.smtp_from_name || '',
encryption: config.smtp_encryption || 'tls',
skip_tls_verify: config.smtp_skip_tls_verify || false,
+ auth_method: config.smtp_auth_method || 'auto',
enabled: channel.status === 'enabled'
};
} else if (channel.type === 'form') {
diff --git a/frontend/src/lib/features/channels/ChannelSMTPConfig.svelte b/frontend/src/lib/features/channels/ChannelSMTPConfig.svelte
index c49646d58..ad3405183 100644
--- a/frontend/src/lib/features/channels/ChannelSMTPConfig.svelte
+++ b/frontend/src/lib/features/channels/ChannelSMTPConfig.svelte
@@ -21,6 +21,7 @@
from_name: '',
encryption: 'tls',
skip_tls_verify: false,
+ auth_method: 'auto',
enabled: false
}),
onSave = () => {}
@@ -98,7 +99,8 @@
smtp_from_email: formData.from_email,
smtp_from_name: formData.from_name || '',
smtp_encryption: formData.encryption || 'tls',
- smtp_skip_tls_verify: plaintext ? false : formData.skip_tls_verify || false
+ smtp_skip_tls_verify: plaintext ? false : formData.skip_tls_verify || false,
+ smtp_auth_method: plaintext ? 'none' : formData.auth_method || 'auto'
};
}
@@ -174,6 +176,24 @@
{t('channel.authentication')}
+
+
+ {t('channel.smtpAuthMethodDescription')}
+
+ {#if formData.auth_method !== 'none'}
+ {/if}
{/if}
diff --git a/frontend/src/lib/locales/de/channels.js b/frontend/src/lib/locales/de/channels.js
index 7231468b4..6e794aa4d 100644
--- a/frontend/src/lib/locales/de/channels.js
+++ b/frontend/src/lib/locales/de/channels.js
@@ -255,6 +255,10 @@ export default {
smtpNoEncryptionWarning: 'E-Mail-Inhalte und Zustellungsmetadaten werden unverschlüsselt übertragen. Verwenden Sie dies nur mit einem vertrauenswürdigen lokalen SMTP-Relay.',
noEncryption: 'Keine (nicht empfohlen)',
authentication: 'Authentifizierung',
+ smtpAuthMethod: 'Authentifizierungsmethode',
+ smtpAuthMethodDescription: 'SASL-Mechanismus für SMTP AUTH. Automatisch wählt den ersten vom Server angebotenen Mechanismus aus PLAIN, LOGIN, CRAM-MD5.',
+ smtpAuthAuto: 'Automatisch (empfohlen)',
+ smtpAuthNone: 'Keine (ohne Authentifizierung)',
senderSettings: 'Absendereinstellungen',
smtpHostAndFromRequired: 'SMTP-Host und Absender-E-Mail sind erforderlich',
testSmtp: 'SMTP testen',
diff --git a/frontend/src/lib/locales/en/channels.js b/frontend/src/lib/locales/en/channels.js
index 60d2b2bf5..814e6a3aa 100644
--- a/frontend/src/lib/locales/en/channels.js
+++ b/frontend/src/lib/locales/en/channels.js
@@ -349,6 +349,10 @@ export default {
smtpNoEncryptionWarning: 'Email contents and delivery metadata will be sent without encryption. Use this only with a trusted local SMTP relay.',
noEncryption: 'None (not recommended)',
authentication: 'Authentication',
+ smtpAuthMethod: 'Authentication method',
+ smtpAuthMethodDescription: 'SASL mechanism used for SMTP AUTH. Automatic picks the first of PLAIN, LOGIN, CRAM-MD5 offered by the server.',
+ smtpAuthAuto: 'Automatic (recommended)',
+ smtpAuthNone: 'None (no authentication)',
senderSettings: 'Sender Settings',
smtpHostAndFromRequired: 'SMTP host and from email are required',
testSmtp: 'Test SMTP',
diff --git a/frontend/src/lib/locales/fr/channels.js b/frontend/src/lib/locales/fr/channels.js
index 0752fae02..d5e2575d0 100644
--- a/frontend/src/lib/locales/fr/channels.js
+++ b/frontend/src/lib/locales/fr/channels.js
@@ -293,6 +293,10 @@ export default {
smtpNoEncryptionWarning: 'Le contenu des e-mails et les métadonnées de livraison seront envoyés sans chiffrement. N’utilisez cette option qu’avec un relais SMTP local de confiance.',
noEncryption: 'Aucun (non recommandé)',
authentication: 'Authentification',
+ smtpAuthMethod: 'Méthode d’authentification',
+ smtpAuthMethodDescription: 'Mécanisme SASL utilisé pour SMTP AUTH. Automatique choisit le premier mécanisme proposé par le serveur parmi PLAIN, LOGIN, CRAM-MD5.',
+ smtpAuthAuto: 'Automatique (recommandé)',
+ smtpAuthNone: 'Aucune (sans authentification)',
senderSettings: 'Paramètres de l’expéditeur',
smtpHostAndFromRequired: 'L’hôte SMTP et l’e-mail de l’expéditeur sont requis',
testSmtp: 'Tester le SMTP',
diff --git a/frontend/src/lib/locales/ru/channels.js b/frontend/src/lib/locales/ru/channels.js
index 7215a3fe4..4c1f6e347 100644
--- a/frontend/src/lib/locales/ru/channels.js
+++ b/frontend/src/lib/locales/ru/channels.js
@@ -339,6 +339,10 @@ export default {
smtpNoEncryptionWarning: 'Содержимое писем и метаданные доставки будут передаваться без шифрования. Используйте только с доверенным локальным SMTP-ретранслятором.',
noEncryption: 'Нет (не рекомендуется)',
authentication: 'Аутентификация',
+ smtpAuthMethod: 'Метод аутентификации',
+ smtpAuthMethodDescription: 'Механизм SASL для SMTP AUTH. Автоматический режим выбирает первый из PLAIN, LOGIN, CRAM-MD5, поддерживаемый сервером.',
+ smtpAuthAuto: 'Автоматически (рекомендуется)',
+ smtpAuthNone: 'Нет (без аутентификации)',
senderSettings: 'Настройки отправителя',
smtpHostAndFromRequired: 'Укажите сервер SMTP и адрес отправителя',
testSmtp: 'Проверить SMTP',
diff --git a/frontend/src/lib/locales/zh-CN/channels.js b/frontend/src/lib/locales/zh-CN/channels.js
index 47bfe70f7..e45b4263a 100644
--- a/frontend/src/lib/locales/zh-CN/channels.js
+++ b/frontend/src/lib/locales/zh-CN/channels.js
@@ -252,6 +252,10 @@ export default {
smtpNoEncryptionWarning: '邮件内容和投递元数据将不加密发送。仅可将此选项用于受信任的本地 SMTP 中继。',
noEncryption: '无(不推荐)',
authentication: '认证',
+ smtpAuthMethod: '认证方式',
+ smtpAuthMethodDescription: '用于 SMTP AUTH 的 SASL 机制。自动模式会按 PLAIN、LOGIN、CRAM-MD5 的顺序选择服务器支持的第一个机制。',
+ smtpAuthAuto: '自动(推荐)',
+ smtpAuthNone: '无(不认证)',
senderSettings: '发件人设置',
smtpHostAndFromRequired: 'SMTP 主机和发件人邮箱为必填项',
testSmtp: '测试 SMTP',
diff --git a/internal/models/portal.go b/internal/models/portal.go
index 26188966b..c5a99d7c3 100644
--- a/internal/models/portal.go
+++ b/internal/models/portal.go
@@ -45,6 +45,7 @@ type ChannelConfig struct {
SMTPFromName string `json:"smtp_from_name,omitempty"`
SMTPEncryption string `json:"smtp_encryption,omitempty"` // tls/starttls, ssl, none
SMTPSkipTLSVerify bool `json:"smtp_skip_tls_verify,omitempty"`
+ SMTPAuthMethod string `json:"smtp_auth_method,omitempty"` // auto (default), plain, login, cram-md5, none
// Webhook Configuration
WebhookURL string `json:"webhook_url,omitempty"`
diff --git a/internal/smtp/auth.go b/internal/smtp/auth.go
new file mode 100644
index 000000000..5c4833fef
--- /dev/null
+++ b/internal/smtp/auth.go
@@ -0,0 +1,160 @@
+package smtp
+
+import (
+ "errors"
+ "fmt"
+ "net/smtp"
+ "strings"
+)
+
+// SMTP AUTH mechanisms selectable per channel. "auto" (also the empty value,
+// so channels saved before the setting existed keep working) picks the first
+// mechanism from authAutoPreference that the server advertises in EHLO.
+const (
+ AuthMethodAuto = "auto"
+ AuthMethodPlain = "plain"
+ AuthMethodLogin = "login"
+ AuthMethodCRAMMD5 = "cram-md5"
+ AuthMethodNone = "none"
+)
+
+// authAutoPreference is the order "auto" tries mechanisms in. PLAIN first
+// preserves the historical behavior for servers that advertise it.
+var authAutoPreference = []string{AuthMethodPlain, AuthMethodLogin, AuthMethodCRAMMD5}
+
+// errAuthRequiresTLS mirrors net/smtp's own guard in PlainAuth: credentials
+// are never sent on an unencrypted connection, whatever mechanism is chosen.
+var errAuthRequiresTLS = errors.New("SMTP authentication requires an encrypted connection")
+
+// normalizeAuthMethod lowercases and trims, mapping "" to AuthMethodAuto.
+func normalizeAuthMethod(method string) string {
+ m := strings.ToLower(strings.TrimSpace(method))
+ if m == "" {
+ return AuthMethodAuto
+ }
+ return m
+}
+
+// AuthMethodAllowed reports whether method is a supported SMTP AUTH setting.
+func AuthMethodAllowed(method string) bool {
+ switch normalizeAuthMethod(method) {
+ case AuthMethodAuto, AuthMethodPlain, AuthMethodLogin, AuthMethodCRAMMD5, AuthMethodNone:
+ return true
+ default:
+ return false
+ }
+}
+
+// newSMTPAuth builds the smtp.Auth for the configured mechanism. It returns
+// nil when no authentication should be attempted.
+func newSMTPAuth(method, username, password, host string) (smtp.Auth, error) {
+ method = normalizeAuthMethod(method)
+ if !AuthMethodAllowed(method) {
+ return nil, fmt.Errorf("SMTP auth method %q not allowed; use \"auto\", \"plain\", \"login\", \"cram-md5\", or \"none\"", method)
+ }
+ if method == AuthMethodNone || username == "" || password == "" {
+ return nil, nil
+ }
+ return &selectingAuth{method: method, username: username, password: password, host: host}, nil
+}
+
+// selectingAuth resolves the concrete mechanism once the server's EHLO
+// capabilities are known, so "auto" can negotiate and explicit choices can
+// fail with a clear message when the server doesn't offer them.
+type selectingAuth struct {
+ method string
+ username string
+ password string
+ host string
+ active smtp.Auth
+}
+
+func (a *selectingAuth) Start(server *smtp.ServerInfo) (proto string, toServer []byte, err error) {
+ if !server.TLS {
+ return "", nil, errAuthRequiresTLS
+ }
+ method := a.method
+ if method == AuthMethodAuto {
+ method = pickAuthMethod(server.Auth)
+ } else if len(server.Auth) > 0 && !serverAdvertises(server.Auth, method) {
+ return "", nil, fmt.Errorf("SMTP server does not support AUTH %s (advertised: %s)",
+ strings.ToUpper(method), strings.Join(server.Auth, " "))
+ }
+
+ switch method {
+ case AuthMethodPlain:
+ a.active = smtp.PlainAuth("", a.username, a.password, a.host)
+ case AuthMethodLogin:
+ a.active = &loginAuth{username: a.username, password: a.password}
+ case AuthMethodCRAMMD5:
+ a.active = smtp.CRAMMD5Auth(a.username, a.password)
+ default:
+ return "", nil, fmt.Errorf("SMTP auth method %q not allowed", method)
+ }
+ return a.active.Start(server)
+}
+
+func (a *selectingAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+ if a.active == nil {
+ return nil, errors.New("SMTP auth not started")
+ }
+ return a.active.Next(fromServer, more)
+}
+
+// pickAuthMethod returns the first preferred mechanism the server advertises.
+// Servers that omit the AUTH extension get PLAIN, matching the old behavior.
+func pickAuthMethod(advertised []string) string {
+ for _, m := range authAutoPreference {
+ if serverAdvertises(advertised, m) {
+ return m
+ }
+ }
+ return AuthMethodPlain
+}
+
+func serverAdvertises(advertised []string, method string) bool {
+ for _, a := range advertised {
+ if strings.EqualFold(a, method) {
+ return true
+ }
+ }
+ return false
+}
+
+// loginAuth implements the non-standard but widely deployed AUTH LOGIN
+// mechanism (Exchange/Office 365, many hosting providers). net/smtp has
+// already base64-decoded the server prompts by the time Next sees them.
+type loginAuth struct {
+ username string
+ password string
+ step int
+}
+
+func (a *loginAuth) Start(server *smtp.ServerInfo) (proto string, toServer []byte, err error) {
+ if !server.TLS {
+ return "", nil, errAuthRequiresTLS
+ }
+ a.step = 0
+ return "LOGIN", nil, nil
+}
+
+func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+ if !more {
+ return nil, nil
+ }
+ prompt := strings.ToLower(strings.TrimSpace(string(fromServer)))
+ a.step++
+ switch {
+ case strings.HasPrefix(prompt, "user"):
+ return []byte(a.username), nil
+ case strings.HasPrefix(prompt, "pass"):
+ return []byte(a.password), nil
+ // Some servers send empty or non-English prompts; fall back to order.
+ case a.step == 1:
+ return []byte(a.username), nil
+ case a.step == 2:
+ return []byte(a.password), nil
+ default:
+ return nil, fmt.Errorf("unexpected AUTH LOGIN challenge %q", fromServer)
+ }
+}
diff --git a/internal/smtp/notification_sender.go b/internal/smtp/notification_sender.go
index a82b2ce85..9f2d3aff4 100644
--- a/internal/smtp/notification_sender.go
+++ b/internal/smtp/notification_sender.go
@@ -50,6 +50,9 @@ func ValidateTransport(config *models.ChannelConfig) error {
if mode == "none" && (strings.TrimSpace(config.SMTPUsername) != "" || config.SMTPPassword != "") {
return ErrSMTPAuthenticationRequiresTLS
}
+ if !AuthMethodAllowed(config.SMTPAuthMethod) {
+ return fmt.Errorf("SMTP auth method %q not allowed; use \"auto\", \"plain\", \"login\", \"cram-md5\", or \"none\"", config.SMTPAuthMethod)
+ }
return nil
}
@@ -336,7 +339,7 @@ func (s *NotificationSMTPSender) getSMTPConfig() (*models.ChannelConfig, error)
}
// SMTPPassword stays encrypted in the returned config — dispatch decrypts
- // it just before AUTH PLAIN. Keeping the encrypted value in the struct
+ // it just before SMTP AUTH. Keeping the encrypted value in the struct
// avoids handing plaintext to any code paths that don't strictly need it.
return &config, nil
}
@@ -559,7 +562,7 @@ func formatMessageIDHeader(value string) string {
// authentication; empty or unknown modes are errors rather than downgrades.
//
// It is a method (rather than a free function) so it can decrypt the at-rest
-// SMTPPassword before passing it to AUTH PLAIN — every caller goes through the
+// SMTPPassword before passing it to SMTP AUTH — every caller goes through the
// encryption-aware sender, even the channel-test path that loads raw config
// from the DB on its own.
func (s *NotificationSMTPSender) dispatchContext(ctx context.Context, config *models.ChannelConfig, toEmail, message string) error {
@@ -588,9 +591,9 @@ func (s *NotificationSMTPSender) dispatchContext(ctx context.Context, config *mo
return err
}
- var auth smtp.Auth
- if config.SMTPUsername != "" && password != "" {
- auth = smtp.PlainAuth("", config.SMTPUsername, password, config.SMTPHost)
+ auth, err := newSMTPAuth(config.SMTPAuthMethod, config.SMTPUsername, password, config.SMTPHost)
+ if err != nil {
+ return err
}
addr := fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)