- 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.
586 lines
15 KiB
Go
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
|
|
}
|
|
}
|