313 lines
7.4 KiB
Go
313 lines
7.4 KiB
Go
package lib
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"html/template"
|
|
"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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|