From e8a720c3e5c792cf7631c3c8b9f632a1ac95f64e Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 19:02:08 +0200 Subject: [PATCH] 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> --- frontend/public/locales/en-US/dashboard.json | 12 ++++++ frontend/public/locales/zh-CN/dashboard.json | 12 ++++++ .../component/Admin/Settings/Email/Email.tsx | 38 ++++++++++++++++++- .../src/component/Admin/Settings/Settings.tsx | 1 + inventory/setting.go | 1 + pkg/email/smtp.go | 32 +++++++++++++++- pkg/email/smtp_test.go | 27 +++++++++++++ pkg/setting/provider.go | 1 + pkg/setting/types.go | 1 + service/admin/tools.go | 3 +- 10 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 pkg/email/smtp_test.go diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index bad878b6..5d8aa001 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -437,6 +437,18 @@ "replyToAddressDes": "The mailbox used to receive reply emails when users reply to emails sent by the system.", "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.", + "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)", "smtpTTLDes": "SMTP connections established during the TTL period will be reused by new mail delivery requests.", "emailTemplates": "Email Templates", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 15867876..c0d0f2ba 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -437,6 +437,18 @@ "replyToAddressDes": "用户回复系统发送的邮件时,用于接收回信的邮箱。", "enforceSSL": "强制使用 SSL 连接", "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 连接有效期 (秒)", "smtpTTLDes": "有效期内建立的 SMTP 连接会被新邮件发送请求复用。", "emailTemplates": "邮件模板", diff --git a/frontend/src/component/Admin/Settings/Email/Email.tsx b/frontend/src/component/Admin/Settings/Email/Email.tsx index 0dc38f42..a80909b5 100644 --- a/frontend/src/component/Admin/Settings/Email/Email.tsx +++ b/frontend/src/component/Admin/Settings/Email/Email.tsx @@ -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 { useContext, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -6,9 +6,10 @@ import { sendTestSMTP } from "../../../../api/api.ts"; import { useAppDispatch } from "../../../../redux/hooks.ts"; import { isTrueVal } from "../../../../session/utils.ts"; 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 MailOutlined from "../../../Icons/MailOutlined.tsx"; +import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx"; import SettingForm from "../../../Pages/Setting/SettingForm.tsx"; import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx"; import { SettingContext } from "../SettingWrapper.tsx"; @@ -174,6 +175,39 @@ const Email = () => { + + + 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) => ( + + + {t(`settings.smtpAuth_${v.replace(/-/g, "_")}`)} + + + ))} + + {t("settings.smtpAuthMethodDes")} + + + { "smtpUser", "smtpPass", "smtpEncryption", + "smtp_auth", "fromName", "mail_activation_template", "mail_reset_template", diff --git a/inventory/setting.go b/inventory/setting.go index e822d9ae..064a6d2d 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -561,6 +561,7 @@ var DefaultSettings = map[string]string{ "email_filter_mode": "0", "email_filter_list": "", "email_disable_subaddress": "0", + "smtp_auth": "autodiscover", "captcha_type": "normal", "captcha_height": "60", "captcha_width": "240", diff --git a/pkg/email/smtp.go b/pkg/email/smtp.go index ebb5c74f..ad9b6468 100644 --- a/pkg/email/smtp.go +++ b/pkg/email/smtp.go @@ -102,6 +102,36 @@ func (client *SMTPPool) Send(ctx context.Context, to, title, body string) error 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 关闭发送队列 func (client *SMTPPool) Close() { if client.ch != nil { @@ -125,7 +155,7 @@ func (client *SMTPPool) Init() { opts := []mail.Option{ mail.WithPort(client.config.Port), 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), } if client.config.ForceEncryption { diff --git a/pkg/email/smtp_test.go b/pkg/email/smtp_test.go new file mode 100644 index 00000000..7b270b33 --- /dev/null +++ b/pkg/email/smtp_test.go @@ -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 ")) +} diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 393927e9..d0878a94 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -807,6 +807,7 @@ func (s *settingProvider) SMTP(ctx context.Context) *SMTP { ForceEncryption: s.getBoolean(ctx, "smtpEncryption", false), Port: s.getInt(ctx, "smtpPort", 25), Keepalive: s.getInt(ctx, "mail_keepalive", 30), + AuthType: s.getString(ctx, "smtp_auth", "autodiscover"), } } diff --git a/pkg/setting/types.go b/pkg/setting/types.go index 339e533e..67d3aece 100644 --- a/pkg/setting/types.go +++ b/pkg/setting/types.go @@ -65,6 +65,7 @@ type SMTP struct { ForceEncryption bool Port int Keepalive int + AuthType string } type TokenAuth struct { diff --git a/service/admin/tools.go b/service/admin/tools.go index ac701b44..be76a999 100644 --- a/service/admin/tools.go +++ b/service/admin/tools.go @@ -10,6 +10,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/email" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" request2 "github.com/cloudreve/Cloudreve/v4/pkg/request" "github.com/cloudreve/Cloudreve/v4/pkg/serializer" @@ -142,7 +143,7 @@ func (s *TestSMTPService) Test(c *gin.Context) error { opts := []mail.Option{ 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"]), } if setting.IsTrueValue(s.Settings["smtpEncryption"]) {