package main import ( "bytes" "fmt" "html/template" "net/smtp" "os" "path/filepath" "strconv" "strings" "time" ) type smtpConfig struct { Host string Port int User string Password string } func getSMTPConfigFromEnv() smtpConfig { port := 587 if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" { if p, err := strconv.Atoi(rawPort); err == nil { port = p } } return smtpConfig{ Host: strings.TrimSpace(os.Getenv("SMTP")), Port: port, User: strings.TrimSpace(os.Getenv("SMTP_USER")), Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), } } func (s smtpConfig) isConfigured() bool { return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0 } func getAppBaseURL() string { base := strings.TrimSpace(os.Getenv("SITE_URL")) if base == "" { base = strings.TrimSpace(os.Getenv("APP_BASE_URL")) } if base == "" { return "http://localhost:6081" } return strings.TrimRight(base, "/") } func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error { body, err := renderTransactionalTemplate("password_reset.html", map[string]string{ "ActionURL": resetURL, }) if err != nil { return err } return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body) } func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error { body, err := renderTransactionalTemplate("verify_email.html", map[string]string{ "ActionURL": verifyURL, }) if err != nil { return err } return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body) } func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error { if isDevEmailMode() { return saveDevEmail(recipientEmail, subject, htmlBody) } auth := smtp.PlainAuth("", config.User, config.Password, config.Host) message := "From: " + config.User + "\r\n" + "To: " + recipientEmail + "\r\n" + "Subject: " + subject + "\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + htmlBody return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message)) } func renderTransactionalTemplate(templateName string, data any) (string, error) { tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName)) if err != nil { return "", err } var out bytes.Buffer if err := tmpl.Execute(&out, data); err != nil { return "", err } return out.String(), nil } func isDevEmailMode() bool { return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev") } func saveDevEmail(recipientEmail, subject, htmlBody string) error { const dir = "devEmails" if err := os.MkdirAll(dir, 0o755); err != nil { return err } filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject)) path := filepath.Join(dir, filename) payload := "\n" + htmlBody return os.WriteFile(path, []byte(payload), 0o644) } func sanitizeFilename(value string) string { var b strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(value)) { switch { case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): b.WriteRune(r) case r == ' ' || r == '-' || r == '_': b.WriteRune('_') } } name := strings.Trim(b.String(), "_") if name == "" { return "email" } return name }