mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 17:46:08 +00:00
9ffbeb4938
TestConnection skipped the empty-from guard that Send enforces, so with no sender and no username configured the test issued the null reverse-path and could report success against a lenient relay while every real notification send kept failing with the missing-sender error. Guard the test path the same way and surface a dedicated translated message.
348 lines
10 KiB
Go
348 lines
10 KiB
Go
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}
|
|
}
|
|
|
|
// 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{smtp.SendMail(addr, auth, from, recipients, msg)}
|
|
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()
|
|
|
|
// 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: 10 * time.Second}
|
|
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()
|
|
|
|
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()
|
|
}
|
|
|
|
// 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())
|
|
}
|