cmc-sales/go/internal/cmc/email/email.go

258 lines
7.1 KiB
Go
Raw Normal View History

package email
import (
"bytes"
"crypto/tls"
"encoding/base64"
"fmt"
"html/template"
"io"
"net/smtp"
"os"
"strconv"
"sync"
)
// Attachment represents an email attachment
type Attachment struct {
Filename string
FilePath string
}
var (
emailServiceInstance *EmailService
once sync.Once
)
// EmailService provides methods to send templated emails via SMTP.
type EmailService struct {
SMTPHost string
SMTPPort int
Username string
Password string
FromAddress string
}
// GetEmailService returns a singleton EmailService loaded from environment variables
func GetEmailService() *EmailService {
once.Do(func() {
host := os.Getenv("SMTP_HOST")
portStr := os.Getenv("SMTP_PORT")
port, err := strconv.Atoi(portStr)
if err != nil {
port = 25 // default SMTP port
}
username := os.Getenv("SMTP_USER")
password := os.Getenv("SMTP_PASS")
from := os.Getenv("SMTP_FROM")
emailServiceInstance = &EmailService{
SMTPHost: host,
SMTPPort: port,
Username: username,
Password: password,
FromAddress: from,
}
})
return emailServiceInstance
}
// SendTemplateEmail renders a template and sends an email with optional CC and BCC.
func (es *EmailService) SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error {
return es.SendTemplateEmailWithAttachments(to, subject, templateName, data, ccs, bccs, nil)
}
// SendTemplateEmailWithAttachments renders a template and sends an email with optional CC, BCC, and attachments.
func (es *EmailService) SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error {
// Convert interface{} attachments to []Attachment
var typedAttachments []Attachment
for _, att := range attachments {
if a, ok := att.(Attachment); ok {
typedAttachments = append(typedAttachments, a)
} else if a, ok := att.(struct{ Filename, FilePath string }); ok {
typedAttachments = append(typedAttachments, Attachment{Filename: a.Filename, FilePath: a.FilePath})
}
}
2025-12-02 03:08:33 -08:00
defaultBccs := []string{"carpis@cmctechnologies.com.au"}
bccs = append(defaultBccs, bccs...)
2025-08-10 05:35:26 -07:00
const templateDir = "templates/quotes"
tmplPath := fmt.Sprintf("%s/%s", templateDir, templateName)
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
var body bytes.Buffer
if err := tmpl.Execute(&body, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
var msg bytes.Buffer
// If there are attachments, use multipart message
if len(typedAttachments) > 0 {
boundary := "boundary123456789"
// Write headers
fmt.Fprintf(&msg, "From: %s\r\n", es.FromAddress)
fmt.Fprintf(&msg, "To: %s\r\n", to)
if len(ccs) > 0 {
fmt.Fprintf(&msg, "Cc: %s\r\n", joinAddresses(ccs))
}
fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n", boundary)
msg.WriteString("\r\n")
// Write HTML body part
fmt.Fprintf(&msg, "--%s\r\n", boundary)
msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
msg.WriteString("\r\n")
msg.Write(body.Bytes())
msg.WriteString("\r\n")
// Write attachments
for _, att := range typedAttachments {
file, err := os.Open(att.FilePath)
if err != nil {
return fmt.Errorf("failed to open attachment %s: %w", att.FilePath, err)
}
fileData, err := io.ReadAll(file)
file.Close()
if err != nil {
return fmt.Errorf("failed to read attachment %s: %w", att.FilePath, err)
}
fmt.Fprintf(&msg, "--%s\r\n", boundary)
fmt.Fprintf(&msg, "Content-Type: application/pdf\r\n")
fmt.Fprintf(&msg, "Content-Transfer-Encoding: base64\r\n")
fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n", att.Filename)
msg.WriteString("\r\n")
encoded := base64.StdEncoding.EncodeToString(fileData)
// Split into lines of 76 characters for proper MIME formatting
for i := 0; i < len(encoded); i += 76 {
end := i + 76
if end > len(encoded) {
end = len(encoded)
}
msg.WriteString(encoded[i:end])
msg.WriteString("\r\n")
}
}
fmt.Fprintf(&msg, "--%s--\r\n", boundary)
} else {
// Simple message without attachments
headers := make(map[string]string)
headers["From"] = es.FromAddress
headers["To"] = to
if len(ccs) > 0 {
headers["Cc"] = joinAddresses(ccs)
}
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=\"UTF-8\""
for k, v := range headers {
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
}
msg.WriteString("\r\n")
msg.Write(body.Bytes())
}
recipients := []string{to}
recipients = append(recipients, ccs...)
recipients = append(recipients, bccs...)
smtpAddr := fmt.Sprintf("%s:%d", es.SMTPHost, es.SMTPPort)
// If no username/password, assume no auth or TLS (e.g., MailHog)
if es.Username == "" && es.Password == "" {
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
auth := smtp.PlainAuth("", es.Username, es.Password, es.SMTPHost)
// Establish connection to SMTP server
c, err := smtp.Dial(smtpAddr)
if err != nil {
return fmt.Errorf("failed to dial SMTP server: %w", err)
}
defer c.Close()
// Upgrade to TLS if supported (STARTTLS)
tlsconfig := &tls.Config{
ServerName: es.SMTPHost,
}
if ok, _ := c.Extension("STARTTLS"); ok {
if err = c.StartTLS(tlsconfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
if err = c.Mail(es.FromAddress); err != nil {
return fmt.Errorf("failed to set from address: %w", err)
}
for _, addr := range recipients {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("failed to add recipient %s: %w", addr, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = w.Write(msg.Bytes())
if err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
return c.Quit()
}
// joinAddresses joins email addresses with a comma and space.
func joinAddresses(addrs []string) string {
return fmt.Sprintf("%s", bytes.Join([][]byte(func() [][]byte {
b := make([][]byte, len(addrs))
for i, a := range addrs {
b[i] = []byte(a)
}
return b
}()), []byte(", ")))
}