more cart
This commit is contained in:
+87
-3
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// EmailMessage is the rendered mail payload a sender delivers.
|
||||
@@ -229,6 +230,12 @@ type EmailTemplate struct {
|
||||
}
|
||||
|
||||
func (t EmailTemplate) validate(name string) error {
|
||||
return t.Validate(name)
|
||||
}
|
||||
|
||||
// Validate checks that the template has the required fields. Exported so callers
|
||||
// (e.g. the email template admin API) can validate before persisting.
|
||||
func (t EmailTemplate) Validate(name string) error {
|
||||
if strings.TrimSpace(t.Subject) == "" {
|
||||
return fmt.Errorf("email template %q: missing subject", name)
|
||||
}
|
||||
@@ -330,6 +337,7 @@ type emailHookParams struct {
|
||||
To string `json:"to,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Category string `json:"category,omitempty"` // "order" or "marketing"; checked against preferences
|
||||
}
|
||||
|
||||
type flowTemplateData struct {
|
||||
@@ -344,16 +352,30 @@ type flowTemplateData struct {
|
||||
BillingAddress any
|
||||
}
|
||||
|
||||
// EmailPreferenceChecker determines whether an email of a given category should
|
||||
// be sent to a customer. Returns true when preferences are unknown (allow).
|
||||
type EmailPreferenceChecker interface {
|
||||
// Allowed returns true if the customer with the given email has opted in to
|
||||
// the given category. category is one of "order" or "marketing".
|
||||
Allowed(ctx context.Context, email string, category string) bool
|
||||
}
|
||||
|
||||
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
|
||||
// order for template data, renders the named template, and delivers it through
|
||||
// the configured sender. Hook params:
|
||||
// the configured sender. When checker is non-nil, emails in a category are
|
||||
// gated by the customer's saved preferences. Hook params:
|
||||
//
|
||||
// {
|
||||
// "template": "fulfillment-shipped",
|
||||
// "to": "{{ .Order.CustomerEmail }}",
|
||||
// "from": "Warehouse <warehouse@example.com>"
|
||||
// "from": "Warehouse <warehouse@example.com>",
|
||||
// "category": "order"
|
||||
// }
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore, checker ...EmailPreferenceChecker) {
|
||||
var prefCheck EmailPreferenceChecker
|
||||
if len(checker) > 0 {
|
||||
prefCheck = checker[0]
|
||||
}
|
||||
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
if sender == nil {
|
||||
return fmt.Errorf("send_email: no sender configured")
|
||||
@@ -414,6 +436,14 @@ func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, temp
|
||||
to = append(to, *rcpt)
|
||||
}
|
||||
|
||||
// Check email preferences before sending.
|
||||
if prefCheck != nil && p.Category != "" && len(to) > 0 {
|
||||
email := to[0].Address
|
||||
if !prefCheck.Allowed(ctx, email, p.Category) {
|
||||
return nil // silently skip — customer opted out
|
||||
}
|
||||
}
|
||||
|
||||
from := sender.DefaultFrom()
|
||||
if strings.TrimSpace(p.From) != "" {
|
||||
fromRendered, err := renderTextTemplate("email-from", p.From, data)
|
||||
@@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any {
|
||||
return v
|
||||
}
|
||||
|
||||
func RenderTextTemplate(name, src string, data any) (string, error) {
|
||||
return renderTextTemplate(name, src, data)
|
||||
}
|
||||
|
||||
// RenderOptionalHTMLTemplate renders an HTML template with the given data, or
|
||||
// returns empty string when src is empty.
|
||||
func RenderOptionalHTMLTemplate(name, src string, data any) (string, error) {
|
||||
return renderOptionalHTMLTemplate(name, src, data)
|
||||
}
|
||||
|
||||
func renderTextTemplate(name, src string, data any) (string, error) {
|
||||
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
|
||||
if err != nil {
|
||||
@@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) {
|
||||
return renderTextTemplate(name, src, data)
|
||||
}
|
||||
|
||||
// SampleTemplateData returns a flowTemplateData populated with sample values
|
||||
// for previewing email templates in the admin UI.
|
||||
func SampleTemplateData() any {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return flowTemplateData{
|
||||
Order: &OrderGrain{
|
||||
Id: 12345,
|
||||
OrderReference: "ORD-SAMPLE-001",
|
||||
Currency: "SEK",
|
||||
Status: StatusCaptured,
|
||||
CustomerName: "Anna Svensson",
|
||||
CustomerEmail: "anna@example.com",
|
||||
TotalAmount: money.Cents(29900),
|
||||
Lines: []Line{
|
||||
{Reference: "l1", Sku: "PROD-001", Name: "Sample Product", Quantity: 1, UnitPrice: 29900, TotalAmount: 29900, TaxRate: 2500},
|
||||
},
|
||||
PlacedAt: now,
|
||||
},
|
||||
OrderID: "abc123",
|
||||
Fulfillment: &Fulfillment{
|
||||
ID: "f_ship001",
|
||||
Carrier: "PostNord",
|
||||
TrackingNumber: "SE-123456789",
|
||||
TrackingURI: "https://tracking.postnord.se/123456789",
|
||||
},
|
||||
ShippingAddress: map[string]any{
|
||||
"givenName": "Anna",
|
||||
"familyName": "Svensson",
|
||||
"street": "Storgatan 1",
|
||||
"city": "Stockholm",
|
||||
"postalCode": "111 22",
|
||||
"country": "SE",
|
||||
},
|
||||
BillingAddress: map[string]any{
|
||||
"givenName": "Anna",
|
||||
"familyName": "Svensson",
|
||||
"street": "Storgatan 1",
|
||||
"city": "Stockholm",
|
||||
"postalCode": "111 22",
|
||||
"country": "SE",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "", nil
|
||||
|
||||
Reference in New Issue
Block a user