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 := net.JoinHostPort(host, fmt.Sprintf("%d", 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 := net.JoinHostPort(host, fmt.Sprintf("%d", 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 } }