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}) } } defaultBccs := []string{"carpis@cmctechnologies.com.au"} bccs = append(defaultBccs, bccs...) 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(", "))) }