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 "" } }