- Replaced fmt.Sprintf with net.JoinHostPort for constructing the SMTP address in smtpValidateConnection and smtpSendTestEmail functions, improving address handling and ensuring proper formatting.
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 := 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
|
|
}
|
|
}
|