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.
This commit is contained in:
parent
c3040559e6
commit
13e16c7099
5 changed files with 1183 additions and 0 deletions
|
|
@ -52,6 +52,12 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
|
||||||
search.PUT("/providers/:key/toggle", handleSearchProviderToggle)
|
search.PUT("/providers/:key/toggle", handleSearchProviderToggle)
|
||||||
search.POST("/providers/:key/test", handleSearchProviderTest)
|
search.POST("/providers/:key/test", handleSearchProviderTest)
|
||||||
search.PUT("/tool-assignment", handleSearchToolAssignment)
|
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.
|
// requireOwner checks that the current user is the team owner.
|
||||||
|
|
|
||||||
586
openapi/setting/smtp.go
Normal file
586
openapi/setting/smtp.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
121
openapi/setting/smtp_presets.yml
Normal file
121
openapi/setting/smtp_presets.yml
Normal file
|
|
@ -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"
|
||||||
|
|
@ -140,3 +140,42 @@ type SearchTestResult struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
LatencyMs int64 `json:"latency_ms,omitempty"`
|
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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
431
openapi/tests/setting/smtp_test.go
Normal file
431
openapi/tests/setting/smtp_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue