2
0
Files
Royal-Pop-Accessory/Backend/internal/mailer/resend.go
T
2026-06-23 18:50:37 +01:00

1066 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mailer
import (
"bytes"
"context"
"encoding/json"
"fmt"
htmltemplate "html/template"
"io"
"net/http"
"net/url"
"strings"
texttemplate "text/template"
"time"
"royal-pop-backend/internal/config"
"royal-pop-backend/internal/orders"
)
const resendEndpoint = "https://api.resend.com/emails"
type ResendMailer struct {
apiKey string
from string
replyTo string
storefrontURL string
httpClient *http.Client
}
type resendEmailRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
}
type shippedEmailTemplateData struct {
FirstName string
OrderID string
ShippedDate string
ShippingCarrier string
TrackingNumber string
TrackingURL string
OrderImageURL string
OrderImageAlt string
Items []string
StorefrontURL string
}
type cancelledEmailTemplateData struct {
FirstName string
OrderID string
CancelledDate string
RefundAmount string
OrderImageURL string
OrderImageAlt string
Items []string
StorefrontURL string
}
type paidEmailTemplateData struct {
FirstName string
OrderID string
PaidDate string
PaidAmount string
OrderImageURL string
OrderImageAlt string
Items []string
StorefrontURL string
}
type createdEmailTemplateData struct {
OrderID string
CreatedDate string
OrderAmount string
CustomerName string
CustomerEmail string
OrderImageURL string
OrderImageAlt string
Items []string
StorefrontURL string
ClientDashboardURL string
}
type deliveredEmailTemplateData struct {
FirstName string
OrderID string
DeliveredDate string
OrderImageURL string
OrderImageAlt string
Items []string
StorefrontURL string
}
var shippedEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("shipped-email-html").Parse(`<!doctype html>
<html lang="en">
<body style="margin:0;padding:0;background:#f7f7f7;color:#111827;font-family:Arial,sans-serif;">
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="background:#ffffff;border-radius:18px;padding:32px;box-shadow:0 18px 44px rgba(15,23,42,0.08);">
<p style="margin:0 0 16px;font-size:16px;">Hi {{.FirstName}},</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Your Royal Pop order is now on the way.</p>
<p style="margin:0 0 20px;font-size:16px;line-height:1.7;">
<strong>Order reference:</strong> {{.OrderID}}<br />
<strong>Shipped:</strong> {{.ShippedDate}}<br />
<strong>Carrier:</strong> {{.ShippingCarrier}}<br />
<strong>Tracking number:</strong> {{.TrackingNumber}}
</p>
{{if .OrderImageURL}}
<div style="margin:0 0 24px;">
<img src="{{.OrderImageURL}}" alt="{{.OrderImageAlt}}" style="display:block;width:100%;max-width:420px;height:auto;border-radius:16px;border:1px solid #e5e7eb;" />
</div>
{{end}}
{{if .TrackingURL}}
<p style="margin:0 0 24px;">
<a href="{{.TrackingURL}}" style="display:inline-block;padding:14px 22px;border-radius:999px;background:#111827;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Track your package</a>
</p>
{{end}}
<p style="margin:0 0 12px;font-size:16px;line-height:1.6;"><strong>In this shipment</strong></p>
<ul style="margin:0 0 20px 20px;padding:0;line-height:1.8;">
{{range .Items}}<li>{{.}}</li>{{end}}
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">If you need anything, just reply to this email and well help.</p>
<p style="margin:0;font-size:16px;line-height:1.6;">Royal Pop</p>
</div>
</div>
</body>
</html>`))
var shippedEmailTextTemplate = texttemplate.Must(texttemplate.New("shipped-email-text").Parse(`Hi {{.FirstName}},
Your Royal Pop order is now on the way.
Order reference: {{.OrderID}}
Shipped: {{.ShippedDate}}
Carrier: {{.ShippingCarrier}}
Tracking number: {{.TrackingNumber}}
{{if .TrackingURL}}Track your package: {{.TrackingURL}}
{{end}}
In this shipment:
{{range .Items}}- {{.}}
{{end}}
If you need anything, just reply to this email and well help.
Royal Pop
`))
var cancelledEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("cancelled-email-html").Parse(`<!doctype html>
<html lang="en">
<body style="margin:0;padding:0;background:#f7f7f7;color:#111827;font-family:Arial,sans-serif;">
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="background:#ffffff;border-radius:18px;padding:32px;box-shadow:0 18px 44px rgba(15,23,42,0.08);">
<p style="margin:0 0 16px;font-size:16px;">Hi {{.FirstName}},</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Your Royal Pop order has now been cancelled.</p>
<p style="margin:0 0 20px;font-size:16px;line-height:1.7;">
<strong>Order reference:</strong> {{.OrderID}}<br />
<strong>Cancelled:</strong> {{.CancelledDate}}
</p>
{{if .OrderImageURL}}
<div style="margin:0 0 24px;">
<img src="{{.OrderImageURL}}" alt="{{.OrderImageAlt}}" style="display:block;width:100%;max-width:420px;height:auto;border-radius:16px;border:1px solid #e5e7eb;" />
</div>
{{end}}
<p style="margin:0 0 16px;font-size:16px;line-height:1.7;">We were not able to complete this order because some of the information provided was missing or invalid.</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.7;">A refund of <strong>{{.RefundAmount}}</strong> has now been issued to the original payment method. Depending on your bank or card provider, the refund may take a few business days to appear.</p>
<p style="margin:0 0 12px;font-size:16px;line-height:1.6;"><strong>This cancelled order included</strong></p>
<ul style="margin:0 0 20px 20px;padding:0;line-height:1.8;">
{{range .Items}}<li>{{.}}</li>{{end}}
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">If you would still like the order, just reply to this email and well help you sort it out.</p>
<p style="margin:0;font-size:16px;line-height:1.6;">Royal Pop</p>
</div>
</div>
</body>
</html>`))
var cancelledEmailTextTemplate = texttemplate.Must(texttemplate.New("cancelled-email-text").Parse(`Hi {{.FirstName}},
Your Royal Pop order has now been cancelled.
Order reference: {{.OrderID}}
Cancelled: {{.CancelledDate}}
We were not able to complete this order because some of the information provided was missing or invalid.
A refund of {{.RefundAmount}} has now been issued to the original payment method. Depending on your bank or card provider, the refund may take a few business days to appear.
This cancelled order included:
{{range .Items}}- {{.}}
{{end}}
If you would still like the order, just reply to this email and well help you sort it out.
Royal Pop
`))
var paidEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("paid-email-html").Parse(`<!doctype html>
<html lang="en">
<body style="margin:0;padding:0;background:#f7f7f7;color:#111827;font-family:Arial,sans-serif;">
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="background:#ffffff;border-radius:18px;padding:32px;box-shadow:0 18px 44px rgba(15,23,42,0.08);">
<p style="margin:0 0 16px;font-size:16px;">Hi {{.FirstName}},</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Weve received your Royal Pop order and your payment has gone through successfully.</p>
<p style="margin:0 0 20px;font-size:16px;line-height:1.7;">
<strong>Order reference:</strong> {{.OrderID}}<br />
<strong>Paid:</strong> {{.PaidDate}}<br />
<strong>Amount received:</strong> {{.PaidAmount}}
</p>
{{if .OrderImageURL}}
<div style="margin:0 0 24px;">
<img src="{{.OrderImageURL}}" alt="{{.OrderImageAlt}}" style="display:block;width:100%;max-width:420px;height:auto;border-radius:16px;border:1px solid #e5e7eb;" />
</div>
{{end}}
<p style="margin:0 0 12px;font-size:16px;line-height:1.6;"><strong>Your order details</strong></p>
<ul style="margin:0 0 20px 20px;padding:0;line-height:1.8;">
{{range .Items}}<li>{{.}}</li>{{end}}
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Well email you again as soon as your order has been packed and shipped.</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">If you need anything in the meantime, just reply to this email and well help.</p>
<p style="margin:0;font-size:16px;line-height:1.6;">Royal Pop</p>
</div>
</div>
</body>
</html>`))
var paidEmailTextTemplate = texttemplate.Must(texttemplate.New("paid-email-text").Parse(`Hi {{.FirstName}},
Weve received your Royal Pop order and your payment has gone through successfully.
Order reference: {{.OrderID}}
Paid: {{.PaidDate}}
Amount received: {{.PaidAmount}}
Your order details:
{{range .Items}}- {{.}}
{{end}}
Well email you again as soon as your order has been packed and shipped.
If you need anything in the meantime, just reply to this email and well help.
Royal Pop
`))
var createdEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("created-email-html").Parse(`<!doctype html>
<html lang="en">
<body style="margin:0;padding:0;background:#f7f7f7;color:#111827;font-family:Arial,sans-serif;">
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="background:#ffffff;border-radius:18px;padding:32px;box-shadow:0 18px 44px rgba(15,23,42,0.08);">
<p style="margin:0 0 16px;font-size:16px;">Hi,</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">A new paid Royal Pop order has been created and is ready for review in the client dashboard.</p>
<p style="margin:0 0 20px;font-size:16px;line-height:1.7;">
<strong>Order reference:</strong> {{.OrderID}}<br />
<strong>Created:</strong> {{.CreatedDate}}<br />
<strong>Order total:</strong> {{.OrderAmount}}<br />
<strong>Customer:</strong> {{.CustomerName}}<br />
<strong>Customer email:</strong> {{.CustomerEmail}}
</p>
{{if .OrderImageURL}}
<div style="margin:0 0 24px;">
<img src="{{.OrderImageURL}}" alt="{{.OrderImageAlt}}" style="display:block;width:100%;max-width:420px;height:auto;border-radius:16px;border:1px solid #e5e7eb;" />
</div>
{{end}}
{{if .ClientDashboardURL}}
<p style="margin:0 0 24px;">
<a href="{{.ClientDashboardURL}}" style="display:inline-block;padding:14px 22px;border-radius:999px;background:#111827;color:#ffffff;text-decoration:none;font-size:15px;font-weight:700;">Open client dashboard</a>
</p>
{{end}}
<p style="margin:0 0 12px;font-size:16px;line-height:1.6;"><strong>Your order details</strong></p>
<ul style="margin:0 0 20px 20px;padding:0;line-height:1.8;">
{{range .Items}}<li>{{.}}</li>{{end}}
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Use the dashboard to review the order, check stock, and continue fulfilment.</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">If you need anything, just reply to this email and well help.</p>
<p style="margin:0;font-size:16px;line-height:1.6;">Royal Pop</p>
</div>
</div>
</body>
</html>`))
var createdEmailTextTemplate = texttemplate.Must(texttemplate.New("created-email-text").Parse(`Hi,
A new paid Royal Pop order has been created and is ready for review in the client dashboard.
Order reference: {{.OrderID}}
Created: {{.CreatedDate}}
Order total: {{.OrderAmount}}
Customer: {{.CustomerName}}
Customer email: {{.CustomerEmail}}
{{if .ClientDashboardURL}}Open client dashboard: {{.ClientDashboardURL}}
{{end}}
Your order details:
{{range .Items}}- {{.}}
{{end}}
Use the dashboard to review the order, check stock, and continue fulfilment.
If you need anything, just reply to this email and well help.
Royal Pop
`))
var deliveredEmailHTMLTemplate = htmltemplate.Must(htmltemplate.New("delivered-email-html").Parse(`<!doctype html>
<html lang="en">
<body style="margin:0;padding:0;background:#f7f7f7;color:#111827;font-family:Arial,sans-serif;">
<div style="max-width:640px;margin:0 auto;padding:32px 20px;">
<div style="background:#ffffff;border-radius:18px;padding:32px;box-shadow:0 18px 44px rgba(15,23,42,0.08);">
<p style="margin:0 0 16px;font-size:16px;">Hi {{.FirstName}},</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Your Royal Pop order has been delivered.</p>
<p style="margin:0 0 20px;font-size:16px;line-height:1.7;">
<strong>Order reference:</strong> {{.OrderID}}<br />
<strong>Delivered:</strong> {{.DeliveredDate}}
</p>
{{if .OrderImageURL}}
<div style="margin:0 0 24px;">
<img src="{{.OrderImageURL}}" alt="{{.OrderImageAlt}}" style="display:block;width:100%;max-width:420px;height:auto;border-radius:16px;border:1px solid #e5e7eb;" />
</div>
{{end}}
<p style="margin:0 0 12px;font-size:16px;line-height:1.6;"><strong>Your delivered order</strong></p>
<ul style="margin:0 0 20px 20px;padding:0;line-height:1.8;">
{{range .Items}}<li>{{.}}</li>{{end}}
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">Thank you for your order — we hope you love it.</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;">If you need anything at all, just reply to this email and well help.</p>
<p style="margin:0;font-size:16px;line-height:1.6;">Royal Pop</p>
</div>
</div>
</body>
</html>`))
var deliveredEmailTextTemplate = texttemplate.Must(texttemplate.New("delivered-email-text").Parse(`Hi {{.FirstName}},
Your Royal Pop order has been delivered.
Order reference: {{.OrderID}}
Delivered: {{.DeliveredDate}}
Your delivered order:
{{range .Items}}- {{.}}
{{end}}
Thank you for your order — we hope you love it.
If you need anything at all, just reply to this email and well help.
Royal Pop
`))
var colorwayNames = map[string]string{
"ocho-negro": "ONYX",
"pure-white": "BLANC",
"pop-pink": "SAKURA",
"racer-green": "FOREST",
"lime-blue": "SAGE",
"deep-blue-orange": "MIDNIGHT",
"light-blue-sprint": "GLACIER",
"sorbet-pop-multi-color": "SORBET",
}
var finishNames = map[string]string{
"silver": "Silver",
"black-pvd": "Black PVD",
"rose-gold": "Rose Gold",
}
func NewResendMailer(cfg *config.Config) *ResendMailer {
if cfg == nil {
return &ResendMailer{}
}
return &ResendMailer{
apiKey: strings.TrimSpace(cfg.ResendAPIKey),
from: strings.TrimSpace(cfg.ResendOrderFrom),
replyTo: strings.TrimSpace(cfg.ResendForward),
storefrontURL: normalizeStorefrontURL(cfg.StorefrontURL),
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
func (m *ResendMailer) Enabled() bool {
if m == nil {
return false
}
return strings.TrimSpace(m.apiKey) != "" && strings.TrimSpace(m.from) != ""
}
func (m *ResendMailer) SendOrderShipped(ctx context.Context, order *orders.Order) error {
if order == nil {
return fmt.Errorf("order is required")
}
if !m.Enabled() {
return fmt.Errorf("resend mailer is not configured")
}
payload, err := m.buildShippedEmailRequest(order)
if err != nil {
return err
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal resend payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create resend request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+m.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send resend request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
message := strings.TrimSpace(string(responseBody))
if message == "" {
message = resp.Status
}
return fmt.Errorf("resend email failed: %s", message)
}
func (m *ResendMailer) SendOrderCancelled(ctx context.Context, order *orders.Order) error {
if order == nil {
return fmt.Errorf("order is required")
}
if !m.Enabled() {
return fmt.Errorf("resend mailer is not configured")
}
payload, err := m.buildCancelledEmailRequest(order)
if err != nil {
return err
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal resend payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create resend request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+m.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send resend request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
message := strings.TrimSpace(string(responseBody))
if message == "" {
message = resp.Status
}
return fmt.Errorf("resend email failed: %s", message)
}
func (m *ResendMailer) SendOrderPaid(ctx context.Context, order *orders.Order) error {
if order == nil {
return fmt.Errorf("order is required")
}
if !m.Enabled() {
return fmt.Errorf("resend mailer is not configured")
}
payload, err := m.buildPaidEmailRequest(order)
if err != nil {
return err
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal resend payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create resend request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+m.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send resend request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
message := strings.TrimSpace(string(responseBody))
if message == "" {
message = resp.Status
}
return fmt.Errorf("resend email failed: %s", message)
}
func (m *ResendMailer) SendOrderCreated(ctx context.Context, order *orders.Order) error {
if order == nil {
return fmt.Errorf("order is required")
}
if !m.Enabled() {
return fmt.Errorf("resend mailer is not configured")
}
payload, err := m.buildCreatedEmailRequest(order)
if err != nil {
return err
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal resend payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create resend request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+m.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send resend request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
message := strings.TrimSpace(string(responseBody))
if message == "" {
message = resp.Status
}
return fmt.Errorf("resend email failed: %s", message)
}
func (m *ResendMailer) SendOrderDelivered(ctx context.Context, order *orders.Order) error {
if order == nil {
return fmt.Errorf("order is required")
}
if !m.Enabled() {
return fmt.Errorf("resend mailer is not configured")
}
payload, err := m.buildDeliveredEmailRequest(order)
if err != nil {
return err
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal resend payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, resendEndpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create resend request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+m.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := m.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send resend request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
message := strings.TrimSpace(string(responseBody))
if message == "" {
message = resp.Status
}
return fmt.Errorf("resend email failed: %s", message)
}
func (m *ResendMailer) buildShippedEmailRequest(order *orders.Order) (*resendEmailRequest, error) {
data := shippedEmailTemplateData{
FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"),
OrderID: strings.TrimSpace(order.ID),
ShippedDate: shippedDateLabel(order),
ShippingCarrier: strings.TrimSpace(order.ShippingCarrier),
TrackingNumber: strings.TrimSpace(order.TrackingNumber),
TrackingURL: buildTrackingURL(order.ShippingCarrier, order.TrackingNumber),
OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items),
OrderImageAlt: buildOrderImageAlt(order.Items),
Items: buildShipmentLineItems(order.Items),
StorefrontURL: m.storefrontURL,
}
var htmlBody bytes.Buffer
if err := shippedEmailHTMLTemplate.Execute(&htmlBody, data); err != nil {
return nil, fmt.Errorf("render shipment email html: %w", err)
}
var textBody bytes.Buffer
if err := shippedEmailTextTemplate.Execute(&textBody, data); err != nil {
return nil, fmt.Errorf("render shipment email text: %w", err)
}
return &resendEmailRequest{
From: m.from,
To: []string{strings.TrimSpace(order.Email)},
Subject: fmt.Sprintf("Your Royal Pop order is on the way — %s", data.OrderID),
HTML: htmlBody.String(),
Text: textBody.String(),
ReplyTo: m.replyTo,
}, nil
}
func (m *ResendMailer) buildCancelledEmailRequest(order *orders.Order) (*resendEmailRequest, error) {
data := cancelledEmailTemplateData{
FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"),
OrderID: strings.TrimSpace(order.ID),
CancelledDate: cancelledDateLabel(order),
RefundAmount: formatMoney(order.Currency, order.Amount),
OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items),
OrderImageAlt: buildOrderImageAlt(order.Items),
Items: buildShipmentLineItems(order.Items),
StorefrontURL: m.storefrontURL,
}
var htmlBody bytes.Buffer
if err := cancelledEmailHTMLTemplate.Execute(&htmlBody, data); err != nil {
return nil, fmt.Errorf("render cancelled email html: %w", err)
}
var textBody bytes.Buffer
if err := cancelledEmailTextTemplate.Execute(&textBody, data); err != nil {
return nil, fmt.Errorf("render cancelled email text: %w", err)
}
return &resendEmailRequest{
From: m.from,
To: []string{strings.TrimSpace(order.Email)},
Subject: fmt.Sprintf("Your Royal Pop order has been cancelled — %s", data.OrderID),
HTML: htmlBody.String(),
Text: textBody.String(),
ReplyTo: m.replyTo,
}, nil
}
func (m *ResendMailer) buildPaidEmailRequest(order *orders.Order) (*resendEmailRequest, error) {
data := paidEmailTemplateData{
FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"),
OrderID: strings.TrimSpace(order.ID),
PaidDate: paidDateLabel(order),
PaidAmount: formatMoney(order.Currency, order.Amount),
OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items),
OrderImageAlt: buildOrderImageAlt(order.Items),
Items: buildShipmentLineItems(order.Items),
StorefrontURL: m.storefrontURL,
}
var htmlBody bytes.Buffer
if err := paidEmailHTMLTemplate.Execute(&htmlBody, data); err != nil {
return nil, fmt.Errorf("render paid email html: %w", err)
}
var textBody bytes.Buffer
if err := paidEmailTextTemplate.Execute(&textBody, data); err != nil {
return nil, fmt.Errorf("render paid email text: %w", err)
}
return &resendEmailRequest{
From: m.from,
To: []string{strings.TrimSpace(order.Email)},
Subject: fmt.Sprintf("Weve received your Royal Pop order — %s", data.OrderID),
HTML: htmlBody.String(),
Text: textBody.String(),
ReplyTo: m.replyTo,
}, nil
}
func (m *ResendMailer) buildCreatedEmailRequest(order *orders.Order) (*resendEmailRequest, error) {
data := createdEmailTemplateData{
OrderID: strings.TrimSpace(order.ID),
CreatedDate: createdDateLabel(order),
OrderAmount: formatMoney(order.Currency, order.Amount),
CustomerName: strings.TrimSpace(strings.Join([]string{strings.TrimSpace(order.FirstName), strings.TrimSpace(order.LastName)}, " ")),
CustomerEmail: strings.TrimSpace(order.Email),
OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items),
OrderImageAlt: buildOrderImageAlt(order.Items),
Items: buildShipmentLineItems(order.Items),
StorefrontURL: m.storefrontURL,
ClientDashboardURL: buildClientDashboardURL(m.storefrontURL),
}
if data.CustomerName == "" {
data.CustomerName = "Customer"
}
if data.CustomerEmail == "" {
data.CustomerEmail = "Not provided"
}
var htmlBody bytes.Buffer
if err := createdEmailHTMLTemplate.Execute(&htmlBody, data); err != nil {
return nil, fmt.Errorf("render created email html: %w", err)
}
var textBody bytes.Buffer
if err := createdEmailTextTemplate.Execute(&textBody, data); err != nil {
return nil, fmt.Errorf("render created email text: %w", err)
}
return &resendEmailRequest{
From: m.from,
To: []string{firstNonEmpty(strings.TrimSpace(m.replyTo), strings.TrimSpace(m.from))},
Subject: fmt.Sprintf("Client reminder: paid order created — %s", data.OrderID),
HTML: htmlBody.String(),
Text: textBody.String(),
ReplyTo: m.replyTo,
}, nil
}
func (m *ResendMailer) buildDeliveredEmailRequest(order *orders.Order) (*resendEmailRequest, error) {
data := deliveredEmailTemplateData{
FirstName: firstNonEmpty(strings.TrimSpace(order.FirstName), "there"),
OrderID: strings.TrimSpace(order.ID),
DeliveredDate: deliveredDateLabel(order),
OrderImageURL: buildOrderImageURL(m.storefrontURL, order.Items),
OrderImageAlt: buildOrderImageAlt(order.Items),
Items: buildShipmentLineItems(order.Items),
StorefrontURL: m.storefrontURL,
}
var htmlBody bytes.Buffer
if err := deliveredEmailHTMLTemplate.Execute(&htmlBody, data); err != nil {
return nil, fmt.Errorf("render delivered email html: %w", err)
}
var textBody bytes.Buffer
if err := deliveredEmailTextTemplate.Execute(&textBody, data); err != nil {
return nil, fmt.Errorf("render delivered email text: %w", err)
}
return &resendEmailRequest{
From: m.from,
To: []string{strings.TrimSpace(order.Email)},
Subject: fmt.Sprintf("Your Royal Pop order has been delivered — %s", data.OrderID),
HTML: htmlBody.String(),
Text: textBody.String(),
ReplyTo: m.replyTo,
}, nil
}
func buildClientDashboardURL(storefrontURL string) string {
base := strings.TrimRight(strings.TrimSpace(storefrontURL), "/")
if base == "" {
base = "https://royal-pop-accessory.com"
}
return base + "/client"
}
func buildShipmentLineItems(items []orders.LineItem) []string {
if len(items) == 0 {
return []string{"Royal Pop order items"}
}
lines := make([]string, 0, len(items))
for _, item := range items {
quantity := item.Quantity
if quantity <= 0 {
quantity = 1
}
styleLabel := "Style " + strings.ToUpper(strings.TrimSpace(item.Style))
if strings.TrimSpace(item.Style) == "" {
styleLabel = "Style"
}
lines = append(lines, fmt.Sprintf(
"%s · %s · %s · %s",
firstNonEmpty(colorwayDisplayName(item.ColorwayID), "Royal Pop"),
styleLabel,
firstNonEmpty(finishDisplayName(item.FinishID), "Finish"),
quantityLabel(quantity),
))
}
return lines
}
func shippedDateLabel(order *orders.Order) string {
if order == nil {
return time.Now().UTC().Format("2 January 2006")
}
if order.ShippedAt != nil && !order.ShippedAt.IsZero() {
return order.ShippedAt.UTC().Format("2 January 2006")
}
if !order.UpdatedAt.IsZero() {
return order.UpdatedAt.UTC().Format("2 January 2006")
}
return time.Now().UTC().Format("2 January 2006")
}
func cancelledDateLabel(order *orders.Order) string {
if order == nil {
return time.Now().UTC().Format("2 January 2006")
}
if !order.UpdatedAt.IsZero() {
return order.UpdatedAt.UTC().Format("2 January 2006")
}
return time.Now().UTC().Format("2 January 2006")
}
func paidDateLabel(order *orders.Order) string {
if order == nil {
return time.Now().UTC().Format("2 January 2006")
}
if !order.UpdatedAt.IsZero() {
return order.UpdatedAt.UTC().Format("2 January 2006")
}
if !order.CreatedAt.IsZero() {
return order.CreatedAt.UTC().Format("2 January 2006")
}
return time.Now().UTC().Format("2 January 2006")
}
func createdDateLabel(order *orders.Order) string {
if order == nil {
return time.Now().UTC().Format("2 January 2006")
}
if !order.CreatedAt.IsZero() {
return order.CreatedAt.UTC().Format("2 January 2006")
}
if !order.UpdatedAt.IsZero() {
return order.UpdatedAt.UTC().Format("2 January 2006")
}
return time.Now().UTC().Format("2 January 2006")
}
func deliveredDateLabel(order *orders.Order) string {
if order == nil {
return time.Now().UTC().Format("2 January 2006")
}
if !order.UpdatedAt.IsZero() {
return order.UpdatedAt.UTC().Format("2 January 2006")
}
if order.ShippedAt != nil && !order.ShippedAt.IsZero() {
return order.ShippedAt.UTC().Format("2 January 2006")
}
return time.Now().UTC().Format("2 January 2006")
}
func quantityLabel(quantity int64) string {
if quantity == 1 {
return "1 kit"
}
return fmt.Sprintf("%d kits", quantity)
}
func formatMoney(currency string, amount int64) string {
code := strings.ToUpper(strings.TrimSpace(currency))
major := float64(amount) / 100
switch code {
case "GBP", "":
return fmt.Sprintf("£%.2f", major)
case "USD":
return fmt.Sprintf("$%.2f", major)
case "EUR":
return fmt.Sprintf("€%.2f", major)
default:
return fmt.Sprintf("%s %.2f", code, major)
}
}
func colorwayDisplayName(id string) string {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return ""
}
if label, ok := colorwayNames[trimmed]; ok {
return label
}
return slugLabel(trimmed)
}
func finishDisplayName(id string) string {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
return ""
}
if label, ok := finishNames[trimmed]; ok {
return label
}
return slugLabel(trimmed)
}
func slugLabel(value string) string {
parts := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(value), "-", " "), "_", " "))
if len(parts) == 0 {
return ""
}
formatted := make([]string, 0, len(parts))
for _, part := range parts {
formatted = append(formatted, strings.ToUpper(part[:1])+strings.ToLower(part[1:]))
}
return strings.Join(formatted, " ")
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func normalizeStorefrontURL(raw string) string {
trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
if trimmed == "" {
return "https://royal-pop-accessory.com"
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "localhost") || strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, ".local") {
return "https://royal-pop-accessory.com"
}
return trimmed
}
func buildTrackingURL(carrier, trackingNumber string) string {
tracking := strings.TrimSpace(trackingNumber)
if tracking == "" {
return ""
}
return "https://www.17track.net/en/track#nums=" + url.QueryEscape(tracking)
}
func buildOrderImageURL(storefrontURL string, items []orders.LineItem) string {
baseURL := strings.TrimRight(strings.TrimSpace(storefrontURL), "/")
if baseURL == "" {
return ""
}
imagePath := "/images/png/ocho-negro.png"
if len(items) > 0 {
colorwayID := strings.TrimSpace(items[0].ColorwayID)
if mappedPath, ok := orderImagePathForColorway(colorwayID); ok {
imagePath = mappedPath
}
}
return baseURL + imagePath
}
func orderImagePathForColorway(colorwayID string) (string, bool) {
switch strings.TrimSpace(colorwayID) {
case "ocho-negro":
return "/images/png/ocho-negro.png", true
case "pure-white":
return "/images/png/pure-white.png", true
case "pop-pink":
return "/images/png/pop-pink.png", true
case "racer-green":
return "/images/png/racer-green.png", true
case "lime-blue":
return "/images/png/lime-blue.png", true
case "deep-blue-orange":
return "/images/png/deep-blue.png", true
case "light-blue-sprint":
return "/images/png/light-blue.png", true
case "sorbet-pop-multi-color":
return "/images/png/sorbet-multi.png", true
default:
return "", false
}
}
func buildOrderImageAlt(items []orders.LineItem) string {
if len(items) == 0 {
return "Royal Pop order preview"
}
colorway := colorwayDisplayName(items[0].ColorwayID)
style := strings.ToUpper(strings.TrimSpace(items[0].Style))
finish := finishDisplayName(items[0].FinishID)
parts := make([]string, 0, 3)
if colorway != "" {
parts = append(parts, colorway)
}
if style != "" {
parts = append(parts, "Style "+style)
}
if finish != "" {
parts = append(parts, finish)
}
if len(parts) == 0 {
return "Royal Pop order preview"
}
return strings.Join(parts, " · ") + " order preview"
}