From 1498d58f14b19825389afa7aadfa3d213b929a81 Mon Sep 17 00:00:00 2001 From: Muse Date: Tue, 20 Oct 2026 10:07:24 +0000 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80:=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E6=A8=A1=E5=9D=97(SMTP=20=E9=80=9A=E7=9F=A5)?= =?UTF-8?q?:=20service/email=20=E5=8C=85=E3=80=81=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E8=80=85=E6=8E=A5=E7=BA=BF=E3=80=81testSmtp=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E3=80=81=E5=85=A8=E9=83=A8=20smtp*=20=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/web/controller/setting.go | 38 +- internal/web/entity/entity.go | 29 +- internal/web/service/email/email.go | 408 ------------------ .../web/service/email/email_deadline_test.go | 46 -- internal/web/service/email/email_test.go | 224 ---------- .../web/service/email/ratelimiter_test.go | 52 --- internal/web/service/email/subscriber.go | 198 --------- internal/web/service/setting.go | 129 +----- internal/web/web.go | 23 - 9 files changed, 9 insertions(+), 1138 deletions(-) delete mode 100644 internal/web/service/email/email.go delete mode 100644 internal/web/service/email/email_deadline_test.go delete mode 100644 internal/web/service/email/email_test.go delete mode 100644 internal/web/service/email/ratelimiter_test.go delete mode 100644 internal/web/service/email/subscriber.go diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 17bd2ac..a2cebf5 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -11,7 +11,6 @@ import ( "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/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/session" @@ -32,8 +31,7 @@ type updateUserForm struct { // see #5724). type updateSettingForm struct { entity.AllSetting - ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` - ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"` + ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` } type validateRegexForm struct { @@ -72,7 +70,6 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/apiTokens/create", a.createApiToken) g.POST("/apiTokens/delete/:id", a.deleteApiToken) g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled) - g.POST("/testSmtp", a.testSmtp) g.POST("/testTgBot", a.testTgBot) } @@ -127,8 +124,7 @@ func (a *SettingController) updateSetting(c *gin.Context) { oldTgChatId, _ := a.settingService.GetTgBotChatId() oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer() err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ - TgBotToken: form.ClearTgBotToken, - SmtpPassword: form.ClearSmtpPassword, + TgBotToken: form.ClearTgBotToken, }) if err == nil && form.PanelOutbound != oldPanelOutbound { // 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)) } -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) { enabled, err := a.settingService.GetTgbotEnabled() if err != nil || !enabled { @@ -314,9 +286,3 @@ func SetTestTgFunc(fn func() error) { testTgFunc = fn } var reloadTgbotFunc func() 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 } diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 489b2a3..8baaf56 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -4,7 +4,6 @@ import ( "crypto/tls" "math" "net" - "net/mail" "net/netip" "strings" "time" @@ -49,19 +48,6 @@ type AllSetting struct { TgLang string `json:"tgLang" form:"tgLang"` 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"` TimeLocation string `json:"timeLocation" form:"timeLocation"` @@ -111,11 +97,10 @@ type AllSetting struct { type AllSettingView struct { AllSetting - HasTgBotToken bool `json:"hasTgBotToken"` - HasApiToken bool `json:"hasApiToken"` - HasWarpSecret bool `json:"hasWarpSecret"` - HasNordSecret bool `json:"hasNordSecret"` - HasSmtpPassword bool `json:"hasSmtpPassword"` + HasTgBotToken bool `json:"hasTgBotToken"` + HasApiToken bool `json:"hasApiToken"` + HasWarpSecret bool `json:"hasWarpSecret"` + HasNordSecret bool `json:"hasNordSecret"` } func pathHasForbiddenChar(s string) bool { @@ -260,12 +245,6 @@ func (s *AllSetting) CheckValid() error { 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 } diff --git a/internal/web/service/email/email.go b/internal/web/service/email/email.go deleted file mode 100644 index 5bcbdab..0000000 --- a/internal/web/service/email/email.go +++ /dev/null @@ -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", - ` -

Test email from 3x-ui

-

If you received this, SMTP is configured correctly.

-`) - - 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", - ` -

Test email from 3x-ui

-

If you received this, SMTP is configured correctly.

-`, - ) -} - -// 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" ) 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()) -} diff --git a/internal/web/service/email/email_deadline_test.go b/internal/web/service/email/email_deadline_test.go deleted file mode 100644 index 17d36c4..0000000 --- a/internal/web/service/email/email_deadline_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/web/service/email/email_test.go b/internal/web/service/email/email_test.go deleted file mode 100644 index d32ad3f..0000000 --- a/internal/web/service/email/email_test.go +++ /dev/null @@ -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"}, "Тест", "hi") - - 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 ", 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) != "hi" { - t.Errorf("body = %q, want %q", body, "hi") - } -} - -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 ")) - mustSet("to", settingService.SetSmtpTo("admin@example.com")) - mustSet("encryption", settingService.SetSmtpEncryptionType("none")) - - if err := NewEmailService(settingService).Send("subject", "hi"); 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:"; mailFrom != want { - t.Errorf("envelope sender = %q, want %q", mailFrom, want) - } - if want := `From: "3x-ui Panel" `; 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) - } -} diff --git a/internal/web/service/email/ratelimiter_test.go b/internal/web/service/email/ratelimiter_test.go deleted file mode 100644 index 4d99064..0000000 --- a/internal/web/service/email/ratelimiter_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/web/service/email/subscriber.go b/internal/web/service/email/subscriber.go deleted file mode 100644 index bf8b483..0000000 --- a/internal/web/service/email/subscriber.go +++ /dev/null @@ -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(` -

📡 %s %s

-%s -

%s

-`, host, title, content, i18n("tgbot.messages.time", "Time=="+ts)) - } - - kv := func(key, val string) string { - return fmt.Sprintf("

%s: %s

", key, val) - } - - switch e.Type { - case eventbus.EventOutboundDown: - subject = host + " " + i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source) - content := kv(i18n("email.labelStatus"), ``+i18n("email.statusDown")+``) - 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"), ``+i18n("email.statusUp")+``) - 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"), ``+i18n("email.statusCrashed")+``) - 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"), ``+i18n("email.statusDown")+``) - 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"), ``+i18n("email.statusUp")+``) - 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"), ``+i18n("email.statusHigh")+``) - 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"), ``+i18n("email.statusHigh")+``) - 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"), ``+i18n("email.statusSuccess")+``) - 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"), ``+i18n("email.statusFailed")+``) - 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"), ``+i18n("email.statusFailed")+``) - content += kv(i18n("email.labelSource"), e.Source) - body = wrap(i18n("tgbot.messages.loginFailed"), content) - } - } - - return -} - -func hostname() (string, error) { - return os.Hostname() -} diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 47730cb..2ca9f8f 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -128,24 +128,10 @@ var defaultValueMap = map[string]string{ "devChannelEnable": "false", // Event bus — per-subscriber event filtering (empty = all disabled) - "tgEnabledEvents": "login.attempt,cpu.high", - "smtpEnabledEvents": "login.attempt,cpu.high", - "smtpCpu": "80", - "smtpMemory": "80", + "tgEnabledEvents": "login.attempt,cpu.high", // Consecutive failed observatory probes before an outbound.down event fires "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. @@ -245,13 +231,11 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasNordSecret = secretConfigured(mustString(s.GetNord())) - view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword) var apiTokenCount int64 if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil { view.HasApiToken = apiTokenCount > 0 } view.TgBotToken = "" - view.SmtpPassword = "" return view, nil } @@ -911,104 +895,6 @@ func (s *SettingService) SetTgEnabledEvents(events string) error { 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 // probes an outbound must accumulate before an outbound.down notification is // 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 // blank to the browser) and the stored value is preserved. type SecretClears struct { - TgBotToken bool - SmtpPassword bool + TgBotToken bool } func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error { @@ -1128,13 +1013,6 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, } allSetting.TgBotToken = value } - if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" { - value, err := s.GetSmtpPassword() - if err != nil { - return err - } - allSetting.SmtpPassword = value - } return nil } @@ -1310,8 +1188,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { } var factoryDefaultSecretKeys = map[string]bool{ - "tgBotToken": true, - "smtpPassword": true, + "tgBotToken": true, } /* diff --git a/internal/web/web.go b/internal/web/web.go index 5dee61d..ab8120a 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -30,7 +30,6 @@ import ( "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/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/tgbot" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket" @@ -432,13 +431,6 @@ func (s *Server) cpuAlarmWanted() bool { return true } } - if on, _ := s.settingService.GetSmtpEnable(); on { - events, _ := s.settingService.GetSmtpEnabledEvents() - cpu, _ := s.settingService.GetSmtpCpu() - if wants(events, cpu) { - return true - } - } return false } @@ -462,13 +454,6 @@ func (s *Server) memoryAlarmWanted() bool { return true } } - if on, _ := s.settingService.GetSmtpEnable(); on { - events, _ := s.settingService.GetSmtpEnabledEvents() - mem, _ := s.settingService.GetSmtpMemory() - if wants(events, mem) { - return true - } - } 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 controller.SetTestTgFunc(func() error { if !s.tgbotService.IsRunning() {