精简: 去掉邮件模块(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/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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user