Files
SingBox-Gopanel/internal/service/email.go
CN-JS-HuiBai b3435e5ef8
Some checks failed
build / build (api, amd64, linux) (push) Has been cancelled
build / build (api, arm64, linux) (push) Has been cancelled
build / build (api.exe, amd64, windows) (push) Has been cancelled
基本功能已初步完善
2026-04-17 20:41:47 +08:00

284 lines
6.9 KiB
Go

package service
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"net/smtp"
"net/textproto"
"strings"
)
type EmailConfig struct {
Host string
Port int
Username string
Password string
Encryption string
FromAddress string
FromName string
TemplateName string
}
type EmailMessage struct {
To []string
Subject string
TextBody string
HTMLBody string
}
func LoadEmailConfig() EmailConfig {
fromAddress := strings.TrimSpace(MustGetString("email_from_address", ""))
if fromAddress == "" {
fromAddress = strings.TrimSpace(MustGetString("email_from", ""))
}
fromName := strings.TrimSpace(MustGetString("email_from_name", ""))
if fromName == "" {
fromName = strings.TrimSpace(MustGetString("app_name", "XBoard"))
}
return EmailConfig{
Host: strings.TrimSpace(MustGetString("email_host", "")),
Port: MustGetInt("email_port", 465),
Username: strings.TrimSpace(MustGetString("email_username", "")),
Password: MustGetString("email_password", ""),
Encryption: normalizeEmailEncryption(MustGetString("email_encryption", "")),
FromAddress: fromAddress,
FromName: fromName,
TemplateName: firstNonEmpty(strings.TrimSpace(MustGetString("email_template", "")), "classic"),
}
}
func (cfg EmailConfig) DebugConfig() map[string]any {
return map[string]any{
"driver": "smtp",
"host": cfg.Host,
"port": cfg.Port,
"encryption": cfg.Encryption,
"username": cfg.Username,
"from_address": cfg.SenderAddress(),
"from_name": cfg.FromName,
}
}
func (cfg EmailConfig) SenderAddress() string {
return firstNonEmpty(strings.TrimSpace(cfg.FromAddress), strings.TrimSpace(cfg.Username))
}
func (cfg EmailConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Host) == "":
return errors.New("email_host is required")
case cfg.Port <= 0:
return errors.New("email_port is required")
case cfg.SenderAddress() == "":
return errors.New("email_from_address or email_username is required")
}
return nil
}
func SendMailWithCurrentSettings(message EmailMessage) error {
return SendMail(LoadEmailConfig(), message)
}
func SendMail(cfg EmailConfig, message EmailMessage) error {
if err := cfg.Validate(); err != nil {
return err
}
if len(message.To) == 0 {
return errors.New("at least one recipient is required")
}
if strings.TrimSpace(message.Subject) == "" {
return errors.New("subject is required")
}
if strings.TrimSpace(message.TextBody) == "" && strings.TrimSpace(message.HTMLBody) == "" {
return errors.New("message body is required")
}
payload, err := buildEmailMessage(cfg, message)
if err != nil {
return err
}
client, err := dialSMTP(cfg)
if err != nil {
return err
}
defer client.Close()
if cfg.Username != "" && cfg.Password != "" {
if ok, _ := client.Extension("AUTH"); !ok {
return errors.New("smtp server does not support authentication")
}
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
return err
}
}
if err := client.Mail(cfg.SenderAddress()); err != nil {
return err
}
for _, recipient := range message.To {
recipient = strings.TrimSpace(recipient)
if recipient == "" {
continue
}
if err := client.Rcpt(recipient); err != nil {
return err
}
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(payload); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
func dialSMTP(cfg EmailConfig) (*smtp.Client, error) {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
switch cfg.Encryption {
case "ssl":
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.Host})
if err != nil {
return nil, err
}
return smtp.NewClient(conn, cfg.Host)
case "tls":
client, err := smtp.Dial(addr)
if err != nil {
return nil, err
}
if ok, _ := client.Extension("STARTTLS"); !ok {
_ = client.Close()
return nil, errors.New("smtp server does not support STARTTLS")
}
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
default:
return smtp.Dial(addr)
}
}
func buildEmailMessage(cfg EmailConfig, message EmailMessage) ([]byte, error) {
fromAddress := cfg.SenderAddress()
if _, err := mail.ParseAddress(fromAddress); err != nil {
return nil, fmt.Errorf("invalid sender address: %w", err)
}
toAddresses := make([]string, 0, len(message.To))
for _, recipient := range message.To {
recipient = strings.TrimSpace(recipient)
if recipient == "" {
continue
}
if _, err := mail.ParseAddress(recipient); err != nil {
return nil, fmt.Errorf("invalid recipient address: %w", err)
}
toAddresses = append(toAddresses, recipient)
}
if len(toAddresses) == 0 {
return nil, errors.New("no valid recipients")
}
fromHeader := (&mail.Address{Name: cfg.FromName, Address: fromAddress}).String()
subjectHeader := mime.QEncoding.Encode("UTF-8", strings.TrimSpace(message.Subject))
var buf bytes.Buffer
writeHeader := func(key, value string) {
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(value)
buf.WriteString("\r\n")
}
writeHeader("From", fromHeader)
writeHeader("To", strings.Join(toAddresses, ", "))
writeHeader("Subject", subjectHeader)
writeHeader("MIME-Version", "1.0")
textBody := message.TextBody
htmlBody := message.HTMLBody
if strings.TrimSpace(htmlBody) != "" {
mw := multipart.NewWriter(&buf)
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, mw.Boundary()))
buf.WriteString("\r\n")
if strings.TrimSpace(textBody) == "" {
textBody = htmlBody
}
if err := writeMIMEPart(mw, "text/plain", textBody); err != nil {
return nil, err
}
if err := writeMIMEPart(mw, "text/html", htmlBody); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
writeHeader("Content-Type", `text/plain; charset="UTF-8"`)
writeHeader("Content-Transfer-Encoding", "quoted-printable")
buf.WriteString("\r\n")
qp := quotedprintable.NewWriter(&buf)
if _, err := qp.Write([]byte(textBody)); err != nil {
return nil, err
}
if err := qp.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeMIMEPart(mw *multipart.Writer, contentType string, body string) error {
header := textproto.MIMEHeader{}
header.Set("Content-Type", fmt.Sprintf(`%s; charset="UTF-8"`, contentType))
header.Set("Content-Transfer-Encoding", "quoted-printable")
part, err := mw.CreatePart(header)
if err != nil {
return err
}
qp := quotedprintable.NewWriter(part)
if _, err := qp.Write([]byte(body)); err != nil {
return err
}
return qp.Close()
}
func normalizeEmailEncryption(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "ssl":
return "ssl"
case "tls", "starttls":
return "tls"
default:
return ""
}
}