Files
BagExchange/smtp.go
2026-02-19 20:08:06 +01:00

137 lines
3.5 KiB
Go

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" +
"To: " + recipientEmail + "\n" +
"Subject: " + subject + "\n" +
"GeneratedAtUTC: " + time.Now().UTC().Format(time.RFC3339) + "\n" +
"-->\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
}