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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProfilePreferencesClient is the subset of the profile service's UCP customer
|
||||
// API that returns email preferences for a given email address.
|
||||
type ProfilePreferencesClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewProfilePreferencesClient creates a client that talks to the profile
|
||||
// service's UCP customer endpoint at baseURL (e.g. "http://profile:8080/ucp/v1/customers").
|
||||
func NewProfilePreferencesClient(baseURL string) *ProfilePreferencesClient {
|
||||
return &ProfilePreferencesClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
timeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// emailPreferencesResponse mirrors ucp.EmailPreferencesResponse for decoding.
|
||||
type emailPreferencesResponse struct {
|
||||
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||
}
|
||||
|
||||
// customerLookupResponse mirrors the UCP customer response, but we only need
|
||||
// email preferences.
|
||||
type customerLookupResponse struct {
|
||||
EmailPreferences *emailPreferencesResponse `json:"emailPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// fetchPreferences calls GET /by-email/{email} on the profile service and
|
||||
// returns the parsed email preferences. Returns nil, nil when the profile
|
||||
// is not found (unknown email -> allow all).
|
||||
func (c *ProfilePreferencesClient) fetchPreferences(ctx context.Context, email string) (*emailPreferencesResponse, error) {
|
||||
url := fmt.Sprintf("%s/by-email/%s", c.baseURL, email)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profile prefs: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil // unknown email -> no preferences
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("profile prefs: unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var data customerLookupResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("profile prefs: decode: %w", err)
|
||||
}
|
||||
return data.EmailPreferences, nil
|
||||
}
|
||||
|
||||
// PreferencesChecker wraps ProfilePreferencesClient to implement
|
||||
// EmailPreferenceChecker.
|
||||
type PreferencesChecker struct {
|
||||
client *ProfilePreferencesClient
|
||||
}
|
||||
|
||||
// NewPreferencesChecker creates an EmailPreferenceChecker that looks up
|
||||
// preferences from the profile service at the given base URL.
|
||||
func NewPreferencesChecker(profileServiceURL string) *PreferencesChecker {
|
||||
return &PreferencesChecker{
|
||||
client: NewProfilePreferencesClient(profileServiceURL),
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed returns true when the customer has not opted out of the given
|
||||
// category. Categories are "order" (order confirmations, shipping updates,
|
||||
// receipts) and "marketing" (promotional offers, newsletters). Returns true
|
||||
// when preferences are unknown (profile not found) — the safe default.
|
||||
func (c *PreferencesChecker) Allowed(ctx context.Context, email string, category string) bool {
|
||||
prefs, err := c.client.fetchPreferences(ctx, email)
|
||||
if err != nil || prefs == nil {
|
||||
return true // safe default: allow when unknown
|
||||
}
|
||||
|
||||
switch category {
|
||||
case "order":
|
||||
return prefs.OrderEmails == nil || *prefs.OrderEmails
|
||||
case "marketing":
|
||||
return prefs.MarketingEmails == nil || *prefs.MarketingEmails
|
||||
default:
|
||||
return true // unknown category -> allow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Order {{ .Order.OrderReference }} confirmed",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has been placed successfully.\n\nWe'll send you an update when your payment is confirmed and your items are on their way.\n\nOrder id: {{ .OrderID }}\n\nThank you for your purchase!",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has been placed successfully.</p><p>We'll send you an update when your payment is confirmed and your items are on their way.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your purchase!</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Payment received for order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour payment for order {{ .Order.OrderReference }} has been received and confirmed.\n\nWe'll let you know as soon as your items ship.\n\nOrder id: {{ .OrderID }}\n\nThank you for shopping with us!",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your payment for order <strong>{{ .Order.OrderReference }}</strong> has been received and confirmed.</p><p>We'll let you know as soon as your items ship.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for shopping with us!</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Refund issued for order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nA refund has been issued for order {{ .Order.OrderReference }}.\n\nThe amount should appear in your account within a few business days, depending on your payment provider.\n\nOrder id: {{ .OrderID }}\n\nThank you for your patience.",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>A refund has been issued for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>The amount should appear in your account within a few business days, depending on your payment provider.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your patience.</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Return request received — order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nWe've received your return request for order {{ .Order.OrderReference }}.\n\nWe'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.\n\nOrder id: {{ .OrderID }}\n\nIf you have any questions, please don't hesitate to contact us.",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>We've received your return request for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>We'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>If you have any questions, please don't hesitate to contact us.</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Package delivered — order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour package for order {{ .Order.OrderReference }} has been delivered.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}{{ end }}\n\nWe hope you're happy with your purchase! If you have any questions, feel free to reply to this email.\n\nOrder id: {{ .OrderID }}",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your package for order <strong>{{ .Order.OrderReference }}</strong> has been delivered.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li></ul>{{ end }}<p>We hope you're happy with your purchase!</p><p>Order id: <code>{{ .OrderID }}</code></p>"
|
||||
}
|
||||
@@ -44,6 +44,10 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
|
||||
// place-and-pay references the emit_order_created hook; register it (nil
|
||||
// publisher = no-op) so flow validation passes in tests too.
|
||||
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
|
||||
// place-and-pay also references send_email hooks; register it with nil
|
||||
// sender and nil templates so validation passes (runtime = error, tests
|
||||
// that exercise email have their own sender/templates).
|
||||
RegisterEmailHook(reg, pool, nil, nil)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "fulfill-order",
|
||||
"description": "Create a fulfillment (shipment) for an order and send the shipping notification to the customer.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "fulfill",
|
||||
"action": "create_fulfillment",
|
||||
"hooks": {
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "fulfillment created" } },
|
||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "fulfillment-shipped" } },
|
||||
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "place-and-pay",
|
||||
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.",
|
||||
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails. Sends order confirmation and payment receipt emails when a customer email is present.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "place",
|
||||
"action": "place_order",
|
||||
"hooks": {
|
||||
"after": [{ "type": "log", "params": { "message": "order placed" } }]
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "order placed" } },
|
||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "order-confirmation" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -24,6 +27,7 @@
|
||||
"hooks": {
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "payment captured" } },
|
||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } },
|
||||
{ "type": "emit_order_created" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/mailersend/mailersend-go"
|
||||
)
|
||||
|
||||
// MailerSendEmailSender sends email through the MailerSend API using the
|
||||
// official Go SDK. It implements the EmailSender interface.
|
||||
type MailerSendEmailSender struct {
|
||||
client *mailersend.Mailersend
|
||||
defaultFrom mail.Address
|
||||
}
|
||||
|
||||
// NewMailerSendEmailSender creates a new MailerSend-backed sender.
|
||||
func NewMailerSendEmailSender(apiKey string, defaultFrom mail.Address) (*MailerSendEmailSender, error) {
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, fmt.Errorf("mailersend sender: missing API key")
|
||||
}
|
||||
if strings.TrimSpace(defaultFrom.Address) == "" {
|
||||
return nil, fmt.Errorf("mailersend sender: missing default from address")
|
||||
}
|
||||
return &MailerSendEmailSender{
|
||||
client: mailersend.NewMailersend(apiKey),
|
||||
defaultFrom: defaultFrom,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultFrom returns the default sender address configured for this sender.
|
||||
func (s *MailerSendEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
||||
|
||||
// Send delivers the rendered email message through the MailerSend API.
|
||||
func (s *MailerSendEmailSender) Send(ctx context.Context, msg EmailMessage) error {
|
||||
if strings.TrimSpace(msg.From.Address) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing from address")
|
||||
}
|
||||
if len(msg.To) == 0 {
|
||||
return fmt.Errorf("mailersend sender: missing recipients")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing subject")
|
||||
}
|
||||
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing body")
|
||||
}
|
||||
|
||||
message := s.client.Email.NewMessage()
|
||||
|
||||
fromName := msg.From.Name
|
||||
if fromName == "" {
|
||||
fromName = s.defaultFrom.Name
|
||||
}
|
||||
message.SetFrom(mailersend.From{
|
||||
Name: fromName,
|
||||
Email: msg.From.Address,
|
||||
})
|
||||
|
||||
recipients := make([]mailersend.Recipient, 0, len(msg.To))
|
||||
for _, rcpt := range msg.To {
|
||||
recipients = append(recipients, mailersend.Recipient{
|
||||
Name: rcpt.Name,
|
||||
Email: rcpt.Address,
|
||||
})
|
||||
}
|
||||
message.SetRecipients(recipients)
|
||||
message.SetSubject(msg.Subject)
|
||||
|
||||
if strings.TrimSpace(msg.HTMLBody) != "" {
|
||||
message.SetHTML(msg.HTMLBody)
|
||||
}
|
||||
if strings.TrimSpace(msg.TextBody) != "" {
|
||||
message.SetText(msg.TextBody)
|
||||
}
|
||||
|
||||
_, err := s.client.Email.Send(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailersend sender: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user