288 lines
7.0 KiB
Go
288 lines
7.0 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": map[string]any{
|
|
"address": cfg.SenderAddress(),
|
|
"name": cfg.FromName,
|
|
},
|
|
"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 ""
|
|
}
|
|
}
|