dealspace/lib/mailer.go

327 lines
7.9 KiB
Go

package lib
import (
"bytes"
"crypto/tls"
"fmt"
"html/template"
"log"
"net"
"net/smtp"
"os"
"path/filepath"
"strconv"
"strings"
)
// Mailer handles sending emails via SMTP.
type Mailer struct {
Host string
Port int
User string
Pass string
From string
FromName string
templates *template.Template
enabled bool
}
// NewMailer creates a new Mailer from environment variables.
// If SMTP_HOST is empty, returns a no-op mailer.
func NewMailer(cfg *Config) *Mailer {
host := os.Getenv("SMTP_HOST")
if host == "" {
return &Mailer{enabled: false}
}
portStr := os.Getenv("SMTP_PORT")
port := 587 // default
if portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
}
from := os.Getenv("SMTP_FROM")
if from == "" {
from = "noreply@muskepo.com"
}
fromName := os.Getenv("SMTP_FROM_NAME")
if fromName == "" {
fromName = "Dealspace"
}
m := &Mailer{
Host: host,
Port: port,
User: os.Getenv("SMTP_USER"),
Pass: os.Getenv("SMTP_PASS"),
From: from,
FromName: fromName,
enabled: true,
}
return m
}
// LoadTemplates loads email templates from the given directory.
// Templates should be in portal/emails/ directory.
func (m *Mailer) LoadTemplates(templateDir string) error {
if !m.enabled {
return nil
}
// Parse base template first, then all others
pattern := filepath.Join(templateDir, "*.html")
tmpl, err := template.New("").Funcs(emailFuncs()).ParseGlob(pattern)
if err != nil {
return fmt.Errorf("parse email templates: %w", err)
}
m.templates = tmpl
return nil
}
// emailFuncs returns template functions for email templates.
func emailFuncs() template.FuncMap {
return template.FuncMap{
"gt": func(a, b int) bool { return a > b },
"sub": func(a, b int) int { return a - b },
"truncate": func(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
},
}
}
// Enabled returns true if the mailer is configured and can send emails.
func (m *Mailer) Enabled() bool {
return m.enabled
}
// HARD WHITELIST — only these domains receive real emails. Everything else is silently dropped.
// DO NOT REMOVE THIS WITHOUT JOHAN'S EXPLICIT APPROVAL.
func emailAllowed(to string) bool {
to = strings.ToLower(strings.TrimSpace(to))
return strings.HasSuffix(to, "@muskepo.com") || strings.HasSuffix(to, "@jongsma.me")
}
// Send sends an email with the given HTML body using explicit STARTTLS.
func (m *Mailer) Send(to, subject, htmlBody string) error {
if !m.enabled {
return nil // no-op
}
if !emailAllowed(to) {
log.Printf("MAIL BLOCKED (not whitelisted): to=%s subject=%s", to, subject)
return nil
}
log.Printf("MAIL SENDING: to=%s subject=%s", to, subject)
msg := m.buildMessage(to, subject, htmlBody)
addr := fmt.Sprintf("%s:%d", m.Host, m.Port)
conn, err := net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, m.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer client.Close()
if err = client.StartTLS(&tls.Config{ServerName: m.Host}); err != nil {
return fmt.Errorf("starttls: %w", err)
}
if m.User != "" && m.Pass != "" {
if err = client.Auth(smtp.PlainAuth("", m.User, m.Pass, m.Host)); err != nil {
return fmt.Errorf("auth: %w", err)
}
}
if err = client.Mail(m.From); err != nil {
return fmt.Errorf("mail from: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("rcpt to %s: %w", to, err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
if _, err = w.Write(msg); err != nil {
return fmt.Errorf("write: %w", err)
}
return w.Close()
}
// SendTemplate renders a template and sends it as an email.
func (m *Mailer) SendTemplate(to, subject, tmplName string, data any) error {
if !m.enabled {
return nil // no-op
}
if m.templates == nil {
return fmt.Errorf("templates not loaded")
}
// Render template
var buf bytes.Buffer
if err := m.templates.ExecuteTemplate(&buf, tmplName, data); err != nil {
return fmt.Errorf("render template %s: %w", tmplName, err)
}
return m.Send(to, subject, buf.String())
}
// RenderTemplate renders a template to a string without sending.
// Useful for testing and previewing emails.
func (m *Mailer) RenderTemplate(tmplName string, data any) (string, error) {
if m.templates == nil {
return "", fmt.Errorf("templates not loaded")
}
var buf bytes.Buffer
if err := m.templates.ExecuteTemplate(&buf, tmplName, data); err != nil {
return "", fmt.Errorf("render template %s: %w", tmplName, err)
}
return buf.String(), nil
}
// buildMessage constructs an RFC 2822 email message.
func (m *Mailer) buildMessage(to, subject, htmlBody string) []byte {
var buf bytes.Buffer
// Headers
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
buf.WriteString(fmt.Sprintf("From: %s <%s>\r\n", m.FromName, m.From))
buf.WriteString(fmt.Sprintf("To: %s\r\n", to))
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", m.encodeSubject(subject)))
buf.WriteString("\r\n")
// Body
buf.WriteString(htmlBody)
return buf.Bytes()
}
// encodeSubject encodes the subject line for non-ASCII characters.
func (m *Mailer) encodeSubject(subject string) string {
// Check if subject contains non-ASCII
for _, r := range subject {
if r > 127 {
// Use RFC 2047 encoding
return "=?UTF-8?B?" + base64Encode(subject) + "?="
}
}
return subject
}
// base64Encode encodes a string to base64.
func base64Encode(s string) string {
const base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
data := []byte(s)
var result strings.Builder
for i := 0; i < len(data); i += 3 {
var n uint32
remain := len(data) - i
if remain >= 3 {
n = uint32(data[i])<<16 | uint32(data[i+1])<<8 | uint32(data[i+2])
result.WriteByte(base64Chars[n>>18&63])
result.WriteByte(base64Chars[n>>12&63])
result.WriteByte(base64Chars[n>>6&63])
result.WriteByte(base64Chars[n&63])
} else if remain == 2 {
n = uint32(data[i])<<16 | uint32(data[i+1])<<8
result.WriteByte(base64Chars[n>>18&63])
result.WriteByte(base64Chars[n>>12&63])
result.WriteByte(base64Chars[n>>6&63])
result.WriteByte('=')
} else {
n = uint32(data[i]) << 16
result.WriteByte(base64Chars[n>>18&63])
result.WriteByte(base64Chars[n>>12&63])
result.WriteString("==")
}
}
return result.String()
}
// ---- Email Data Structures ----
// InviteData is the data for invite.html template.
type InviteData struct {
InviterName string
InviterOrg string
ProjectName string
InviteURL string
RecipientName string
Role string
ExpiresIn string
}
// TasksAssignedData is the data for tasks_assigned.html template.
type TasksAssignedData struct {
RecipientName string
ProjectName string
Count int
Tasks []TaskItem
TasksURL string
}
// TaskItem represents a single task in the tasks list.
type TaskItem struct {
Title string
DueDate string
Priority string
}
// AnswerSubmittedData is the data for answer_submitted.html template.
type AnswerSubmittedData struct {
RecipientName string
AnswererName string
RequestTitle string
WorkstreamName string
AnswerPreview string
ReviewURL string
}
// AnswerApprovedData is the data for answer_approved.html template.
type AnswerApprovedData struct {
RecipientName string
RequestTitle string
Published bool
DataRoomURL string
}
// AnswerRejectedData is the data for answer_rejected.html template.
type AnswerRejectedData struct {
RecipientName string
RequestTitle string
Reason string
RequestURL string
}
// RequestForwardedData is the data for request_forwarded.html template.
type RequestForwardedData struct {
RecipientName string
SenderName string
RequestTitle string
RequestURL string
DueDate string
HasDueDate bool
}
// WelcomeData is the data for welcome.html template.
type WelcomeData struct {
RecipientName string
TasksURL string
}