精简: 去掉邮件模块(SMTP 通知): service/email 包、订阅者接线、testSmtp 端点、全部 smtp* 设置
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user