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 }