package lib import ( "bytes" "fmt" "html/template" "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 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" } m := &Mailer{ Host: host, Port: port, User: os.Getenv("SMTP_USER"), Pass: os.Getenv("SMTP_PASS"), From: from, 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 } // Send sends an email with the given HTML body. func (m *Mailer) Send(to, subject, htmlBody string) error { if !m.enabled { return nil // no-op } // Build email message msg := m.buildMessage(to, subject, htmlBody) // Connect and send addr := fmt.Sprintf("%s:%d", m.Host, m.Port) var auth smtp.Auth if m.User != "" && m.Pass != "" { auth = smtp.PlainAuth("", m.User, m.Pass, m.Host) } err := smtp.SendMail(addr, auth, m.From, []string{to}, msg) if err != nil { return fmt.Errorf("send mail to %s: %w", to, err) } return nil } // 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: Dealspace <%s>\r\n", 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 }