精简: 去掉邮件模块(SMTP 通知): service/email 包、订阅者接线、testSmtp 端点、全部 smtp* 设置

This commit is contained in:
Muse
2026-10-20 10:07:24 +00:00
parent 981ba8abe1
commit 1498d58f14
9 changed files with 9 additions and 1138 deletions
+2 -36
View File
@@ -11,7 +11,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/session" "github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -32,8 +31,7 @@ type updateUserForm struct {
// see #5724). // see #5724).
type updateSettingForm struct { type updateSettingForm struct {
entity.AllSetting entity.AllSetting
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
} }
type validateRegexForm struct { type validateRegexForm struct {
@@ -72,7 +70,6 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
g.POST("/apiTokens/create", a.createApiToken) g.POST("/apiTokens/create", a.createApiToken)
g.POST("/apiTokens/delete/:id", a.deleteApiToken) g.POST("/apiTokens/delete/:id", a.deleteApiToken)
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled) g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
g.POST("/testSmtp", a.testSmtp)
g.POST("/testTgBot", a.testTgBot) g.POST("/testTgBot", a.testTgBot)
} }
@@ -127,8 +124,7 @@ func (a *SettingController) updateSetting(c *gin.Context) {
oldTgChatId, _ := a.settingService.GetTgBotChatId() oldTgChatId, _ := a.settingService.GetTgBotChatId()
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer() oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
TgBotToken: form.ClearTgBotToken, TgBotToken: form.ClearTgBotToken,
SmtpPassword: form.ClearSmtpPassword,
}) })
if err == nil && form.PanelOutbound != oldPanelOutbound { if err == nil && form.PanelOutbound != oldPanelOutbound {
// The egress bridge lives in the generated config; reconcile the // The egress bridge lives in the generated config; reconcile the
@@ -261,30 +257,6 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled)) jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
} }
func (a *SettingController) testSmtp(c *gin.Context) {
if emailService == nil {
jsonMsg(c, I18nWeb(c, "pages.settings.smtpNotInitialized"), errors.New("email service not available"))
return
}
logger.Info("SMTP test: starting...")
result := emailService.TestConnection()
if !result.Success {
logger.Warning("SMTP test failed at", result.Stage+":", result.Message)
c.JSON(200, gin.H{
"success": false,
"stage": result.Stage,
"msg": result.Message,
})
return
}
logger.Info("SMTP test: success")
c.JSON(200, gin.H{
"success": true,
"stage": result.Stage,
"msg": result.Message,
})
}
func (a *SettingController) testTgBot(c *gin.Context) { func (a *SettingController) testTgBot(c *gin.Context) {
enabled, err := a.settingService.GetTgbotEnabled() enabled, err := a.settingService.GetTgbotEnabled()
if err != nil || !enabled { if err != nil || !enabled {
@@ -314,9 +286,3 @@ func SetTestTgFunc(fn func() error) { testTgFunc = fn }
var reloadTgbotFunc func() var reloadTgbotFunc func()
func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn } func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn }
// emailService is set from web layer.
var emailService *email.EmailService
// SetEmailService registers the email service for test endpoints.
func SetEmailService(s *email.EmailService) { emailService = s }
+4 -25
View File
@@ -4,7 +4,6 @@ import (
"crypto/tls" "crypto/tls"
"math" "math"
"net" "net"
"net/mail"
"net/netip" "net/netip"
"strings" "strings"
"time" "time"
@@ -49,19 +48,6 @@ type AllSetting struct {
TgLang string `json:"tgLang" form:"tgLang"` TgLang string `json:"tgLang" form:"tgLang"`
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"`
SmtpHost string `json:"smtpHost" form:"smtpHost"`
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"`
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"`
SmtpFrom string `json:"smtpFrom" form:"smtpFrom"`
SmtpFromName string `json:"smtpFromName" form:"smtpFromName"`
SmtpTo string `json:"smtpTo" form:"smtpTo"`
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"` OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
TimeLocation string `json:"timeLocation" form:"timeLocation"` TimeLocation string `json:"timeLocation" form:"timeLocation"`
@@ -111,11 +97,10 @@ type AllSetting struct {
type AllSettingView struct { type AllSettingView struct {
AllSetting AllSetting
HasTgBotToken bool `json:"hasTgBotToken"` HasTgBotToken bool `json:"hasTgBotToken"`
HasApiToken bool `json:"hasApiToken"` HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"` HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"` HasNordSecret bool `json:"hasNordSecret"`
HasSmtpPassword bool `json:"hasSmtpPassword"`
} }
func pathHasForbiddenChar(s string) bool { func pathHasForbiddenChar(s string) bool {
@@ -260,12 +245,6 @@ func (s *AllSetting) CheckValid() error {
return common.NewError("time location not exist:", s.TimeLocation) return common.NewError("time location not exist:", s.TimeLocation)
} }
if s.SmtpFrom != "" {
if _, err := mail.ParseAddress(s.SmtpFrom); err != nil {
return common.NewError("SMTP from address is not valid:", s.SmtpFrom)
}
}
return nil return nil
} }
-408
View File
@@ -1,408 +0,0 @@
package email
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// EmailService sends email notifications via SMTP.
type EmailService struct {
settingService service.SettingService
}
// SMTPTestResult holds the result of an SMTP connection test.
type SMTPTestResult struct {
Success bool `json:"success"`
Stage string `json:"stage"` // "connect" | "auth" | "send"
Message string `json:"message"` // classified error message
}
// NewEmailService creates a new EmailService.
func NewEmailService(settingService service.SettingService) *EmailService {
return &EmailService{settingService: settingService}
}
// smtpConnectTimeout bounds the TCP dial. smtpDeadline bounds every SMTP
// protocol step after the connection is up, so a server that accepts the socket
// but then stalls cannot block the sender goroutine (and leak its socket) long
// after the caller's own timeout has already fired. smtpDeadline is a var only
// so tests can shorten it.
const smtpConnectTimeout = 10 * time.Second
var smtpDeadline = 30 * time.Second
// Send sends an HTML email to all configured recipients.
func (s *EmailService) Send(subject, body string) error {
host, err := s.settingService.GetSmtpHost()
if err != nil || host == "" {
return fmt.Errorf("smtp host not configured")
}
port, err := s.settingService.GetSmtpPort()
if err != nil || port <= 0 {
port = 587
}
username, _ := s.settingService.GetSmtpUsername()
password, _ := s.settingService.GetSmtpPassword()
fromAddr, _ := s.settingService.GetSmtpFrom()
fromName, _ := s.settingService.GetSmtpFromName()
toStr, _ := s.settingService.GetSmtpTo()
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
from := fromAddr
if from == "" {
from = username
}
if from == "" {
return fmt.Errorf("smtp from not configured")
}
from, fromName = resolveFrom(from, fromName)
recipients := parseRecipients(toStr)
if len(recipients) == 0 {
return fmt.Errorf("no recipients configured")
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
msg := buildMessage(from, fromName, recipients, subject, body)
// Authenticate only when credentials are set. Go's PlainAuth refuses to run
// over the unencrypted "none" transport, so an open relay must use nil auth.
var auth smtp.Auth
if username != "" && password != "" {
auth = smtp.PlainAuth("", username, password, host)
}
// Wrap in a channel with timeout to prevent indefinite blocking
type result struct{ err error }
ch := make(chan result, 1)
go func() {
switch encryptionType {
case "tls":
ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
case "starttls", "none":
ch <- result{s.sendPlain(addr, auth, from, recipients, msg, host)}
default:
ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
}
}()
select {
case r := <-ch:
return r.err
case <-time.After(30 * time.Second):
return fmt.Errorf("smtp connection timed out after 30s")
}
}
// TestConnection tests SMTP connection stage by stage and sends a test email.
func (s *EmailService) TestConnection() SMTPTestResult {
host, err := s.settingService.GetSmtpHost()
if err != nil || host == "" {
return SMTPTestResult{false, "connect", "smtpHostNotConfigured"}
}
port, err := s.settingService.GetSmtpPort()
if err != nil || port <= 0 {
port = 587
}
username, _ := s.settingService.GetSmtpUsername()
password, _ := s.settingService.GetSmtpPassword()
fromAddr, _ := s.settingService.GetSmtpFrom()
fromName, _ := s.settingService.GetSmtpFromName()
toStr, _ := s.settingService.GetSmtpTo()
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
from := fromAddr
if from == "" {
from = username
}
if from == "" {
return SMTPTestResult{false, "send", "smtpFromNotConfigured"}
}
from, fromName = resolveFrom(from, fromName)
recipients := parseRecipients(toStr)
if len(recipients) == 0 {
return SMTPTestResult{false, "send", "smtpNoRecipients"}
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
// Stage 1: Connect
var conn net.Conn
dialer := &net.Dialer{Timeout: 5 * time.Second}
switch encryptionType {
case "tls":
conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
ServerName: host,
InsecureSkipVerify: false,
}}).DialContext(context.Background(), "tcp", addr)
default:
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return SMTPTestResult{false, "connect", classifySMTPError(err)}
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
// Stage 2: Handshake + Auth
client, err := smtp.NewClient(conn, host)
if err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
defer client.Close()
if err = client.Hello("localhost"); err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
// STARTTLS upgrade for non-TLS connections
if encryptionType == "starttls" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
}
}
if username != "" && password != "" {
auth := smtp.PlainAuth("", username, password, host)
if err = client.Auth(auth); err != nil {
return SMTPTestResult{false, "auth", classifySMTPError(err)}
}
}
// Stage 3: Send test email
if err = client.Mail(from); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
for _, r := range recipients {
if err = client.Rcpt(r); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
}
msg := buildMessage(from, fromName, recipients, "[3x-ui] Test email",
`<html><body style="font-family:monospace;font-size:14px">
<h2>Test email from 3x-ui</h2>
<p>If you received this, SMTP is configured correctly.</p>
</body></html>`)
w, err := client.Data()
if err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
if _, err = w.Write(msg); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
if err = w.Close(); err != nil {
return SMTPTestResult{false, "send", classifySMTPError(err)}
}
return SMTPTestResult{true, "send", "smtpTestSuccess"}
}
func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
// Dial with explicit timeout
dialer := &net.Dialer{Timeout: smtpConnectTimeout}
conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
ServerName: host,
InsecureSkipVerify: false,
}}).DialContext(context.Background(), "tcp", addr)
if err != nil {
return err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Close()
if err = client.Hello("localhost"); err != nil {
return err
}
if auth != nil {
if err = client.Auth(auth); err != nil {
return err
}
}
if err = client.Mail(from); err != nil {
return err
}
for _, r := range to {
if err = client.Rcpt(r); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(msg); err != nil {
return err
}
return w.Close()
}
// sendPlain delivers over a plain TCP connection, opportunistically upgrading
// via STARTTLS when the server advertises it (the behavior net/smtp.SendMail
// gives the "starttls" and "none" transports). Unlike SendMail it dials with a
// timeout and arms a connection deadline, so a server that never speaks or
// stalls mid-protocol cannot block the sender goroutine past smtpDeadline.
func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr)
if err != nil {
return err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Close()
if err = client.Hello("localhost"); err != nil {
return err
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
}
if auth != nil {
if err = client.Auth(auth); err != nil {
return err
}
}
if err = client.Mail(from); err != nil {
return err
}
for _, r := range to {
if err = client.Rcpt(r); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
if _, err = w.Write(msg); err != nil {
return err
}
return w.Close()
}
// SendTest sends a test email and returns any error with detail.
func (s *EmailService) SendTest() error {
return s.Send(
"[3x-ui] Test email",
`<html><body style="font-family:monospace;font-size:14px">
<h2>Test email from 3x-ui</h2>
<p>If you received this, SMTP is configured correctly.</p>
</body></html>`,
)
}
// classifySMTPError maps raw SMTP errors to human-readable messages.
func classifySMTPError(err error) string {
msg := err.Error()
msgLower := strings.ToLower(msg)
switch {
case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
return "pages.settings.smtpErrorAuth"
case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
return "pages.settings.smtpErrorStarttls"
case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
return "pages.settings.smtpErrorTls"
case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
return "pages.settings.smtpErrorRefused"
case strings.Contains(msgLower, "timeout"):
return "pages.settings.smtpErrorTimeout"
case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
return "pages.settings.smtpErrorRelay"
case strings.Contains(msgLower, "eof"):
return "pages.settings.smtpErrorEof"
default:
return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
}
}
func parseRecipients(toStr string) []string {
if toStr == "" {
return nil
}
var out []string
for s := range strings.SplitSeq(toStr, ",") {
s = strings.TrimSpace(s)
if s != "" {
out = append(out, s)
}
}
return out
}
// buildMessage assembles an RFC 5322 message. It emits the two mandatory
// header fields (Date, From) plus Message-ID, so strict receivers such as Gmail
// accept it and spam filters do not penalize a missing date or message id. The
// From header is a proper name-addr ("Name" <addr>) via net/mail, and a
// non-ASCII subject is RFC 2047 encoded.
// headerSanitizer drops CR/LF so a crafted address or name cannot inject extra
// header lines. Configured addresses are already validated at save time
// (entity.AllSetting.CheckValid), this is defense in depth for buildMessage.
var headerSanitizer = strings.NewReplacer("\r", "", "\n", "")
func resolveFrom(from, fromName string) (string, string) {
parsed, err := mail.ParseAddress(from)
if err != nil {
return from, fromName
}
if fromName == "" {
fromName = parsed.Name
}
return parsed.Address, fromName
}
func buildMessage(fromAddr, fromName string, to []string, subject, body string) []byte {
fromAddr = headerSanitizer.Replace(fromAddr)
fromName = headerSanitizer.Replace(fromName)
from := (&mail.Address{Name: fromName, Address: fromAddr}).String()
domain := "localhost"
if at := strings.LastIndex(fromAddr, "@"); at >= 0 && at+1 < len(fromAddr) {
domain = fromAddr[at+1:]
}
var token [16]byte
_, _ = rand.Read(token[:])
messageID := fmt.Sprintf("<%s@%s>", hex.EncodeToString(token[:]), domain)
var msg strings.Builder
fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
fmt.Fprintf(&msg, "From: %s\r\n", from)
fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(to, ", "))
fmt.Fprintf(&msg, "Message-ID: %s\r\n", messageID)
fmt.Fprintf(&msg, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/html; charset=utf-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(body)
return []byte(msg.String())
}
@@ -1,46 +0,0 @@
package email
import (
"net"
"testing"
"time"
)
func TestSendPlainReturnsOnStalledServer(t *testing.T) {
orig := smtpDeadline
smtpDeadline = 300 * time.Millisecond
t.Cleanup(func() { smtpDeadline = orig })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
stall := make(chan struct{})
defer close(stall)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
<-stall
}()
s := &EmailService{}
done := make(chan error, 1)
go func() {
done <- s.sendPlain(ln.Addr().String(), nil, "from@example.com",
[]string{"to@example.com"}, []byte("body"), "example.com")
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected an error from a silent SMTP server, got nil")
}
case <-time.After(3 * time.Second):
t.Fatal("sendPlain did not return on a stalled server within the deadline")
}
}
-224
View File
@@ -1,224 +0,0 @@
package email
import (
"bufio"
"fmt"
"io"
"mime"
"net"
"net/mail"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
func TestBuildMessageIsRFC5322(t *testing.T) {
raw := buildMessage("panel@example.com", "3x-ui", []string{"a@example.com", "b@example.com"}, "Тест", "<b>hi</b>")
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
t.Fatalf("message does not parse as RFC 5322: %v", err)
}
from, err := mail.ParseAddress(msg.Header.Get("From"))
if err != nil {
t.Fatalf("From header does not parse: %v", err)
}
if from.Name != "3x-ui" || from.Address != "panel@example.com" {
t.Errorf("From = %q <%q>, want name %q addr %q", from.Name, from.Address, "3x-ui", "panel@example.com")
}
if _, err := msg.Header.Date(); err != nil {
t.Errorf("Date header missing or unparseable: %v", err)
}
id := msg.Header.Get("Message-ID")
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
t.Errorf("Message-ID = %q, want <token@example.com>", id)
}
subject, err := (&mime.WordDecoder{}).DecodeHeader(msg.Header.Get("Subject"))
if err != nil {
t.Fatalf("Subject does not decode: %v", err)
}
if subject != "Тест" {
t.Errorf("Subject = %q, want %q", subject, "Тест")
}
body, _ := io.ReadAll(msg.Body)
if string(body) != "<b>hi</b>" {
t.Errorf("body = %q, want %q", body, "<b>hi</b>")
}
}
func TestBuildMessageFromWithoutName(t *testing.T) {
raw := buildMessage("panel@example.com", "", []string{"a@example.com"}, "s", "b")
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
t.Fatalf("parse: %v", err)
}
from, err := mail.ParseAddress(msg.Header.Get("From"))
if err != nil {
t.Fatalf("From header does not parse: %v", err)
}
if from.Name != "" || from.Address != "panel@example.com" {
t.Errorf("From = %q <%q>, want bare addr", from.Name, from.Address)
}
}
func startFakeSMTPServer(t *testing.T) (string, func() []string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
var mu sync.Mutex
var lines []string
record := func(line string) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, line)
}
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
fmt.Fprint(conn, "220 fake ready\r\n")
inData := false
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
record(line)
if inData {
if line == "." {
inData = false
fmt.Fprint(conn, "250 ok\r\n")
}
continue
}
switch {
case strings.HasPrefix(line, "DATA"):
inData = true
fmt.Fprint(conn, "354 send\r\n")
case strings.HasPrefix(line, "QUIT"):
fmt.Fprint(conn, "221 bye\r\n")
return
default:
fmt.Fprint(conn, "250 ok\r\n")
}
}
}()
return ln.Addr().String(), func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), lines...)
}
}
func TestSendUsesBareAddressFromNameAddrSmtpFrom(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.CloseDB() })
addr, recordedLines := startFakeSMTPServer(t)
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
settingService := service.SettingService{}
mustSet := func(name string, err error) {
t.Helper()
if err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
mustSet("host", settingService.SetSmtpHost(host))
mustSet("port", settingService.SetSmtpPort(port))
mustSet("from", settingService.SetSmtpFrom("3x-ui Panel <panel@example.com>"))
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
if err := NewEmailService(settingService).Send("subject", "<b>hi</b>"); err != nil {
t.Fatalf("send: %v", err)
}
var mailFrom, fromHeader string
for _, line := range recordedLines() {
if strings.HasPrefix(line, "MAIL FROM:") {
mailFrom = line
}
if strings.HasPrefix(line, "From: ") {
fromHeader = line
}
}
if want := "MAIL FROM:<panel@example.com>"; mailFrom != want {
t.Errorf("envelope sender = %q, want %q", mailFrom, want)
}
if want := `From: "3x-ui Panel" <panel@example.com>`; fromHeader != want {
t.Errorf("from header = %q, want %q", fromHeader, want)
}
}
func TestConnectionReportsMissingFrom(t *testing.T) {
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.CloseDB() })
settingService := service.SettingService{}
mustSet := func(name string, err error) {
t.Helper()
if err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
mustSet("host", settingService.SetSmtpHost("127.0.0.1"))
mustSet("port", settingService.SetSmtpPort(1))
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
got := NewEmailService(settingService).TestConnection()
want := SMTPTestResult{Success: false, Stage: "send", Message: "smtpFromNotConfigured"}
if got != want {
t.Errorf("TestConnection() = %+v, want %+v", got, want)
}
}
func TestBuildMessageStripsHeaderInjection(t *testing.T) {
raw := buildMessage(
"panel@example.com\r\nBcc: evil@example.com",
"Name\r\nX-Evil: 1",
[]string{"a@example.com"}, "s", "b",
)
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got := msg.Header.Get("Bcc"); got != "" {
t.Errorf("injected Bcc header leaked: %q", got)
}
if got := msg.Header.Get("X-Evil"); got != "" {
t.Errorf("injected X-Evil header leaked: %q", got)
}
}
@@ -1,52 +0,0 @@
package email
import (
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
)
func TestRateLimiterAllow(t *testing.T) {
rl := eventbus.NewRateLimiter(time.Minute)
if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
t.Error("first call should be allowed")
}
}
func TestRateLimiterCooldown(t *testing.T) {
rl := eventbus.NewRateLimiter(100 * time.Millisecond)
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
if rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
t.Error("should be blocked during cooldown")
}
time.Sleep(110 * time.Millisecond)
if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
t.Error("should be allowed after cooldown")
}
}
func TestRateLimiterPerType(t *testing.T) {
rl := eventbus.NewRateLimiter(time.Minute)
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
if !rl.Allow(eventbus.EventOutboundUp, "proxy-1") {
t.Error("different event types should be independent")
}
}
func TestRateLimiterPerSource(t *testing.T) {
rl := eventbus.NewRateLimiter(time.Minute)
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
if !rl.Allow(eventbus.EventOutboundDown, "proxy-2") {
t.Error("different sources should be independent")
}
}
-198
View File
@@ -1,198 +0,0 @@
package email
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// Subscriber handles event bus messages and sends email notifications.
type Subscriber struct {
settingService service.SettingService
emailService *EmailService
limiter *eventbus.RateLimiter
}
// NewSubscriber creates a new email event subscriber.
func NewSubscriber(settingService service.SettingService, emailService *EmailService) *Subscriber {
return &Subscriber{
settingService: settingService,
emailService: emailService,
limiter: eventbus.NewRateLimiter(1 * time.Minute),
}
}
// HandleEvent is the eventbus subscriber callback.
func (s *Subscriber) HandleEvent(e eventbus.Event) {
if on, err := s.settingService.GetSmtpEnable(); err != nil || !on {
return
}
if !s.isEventEnabled(e.Type) {
return
}
if e.Type != eventbus.EventLoginAttempt {
if !s.limiter.Allow(e.Type, e.Source) {
return
}
}
subject, body := s.formatMessage(e)
if subject == "" {
return
}
if err := s.emailService.Send(subject, body); err != nil {
logger.Warning("email subscriber: send failed:", err)
}
}
func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
events, err := s.settingService.GetSmtpEnabledEvents()
if err != nil || events == "" {
return false
}
for e := range strings.SplitSeq(events, ",") {
if strings.TrimSpace(e) == string(t) {
return true
}
}
return false
}
func i18n(key string, params ...string) string {
return locale.I18n(locale.Bot, key, params...)
}
func (s *Subscriber) formatMessage(e eventbus.Event) (subject, body string) {
h, _ := hostname()
host := h
ts := e.Timestamp.Format("2006-01-02 15:04:05")
wrap := func(title, content string) string {
// Strip newlines from title to prevent broken HTML
title = strings.ReplaceAll(title, "\r\n", "")
title = strings.ReplaceAll(title, "\n", "")
return fmt.Sprintf(`<html><body style="font-family:monospace;font-size:14px;color:#333">
<h2 style="color:#555;border-bottom:1px solid #ddd;padding-bottom:8px">📡 %s %s</h2>
%s
<p style="color:#999;font-size:12px;margin-top:20px">%s</p>
</body></html>`, host, title, content, i18n("tgbot.messages.time", "Time=="+ts))
}
kv := func(key, val string) string {
return fmt.Sprintf("<p><b>%s:</b> %s</p>", key, val)
}
switch e.Type {
case eventbus.EventOutboundDown:
subject = host + " " + i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
content += kv(i18n("email.labelOutbound"), e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
if data.Error != "" {
content += kv(i18n("email.labelError"), data.Error)
}
if data.Delay > 0 {
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
}
}
body = wrap(i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source), content)
case eventbus.EventOutboundUp:
subject = host + " " + i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
content += kv(i18n("email.labelOutbound"), e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
}
body = wrap(i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source), content)
case eventbus.EventXrayCrash:
subject = host + " " + i18n("tgbot.messages.eventXrayCrash")
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusCrashed")+`</span>`)
if e.Data != nil {
content += kv(i18n("email.labelError"), fmt.Sprint(e.Data))
}
body = wrap(i18n("tgbot.messages.eventXrayCrash"), content)
case eventbus.EventNodeDown:
subject = host + " " + i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
content += kv(i18n("email.labelNode"), e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
content += kv(i18n("email.labelError"), data.XrayError)
}
body = wrap(i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source), content)
case eventbus.EventNodeUp:
subject = host + " " + i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source)
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
content += kv(i18n("email.labelNode"), e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.LatencyMs))
}
body = wrap(i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source), content)
case eventbus.EventCPUHigh:
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
smtpCpu, err := s.settingService.GetSmtpCpu()
if err != nil || smtpCpu <= 0 || data.Percent <= float64(smtpCpu) {
return
}
subject = host + " " + i18n("tgbot.messages.cpuThreshold",
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
"Threshold=="+fmt.Sprintf("%d", smtpCpu))
content := kv(i18n("email.labelStatus"), `<span style="color:orange">`+i18n("email.statusHigh")+`</span>`)
body = wrap(subject, content)
}
case eventbus.EventMemoryHigh:
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
smtpMemory, err := s.settingService.GetSmtpMemory()
if err != nil || smtpMemory <= 0 || data.Percent <= float64(smtpMemory) {
return
}
subject = host + " " + i18n("tgbot.messages.memoryThreshold",
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
"Threshold=="+fmt.Sprintf("%d", smtpMemory))
content := kv(i18n("email.labelStatus"), `<span style="color:orange">`+i18n("email.statusHigh")+`</span>`)
body = wrap(subject, content)
}
case eventbus.EventLoginAttempt:
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
if data.Status == "success" {
subject = host + " " + i18n("tgbot.messages.loginSuccess")
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusSuccess")+`</span>`)
content += kv(i18n("email.labelUsername"), data.Username)
content += kv(i18n("email.labelIP"), data.IP)
body = wrap(i18n("tgbot.messages.loginSuccess"), content)
} else {
subject = host + " " + i18n("tgbot.messages.loginFailed")
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
if data.Reason != "" {
content += kv(i18n("email.labelReason"), data.Reason)
}
content += kv(i18n("email.labelUsername"), data.Username)
content += kv(i18n("email.labelIP"), data.IP)
body = wrap(i18n("tgbot.messages.loginFailed"), content)
}
} else {
subject = host + " " + i18n("tgbot.messages.loginFailed")
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
content += kv(i18n("email.labelSource"), e.Source)
body = wrap(i18n("tgbot.messages.loginFailed"), content)
}
}
return
}
func hostname() (string, error) {
return os.Hostname()
}
+3 -126
View File
@@ -128,24 +128,10 @@ var defaultValueMap = map[string]string{
"devChannelEnable": "false", "devChannelEnable": "false",
// Event bus — per-subscriber event filtering (empty = all disabled) // Event bus — per-subscriber event filtering (empty = all disabled)
"tgEnabledEvents": "login.attempt,cpu.high", "tgEnabledEvents": "login.attempt,cpu.high",
"smtpEnabledEvents": "login.attempt,cpu.high",
"smtpCpu": "80",
"smtpMemory": "80",
// Consecutive failed observatory probes before an outbound.down event fires // Consecutive failed observatory probes before an outbound.down event fires
"outboundDownThreshold": "3", "outboundDownThreshold": "3",
// Email (SMTP) notifications
"smtpEnable": "false",
"smtpHost": "",
"smtpPort": "587",
"smtpUsername": "",
"smtpPassword": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpTo": "",
"smtpEncryptionType": "starttls", // no, starttls, tls
} }
// SettingService provides business logic for application settings management. // SettingService provides business logic for application settings management.
@@ -245,13 +231,11 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord())) view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword)
var apiTokenCount int64 var apiTokenCount int64
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil { if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
view.HasApiToken = apiTokenCount > 0 view.HasApiToken = apiTokenCount > 0
} }
view.TgBotToken = "" view.TgBotToken = ""
view.SmtpPassword = ""
return view, nil return view, nil
} }
@@ -911,104 +895,6 @@ func (s *SettingService) SetTgEnabledEvents(events string) error {
return s.setString("tgEnabledEvents", events) return s.setString("tgEnabledEvents", events)
} }
func (s *SettingService) GetSmtpEnabledEvents() (string, error) {
return s.getString("smtpEnabledEvents")
}
func (s *SettingService) SetSmtpEnabledEvents(events string) error {
return s.setString("smtpEnabledEvents", events)
}
// Email (SMTP) settings
func (s *SettingService) GetSmtpEnable() (bool, error) {
return s.getBool("smtpEnable")
}
func (s *SettingService) SetSmtpEnable(value bool) error {
return s.setBool("smtpEnable", value)
}
func (s *SettingService) GetSmtpHost() (string, error) {
return s.getString("smtpHost")
}
func (s *SettingService) SetSmtpHost(value string) error {
return s.setString("smtpHost", value)
}
func (s *SettingService) GetSmtpPort() (int, error) {
return s.getInt("smtpPort")
}
func (s *SettingService) SetSmtpPort(value int) error {
return s.setInt("smtpPort", value)
}
func (s *SettingService) GetSmtpUsername() (string, error) {
return s.getString("smtpUsername")
}
func (s *SettingService) SetSmtpUsername(value string) error {
return s.setString("smtpUsername", value)
}
func (s *SettingService) GetSmtpFrom() (string, error) {
return s.getString("smtpFrom")
}
func (s *SettingService) SetSmtpFrom(value string) error {
return s.setString("smtpFrom", value)
}
func (s *SettingService) GetSmtpFromName() (string, error) {
return s.getString("smtpFromName")
}
func (s *SettingService) SetSmtpFromName(value string) error {
return s.setString("smtpFromName", value)
}
func (s *SettingService) GetSmtpPassword() (string, error) {
return s.getString("smtpPassword")
}
func (s *SettingService) SetSmtpPassword(value string) error {
return s.setString("smtpPassword", value)
}
func (s *SettingService) GetSmtpTo() (string, error) {
return s.getString("smtpTo")
}
func (s *SettingService) SetSmtpTo(value string) error {
return s.setString("smtpTo", value)
}
func (s *SettingService) GetSmtpEncryptionType() (string, error) {
return s.getString("smtpEncryptionType")
}
func (s *SettingService) SetSmtpEncryptionType(value string) error {
return s.setString("smtpEncryptionType", value)
}
func (s *SettingService) GetSmtpCpu() (int, error) {
return s.getInt("smtpCpu")
}
func (s *SettingService) SetSmtpCpu(value int) error {
return s.setInt("smtpCpu", value)
}
func (s *SettingService) GetSmtpMemory() (int, error) {
return s.getInt("smtpMemory")
}
func (s *SettingService) SetSmtpMemory(value int) error {
return s.setInt("smtpMemory", value)
}
// GetOutboundDownThreshold returns how many consecutive failed observatory // GetOutboundDownThreshold returns how many consecutive failed observatory
// probes an outbound must accumulate before an outbound.down notification is // probes an outbound must accumulate before an outbound.down notification is
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour. // emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
@@ -1024,8 +910,7 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error {
// flag, a blank submitted secret means "unchanged" (the field is always served // flag, a blank submitted secret means "unchanged" (the field is always served
// blank to the browser) and the stored value is preserved. // blank to the browser) and the stored value is preserved.
type SecretClears struct { type SecretClears struct {
TgBotToken bool TgBotToken bool
SmtpPassword bool
} }
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
@@ -1128,13 +1013,6 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting,
} }
allSetting.TgBotToken = value allSetting.TgBotToken = value
} }
if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
value, err := s.GetSmtpPassword()
if err != nil {
return err
}
allSetting.SmtpPassword = value
}
return nil return nil
} }
@@ -1310,8 +1188,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
} }
var factoryDefaultSecretKeys = map[string]bool{ var factoryDefaultSecretKeys = map[string]bool{
"tgBotToken": true, "tgBotToken": true,
"smtpPassword": true,
} }
/* /*
-23
View File
@@ -30,7 +30,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/network" "github.com/mhsanaei/3x-ui/v3/internal/web/network"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
@@ -432,13 +431,6 @@ func (s *Server) cpuAlarmWanted() bool {
return true return true
} }
} }
if on, _ := s.settingService.GetSmtpEnable(); on {
events, _ := s.settingService.GetSmtpEnabledEvents()
cpu, _ := s.settingService.GetSmtpCpu()
if wants(events, cpu) {
return true
}
}
return false return false
} }
@@ -462,13 +454,6 @@ func (s *Server) memoryAlarmWanted() bool {
return true return true
} }
} }
if on, _ := s.settingService.GetSmtpEnable(); on {
events, _ := s.settingService.GetSmtpEnabledEvents()
mem, _ := s.settingService.GetSmtpMemory()
if wants(events, mem) {
return true
}
}
return false return false
} }
@@ -616,14 +601,6 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
} }
} }
// Register email subscriber (always — it checks smtpEnable at runtime)
emailService := email.NewEmailService(s.settingService)
emailSub := email.NewSubscriber(s.settingService, emailService)
s.bus.Subscribe("email-notifier", emailSub.HandleEvent)
// Wire email service to controller for test endpoint
controller.SetEmailService(emailService)
// Wire Telegram test function to controller // Wire Telegram test function to controller
controller.SetTestTgFunc(func() error { controller.SetTestTgFunc(func() error {
if !s.tgbotService.IsRunning() { if !s.tgbotService.IsRunning() {