email.go 12 KB

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