嵌入与邮箱

This commit is contained in:
ryan
2026-06-08 14:10:56 +08:00
parent 62bd5d09d4
commit db68a130ce
26 changed files with 6089 additions and 129 deletions
+85 -5
View File
@@ -24,6 +24,7 @@ import (
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
mail "github.com/linux-do/credit/internal/util/mail"
"gorm.io/gorm"
)
@@ -123,6 +124,12 @@ func ListSystemConfigs(c *gin.Context) {
return
}
for i := range configs {
if configs[i].Key == model.ConfigKeySMTPPassword && configs[i].Value != "" {
configs[i].Value = "******"
}
}
c.JSON(http.StatusOK, util.OK(configs))
}
@@ -150,6 +157,10 @@ func GetSystemConfig(c *gin.Context) {
return
}
if config.Key == model.ConfigKeySMTPPassword && config.Value != "" {
config.Value = "******"
}
c.JSON(http.StatusOK, util.OK(config))
}
@@ -191,11 +202,14 @@ func UpdateSystemConfig(c *gin.Context) {
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
// 更新配置
if err := tx.Model(&config).
Updates(map[string]interface{}{
"value": req.Value,
"description": req.Description,
}).Error; err != nil {
updates := map[string]interface{}{
"description": req.Description,
}
if !(key == model.ConfigKeySMTPPassword && req.Value == "******") {
updates["value"] = req.Value
config.Value = req.Value
}
if err := tx.Model(&config).Updates(updates).Error; err != nil {
return err
}
@@ -257,3 +271,69 @@ func DeleteSystemConfig(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
}
// TestSMTPRequest 测试 SMTP 配置请求
type TestSMTPRequest struct {
SMTPHost string `json:"smtp_host" binding:"required,max=255"`
SMTPPort int `json:"smtp_port" binding:"required"`
SMTPUsername string `json:"smtp_username" binding:"required,max=255"`
SMTPPassword string `json:"smtp_password" binding:"required,max=255"`
To string `json:"to" binding:"required,email"`
}
// TestSMTPResponse 测试 SMTP 配置响应
type TestSMTPResponse struct {
Success bool `json:"success"`
Log string `json:"log"`
Error string `json:"error"`
}
// TestSMTP 测试 SMTP 邮件发送
// @Summary 测试 SMTP 邮件发送
// @Description 使用传入的配置进行 SMTP 邮件发送测试,支持使用 ****** 占位符使用保存的数据库密码
// @Tags admin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body system_config.TestSMTPRequest true "测试请求参数"
// @Success 200 {object} util.ResponseAny{data=system_config.TestSMTPResponse} "测试执行完毕"
// @Failure 400 {object} util.ResponseAny "参数错误"
// @Router /api/v1/admin/system-configs/smtp/test [post]
func TestSMTP(c *gin.Context) {
var req TestSMTPRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
password := req.SMTPPassword
if password == "******" {
var sc model.SystemConfig
if err := sc.GetByKey(c.Request.Context(), model.ConfigKeySMTPPassword); err == nil {
password = sc.Value
}
}
cfg := mail.Config{
Host: req.SMTPHost,
Port: req.SMTPPort,
Username: req.SMTPUsername,
Password: password,
}
subject := "Wavelet SMTP Test Mail"
body := `<h3>SMTP Mail Connection Test</h3>
<p>If you received this message, your SMTP configuration is correct and mail sending is working properly.</p>
<p>Sent from Wavelet.</p>`
logs, err := mail.SendMailWithLog(cfg, req.To, subject, body)
resp := TestSMTPResponse{
Success: err == nil,
Log: logs,
}
if err != nil {
resp.Error = err.Error()
}
c.JSON(http.StatusOK, util.OK(resp))
}
@@ -17,11 +17,14 @@ limitations under the License.
package system_config
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/textproto"
"testing"
"github.com/gin-gonic/gin"
@@ -143,9 +146,9 @@ func TestListSystemConfigs(t *testing.T) {
var configs []model.SystemConfig
json.Unmarshal(dataBytes, &configs)
// Defaults seed 15 configurations
if len(configs) != 15 {
t.Errorf("expected 15 default configs, got %d", len(configs))
// Defaults seed 19 configurations
if len(configs) != 19 {
t.Errorf("expected 19 default configs, got %d", len(configs))
}
})
@@ -300,3 +303,106 @@ func TestDeleteSystemConfig(t *testing.T) {
}
})
}
func TestTestSMTP(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true}
r := setupTestRouter(adminUser)
r.POST("/api/v1/admin/system-configs/smtp/test", TestSMTP)
// Start a mock SMTP server
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start mock smtp server: %v", err)
}
defer l.Close()
port := l.Addr().(*net.TCPAddr).Port
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
writer := bufio.NewWriter(conn)
reader := bufio.NewReader(conn)
tp := textproto.NewReader(reader)
// 220 Ready
writer.WriteString("220 mock.smtp.com SMTP Ready\r\n")
writer.Flush()
// Read HELO/EHLO
tp.ReadLine()
writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n")
writer.Flush()
// Read AUTH PLAIN
tp.ReadLine()
writer.WriteString("235 Authentication successful\r\n")
writer.Flush()
// Read MAIL FROM
tp.ReadLine()
writer.WriteString("250 OK\r\n")
writer.Flush()
// Read RCPT TO
tp.ReadLine()
writer.WriteString("250 OK\r\n")
writer.Flush()
// Read DATA
tp.ReadLine()
writer.WriteString("354 Start mail input\r\n")
writer.Flush()
// Read body lines until dot
for {
line, err := tp.ReadLine()
if err != nil || line == "." {
break
}
}
writer.WriteString("250 OK\r\n")
writer.Flush()
// Read QUIT
tp.ReadLine()
writer.WriteString("221 Bye\r\n")
writer.Flush()
}()
payload := TestSMTPRequest{
SMTPHost: "127.0.0.1",
SMTPPort: port,
SMTPUsername: "sender@example.com",
SMTPPassword: "password",
To: "recipient@example.com",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs/smtp/test", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
json.Unmarshal(w.Body.Bytes(), &resp)
dataBytes, _ := json.Marshal(resp.Data)
var testResp TestSMTPResponse
json.Unmarshal(dataBytes, &testResp)
if !testResp.Success {
t.Errorf("expected test success, got failed: %s. Log: %s", testResp.Error, testResp.Log)
}
}
+28 -3
View File
@@ -66,7 +66,16 @@ func newMockRedisClient() *mockRedisClient {
func (m *mockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
cmd := redis.NewStatusCmd(ctx)
m.store[key] = fmt.Sprintf("%v", value)
var val string
switch v := value.(type) {
case []byte:
val = string(v)
case string:
val = v
default:
val = fmt.Sprintf("%v", v)
}
m.store[key] = val
cmd.SetVal("OK")
return cmd
}
@@ -99,7 +108,15 @@ func (m *mockRedisClient) HSet(ctx context.Context, key string, values ...interf
cmd := redis.NewIntCmd(ctx)
if len(values) >= 2 {
field := fmt.Sprintf("%v", values[0])
val := fmt.Sprintf("%v", values[1])
var val string
switch v := values[1].(type) {
case []byte:
val = string(v)
case string:
val = v
default:
val = fmt.Sprintf("%v", v)
}
compositeKey := key + ":" + field
m.store[compositeKey] = val
cmd.SetVal(1)
@@ -265,6 +282,15 @@ func setupTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatalf("failed to migrate schema: %v", err)
}
// 注入测试所需的服务器地址配置
if err := dbConn.Create(&model.SystemConfig{
Key: model.ConfigKeyServerAddress,
Value: "http://localhost:3000",
}).Error; err != nil {
t.Fatalf("failed to seed server_address config: %v", err)
}
return dbConn
}
@@ -332,7 +358,6 @@ func initializeTestConfig() {
config.Config.App.SessionCookieName = "test_session_id"
config.Config.App.SessionSecret = "test_session_secret"
config.Config.App.APIPrefix = "/api"
config.Config.App.FrontendURL = "http://localhost:3000"
}
// -----------------------------------------------------------------------------
+19 -9
View File
@@ -15,7 +15,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
@@ -104,11 +103,12 @@ func activeLoginSources() []AuthSourceView {
return sources
}
func frontendLoginRedirectURL() string {
if config.Config.App.FrontendURL != "" {
return strings.TrimRight(config.Config.App.FrontendURL, "/") + "/login"
func getFrontendLoginRedirectURL(ctx context.Context) (string, error) {
var sc model.SystemConfig
if err := sc.GetByKey(ctx, model.ConfigKeyServerAddress); err != nil || strings.TrimSpace(sc.Value) == "" {
return "", errors.New("服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试")
}
return "/login"
return strings.TrimRight(sc.Value, "/") + "/login", nil
}
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
@@ -304,14 +304,18 @@ func GetLoginURL(c *gin.Context) {
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state string) (string, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, frontendLoginRedirectURL())
redirectURL, err := getFrontendLoginRedirectURL(ctx)
if err != nil {
return "", err
}
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return "", err
}
@@ -361,7 +365,7 @@ func Authorize(c *gin.Context) {
}
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
@@ -407,7 +411,13 @@ func Callback(c *gin.Context) {
return
}
userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, frontendLoginRedirectURL())
redirectURL, err := getFrontendLoginRedirectURL(ctx)
if err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
-1
View File
@@ -38,7 +38,6 @@ type appConfig struct {
NodeID int64 `mapstructure:"node_id"`
APIPrefix string `mapstructure:"api_prefix"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
FrontendURL string `mapstructure:"frontend_url"`
SessionCookieName string `mapstructure:"session_cookie_name"`
SessionSecret string `mapstructure:"session_secret"`
SessionDomain string `mapstructure:"session_domain"`
+29 -1
View File
@@ -90,6 +90,10 @@ func initSystemConfigs() {
ensureConfigKeyExists(model.ConfigKeyCapChallengeTTL, "600", "system", "人机验证难题有效时间(秒)")
ensureConfigKeyExists(model.ConfigKeyCapTokenTTL, "1200", "system", "人机验证兑换凭证有效时间(秒)")
ensureConfigKeyExists(model.ConfigKeyServerAddress, "", "system", "服务器地址(用于跨域源控制,不设定则允许任意源)")
ensureConfigKeyExists(model.ConfigKeySMTPHost, "", "system", "SMTP 服务器地址(例如 smtp.example.com)")
ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)")
ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)")
ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)")
return
}
@@ -142,6 +146,30 @@ func initSystemConfigs() {
Type: "system",
Description: "服务器地址(用于跨域源控制,不设定则允许任意源)",
},
{
Key: model.ConfigKeySMTPHost,
Value: "",
Type: "system",
Description: "SMTP 服务器地址(例如 smtp.example.com)",
},
{
Key: model.ConfigKeySMTPPort,
Value: "587",
Type: "system",
Description: "SMTP 端口(例如 587 或 465)",
},
{
Key: model.ConfigKeySMTPUsername,
Value: "",
Type: "system",
Description: "SMTP 账户(如 sender@example.com)",
},
{
Key: model.ConfigKeySMTPPassword,
Value: "",
Type: "system",
Description: "SMTP 访问凭证(授权码/密码)",
},
{
Key: model.ConfigKeyUploadAllowedExtensions,
Value: "jpg,png,webp",
@@ -150,7 +178,7 @@ func initSystemConfigs() {
},
{
Key: model.ConfigKeySiteName,
Value: "Antigravity Project",
Value: "Wavelet",
Type: "system",
Description: "系统平台的展示名称",
},
+4
View File
@@ -46,6 +46,10 @@ const (
ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒)
ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒)
ConfigKeyServerAddress = "server_address" // 服务器地址
ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址
ConfigKeySMTPPort = "smtp_port" // SMTP 端口
ConfigKeySMTPUsername = "smtp_username" // SMTP 账户
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
)
const (
+25
View File
@@ -0,0 +1,25 @@
//go:build !embed_frontend
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package router
import "github.com/gin-gonic/gin"
func registerFrontend(r *gin.Engine) {
// No-op when not embedding frontend
}
+121
View File
@@ -0,0 +1,121 @@
//go:build embed_frontend
/*
Copyright 2025 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package router
import (
"embed"
"io"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed all:dist
var frontendFS embed.FS
func serveFileDirect(c *gin.Context, subFS fs.FS, filePath string) bool {
file, err := subFS.Open(filePath)
if err != nil {
return false
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return false
}
if stat.IsDir() {
return false
}
seeker, ok := file.(io.ReadSeeker)
if !ok {
return false
}
// 使用 http.ServeContent 直接输出文件内容,不进行路径规范化重定向
http.ServeContent(c.Writer, c.Request, filePath, stat.ModTime(), seeker)
return true
}
func registerFrontend(r *gin.Engine) {
subFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
panic(err)
}
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// API 接口路由或文件服务路由 -> 直接返回,由 Gin 处理标准 404
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/f/") {
return
}
// 只处理 GET 和 HEAD 请求
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error_msg": "Method not allowed"})
return
}
// 移除开头的斜杠以在嵌入文件系统中查找
cleanPath := strings.TrimPrefix(path, "/")
// 1. 根路径 -> 直接输出 index.html
if cleanPath == "" {
if serveFileDirect(c, subFS, "index.html") {
return
}
}
// 2. 精确匹配(如果对应的文件存在,直接输出)
if serveFileDirect(c, subFS, cleanPath) {
return
}
// 如果是个目录(例如请求了 "/login",同时 dist 目录下存在一个叫 "login" 的文件夹目录),
// 则查找是否有对应的 ".html" 文件(例如 "login.html")并进行输出。
if cleanPath != "" {
htmlPath := cleanPath + ".html"
if serveFileDirect(c, subFS, htmlPath) {
return
}
}
// 3. Next.js Clean URLs 兜底逻辑(例如访问 /settings/security -> 实际映射输出 settings/security.html)
if !strings.Contains(cleanPath, ".") {
htmlPath := cleanPath + ".html"
if serveFileDirect(c, subFS, htmlPath) {
return
}
indexPath := cleanPath + "/index.html"
if serveFileDirect(c, subFS, indexPath) {
return
}
}
// 4. 单页应用(SPA)前端路由兜底:返回 index.html
if serveFileDirect(c, subFS, "index.html") {
return
}
})
}
+4
View File
@@ -200,6 +200,7 @@ func Serve() {
// System Config
adminRouter.POST("/system-configs", system_config.CreateSystemConfig)
adminRouter.GET("/system-configs", system_config.ListSystemConfigs)
adminRouter.POST("/system-configs/smtp/test", system_config.TestSMTP)
systemConfigRouter := adminRouter.Group("/system-configs/:key")
{
@@ -218,6 +219,9 @@ func Serve() {
}
}
// 注册前端静态路由(当启用 embed_frontend 编译标签时)
registerFrontend(r)
srv := &http.Server{
Addr: config.Config.App.Addr,
Handler: r,
+25 -1
View File
@@ -100,7 +100,7 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
},
{
Key: model.ConfigKeySiteName,
Value: "Antigravity Project",
Value: "Wavelet",
Type: "system",
Description: "系统平台的展示名称",
},
@@ -182,6 +182,30 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
Type: "system",
Description: "服务器地址(用于跨域源控制,不设定则允许任意源)",
},
{
Key: model.ConfigKeySMTPHost,
Value: "",
Type: "system",
Description: "SMTP 服务器地址(例如 smtp.example.com)",
},
{
Key: model.ConfigKeySMTPPort,
Value: "587",
Type: "system",
Description: "SMTP 端口(例如 587 或 465)",
},
{
Key: model.ConfigKeySMTPUsername,
Value: "",
Type: "system",
Description: "SMTP 账户(如 sender@example.com)",
},
{
Key: model.ConfigKeySMTPPassword,
Value: "",
Type: "system",
Description: "SMTP 访问凭证(授权码/密码)",
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
+236
View File
@@ -0,0 +1,236 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package mail
import (
"bytes"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strconv"
"time"
)
// Config represents SMTP mail configuration
type Config struct {
Host string
Port int
Username string
Password string
}
// SendMail sends an HTML email using the provided config and message details
func SendMail(cfg Config, to string, subject, body string) error {
return SendMailHTML(cfg, to, subject, body)
}
// SendMailHTML sends an HTML format email
func SendMailHTML(cfg Config, to string, subject, body string) error {
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
// Header & MIME settings for HTML email
header := make(map[string]string)
header["From"] = cfg.Username
header["To"] = to
header["Subject"] = subject
header["MIME-Version"] = "1.0"
header["Content-Type"] = "text/html; charset=UTF-8"
message := ""
for k, v := range header {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + body
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
// If using SSL port 465, we connection via TLS dial
if cfg.Port == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: cfg.Host,
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("dial tls failed: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("smtp client creation failed: %w", err)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth failed: %w", err)
}
if err = client.Mail(cfg.Username); err != nil {
return fmt.Errorf("smtp mail command failed: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt command failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data command failed: %w", err)
}
defer w.Close()
_, err = w.Write([]byte(message))
if err != nil {
return fmt.Errorf("smtp writing body failed: %w", err)
}
return nil
}
// For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it)
err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message))
if err != nil {
return fmt.Errorf("send mail failed: %w", err)
}
return nil
}
// SendMailWithLog sends a test email and records a detailed SMTP connection log
func SendMailWithLog(cfg Config, to string, subject, body string) (string, error) {
var logBuf bytes.Buffer
logLine := func(dir string, format string, args ...interface{}) {
logBuf.WriteString(fmt.Sprintf("[%s] %s\n", dir, fmt.Sprintf(format, args...)))
}
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
logLine("System", "Connecting to %s...", addr)
var conn net.Conn
var err error
dialer := &net.Dialer{Timeout: 5 * time.Second}
if cfg.Port == 465 {
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: cfg.Host,
}
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
} else {
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
logLine("Error", "Connection failed: %v", err)
return logBuf.String(), err
}
defer conn.Close()
logLine("System", "Connected successfully.")
// Set a 10-second session deadline for read/write operations
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
logLine("Error", "SMTP client handshake failed: %v", err)
return logBuf.String(), err
}
defer client.Close()
// If not 465, support STARTTLS if available
if cfg.Port != 465 {
if ok, _ := client.Extension("STARTTLS"); ok {
logLine("C", "STARTTLS")
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: cfg.Host,
}
if err = client.StartTLS(tlsConfig); err != nil {
logLine("Error", "STARTTLS failed: %v", err)
return logBuf.String(), err
}
logLine("S", "220 Ready to start TLS")
}
}
// Authentication
if cfg.Username != "" && cfg.Password != "" {
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
logLine("C", "AUTH PLAIN **********")
if err = client.Auth(auth); err != nil {
logLine("Error", "Authentication failed: %v", err)
return logBuf.String(), err
}
logLine("S", "235 Authentication successful")
}
// Mail command
logLine("C", "MAIL FROM:<%s>", cfg.Username)
if err = client.Mail(cfg.Username); err != nil {
logLine("Error", "MAIL FROM command failed: %v", err)
return logBuf.String(), err
}
logLine("S", "250 OK")
// Rcpt command
logLine("C", "RCPT TO:<%s>", to)
if err = client.Rcpt(to); err != nil {
logLine("Error", "RCPT TO command failed: %v", err)
return logBuf.String(), err
}
logLine("S", "250 OK")
// Data command
logLine("C", "DATA")
w, err := client.Data()
if err != nil {
logLine("Error", "DATA command failed: %v", err)
return logBuf.String(), err
}
logLine("S", "354 Start mail input")
// Header & MIME settings for HTML email
header := make(map[string]string)
header["From"] = cfg.Username
header["To"] = to
header["Subject"] = subject
header["MIME-Version"] = "1.0"
header["Content-Type"] = "text/html; charset=UTF-8"
message := ""
for k, v := range header {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + body
logLine("System", "Sending message body...")
if _, err = w.Write([]byte(message)); err != nil {
w.Close()
logLine("Error", "Writing message body failed: %v", err)
return logBuf.String(), err
}
w.Close()
logLine("S", "250 OK")
logLine("C", "QUIT")
_ = client.Quit()
logLine("System", "Mail sent successfully!")
return logBuf.String(), nil
}
+103
View File
@@ -0,0 +1,103 @@
/*
Copyright 2026 linux.do
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package mail
import (
"bufio"
"net"
"net/textproto"
"testing"
)
func TestSendMailMock(t *testing.T) {
// Start a mock SMTP server
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start mock smtp server: %v", err)
}
defer l.Close()
port := l.Addr().(*net.TCPAddr).Port
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
writer := bufio.NewWriter(conn)
reader := bufio.NewReader(conn)
tp := textproto.NewReader(reader)
// 220 Ready
_, _ = writer.WriteString("220 mock.smtp.com SMTP Ready\r\n")
_ = writer.Flush()
// Read HELO/EHLO
_, _ = tp.ReadLine()
_, _ = writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n")
_ = writer.Flush()
// Read AUTH PLAIN
_, _ = tp.ReadLine()
_, _ = writer.WriteString("235 Authentication successful\r\n")
_ = writer.Flush()
// Read MAIL FROM
_, _ = tp.ReadLine()
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
// Read RCPT TO
_, _ = tp.ReadLine()
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
// Read DATA
_, _ = tp.ReadLine()
_, _ = writer.WriteString("354 Start mail input\r\n")
_ = writer.Flush()
// Read body lines until dot
for {
line, err := tp.ReadLine()
if err != nil || line == "." {
break
}
}
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
// Read QUIT
_, _ = tp.ReadLine()
_, _ = writer.WriteString("221 Bye\r\n")
_ = writer.Flush()
}()
cfg := Config{
Host: "127.0.0.1",
Port: port,
Username: "test@example.com",
Password: "password",
}
err = SendMail(cfg, "recipient@example.com", "Test Subject", "<h1>Test Body</h1>")
if err != nil {
t.Errorf("failed to send mail: %v", err)
}
}