yao/openapi/setting/smtp.go
Max 13e16c7099 feat(smtp): add SMTP configuration and management endpoints
- Introduced new endpoints for managing SMTP settings, including GET, PUT, and POST methods for retrieving, updating, and testing SMTP configurations.
- Added data structures for SMTP presets and configuration, enhancing the OpenAPI settings to support email functionality.
- Organized routing under a new /smtp group for better endpoint management.
2026-04-29 12:21:19 +08:00

586 lines
15 KiB
Go

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
}
}