Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/src/lib/dialogs/ChannelConfigModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
from_name: '',
encryption: 'tls',
skip_tls_verify: false,
auth_method: 'auto',
enabled: false
});

Expand Down Expand Up @@ -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') {
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/lib/features/channels/ChannelSMTPConfig.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from_name: '',
encryption: 'tls',
skip_tls_verify: false,
auth_method: 'auto',
enabled: false
}),
onSave = () => {}
Expand Down Expand Up @@ -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'
};
}

Expand Down Expand Up @@ -174,6 +176,24 @@
<div class="pt-4 border-t" style="border-color: var(--ds-border);">
<h5 class="text-sm font-semibold mb-3" style="color: var(--ds-text);">{t('channel.authentication')}</h5>
<div class="space-y-4">
<div>
<SelectField
label={t('channel.smtpAuthMethod')}
id="smtp-auth-method"
labelColor="default"
ariaLabel={t('channel.smtpAuthMethod')}
options={[
{ value: 'auto', label: t('channel.smtpAuthAuto') },
{ value: 'plain', label: 'PLAIN' },
{ value: 'login', label: 'LOGIN' },
{ value: 'cram-md5', label: 'CRAM-MD5' },
{ value: 'none', label: t('channel.smtpAuthNone') }
]}
bind:value={formData.auth_method}
/>
<DescriptionText>{t('channel.smtpAuthMethodDescription')}</DescriptionText>
</div>
{#if formData.auth_method !== 'none'}
<div>
<TextField
label={t('channel.smtpUsername')}
Expand All @@ -197,6 +217,7 @@
{t('channel.leaveBlankPassword')}
</DescriptionText>
</div>
{/if}
</div>
</div>
{/if}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/de/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/en/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/fr/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/ru/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/locales/zh-CN/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions internal/models/portal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
160 changes: 160 additions & 0 deletions internal/smtp/auth.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 8 additions & 5 deletions internal/smtp/notification_sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading