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)