go-quasar-partial-ssr/backend/internal/mail/service.go

234 lines
5.9 KiB
Go

package mail
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"html/template"
"net"
"net/smtp"
"os"
"path/filepath"
"strings"
texttemplate "text/template"
"time"
)
type Config struct {
AppName string
Mode string
From string
DebugDir string
TemplatesDir string
FrontendBaseURL string
ResetPasswordPath string
SMTP SMTPConfig
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
}
type Message struct {
To string
Subject string
Template string
TemplateData any
}
type Service struct {
cfg Config
}
type TemplateData struct {
AppName string
UserName string
UserEmail string
ResetURL string
ResetToken string
}
func New(cfg Config) (*Service, error) {
if cfg.Mode != "smtp" && cfg.Mode != "file" {
return nil, fmt.Errorf("unsupported mail mode %q", cfg.Mode)
}
if cfg.From == "" {
return nil, fmt.Errorf("mail sender is required")
}
if cfg.AppName == "" {
cfg.AppName = "Application"
}
if cfg.TemplatesDir == "" {
return nil, fmt.Errorf("mail templates directory is required")
}
if cfg.Mode == "file" {
if cfg.DebugDir == "" {
return nil, fmt.Errorf("mail debug directory is required")
}
if err := os.MkdirAll(cfg.DebugDir, 0o755); err != nil {
return nil, fmt.Errorf("create mail debug directory: %w", err)
}
}
if cfg.Mode == "smtp" {
if cfg.SMTP.Host == "" || cfg.SMTP.Port <= 0 {
return nil, fmt.Errorf("smtp host and port are required")
}
}
return &Service{cfg: cfg}, nil
}
func (s *Service) Send(ctx context.Context, msg Message) error {
htmlBody, textBody, err := s.renderBodies(msg.Template, msg.TemplateData)
if err != nil {
return err
}
rawMessage := buildMessage(s.cfg.From, msg.To, msg.Subject, textBody, htmlBody)
switch s.cfg.Mode {
case "smtp":
return s.sendSMTP(ctx, msg.To, rawMessage)
case "file":
return s.writeDebugMail(msg.To, msg.Subject, rawMessage)
default:
return fmt.Errorf("unsupported mail mode %q", s.cfg.Mode)
}
}
func (s *Service) ResetLink(token string) string {
base := strings.TrimRight(s.cfg.FrontendBaseURL, "/")
path := s.cfg.ResetPasswordPath
if path == "" {
path = "/#reset-password"
}
if token == "" {
return base + path
}
if base == "" {
return path + "?token=" + token
}
return base + path + "?token=" + token
}
func (s *Service) AppName() string {
return s.cfg.AppName
}
func (s *Service) renderBodies(templateName string, data any) (string, string, error) {
htmlPath := filepath.Join(s.cfg.TemplatesDir, templateName+".html.tmpl")
textPath := filepath.Join(s.cfg.TemplatesDir, templateName+".txt.tmpl")
htmlTpl, err := template.ParseFiles(htmlPath)
if err != nil {
return "", "", fmt.Errorf("parse html template %s: %w", htmlPath, err)
}
textTpl, err := texttemplate.ParseFiles(textPath)
if err != nil {
return "", "", fmt.Errorf("parse text template %s: %w", textPath, err)
}
var htmlBuf bytes.Buffer
if err := htmlTpl.Execute(&htmlBuf, data); err != nil {
return "", "", fmt.Errorf("execute html template %s: %w", htmlPath, err)
}
var textBuf bytes.Buffer
if err := textTpl.Execute(&textBuf, data); err != nil {
return "", "", fmt.Errorf("execute text template %s: %w", textPath, err)
}
return htmlBuf.String(), textBuf.String(), nil
}
func buildMessage(from, to, subject, textBody, htmlBody string) []byte {
boundary := fmt.Sprintf("mixed-%d", time.Now().UnixNano())
headers := []string{
"MIME-Version: 1.0",
fmt.Sprintf("From: %s", from),
fmt.Sprintf("To: %s", to),
fmt.Sprintf("Subject: %s", subject),
fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q", boundary),
"",
}
body := []string{
fmt.Sprintf("--%s", boundary),
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
textBody,
fmt.Sprintf("--%s", boundary),
"Content-Type: text/html; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
htmlBody,
fmt.Sprintf("--%s--", boundary),
"",
}
return []byte(strings.Join(append(headers, body...), "\r\n"))
}
func (s *Service) sendSMTP(ctx context.Context, to string, raw []byte) error {
addr := fmt.Sprintf("%s:%d", s.cfg.SMTP.Host, s.cfg.SMTP.Port)
dialer := &net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("dial smtp server: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.SMTP.Host)
if err != nil {
return fmt.Errorf("create smtp client: %w", err)
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
tlsCfg := &tls.Config{ServerName: s.cfg.SMTP.Host}
if err := client.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if s.cfg.SMTP.Username != "" {
auth := smtp.PlainAuth("", s.cfg.SMTP.Username, s.cfg.SMTP.Password, s.cfg.SMTP.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := client.Mail(s.cfg.From); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := wc.Write(raw); err != nil {
_ = wc.Close()
return fmt.Errorf("write smtp message: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("close smtp message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("smtp quit: %w", err)
}
return nil
}
func (s *Service) writeDebugMail(to, subject string, raw []byte) error {
safeRecipient := strings.NewReplacer("@", "_at_", "/", "_", "\\", "_", ":", "_", " ", "_").Replace(to)
filename := fmt.Sprintf("%d_%s.eml", time.Now().UnixNano(), safeRecipient)
path := filepath.Join(s.cfg.DebugDir, filename)
if err := os.WriteFile(path, raw, 0o644); err != nil {
return fmt.Errorf("write debug mail %s: %w", path, err)
}
_ = subject
return nil
}