From d898149810443b26e10a9d06b3f3f2481965d066 Mon Sep 17 00:00:00 2001 From: Finley Ghosh Date: Thu, 4 Dec 2025 00:35:03 +1100 Subject: [PATCH] Adding manual email templates, adding support for pdf attachments in emails, attaching quote to reminder if possible --- go/internal/cmc/email/email.go | 108 +++++++++++-- go/internal/cmc/handlers/quotes/quotes.go | 146 +++++++++++++++--- .../cmc/handlers/quotes/quotes_test.go | 28 ++-- .../quotes/manual_final_reminder.html | 12 ++ .../quotes/manual_first_reminder.html | 15 ++ .../quotes/manual_second_reminder.html | 12 ++ 6 files changed, 269 insertions(+), 52 deletions(-) create mode 100644 go/templates/quotes/manual_final_reminder.html create mode 100644 go/templates/quotes/manual_first_reminder.html create mode 100644 go/templates/quotes/manual_second_reminder.html diff --git a/go/internal/cmc/email/email.go b/go/internal/cmc/email/email.go index 4f03580b..03e6ef92 100644 --- a/go/internal/cmc/email/email.go +++ b/go/internal/cmc/email/email.go @@ -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...) diff --git a/go/internal/cmc/handlers/quotes/quotes.go b/go/internal/cmc/handlers/quotes/quotes.go index f3a87c82..9fb3d62a 100644 --- a/go/internal/cmc/handlers/quotes/quotes.go +++ b/go/internal/cmc/handlers/quotes/quotes.go @@ -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 { diff --git a/go/internal/cmc/handlers/quotes/quotes_test.go b/go/internal/cmc/handlers/quotes/quotes_test.go index a85abf93..9ba3adfc 100644 --- a/go/internal/cmc/handlers/quotes/quotes_test.go +++ b/go/internal/cmc/handlers/quotes/quotes_test.go @@ -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) } diff --git a/go/templates/quotes/manual_final_reminder.html b/go/templates/quotes/manual_final_reminder.html new file mode 100644 index 00000000..0b784d52 --- /dev/null +++ b/go/templates/quotes/manual_final_reminder.html @@ -0,0 +1,12 @@ +

Dear {{.CustomerName}},

+ +

We're getting in touch regarding the quotation issued on {{.SubmissionDate}}, reference {{.QuoteRef}}, expiring {{.ExpiryDate}}. As we haven't received a response, we wanted to reach out one final time.

+ +

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.

+ +

If you have any outstanding questions or would like to initiate a new quote, simply reply to this email — we're here to help.

+ +

Thank you again for your time and consideration.

+ +

Warm regards,

+

CMC Technologies

diff --git a/go/templates/quotes/manual_first_reminder.html b/go/templates/quotes/manual_first_reminder.html new file mode 100644 index 00000000..01a51de4 --- /dev/null +++ b/go/templates/quotes/manual_first_reminder.html @@ -0,0 +1,15 @@ + + +

Dear {{.CustomerName}},

+ +

We'd like to remind you that the quote we provided on {{.SubmissionDate}}, reference {{.QuoteRef}}, will expire on {{.ExpiryDate}}.

+ +

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.

+ +

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.

+ +

Thank you for time and consideration. We look forward to the opportunity to work with you.

+

Warm regards,

+

CMC Technologies

+ + diff --git a/go/templates/quotes/manual_second_reminder.html b/go/templates/quotes/manual_second_reminder.html new file mode 100644 index 00000000..970dc608 --- /dev/null +++ b/go/templates/quotes/manual_second_reminder.html @@ -0,0 +1,12 @@ +

Dear {{.CustomerName}},

+ +

We're following up on the quotation submitted on {{.SubmissionDate}}, reference {{.QuoteRef}}, 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.

+ +

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.

+ +

If you have any questions or feedback regarding the original quotation, please don't hesitate to let us know.

+ +

Thank you again for your time and consideration. We'd be glad to support you whenever you're ready.

+ +

Warm regards,

+

CMC Technologies