1066 lines
33 KiB
Go
1066 lines
33 KiB
Go
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 we’ll 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 we’ll 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 we’ll 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 we’ll 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;">We’ve 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;">We’ll 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 we’ll 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}},
|
||
|
||
We’ve 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}}
|
||
|
||
We’ll 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 we’ll 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 we’ll 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 we’ll 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 we’ll 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 we’ll 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("We’ve 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"
|
||
}
|