diff --git a/openapi/setting/setting.go b/openapi/setting/setting.go index f3f43dba..3675065a 100644 --- a/openapi/setting/setting.go +++ b/openapi/setting/setting.go @@ -52,6 +52,12 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) { search.PUT("/providers/:key/toggle", handleSearchProviderToggle) search.POST("/providers/:key/test", handleSearchProviderTest) search.PUT("/tool-assignment", handleSearchToolAssignment) + + smtpG := group.Group("/smtp") + smtpG.GET("", handleSmtpGet) + smtpG.PUT("", handleSmtpUpdate) + smtpG.PUT("/toggle", handleSmtpToggle) + smtpG.POST("/test", handleSmtpTest) } // requireOwner checks that the current user is the team owner. diff --git a/openapi/setting/smtp.go b/openapi/setting/smtp.go new file mode 100644 index 00000000..5c0a803a --- /dev/null +++ b/openapi/setting/smtp.go @@ -0,0 +1,586 @@ +package setting + +import ( + "crypto/tls" + _ "embed" + "fmt" + "net" + "net/http" + "net/smtp" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/openapi/oauth/authorized" + oauthTypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/setting" + "gopkg.in/yaml.v3" +) + +//go:embed smtp_presets.yml +var smtpPresetsYML []byte + +const smtpNS = "smtp" + +var smtpPresetsMap map[string][]SmtpPreset + +func init() { + smtpPresetsMap = make(map[string][]SmtpPreset) + if err := yaml.Unmarshal(smtpPresetsYML, &smtpPresetsMap); err != nil { + smtpPresetsMap = map[string][]SmtpPreset{} + } +} + +func smtpGetPresets(locale string) []SmtpPreset { + locale = strings.ToLower(locale) + if presets, ok := smtpPresetsMap[locale]; ok { + return presets + } + if presets, ok := smtpPresetsMap["en-us"]; ok { + return presets + } + return nil +} + +func smtpDefaultPreset(presets []SmtpPreset) *SmtpPreset { + for i := range presets { + if presets[i].Default { + return &presets[i] + } + } + if len(presets) > 0 { + return &presets[0] + } + return nil +} + +func smtpScope(info *oauthTypes.AuthorizedInfo) setting.ScopeID { + if info.TeamID != "" { + return setting.ScopeID{Scope: setting.ScopeTeam, TeamID: info.TeamID} + } + return setting.ScopeID{Scope: setting.ScopeUser, UserID: info.UserID} +} + +// --------------------------------------------------------------------------- +// Rate limiter: 5 test emails per minute per scope +// --------------------------------------------------------------------------- + +var ( + smtpRateMu sync.Mutex + smtpRateStore = map[string][]time.Time{} +) + +const smtpRateLimit = 5 +const smtpRateWindow = time.Minute + +func smtpCheckRateLimit(key string) bool { + smtpRateMu.Lock() + defer smtpRateMu.Unlock() + + now := time.Now() + cutoff := now.Add(-smtpRateWindow) + + var recent []time.Time + for _, t := range smtpRateStore[key] { + if t.After(cutoff) { + recent = append(recent, t) + } + } + + if len(recent) >= smtpRateLimit { + smtpRateStore[key] = recent + return false + } + + smtpRateStore[key] = append(recent, now) + return true +} + +// --------------------------------------------------------------------------- +// GET /setting/smtp +// --------------------------------------------------------------------------- + +func handleSmtpGet(c *gin.Context) { + info := authorized.GetInfo(c) + locale := c.Query("locale") + if locale == "" { + locale = "en-us" + } + + presets := smtpGetPresets(locale) + + cfg := SmtpConfig{ + Enabled: false, + PresetKey: "custom", + Host: "", + Port: 465, + Encryption: "ssl", + Username: "", + Password: "", + FromName: "", + FromEmail: "", + Status: "unconfigured", + } + + hasSaved := false + if setting.Global != nil { + saved, _ := setting.Global.GetMerged(info.UserID, info.TeamID, smtpNS) + if saved != nil { + smtpLoadConfig(&cfg, saved) + hasSaved = true + } + } + + if !hasSaved { + if def := smtpDefaultPreset(presets); def != nil { + cfg.PresetKey = def.Key + cfg.Host = def.Host + cfg.Port = def.Port + cfg.Encryption = def.Encryption + } + } + + response.RespondWithSuccess(c, http.StatusOK, SmtpPageData{ + Presets: presets, + Config: cfg, + }) +} + +// --------------------------------------------------------------------------- +// PUT /setting/smtp +// --------------------------------------------------------------------------- + +func handleSmtpUpdate(c *gin.Context) { + if !guardOwner(c) { + return + } + info := authorized.GetInfo(c) + scope := smtpScope(info) + + var body struct { + PresetKey string `json:"preset_key"` + Host string `json:"host"` + Port int `json:"port"` + Encryption string `json:"encryption"` + Username string `json:"username"` + Password string `json:"password"` + FromName string `json:"from_name"` + FromEmail string `json:"from_email"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, http.StatusBadRequest, "invalid request body") + return + } + + if setting.Global == nil { + respondError(c, http.StatusInternalServerError, "setting registry not initialized") + return + } + + existing, _ := setting.Global.Get(scope, smtpNS) + + pwd := body.Password + if pwd == "" { + if v, ok := existing["password"].(string); ok && v != "" { + pwd = cloudDecrypt(v) + } + } + + validated := false + if body.Host != "" && body.Username != "" && pwd != "" { + if err := smtpValidateConnection(body.Host, body.Port, body.Encryption, body.Username, pwd); err != nil { + respondError(c, http.StatusBadRequest, err.Error()) + return + } + validated = true + } + + m := make(map[string]interface{}) + for k, v := range existing { + m[k] = v + } + + m["preset_key"] = body.PresetKey + m["host"] = body.Host + m["port"] = body.Port + m["encryption"] = body.Encryption + m["username"] = body.Username + m["from_name"] = body.FromName + m["from_email"] = body.FromEmail + + if body.Password != "" { + m["password"] = cloudEncrypt(body.Password) + } + + if validated { + m["status"] = "connected" + } + + if _, err := setting.Global.Set(scope, smtpNS, m); err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + + cfg := SmtpConfig{ + PresetKey: "custom", + Port: 465, + Encryption: "ssl", + Status: "unconfigured", + } + smtpLoadConfig(&cfg, m) + + response.RespondWithSuccess(c, http.StatusOK, cfg) +} + +// --------------------------------------------------------------------------- +// PUT /setting/smtp/toggle +// --------------------------------------------------------------------------- + +func handleSmtpToggle(c *gin.Context) { + if !guardOwner(c) { + return + } + info := authorized.GetInfo(c) + scope := smtpScope(info) + + var body struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, http.StatusBadRequest, "invalid request body") + return + } + + if setting.Global == nil { + respondError(c, http.StatusInternalServerError, "setting registry not initialized") + return + } + + existing, _ := setting.Global.Get(scope, smtpNS) + m := make(map[string]interface{}) + for k, v := range existing { + m[k] = v + } + m["enabled"] = body.Enabled + if !body.Enabled { + m["status"] = "unconfigured" + } + + if _, err := setting.Global.Set(scope, smtpNS, m); err != nil { + respondError(c, http.StatusInternalServerError, err.Error()) + return + } + + cfg := SmtpConfig{ + PresetKey: "custom", + Port: 465, + Encryption: "ssl", + Status: "unconfigured", + } + smtpLoadConfig(&cfg, m) + + response.RespondWithSuccess(c, http.StatusOK, cfg) +} + +// --------------------------------------------------------------------------- +// POST /setting/smtp/test +// --------------------------------------------------------------------------- + +func handleSmtpTest(c *gin.Context) { + if !guardOwner(c) { + return + } + info := authorized.GetInfo(c) + scope := smtpScope(info) + + var body struct { + ToEmail string `json:"to_email"` + } + if err := c.ShouldBindJSON(&body); err != nil { + respondError(c, http.StatusBadRequest, "invalid request body") + return + } + if strings.TrimSpace(body.ToEmail) == "" { + respondError(c, http.StatusBadRequest, "to_email is required") + return + } + + rateKey := scope.TeamID + if rateKey == "" { + rateKey = scope.UserID + } + if !smtpCheckRateLimit(rateKey) { + response.RespondWithSuccess(c, http.StatusOK, SmtpTestResult{ + Success: false, + Message: "Rate limit exceeded, please wait a moment", + }) + return + } + + if setting.Global == nil { + respondError(c, http.StatusInternalServerError, "setting registry not initialized") + return + } + + saved, _ := setting.Global.Get(scope, smtpNS) + if saved == nil { + response.RespondWithSuccess(c, http.StatusOK, SmtpTestResult{ + Success: false, + Message: "SMTP not configured", + }) + return + } + + cfg := SmtpConfig{PresetKey: "custom", Port: 465, Encryption: "ssl", Status: "unconfigured"} + smtpLoadConfig(&cfg, saved) + + if cfg.Host == "" || cfg.Username == "" { + response.RespondWithSuccess(c, http.StatusOK, SmtpTestResult{ + Success: false, + Message: "SMTP host and username are required", + }) + return + } + + password := "" + if v, ok := saved["password"].(string); ok && v != "" { + password = cloudDecrypt(v) + } + + fromAddr := cfg.FromEmail + if fromAddr == "" { + fromAddr = cfg.Username + } + + err := smtpSendTestEmail(cfg.Host, cfg.Port, cfg.Encryption, cfg.Username, password, cfg.FromName, fromAddr, body.ToEmail) + if err != nil { + saved["status"] = "disconnected" + setting.Global.Set(scope, smtpNS, saved) + response.RespondWithSuccess(c, http.StatusOK, SmtpTestResult{ + Success: false, + Message: err.Error(), + }) + return + } + + saved["status"] = "connected" + saved["last_sent_at"] = time.Now().UTC().Format(time.RFC3339) + setting.Global.Set(scope, smtpNS, saved) + + response.RespondWithSuccess(c, http.StatusOK, SmtpTestResult{ + Success: true, + Message: "Test email sent successfully", + }) +} + +// --------------------------------------------------------------------------- +// SMTP connection validation (dial + auth, no email) +// --------------------------------------------------------------------------- + +func smtpValidateConnection(host string, port int, encryption, username, password string) error { + addr := fmt.Sprintf("%s:%d", host, port) + auth := smtp.PlainAuth("", username, password, host) + + switch encryption { + case "ssl": + tlsConfig := &tls.Config{ServerName: host} + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("SSL connection failed: %s", err.Error()) + } + defer conn.Close() + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("SMTP client failed: %s", err.Error()) + } + defer client.Quit() + if err = client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %s", err.Error()) + } + return nil + + case "tls": + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("connection failed: %s", err.Error()) + } + defer conn.Close() + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("SMTP client failed: %s", err.Error()) + } + defer client.Quit() + if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil { + return fmt.Errorf("STARTTLS failed: %s", err.Error()) + } + if err = client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %s", err.Error()) + } + return nil + + default: + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("connection failed: %s", err.Error()) + } + defer conn.Close() + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("SMTP client failed: %s", err.Error()) + } + defer client.Quit() + if err = client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %s", err.Error()) + } + return nil + } +} + +// --------------------------------------------------------------------------- +// SMTP send helper +// --------------------------------------------------------------------------- + +func smtpSendTestEmail(host string, port int, encryption, username, password, fromName, fromEmail, toEmail string) error { + addr := fmt.Sprintf("%s:%d", host, port) + + subject := "Yao SMTP Test" + body := "This is a test email from Yao to verify your SMTP configuration." + + from := fromEmail + if fromName != "" { + from = fmt.Sprintf("%s <%s>", fromName, fromEmail) + } + + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + from, toEmail, subject, body) + + auth := smtp.PlainAuth("", username, password, host) + + switch encryption { + case "ssl": + return smtpSendSSL(addr, host, auth, fromEmail, toEmail, []byte(msg)) + case "tls": + return smtpSendStartTLS(addr, host, auth, fromEmail, toEmail, []byte(msg)) + default: + return smtp.SendMail(addr, auth, fromEmail, []string{toEmail}, []byte(msg)) + } +} + +func smtpSendSSL(addr, host string, auth smtp.Auth, from, to string, msg []byte) error { + tlsConfig := &tls.Config{ServerName: host} + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("SSL connection failed: %s", err.Error()) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("SMTP client failed: %s", err.Error()) + } + defer client.Quit() + + if err = client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %s", err.Error()) + } + if err = client.Mail(from); err != nil { + return fmt.Errorf("MAIL FROM failed: %s", err.Error()) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO failed: %s", err.Error()) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("DATA failed: %s", err.Error()) + } + if _, err = w.Write(msg); err != nil { + return fmt.Errorf("write failed: %s", err.Error()) + } + return w.Close() +} + +func smtpSendStartTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error { + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("connection failed: %s", err.Error()) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("SMTP client failed: %s", err.Error()) + } + defer client.Quit() + + if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil { + return fmt.Errorf("STARTTLS failed: %s", err.Error()) + } + if err = client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %s", err.Error()) + } + if err = client.Mail(from); err != nil { + return fmt.Errorf("MAIL FROM failed: %s", err.Error()) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO failed: %s", err.Error()) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("DATA failed: %s", err.Error()) + } + if _, err = w.Write(msg); err != nil { + return fmt.Errorf("write failed: %s", err.Error()) + } + return w.Close() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func smtpLoadConfig(cfg *SmtpConfig, m map[string]interface{}) { + if v, ok := m["enabled"].(bool); ok { + cfg.Enabled = v + } + if v, ok := m["preset_key"].(string); ok && v != "" { + cfg.PresetKey = v + } + if v, ok := m["host"].(string); ok { + cfg.Host = v + } + if v, ok := m["port"]; ok { + switch p := v.(type) { + case int: + cfg.Port = p + case float64: + cfg.Port = int(p) + case int64: + cfg.Port = int(p) + } + } + if v, ok := m["encryption"].(string); ok && v != "" { + cfg.Encryption = v + } + if v, ok := m["username"].(string); ok { + cfg.Username = v + } + if v, ok := m["password"].(string); ok && v != "" { + cfg.Password = cloudMaskKey(cloudDecrypt(v)) + } + if v, ok := m["from_name"].(string); ok { + cfg.FromName = v + } + if v, ok := m["from_email"].(string); ok { + cfg.FromEmail = v + } + if v, ok := m["status"].(string); ok && v != "" { + cfg.Status = v + } + if v, ok := m["last_sent_at"].(string); ok && v != "" { + cfg.LastSentAt = v + } +} diff --git a/openapi/setting/smtp_presets.yml b/openapi/setting/smtp_presets.yml new file mode 100644 index 00000000..209494ab --- /dev/null +++ b/openapi/setting/smtp_presets.yml @@ -0,0 +1,121 @@ +zh-cn: + - key: tencent + name: 腾讯企邮 + host: smtp.exmail.qq.com + port: 465 + encryption: ssl + default: true + url: https://exmail.qq.com/ + hint: + zh-CN: "密码需使用客户端专用密码,在企业邮设置中生成" + en-US: "Use a client-specific password generated in Tencent Exmail settings" + + - key: feishu + name: 飞书邮箱 + host: smtp.feishu.cn + port: 465 + encryption: ssl + url: https://www.feishu.cn/ + hint: + zh-CN: "需在飞书管理后台开启邮箱 SMTP 服务" + en-US: "Enable SMTP in Feishu admin console" + + - key: aliyun + name: 阿里邮箱 + host: smtp.aliyun.com + port: 465 + encryption: ssl + url: https://mail.aliyun.com/ + hint: + zh-CN: "需在阿里邮箱设置中开启 SMTP 服务" + en-US: "Enable SMTP in Aliyun Mail settings" + + - key: qq + name: QQ邮箱 + host: smtp.qq.com + port: 465 + encryption: ssl + url: https://mail.qq.com/ + hint: + zh-CN: "需在QQ邮箱设置中开启 SMTP 服务并获取授权码" + en-US: "Enable SMTP in QQ Mail settings and get authorization code" + + - key: netease163 + name: 163邮箱 + host: smtp.163.com + port: 465 + encryption: ssl + url: https://mail.163.com/ + hint: + zh-CN: "需在163邮箱设置中开启 SMTP 服务并获取授权码" + en-US: "Enable SMTP in 163 Mail settings and get authorization code" + + - key: custom + name: 自定义 + host: "" + port: 465 + encryption: ssl + hint: + zh-CN: "手动填写 SMTP 服务器信息" + en-US: "Manually enter SMTP server details" + +en-us: + - key: gmail + name: Gmail + host: smtp.gmail.com + port: 465 + encryption: ssl + default: true + url: https://myaccount.google.com/apppasswords + hint: + zh-CN: "Gmail 需要专用密码(App Password),非登录密码" + en-US: "Gmail requires an App Password, not your login password" + + - key: yahoo + name: Yahoo Mail + host: smtp.mail.yahoo.com + port: 465 + encryption: ssl + url: https://login.yahoo.com/account/security + hint: + zh-CN: "Yahoo 需要应用专用密码" + en-US: "Yahoo requires an App Password generated in account security settings" + + - key: sendgrid + name: SendGrid + host: smtp.sendgrid.net + port: 587 + encryption: tls + url: https://app.sendgrid.com/ + hint: + zh-CN: "用户名固定为 apikey,密码填 API Key" + en-US: "Username is always \"apikey\", password is your API Key" + + - key: mailgun + name: Mailgun + host: smtp.mailgun.org + port: 587 + encryption: tls + url: https://app.mailgun.com/ + hint: + zh-CN: "在 Mailgun 控制台获取 SMTP 凭证" + en-US: "Get SMTP credentials from Mailgun dashboard" + + - key: ses + name: Amazon SES + host: email-smtp.us-east-1.amazonaws.com + port: 587 + encryption: tls + url: https://console.aws.amazon.com/ses/ + hint: + zh-CN: "需在 AWS SES 控制台创建 SMTP 凭证,非 IAM 密钥" + en-US: "Create SMTP credentials in AWS SES console, not IAM keys" + + - key: custom + name: Custom + host: "" + port: 465 + encryption: ssl + hint: + zh-CN: "手动填写 SMTP 服务器信息" + en-US: "Manually enter SMTP server details" diff --git a/openapi/setting/types.go b/openapi/setting/types.go index cc2da466..ca09a31e 100644 --- a/openapi/setting/types.go +++ b/openapi/setting/types.go @@ -140,3 +140,42 @@ type SearchTestResult struct { Message string `json:"message"` LatencyMs int64 `json:"latency_ms,omitempty"` } + +// --------------------------------------------------------------------------- +// SMTP +// --------------------------------------------------------------------------- + +type SmtpPreset struct { + Key string `json:"key" yaml:"key"` + Name string `json:"name" yaml:"name"` + Host string `json:"host" yaml:"host"` + Port int `json:"port" yaml:"port"` + Encryption string `json:"encryption" yaml:"encryption"` + Hint map[string]string `json:"hint,omitempty" yaml:"hint"` + URL string `json:"url,omitempty" yaml:"url"` + Default bool `json:"default,omitempty" yaml:"default"` +} + +type SmtpConfig struct { + Enabled bool `json:"enabled"` + PresetKey string `json:"preset_key"` + Host string `json:"host"` + Port int `json:"port"` + Encryption string `json:"encryption"` + Username string `json:"username"` + Password string `json:"password"` + FromName string `json:"from_name"` + FromEmail string `json:"from_email"` + Status string `json:"status"` + LastSentAt string `json:"last_sent_at,omitempty"` +} + +type SmtpPageData struct { + Presets []SmtpPreset `json:"presets"` + Config SmtpConfig `json:"config"` +} + +type SmtpTestResult struct { + Success bool `json:"success"` + Message string `json:"message"` +} diff --git a/openapi/tests/setting/smtp_test.go b/openapi/tests/setting/smtp_test.go new file mode 100644 index 00000000..a9764354 --- /dev/null +++ b/openapi/tests/setting/smtp_test.go @@ -0,0 +1,431 @@ +package setting_test + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// --------------------------------------------------------------------------- +// Functional tests (system:root token) +// --------------------------------------------------------------------------- + +func TestSmtpGet(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/smtp", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if !assert.NoError(t, err) || !assert.NotNil(t, resp) { + return + } + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + + assert.Contains(t, body, "presets") + assert.Contains(t, body, "config") + + presets, ok := body["presets"].([]interface{}) + assert.True(t, ok) + assert.Equal(t, 6, len(presets), "should have 6 en-us presets: gmail, yahoo, sendgrid, mailgun, ses, custom") + + config, ok := body["config"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, false, config["enabled"]) + assert.Equal(t, "unconfigured", config["status"]) + assert.Equal(t, "gmail", config["preset_key"], "default preset for en-us should be gmail") + assert.Equal(t, "", config["password"], "password should be empty when unconfigured") +} + +func TestSmtpGetZhCN(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/smtp?locale=zh-cn", nil) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if !assert.NoError(t, err) || !assert.NotNil(t, resp) { + return + } + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + + presets, ok := body["presets"].([]interface{}) + assert.True(t, ok) + assert.Equal(t, 6, len(presets), "should have 6 zh-cn presets: tencent, feishu, aliyun, qq, netease163, custom") + + first, _ := presets[0].(map[string]interface{}) + assert.Equal(t, "tencent", first["key"]) + + config, _ := body["config"].(map[string]interface{}) + assert.Equal(t, "tencent", config["preset_key"], "default preset for zh-cn should be tencent") +} + +func TestSmtpGetUnauthenticated(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + req, err := http.NewRequest("GET", serverURL+baseURL()+"/setting/smtp", nil) + assert.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestSmtpUpdate(t *testing.T) { + host := os.Getenv("RELIABLE_SMTP_HOST") + port := os.Getenv("RELIABLE_SMTP_PORT") + user := os.Getenv("RELIABLE_SMTP_USERNAME") + pass := os.Getenv("RELIABLE_SMTP_PASSWORD") + if host == "" || user == "" || pass == "" { + host = os.Getenv("SMTP_HOST") + port = os.Getenv("SMTP_PORT") + user = os.Getenv("SMTP_USERNAME") + pass = os.Getenv("SMTP_PASSWORD") + } + if host == "" || user == "" || pass == "" { + t.Skip("SMTP credentials not set, skipping") + } + portNum := 465 + if port != "" { + if p, err := strconv.Atoi(port); err == nil { + portNum = p + } + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + payload := map[string]interface{}{ + "preset_key": "custom", + "host": host, + "port": portNum, + "encryption": "ssl", + "username": user, + "password": pass, + "from_name": "Test Sender", + "from_email": user, + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + assert.Equal(t, host, body["host"]) + assert.Equal(t, user, body["username"]) + maskedPwd, _ := body["password"].(string) + assert.True(t, strings.Contains(maskedPwd, "..."), "password should be masked: got %s", maskedPwd) + + // GET should also return masked password + req2, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/smtp", nil) + req2.Header.Set("Authorization", "Bearer "+token) + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + + var getData map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&getData) + config, _ := getData["config"].(map[string]interface{}) + getMasked, _ := config["password"].(string) + assert.True(t, strings.Contains(getMasked, "..."), "GET should return masked password") +} + +func TestSmtpUpdateValidationFailure(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + payload := map[string]interface{}{ + "preset_key": "gmail", + "host": "smtp.gmail.com", + "port": 465, + "encryption": "ssl", + "username": "fake@gmail.com", + "password": "wrong-password", + "from_name": "Test", + "from_email": "fake@gmail.com", + } + raw, _ := json.Marshal(payload) + req, err := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + assert.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "should reject invalid SMTP credentials") +} + +func TestSmtpUpdateKeepPassword(t *testing.T) { + host := os.Getenv("RELIABLE_SMTP_HOST") + port := os.Getenv("RELIABLE_SMTP_PORT") + user := os.Getenv("RELIABLE_SMTP_USERNAME") + pass := os.Getenv("RELIABLE_SMTP_PASSWORD") + if host == "" || user == "" || pass == "" { + host = os.Getenv("SMTP_HOST") + port = os.Getenv("SMTP_PORT") + user = os.Getenv("SMTP_USERNAME") + pass = os.Getenv("SMTP_PASSWORD") + } + if host == "" || user == "" || pass == "" { + t.Skip("SMTP credentials not set, skipping") + } + portNum := 465 + if port != "" { + if p, err := strconv.Atoi(port); err == nil { + portNum = p + } + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + // First save with password + payload1 := map[string]interface{}{ + "preset_key": "custom", + "host": host, + "port": portNum, + "encryption": "ssl", + "username": user, + "password": pass, + "from_name": "Test", + "from_email": user, + } + raw, _ := json.Marshal(payload1) + req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + resp.Body.Close() + + // Update without password — should keep original and re-validate with existing password + payload2 := map[string]interface{}{ + "preset_key": "custom", + "host": host, + "port": portNum, + "encryption": "ssl", + "username": user, + "password": "", + "from_name": "Updated", + "from_email": user, + } + raw, _ = json.Marshal(payload2) + req2, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&body) + assert.Equal(t, "Updated", body["from_name"]) + keepMasked, _ := body["password"].(string) + assert.True(t, strings.Contains(keepMasked, "..."), "password should be masked (kept original)") +} + +func TestSmtpToggle(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + // Save config first + savePayload := map[string]interface{}{ + "preset_key": "gmail", + "host": "smtp.gmail.com", + "port": 465, + "encryption": "ssl", + "username": "test@gmail.com", + "password": "test-pass", + "from_name": "Test", + "from_email": "test@gmail.com", + } + raw, _ := json.Marshal(savePayload) + req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + resp.Body.Close() + + // Enable + enablePayload := map[string]interface{}{"enabled": true} + raw, _ = json.Marshal(enablePayload) + req2, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp/toggle", bytes.NewReader(raw)) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&body) + assert.Equal(t, true, body["enabled"]) + + // Disable + disablePayload := map[string]interface{}{"enabled": false} + raw, _ = json.Marshal(disablePayload) + req3, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp/toggle", bytes.NewReader(raw)) + req3.Header.Set("Authorization", "Bearer "+token) + req3.Header.Set("Content-Type", "application/json") + resp3, err := http.DefaultClient.Do(req3) + assert.NoError(t, err) + defer resp3.Body.Close() + assert.Equal(t, http.StatusOK, resp3.StatusCode) + + var body2 map[string]interface{} + json.NewDecoder(resp3.Body).Decode(&body2) + assert.Equal(t, false, body2["enabled"]) + assert.Equal(t, "unconfigured", body2["status"]) +} + +func TestSmtpTest(t *testing.T) { + host := os.Getenv("RELIABLE_SMTP_HOST") + port := os.Getenv("RELIABLE_SMTP_PORT") + user := os.Getenv("RELIABLE_SMTP_USERNAME") + pass := os.Getenv("RELIABLE_SMTP_PASSWORD") + if host == "" || user == "" || pass == "" { + host = os.Getenv("SMTP_HOST") + port = os.Getenv("SMTP_PORT") + user = os.Getenv("SMTP_USERNAME") + pass = os.Getenv("SMTP_PASSWORD") + } + if host == "" || user == "" || pass == "" { + t.Skip("RELIABLE_SMTP_* or SMTP_* env not set, skipping SMTP test") + } + + toEmail := os.Getenv("SMTP_TEST_TO") + if toEmail == "" { + toEmail = user + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + token := obtainToken(t, serverURL) + + portNum := 465 + if port != "" { + if p, err := strconv.Atoi(port); err == nil { + portNum = p + } + } + + savePayload := map[string]interface{}{ + "preset_key": "custom", + "host": host, + "port": portNum, + "encryption": "ssl", + "username": user, + "password": pass, + "from_name": "Yao SMTP Test", + "from_email": user, + } + raw, _ := json.Marshal(savePayload) + req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + resp.Body.Close() + + testPayload := map[string]interface{}{"to_email": toEmail} + raw, _ = json.Marshal(testPayload) + req2, err := http.NewRequest("POST", serverURL+baseURL()+"/setting/smtp/test", bytes.NewReader(raw)) + assert.NoError(t, err) + req2.Header.Set("Authorization", "Bearer "+token) + req2.Header.Set("Content-Type", "application/json") + + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusOK, resp2.StatusCode) + + var body map[string]interface{} + json.NewDecoder(resp2.Body).Decode(&body) + t.Logf("SMTP test result: %+v", body) + assert.Equal(t, true, body["success"]) +} + +// --------------------------------------------------------------------------- +// ACL permission tests +// --------------------------------------------------------------------------- + +func TestSmtpACL_ReadOnlyScopeCannotWrite(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + initSettingRegistry(t) + + readToken := obtainRestrictedToken(t, serverURL, "setting:smtp:read:all") + + // GET should work + req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/smtp", nil) + req.Header.Set("Authorization", "Bearer "+readToken) + resp, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode, "read-only scope should allow GET") + + // PUT should be denied + payload := map[string]interface{}{ + "preset_key": "gmail", + "host": "smtp.gmail.com", + "port": 465, + "encryption": "ssl", + "username": "test@gmail.com", + "password": "test-pass", + "from_name": "Test", + "from_email": "test@gmail.com", + } + raw, _ := json.Marshal(payload) + req2, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/smtp", bytes.NewReader(raw)) + req2.Header.Set("Authorization", "Bearer "+readToken) + req2.Header.Set("Content-Type", "application/json") + resp2, err := http.DefaultClient.Do(req2) + assert.NoError(t, err) + defer resp2.Body.Close() + assert.Equal(t, http.StatusForbidden, resp2.StatusCode, "read-only scope should deny PUT") +}