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:
Max 2026-04-29 12:21:19 +08:00
parent c3040559e6
commit 13e16c7099
5 changed files with 1183 additions and 0 deletions

View file

@ -52,6 +52,12 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
search.PUT("/providers/:key/toggle", handleSearchProviderToggle)
search.POST("/providers/:key/test", handleSearchProviderTest)
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.

586
openapi/setting/smtp.go Normal file
View 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
}
}

View 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"

View file

@ -140,3 +140,42 @@ type SearchTestResult struct {
Message string `json:"message"`
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"`
}

View 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")
}