Adding manual email templates, adding support for pdf attachments in emails, attaching quote to reminder if possible

This commit is contained in:
Finley Ghosh 2025-12-04 00:35:03 +11:00
parent 8dbf221908
commit d898149810
6 changed files with 269 additions and 52 deletions

View file

@ -3,14 +3,22 @@ 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
@ -50,6 +58,20 @@ func GetEmailService() *EmailService {
// 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...)
@ -65,22 +87,80 @@ func (es *EmailService) SendTemplateEmail(to string, subject string, templateNam
return fmt.Errorf("failed to execute template: %w", err)
}
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\""
var msg bytes.Buffer
for k, v := range headers {
fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
// 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())
}
msg.WriteString("\r\n")
msg.Write(body.Bytes())
recipients := []string{to}
recipients = append(recipients, ccs...)

View file

@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
@ -181,6 +182,7 @@ type QuoteQueries interface {
type EmailSender interface {
SendTemplateEmail(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string) error
SendTemplateEmailWithAttachments(to string, subject string, templateName string, data interface{}, ccs []string, bccs []string, attachments []interface{}) error
}
type QuotesHandler struct {
@ -317,6 +319,20 @@ func getReminderDetails(reminderType QuoteReminderType) (subject, template strin
}
}
// getReminderDetailsManual returns the subject and template for a manually sent reminder (generic templates without time references)
func getReminderDetailsManual(reminderType QuoteReminderType) (subject, template string) {
switch reminderType {
case FirstReminder:
return "Reminder: Quote %s Expires Soon", "manual_first_reminder.html"
case SecondReminder:
return "Follow-Up: Quote %s Expired", "manual_second_reminder.html"
case ThirdReminder:
return "Final Reminder: Quote %s Closed", "manual_final_reminder.html"
default:
return "", ""
}
}
// formatQuoteDates formats ISO date strings to DD/MM/YYYY format
func formatQuoteDates(dateIssuedStr, validUntilStr string) (submissionDate, expiryDate string) {
if dateIssuedStr != "" {
@ -398,6 +414,7 @@ func (h *QuotesHandler) DailyQuoteExpirationCheck() {
templateData,
ccs,
nil,
false,
)
if err != nil {
fmt.Printf("Error sending %s for quote %v: %v\n", job.ReminderType.String(), q["ID"], err)
@ -409,7 +426,7 @@ func (h *QuotesHandler) DailyQuoteExpirationCheck() {
}
// SendQuoteReminderEmail checks if a reminder of the given type has already been sent for the quote, sends the email if not, and records it.
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string) error {
func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, allowDuplicate bool) error {
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
@ -423,22 +440,24 @@ func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int3
return fmt.Errorf("invalid reminder type: %v", reminderType)
}
// Check if reminder already sent
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
})
if err != nil {
return fmt.Errorf("failed to check existing reminders: %w", err)
}
// Check if reminder already sent (only if duplicates not allowed)
if !allowDuplicate {
reminders, err := h.queries.GetQuoteRemindersByType(ctx, db.GetQuoteRemindersByTypeParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
})
if err != nil {
return fmt.Errorf("failed to check existing reminders: %w", err)
}
// Exit if the email has already been sent
if len(reminders) > 0 {
return nil
// Exit if the email has already been sent
if len(reminders) > 0 {
return nil
}
}
// Send the email
err = h.emailService.SendTemplateEmail(
err := h.emailService.SendTemplateEmail(
recipient,
subject,
templateName,
@ -468,6 +487,71 @@ func (h *QuotesHandler) SendQuoteReminderEmail(ctx context.Context, quoteID int3
return nil
}
// SendQuoteReminderEmailWithAttachment is like SendQuoteReminderEmail but includes a PDF attachment
func (h *QuotesHandler) SendQuoteReminderEmailWithAttachment(ctx context.Context, quoteID int32, reminderType QuoteReminderType, recipient string, subject string, templateName string, templateData map[string]interface{}, ccs []string, username *string, pdfPath string, pdfFilename string) error {
// Safeguard: check for valid recipient
if strings.TrimSpace(recipient) == "" {
return fmt.Errorf("recipient email is required")
}
// Safeguard: check for valid template data
if templateData == nil {
return fmt.Errorf("template data is required")
}
// Safeguard: check for valid reminder type
if reminderType != FirstReminder && reminderType != SecondReminder && reminderType != ThirdReminder {
return fmt.Errorf("invalid reminder type: %v", reminderType)
}
// Check if PDF exists and prepare attachment if found
var attachments []interface{}
if _, err := os.Stat(pdfPath); err == nil {
// PDF exists, attach it
type Attachment struct {
Filename string
FilePath string
}
attachments = []interface{}{
Attachment{
Filename: pdfFilename,
FilePath: pdfPath,
},
}
}
// If PDF doesn't exist, just send email without attachment
// Send the email (with or without attachment)
err := h.emailService.SendTemplateEmailWithAttachments(
recipient,
subject,
templateName,
templateData,
ccs,
nil,
attachments,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
// Record the reminder
var user sql.NullString
if username != nil {
user = sql.NullString{String: *username, Valid: true}
} else {
user = sql.NullString{Valid: false}
}
_, err = h.queries.InsertQuoteReminder(ctx, db.InsertQuoteReminderParams{
QuoteID: quoteID,
ReminderType: int32(reminderType),
DateSent: time.Now().UTC(),
Username: user,
})
if err != nil {
return fmt.Errorf("failed to record reminder: %w", err)
}
return nil
}
// SendManualReminder handles POST requests to manually send a quote reminder
func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@ -515,22 +599,34 @@ func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Reques
return
}
// Use shared function to prepare email data
subject, template, templateData, ccs := prepareReminderEmail(
reminderType,
customerName,
dateIssuedStr,
validUntilStr,
enquiryRef,
userEmail,
)
// Use manual template for manually sent reminders (generic without time references)
subject, template := getReminderDetailsManual(reminderType)
subject = fmt.Sprintf(subject, enquiryRef)
submissionDate, expiryDate := formatQuoteDates(dateIssuedStr, validUntilStr)
templateData := map[string]interface{}{
"CustomerName": customerName,
"SubmissionDate": submissionDate,
"ExpiryDate": expiryDate,
"QuoteRef": enquiryRef,
}
ccs := []string{"sales@cmctechnologies.com.au"}
if userEmail != "" {
ccs = append(ccs, userEmail)
}
// Attach PDF quote
pdfPath := fmt.Sprintf("webroot/pdf/%d.pdf", quoteID)
pdfFilename := fmt.Sprintf("%s.pdf", enquiryRef)
// Get username from request
username := getUsername(r)
usernamePtr := &username
// Send the reminder
err = h.SendQuoteReminderEmail(
// Send the reminder with attachment
err = h.SendQuoteReminderEmailWithAttachment(
r.Context(),
int32(quoteID),
reminderType,
@ -540,6 +636,8 @@ func (h *QuotesHandler) SendManualReminder(w http.ResponseWriter, r *http.Reques
templateData,
ccs,
usernamePtr,
pdfPath,
pdfFilename,
)
if err != nil {

View file

@ -143,7 +143,7 @@ func TestSendQuoteReminderEmail_OnDesignatedDay(t *testing.T) {
emailService: me,
}
// Simulate designated day logic by calling SendQuoteReminderEmail
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
@ -164,7 +164,7 @@ func TestSendQuoteReminderEmail_AlreadyReminded(t *testing.T) {
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err == nil {
t.Error("Expected error for already sent reminder")
}
@ -186,7 +186,7 @@ func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
emailService: me,
}
// First call should succeed
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil)
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err1 != nil {
t.Fatalf("Expected first call to succeed, got %v", err1)
}
@ -197,7 +197,7 @@ func TestSendQuoteReminderEmail_OnlyOnce(t *testing.T) {
t.Errorf("Expected 1 reminder recorded in DB, got %d", len(mq.reminders))
}
// Second call should fail
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil)
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err2 == nil {
t.Error("Expected error for already sent reminder on second call")
}
@ -215,7 +215,7 @@ func TestSendQuoteReminderEmail_SendsIfNotAlreadySent(t *testing.T) {
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
@ -236,7 +236,7 @@ func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
queries: mq,
emailService: me,
}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err == nil {
t.Error("Expected error for already sent reminder")
}
@ -252,7 +252,7 @@ func TestSendQuoteReminderEmail_IgnoresIfAlreadySent(t *testing.T) {
// Description: Simulates a DB error and expects the handler to return an error. Should fail if DB errors are not handled.
func TestSendQuoteReminderEmail_DBError(t *testing.T) {
h := &QuotesHandler{queries: &mockQueriesError{}}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil {
t.Error("Expected error when DB fails, got nil")
}
@ -264,7 +264,7 @@ func TestSendQuoteReminderEmail_DBError(t *testing.T) {
func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil {
t.Error("Expected error for nil recipient, got nil")
}
@ -276,7 +276,7 @@ func TestSendQuoteReminderEmail_NilRecipient(t *testing.T) {
func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", nil, nil, nil, false)
if err == nil {
t.Error("Expected error for missing template data, got nil")
}
@ -288,7 +288,7 @@ func TestSendQuoteReminderEmail_MissingTemplateData(t *testing.T) {
func TestSendQuoteReminderEmail_InvalidReminderType(t *testing.T) {
mq := &mockQueries{reminders: []db.QuoteReminder{}}
h := &QuotesHandler{queries: mq}
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil)
err := h.SendQuoteReminderEmail(context.Background(), 123, 99, "test@example.com", "Subject", "template", map[string]interface{}{}, nil, nil, false)
if err == nil {
t.Error("Expected error for invalid reminder type, got nil")
}
@ -302,7 +302,7 @@ func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
h := &QuotesHandler{queries: mq, emailService: me}
// First reminder type
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil)
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err1 != nil {
t.Fatalf("Expected first reminder to be sent, got %v", err1)
}
@ -314,7 +314,7 @@ func TestSendQuoteReminderEmail_MultipleTypes(t *testing.T) {
}
// Second reminder type
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil, nil)
err2 := h.SendQuoteReminderEmail(context.Background(), 123, 2, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 2}, nil, nil, false)
if err2 != nil {
t.Fatalf("Expected second reminder to be sent, got %v", err2)
}
@ -334,7 +334,7 @@ func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
h := &QuotesHandler{queries: mq, emailService: me}
// Send reminder for quote 123
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil)
err1 := h.SendQuoteReminderEmail(context.Background(), 123, 1, "test@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 123, "ReminderType": 1}, nil, nil, false)
if err1 != nil {
t.Fatalf("Expected reminder for quote 123 to be sent, got %v", err1)
}
@ -346,7 +346,7 @@ func TestSendQuoteReminderEmail_DifferentQuotes(t *testing.T) {
}
// Send reminder for quote 456
err2 := h.SendQuoteReminderEmail(context.Background(), 456, 1, "test2@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 456, "ReminderType": 1}, nil, nil)
err2 := h.SendQuoteReminderEmail(context.Background(), 456, 1, "test2@example.com", "Subject", "template", map[string]interface{}{"QuoteID": 456, "ReminderType": 1}, nil, nil, false)
if err2 != nil {
t.Fatalf("Expected reminder for quote 456 to be sent, got %v", err2)
}

View file

@ -0,0 +1,12 @@
<p>Dear {{.CustomerName}},</p>
<p>We're getting in touch regarding the quotation issued on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, expiring {{.ExpiryDate}}. As we haven't received a response, we wanted to reach out one final time.</p>
<p>This quotation is no longer valid, and we'll consider the proposal closed unless we hear otherwise. If you're still interested in moving forward, we'd be happy to provide a new quote tailored to your current requirements.</p>
<p>If you have any outstanding questions or would like to initiate a new quote, simply reply to this email — we're here to help.</p>
<p>Thank you again for your time and consideration.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>

View file

@ -0,0 +1,15 @@
<html>
<body>
<p>Dear {{.CustomerName}},</p>
<p>We'd like to remind you that the quote we provided on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, will expire on {{.ExpiryDate}}.</p>
<p>If you require more time, we're happy to help — simply reply to this email to request an extension or a refreshed version of the quote.</p>
<p>Should you have any questions or need further clarification, please don't hesitate to get in touch. We're here to support you and ensure everything is in order.</p>
<p>Thank you for time and consideration. We look forward to the opportunity to work with you.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>
</body>
</html>

View file

@ -0,0 +1,12 @@
<p>Dear {{.CustomerName}},</p>
<p>We're following up on the quotation submitted on {{.SubmissionDate}}, reference <strong>{{.QuoteRef}}</strong>, expiring {{.ExpiryDate}}. As we haven't heard back, we wanted to check whether you're still considering this proposal or if your requirements have changed.</p>
<p>If you'd like an updated or revised quote tailored to your current needs, simply reply to this email — we're more than happy to assist.</p>
<p>If you have any questions or feedback regarding the original quotation, please don't hesitate to let us know.</p>
<p>Thank you again for your time and consideration. We'd be glad to support you whenever you're ready.</p>
<p>Warm regards,</p>
<p>CMC Technologies</p>