email.go 10 KB

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