From 327a894d04c6bacf21fb014eaac0ac40414787cc Mon Sep 17 00:00:00 2001 From: Pietro Montorfano Date: Thu, 15 Oct 2026 14:02:54 +0200 Subject: [PATCH] feat: make SMTP AUTH mechanism selectable per channel The outbound SMTP channel always authenticated with AUTH PLAIN, so servers that only offer LOGIN (Office 365/Exchange, many hosting providers) or CRAM-MD5 could not be used. Add an `smtp_auth_method` channel setting: - auto (default, also used for existing channels): pick the first of PLAIN, LOGIN, CRAM-MD5 advertised by the server in EHLO, falling back to PLAIN when nothing is advertised (previous behavior) - plain / login / cram-md5: force a mechanism, with a clear error when the server does not advertise it - none: never authenticate, even if credentials are stored AUTH LOGIN is implemented locally since net/smtp does not provide it. All mechanisms refuse to send credentials over an unencrypted connection, and unknown values are rejected by ValidateTransport. The channel config UI gets an "Authentication method" selector, with translations for the required locales. Co-Authored-By: Claude Opus 5.5 --- .../src/lib/dialogs/ChannelConfigModal.svelte | 2 + .../channels/ChannelSMTPConfig.svelte | 23 ++- frontend/src/lib/locales/de/channels.js | 4 + frontend/src/lib/locales/en/channels.js | 4 + frontend/src/lib/locales/fr/channels.js | 4 + frontend/src/lib/locales/ru/channels.js | 4 + frontend/src/lib/locales/zh-CN/channels.js | 4 + internal/models/portal.go | 1 + internal/smtp/auth.go | 160 ++++++++++++++++++ internal/smtp/notification_sender.go | 13 +- 10 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 internal/smtp/auth.go 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)