fix(email): allow explicit SMTP auth mechanism selection

Auto-discovery in go-mail never picks PLAIN/LOGIN on unencrypted
connections, so relays that only advertise plaintext mechanisms
fail with ErrNoSupportedAuthDiscovered (upstream issue #3375).

Add an "smtp_auth" setting (default "autodiscover", unchanged
behavior) that maps to explicit go-mail mechanisms, including
PLAIN-NOENC/LOGIN-NOENC for relays without TLS and NOAUTH for
open relays. Admin UI gains a select with a plaintext-credential
warning; the test-mail endpoint honors the same setting.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 6e03fdf8c1
commit e8a720c3e5

@ -437,6 +437,18 @@
"replyToAddressDes": "The mailbox used to receive reply emails when users reply to emails sent by the system.", "replyToAddressDes": "The mailbox used to receive reply emails when users reply to emails sent by the system.",
"enforceSSL": "Enforce SSL connection", "enforceSSL": "Enforce SSL connection",
"enforceSSLDes": "Whether to enforce an SSL encrypted connection. If you cannot send emails, you can turn this off and Cloudreve will try to use STARTTLS and decide whether to use encrypted connections.", "enforceSSLDes": "Whether to enforce an SSL encrypted connection. If you cannot send emails, you can turn this off and Cloudreve will try to use STARTTLS and decide whether to use encrypted connections.",
"smtpAuthMethod": "SMTP authentication method",
"smtpAuthMethodDes": "Auto-discovery only selects secure mechanisms and never sends credentials in plaintext on unencrypted connections. If your server only offers PLAIN/LOGIN without encryption, pick the corresponding \"-noenc\" method — credentials will then be transmitted unencrypted.",
"smtpAuth_autodiscover": "Auto discovery",
"smtpAuth_plain": "PLAIN",
"smtpAuth_plain_noenc": "PLAIN (allow unencrypted)",
"smtpAuth_login": "LOGIN",
"smtpAuth_login_noenc": "LOGIN (allow unencrypted)",
"smtpAuth_cram_md5": "CRAM-MD5",
"smtpAuth_scram_sha_1": "SCRAM-SHA-1",
"smtpAuth_scram_sha_256": "SCRAM-SHA-256",
"smtpAuth_xoauth2": "XOAUTH2",
"smtpAuth_noauth": "No authentication",
"smtpTTL": "SMTP connection TTL (seconds)", "smtpTTL": "SMTP connection TTL (seconds)",
"smtpTTLDes": "SMTP connections established during the TTL period will be reused by new mail delivery requests.", "smtpTTLDes": "SMTP connections established during the TTL period will be reused by new mail delivery requests.",
"emailTemplates": "Email Templates", "emailTemplates": "Email Templates",

@ -437,6 +437,18 @@
"replyToAddressDes": "用户回复系统发送的邮件时,用于接收回信的邮箱。", "replyToAddressDes": "用户回复系统发送的邮件时,用于接收回信的邮箱。",
"enforceSSL": "强制使用 SSL 连接", "enforceSSL": "强制使用 SSL 连接",
"enforceSSLDes": "是否强制使用 SSL 加密连接。如果无法发送邮件,可关闭此项,Cloudreve 会尝试使用 STARTTLS 并决定是否使用加密连接。", "enforceSSLDes": "是否强制使用 SSL 加密连接。如果无法发送邮件,可关闭此项,Cloudreve 会尝试使用 STARTTLS 并决定是否使用加密连接。",
"smtpAuthMethod": "SMTP 认证方式",
"smtpAuthMethodDes": "自动发现只会选择安全的认证机制,不会在未加密的连接上以明文发送凭据。如果服务器仅提供 PLAIN/LOGIN 且不支持加密,请选择对应的 \"-noenc\" 方式 —— 凭据将以明文传输。",
"smtpAuth_autodiscover": "自动发现",
"smtpAuth_plain": "PLAIN",
"smtpAuth_plain_noenc": "PLAIN(允许明文传输)",
"smtpAuth_login": "LOGIN",
"smtpAuth_login_noenc": "LOGIN(允许明文传输)",
"smtpAuth_cram_md5": "CRAM-MD5",
"smtpAuth_scram_sha_1": "SCRAM-SHA-1",
"smtpAuth_scram_sha_256": "SCRAM-SHA-256",
"smtpAuth_xoauth2": "XOAUTH2",
"smtpAuth_noauth": "不使用认证",
"smtpTTL": "SMTP 连接有效期 (秒)", "smtpTTL": "SMTP 连接有效期 (秒)",
"smtpTTLDes": "有效期内建立的 SMTP 连接会被新邮件发送请求复用。", "smtpTTLDes": "有效期内建立的 SMTP 连接会被新邮件发送请求复用。",
"emailTemplates": "邮件模板", "emailTemplates": "邮件模板",

@ -1,4 +1,4 @@
import { Box, DialogContent, FormControl, FormControlLabel, Stack, Switch, Typography } from "@mui/material"; import { Box, DialogContent, FormControl, FormControlLabel, ListItemText, Stack, Switch, Typography } from "@mui/material";
import { useSnackbar } from "notistack"; import { useSnackbar } from "notistack";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -6,9 +6,10 @@ import { sendTestSMTP } from "../../../../api/api.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts"; import { useAppDispatch } from "../../../../redux/hooks.ts";
import { isTrueVal } from "../../../../session/utils.ts"; import { isTrueVal } from "../../../../session/utils.ts";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx"; import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents.tsx"; import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../Common/StyledComponents.tsx";
import DraggableDialog, { StyledDialogContentText } from "../../../Dialogs/DraggableDialog.tsx"; import DraggableDialog, { StyledDialogContentText } from "../../../Dialogs/DraggableDialog.tsx";
import MailOutlined from "../../../Icons/MailOutlined.tsx"; import MailOutlined from "../../../Icons/MailOutlined.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx"; import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx"; import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx"; import { SettingContext } from "../SettingWrapper.tsx";
@ -174,6 +175,39 @@ const Email = () => {
</FormControl> </FormControl>
</SettingForm> </SettingForm>
<SettingForm title={t("settings.smtpAuthMethod")} lgWidth={5}>
<FormControl>
<DenseSelect
value={values.smtp_auth ?? "autodiscover"}
onChange={(e) => setSettings({ smtp_auth: e.target.value as string })}
>
{[
"autodiscover",
"plain",
"plain-noenc",
"login",
"login-noenc",
"cram-md5",
"scram-sha-1",
"scram-sha-256",
"xoauth2",
"noauth",
].map((v) => (
<SquareMenuItem key={v} value={v}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`settings.smtpAuth_${v.replace(/-/g, "_")}`)}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("settings.smtpAuthMethodDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.smtpTTL")} lgWidth={5}> <SettingForm title={t("settings.smtpTTL")} lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<DenseFilledTextField <DenseFilledTextField

@ -302,6 +302,7 @@ const Settings = () => {
"smtpUser", "smtpUser",
"smtpPass", "smtpPass",
"smtpEncryption", "smtpEncryption",
"smtp_auth",
"fromName", "fromName",
"mail_activation_template", "mail_activation_template",
"mail_reset_template", "mail_reset_template",

@ -561,6 +561,7 @@ var DefaultSettings = map[string]string{
"email_filter_mode": "0", "email_filter_mode": "0",
"email_filter_list": "", "email_filter_list": "",
"email_disable_subaddress": "0", "email_disable_subaddress": "0",
"smtp_auth": "autodiscover",
"captcha_type": "normal", "captcha_type": "normal",
"captcha_height": "60", "captcha_height": "60",
"captcha_width": "240", "captcha_width": "240",

@ -102,6 +102,36 @@ func (client *SMTPPool) Send(ctx context.Context, to, title, body string) error
return nil return nil
} }
// SMTPAuthType maps the admin-configured auth method to a go-mail mechanism.
// The library's own auto-discovery never picks plaintext mechanisms
// (PLAIN/LOGIN) on unencrypted connections, so relays that only advertise
// them need the explicit *-noenc choices. Anything unrecognized falls back
// to auto-discovery.
func SMTPAuthType(configured string) mail.SMTPAuthType {
switch strings.ToUpper(strings.TrimSpace(configured)) {
case string(mail.SMTPAuthPlain):
return mail.SMTPAuthPlain
case string(mail.SMTPAuthPlainNoEnc):
return mail.SMTPAuthPlainNoEnc
case string(mail.SMTPAuthLogin):
return mail.SMTPAuthLogin
case string(mail.SMTPAuthLoginNoEnc):
return mail.SMTPAuthLoginNoEnc
case string(mail.SMTPAuthCramMD5):
return mail.SMTPAuthCramMD5
case string(mail.SMTPAuthSCRAMSHA1):
return mail.SMTPAuthSCRAMSHA1
case string(mail.SMTPAuthSCRAMSHA256):
return mail.SMTPAuthSCRAMSHA256
case string(mail.SMTPAuthXOAUTH2):
return mail.SMTPAuthXOAUTH2
case string(mail.SMTPAuthNoAuth):
return mail.SMTPAuthNoAuth
default:
return mail.SMTPAuthAutoDiscover
}
}
// Close 关闭发送队列 // Close 关闭发送队列
func (client *SMTPPool) Close() { func (client *SMTPPool) Close() {
if client.ch != nil { if client.ch != nil {
@ -125,7 +155,7 @@ func (client *SMTPPool) Init() {
opts := []mail.Option{ opts := []mail.Option{
mail.WithPort(client.config.Port), mail.WithPort(client.config.Port),
mail.WithTimeout(time.Duration(client.config.Keepalive+5) * time.Second), mail.WithTimeout(time.Duration(client.config.Keepalive+5) * time.Second),
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSOpportunistic), mail.WithSMTPAuth(SMTPAuthType(client.config.AuthType)), mail.WithTLSPortPolicy(mail.TLSOpportunistic),
mail.WithUsername(client.config.User), mail.WithPassword(client.config.Password), mail.WithUsername(client.config.User), mail.WithPassword(client.config.Password),
} }
if client.config.ForceEncryption { if client.config.ForceEncryption {

@ -0,0 +1,27 @@
package email
import (
"testing"
"github.com/stretchr/testify/assert"
mail "github.com/wneessen/go-mail"
)
func TestSMTPAuthType(t *testing.T) {
assert.Equal(t, mail.SMTPAuthAutoDiscover, SMTPAuthType(""))
assert.Equal(t, mail.SMTPAuthAutoDiscover, SMTPAuthType("autodiscover"))
assert.Equal(t, mail.SMTPAuthAutoDiscover, SMTPAuthType("bogus"))
assert.Equal(t, mail.SMTPAuthPlain, SMTPAuthType("plain"))
assert.Equal(t, mail.SMTPAuthPlainNoEnc, SMTPAuthType("plain-noenc"))
assert.Equal(t, mail.SMTPAuthLogin, SMTPAuthType("login"))
assert.Equal(t, mail.SMTPAuthLoginNoEnc, SMTPAuthType("login-noenc"))
assert.Equal(t, mail.SMTPAuthCramMD5, SMTPAuthType("cram-md5"))
assert.Equal(t, mail.SMTPAuthSCRAMSHA1, SMTPAuthType("scram-sha-1"))
assert.Equal(t, mail.SMTPAuthSCRAMSHA256, SMTPAuthType("scram-sha-256"))
assert.Equal(t, mail.SMTPAuthXOAUTH2, SMTPAuthType("xoauth2"))
assert.Equal(t, mail.SMTPAuthNoAuth, SMTPAuthType("noauth"))
// Values are normalized before matching.
assert.Equal(t, mail.SMTPAuthPlainNoEnc, SMTPAuthType(" Plain-NoEnc "))
}

@ -807,6 +807,7 @@ func (s *settingProvider) SMTP(ctx context.Context) *SMTP {
ForceEncryption: s.getBoolean(ctx, "smtpEncryption", false), ForceEncryption: s.getBoolean(ctx, "smtpEncryption", false),
Port: s.getInt(ctx, "smtpPort", 25), Port: s.getInt(ctx, "smtpPort", 25),
Keepalive: s.getInt(ctx, "mail_keepalive", 30), Keepalive: s.getInt(ctx, "mail_keepalive", 30),
AuthType: s.getString(ctx, "smtp_auth", "autodiscover"),
} }
} }

@ -65,6 +65,7 @@ type SMTP struct {
ForceEncryption bool ForceEncryption bool
Port int Port int
Keepalive int Keepalive int
AuthType string
} }
type TokenAuth struct { type TokenAuth struct {

@ -10,6 +10,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/email"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
request2 "github.com/cloudreve/Cloudreve/v4/pkg/request" request2 "github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/serializer"
@ -142,7 +143,7 @@ func (s *TestSMTPService) Test(c *gin.Context) error {
opts := []mail.Option{ opts := []mail.Option{
mail.WithPort(port), mail.WithPort(port),
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSOpportunistic), mail.WithSMTPAuth(email.SMTPAuthType(s.Settings["smtp_auth"])), mail.WithTLSPortPolicy(mail.TLSOpportunistic),
mail.WithUsername(s.Settings["smtpUser"]), mail.WithPassword(s.Settings["smtpPass"]), mail.WithUsername(s.Settings["smtpUser"]), mail.WithPassword(s.Settings["smtpPass"]),
} }
if setting.IsTrueValue(s.Settings["smtpEncryption"]) { if setting.IsTrueValue(s.Settings["smtpEncryption"]) {

Loading…
Cancel
Save