email.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. package email
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "fmt"
  6. "net"
  7. "net/smtp"
  8. "strings"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  11. )
  12. // EmailService sends email notifications via SMTP.
  13. type EmailService struct {
  14. settingService service.SettingService
  15. }
  16. // SMTPTestResult holds the result of an SMTP connection test.
  17. type SMTPTestResult struct {
  18. Success bool `json:"success"`
  19. Stage string `json:"stage"` // "connect" | "auth" | "send"
  20. Message string `json:"message"` // classified error message
  21. }
  22. // NewEmailService creates a new EmailService.
  23. func NewEmailService(settingService service.SettingService) *EmailService {
  24. return &EmailService{settingService: settingService}
  25. }
  26. // Send sends an HTML email to all configured recipients.
  27. func (s *EmailService) Send(subject, body string) error {
  28. host, err := s.settingService.GetSmtpHost()
  29. if err != nil || host == "" {
  30. return fmt.Errorf("smtp host not configured")
  31. }
  32. port, err := s.settingService.GetSmtpPort()
  33. if err != nil || port <= 0 {
  34. port = 587
  35. }
  36. username, _ := s.settingService.GetSmtpUsername()
  37. password, _ := s.settingService.GetSmtpPassword()
  38. toStr, _ := s.settingService.GetSmtpTo()
  39. encryptionType, _ := s.settingService.GetSmtpEncryptionType()
  40. from := username
  41. if from == "" {
  42. return fmt.Errorf("smtp from not configured")
  43. }
  44. recipients := parseRecipients(toStr)
  45. if len(recipients) == 0 {
  46. return fmt.Errorf("no recipients configured")
  47. }
  48. addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
  49. msg := buildMessage(from, recipients, subject, body)
  50. // Authenticate only when credentials are set. Go's PlainAuth refuses to run
  51. // over the unencrypted "none" transport, so an open relay must use nil auth.
  52. var auth smtp.Auth
  53. if username != "" && password != "" {
  54. auth = smtp.PlainAuth("", username, password, host)
  55. }
  56. // Wrap in a channel with timeout to prevent indefinite blocking
  57. type result struct{ err error }
  58. ch := make(chan result, 1)
  59. go func() {
  60. switch encryptionType {
  61. case "tls":
  62. ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
  63. case "starttls", "none":
  64. ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
  65. default:
  66. ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
  67. }
  68. }()
  69. select {
  70. case r := <-ch:
  71. return r.err
  72. case <-time.After(30 * time.Second):
  73. return fmt.Errorf("smtp connection timed out after 30s")
  74. }
  75. }
  76. // TestConnection tests SMTP connection stage by stage and sends a test email.
  77. func (s *EmailService) TestConnection() SMTPTestResult {
  78. host, err := s.settingService.GetSmtpHost()
  79. if err != nil || host == "" {
  80. return SMTPTestResult{false, "connect", "smtpHostNotConfigured"}
  81. }
  82. port, err := s.settingService.GetSmtpPort()
  83. if err != nil || port <= 0 {
  84. port = 587
  85. }
  86. username, _ := s.settingService.GetSmtpUsername()
  87. password, _ := s.settingService.GetSmtpPassword()
  88. toStr, _ := s.settingService.GetSmtpTo()
  89. encryptionType, _ := s.settingService.GetSmtpEncryptionType()
  90. from := username
  91. recipients := parseRecipients(toStr)
  92. if len(recipients) == 0 {
  93. return SMTPTestResult{false, "send", "smtpNoRecipients"}
  94. }
  95. addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
  96. // Stage 1: Connect
  97. var conn net.Conn
  98. dialer := &net.Dialer{Timeout: 5 * time.Second}
  99. switch encryptionType {
  100. case "tls":
  101. conn, err = (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
  102. ServerName: host,
  103. InsecureSkipVerify: false,
  104. }}).DialContext(context.Background(), "tcp", addr)
  105. default:
  106. conn, err = dialer.Dial("tcp", addr)
  107. }
  108. if err != nil {
  109. return SMTPTestResult{false, "connect", classifySMTPError(err)}
  110. }
  111. defer conn.Close()
  112. // Stage 2: Handshake + Auth
  113. client, err := smtp.NewClient(conn, host)
  114. if err != nil {
  115. return SMTPTestResult{false, "auth", classifySMTPError(err)}
  116. }
  117. defer client.Close()
  118. if err = client.Hello("localhost"); err != nil {
  119. return SMTPTestResult{false, "auth", classifySMTPError(err)}
  120. }
  121. // STARTTLS upgrade for non-TLS connections
  122. if encryptionType == "starttls" {
  123. if ok, _ := client.Extension("STARTTLS"); ok {
  124. if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
  125. return SMTPTestResult{false, "auth", classifySMTPError(err)}
  126. }
  127. }
  128. }
  129. if username != "" && password != "" {
  130. auth := smtp.PlainAuth("", username, password, host)
  131. if err = client.Auth(auth); err != nil {
  132. return SMTPTestResult{false, "auth", classifySMTPError(err)}
  133. }
  134. }
  135. // Stage 3: Send test email
  136. if err = client.Mail(from); err != nil {
  137. return SMTPTestResult{false, "send", classifySMTPError(err)}
  138. }
  139. for _, r := range recipients {
  140. if err = client.Rcpt(r); err != nil {
  141. return SMTPTestResult{false, "send", classifySMTPError(err)}
  142. }
  143. }
  144. msg := buildMessage(from, recipients, "[3x-ui] Test email",
  145. `<html><body style="font-family:monospace;font-size:14px">
  146. <h2>Test email from 3x-ui</h2>
  147. <p>If you received this, SMTP is configured correctly.</p>
  148. </body></html>`)
  149. w, err := client.Data()
  150. if err != nil {
  151. return SMTPTestResult{false, "send", classifySMTPError(err)}
  152. }
  153. if _, err = w.Write(msg); err != nil {
  154. return SMTPTestResult{false, "send", classifySMTPError(err)}
  155. }
  156. if err = w.Close(); err != nil {
  157. return SMTPTestResult{false, "send", classifySMTPError(err)}
  158. }
  159. return SMTPTestResult{true, "send", "smtpTestSuccess"}
  160. }
  161. func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
  162. // Dial with explicit timeout
  163. dialer := &net.Dialer{Timeout: 10 * time.Second}
  164. conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
  165. ServerName: host,
  166. InsecureSkipVerify: false,
  167. }}).DialContext(context.Background(), "tcp", addr)
  168. if err != nil {
  169. return err
  170. }
  171. defer conn.Close()
  172. client, err := smtp.NewClient(conn, host)
  173. if err != nil {
  174. return err
  175. }
  176. defer client.Close()
  177. if err = client.Hello("localhost"); err != nil {
  178. return err
  179. }
  180. if auth != nil {
  181. if err = client.Auth(auth); err != nil {
  182. return err
  183. }
  184. }
  185. if err = client.Mail(from); err != nil {
  186. return err
  187. }
  188. for _, r := range to {
  189. if err = client.Rcpt(r); err != nil {
  190. return err
  191. }
  192. }
  193. w, err := client.Data()
  194. if err != nil {
  195. return err
  196. }
  197. if _, err = w.Write(msg); err != nil {
  198. return err
  199. }
  200. return w.Close()
  201. }
  202. // SendTest sends a test email and returns any error with detail.
  203. func (s *EmailService) SendTest() error {
  204. return s.Send(
  205. "[3x-ui] Test email",
  206. `<html><body style="font-family:monospace;font-size:14px">
  207. <h2>Test email from 3x-ui</h2>
  208. <p>If you received this, SMTP is configured correctly.</p>
  209. </body></html>`,
  210. )
  211. }
  212. // classifySMTPError maps raw SMTP errors to human-readable messages.
  213. func classifySMTPError(err error) string {
  214. msg := err.Error()
  215. msgLower := strings.ToLower(msg)
  216. switch {
  217. case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
  218. return "pages.settings.smtpErrorAuth"
  219. case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
  220. return "pages.settings.smtpErrorStarttls"
  221. case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
  222. return "pages.settings.smtpErrorTls"
  223. case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
  224. return "pages.settings.smtpErrorRefused"
  225. case strings.Contains(msgLower, "timeout"):
  226. return "pages.settings.smtpErrorTimeout"
  227. case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
  228. return "pages.settings.smtpErrorRelay"
  229. case strings.Contains(msgLower, "eof"):
  230. return "pages.settings.smtpErrorEof"
  231. default:
  232. return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
  233. }
  234. }
  235. func parseRecipients(toStr string) []string {
  236. if toStr == "" {
  237. return nil
  238. }
  239. var out []string
  240. for s := range strings.SplitSeq(toStr, ",") {
  241. s = strings.TrimSpace(s)
  242. if s != "" {
  243. out = append(out, s)
  244. }
  245. }
  246. return out
  247. }
  248. func buildMessage(from string, to []string, subject, body string) []byte {
  249. headers := map[string]string{
  250. "From": from,
  251. "To": strings.Join(to, ","),
  252. "Subject": subject,
  253. "MIME-Version": "1.0",
  254. "Content-Type": "text/html; charset=utf-8",
  255. }
  256. var msg strings.Builder
  257. for k, v := range headers {
  258. fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
  259. }
  260. msg.WriteString("\r\n")
  261. msg.WriteString(body)
  262. return []byte(msg.String())
  263. }