more cart

This commit is contained in:
2026-07-01 10:40:28 +02:00
parent 75db64ce75
commit b1e99891e9
30 changed files with 1058 additions and 93 deletions
+1 -31
View File
@@ -29,7 +29,6 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
) )
@@ -160,35 +159,6 @@ func (c *catalogProjectionCache) Len() int {
return base + overlay return base + overlay
} }
// projectionDeliveryHandler is the AMQP delivery handler for
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
// that doesn't apply locally so the AMQP delivery is acked; returns an error
// on a decode failure so the broker knows to redeliver / quarantine.
//
// Recovery: any panic in a single event is caught so the goroutine keeps
// draining the queue; the bus stays live across malformed events.
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
return func(d amqp.Delivery) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("projection handler panic recovered: %v", r)
}
}()
var ev event.Event
if err := json.Unmarshal(d.Body, &ev); err != nil {
return fmt.Errorf("decode event envelope: %w", err)
}
// Route the frame (snapshot begin/chunk/end or delta) into the store;
// epoch ordering, snapshot assembly + atomic swap, and tombstones all live
// in platform/catalog.ProjectionStore.
if err := cache.HandleFrame(ev.Meta, ev.Payload); err != nil {
return fmt.Errorf("apply projection frame: %w", err)
}
return nil
}
}
func main() { func main() {
// cartPort is the bare HTTP port. It drives both the local listener and the // cartPort is the bare HTTP port. It drives both the local listener and the
@@ -387,7 +357,7 @@ func main() {
log.Printf("cart: channel on projection reconnect: %v", err) log.Printf("cart: channel on projection reconnect: %v", err)
return return
} }
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil { if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), catalog.MakeAmqpHandler(projectionCache.store)); err != nil {
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err) log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
_ = ch.Close() _ = ch.Close()
} }
+1 -1
View File
@@ -41,7 +41,7 @@ func testServer(t *testing.T) *server {
applier := &orderedApplier{pool: pool, storage: storage} applier := &orderedApplier{pool: pool, storage: storage}
provider := order.NewPassthroughProvider("legacy", "ref", 15000) provider := order.NewPassthroughProvider("legacy", "ref", 15000)
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil))) engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
def, err := order.EmbeddedFlow("place-and-pay") def, err := order.EmbeddedFlow("place-and-pay")
+1 -1
View File
@@ -26,7 +26,7 @@ func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(&logs, nil)) logger := slog.New(slog.NewJSONHandler(&logs, nil))
provider := order.NewPassthroughProvider("legacy", "ref", 25000) provider := order.NewPassthroughProvider("legacy", "ref", 25000)
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, logger) freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, nil, logger)
engine := flow.NewEngine(freg, logger) engine := flow.NewEngine(freg, logger)
st := flow.NewState(801, logger) st := flow.NewState(801, logger)
+221
View File
@@ -0,0 +1,221 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
// emailTemplateInfo is the metadata returned by the list endpoint.
type emailTemplateInfo struct {
Name string `json:"name"`
Embedded bool `json:"embedded,omitempty"`
}
// emailTemplateHandler serves CRUD operations for email templates stored in the
// ORDER_EMAIL_TEMPLATES directory. Embedded templates are read-only; on-disk
// copies override them and can be edited via PUT.
type emailTemplateHandler struct {
dir string // the on-disk templates directory, from ORDER_EMAIL_TEMPLATES env
}
func newEmailTemplateHandler(dir string) *emailTemplateHandler {
if dir == "" {
dir = "data/order-email-templates"
}
return &emailTemplateHandler{dir: dir}
}
// list returns all known email templates (embedded + on-disk) with their source.
func (h *emailTemplateHandler) list(w http.ResponseWriter, r *http.Request) {
embedded := knownEmbeddedTemplates()
onDisk := h.listOnDisk()
seen := map[string]bool{}
var infos []emailTemplateInfo
// List on-disk first (they take precedence).
for _, name := range onDisk {
infos = append(infos, emailTemplateInfo{Name: name})
seen[name] = true
}
// Then add embedded templates not overridden by disk.
for _, name := range embedded {
if !seen[name] {
infos = append(infos, emailTemplateInfo{Name: name, Embedded: true})
seen[name] = true
}
}
if infos == nil {
infos = []emailTemplateInfo{}
}
writeJSON(w, http.StatusOK, infos)
}
// get returns the template content for the given name.
func (h *emailTemplateHandler) get(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// save persists a template to the on-disk directory. Returns the saved template.
func (h *emailTemplateHandler) save(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" || !validEmailTemplateName(name) {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template name %q", name))
return
}
var tmpl order.EmailTemplate
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template body: %w", err))
return
}
if err := tmpl.Validate(name); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := os.MkdirAll(h.dir, 0o755); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("create templates dir: %w", err))
return
}
data, err := json.MarshalIndent(tmpl, "", " ")
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("marshal template: %w", err))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("write template: %w", err))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// delete removes an on-disk template. Embedded templates cannot be deleted.
func (h *emailTemplateHandler) delete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
// Check if it's embedded (read-only).
if isEmbeddedTemplate(name) {
writeErr(w, http.StatusForbidden, fmt.Errorf("template %q is built-in and cannot be deleted; save an override instead", name))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found on disk", name))
return
}
writeErr(w, http.StatusInternalServerError, fmt.Errorf("delete template: %w", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// preview renders a template with sample data and returns the result.
func (h *emailTemplateHandler) preview(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
// Build sample data for preview.
sampleData := order.SampleTemplateData()
subject, _ := order.RenderTextTemplate("preview-subject", tmpl.Subject, sampleData)
textBody, _ := order.RenderTextTemplate("preview-text", tmpl.Text, sampleData)
htmlBody, _ := order.RenderOptionalHTMLTemplate("preview-html", tmpl.HTML, sampleData)
writeJSON(w, http.StatusOK, map[string]string{
"subject": subject,
"text": textBody,
"html": htmlBody,
})
}
// listOnDisk returns template names from the on-disk directory.
func (h *emailTemplateHandler) listOnDisk() []string {
matches, err := filepath.Glob(filepath.Join(h.dir, "*.json"))
if err != nil {
return nil
}
var names []string
for _, m := range matches {
name := strings.TrimSuffix(filepath.Base(m), ".json")
if validEmailTemplateName(name) {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// knownEmbeddedTemplates returns the names of templates embedded in the binary.
// This must be kept in sync with the files in pkg/order/email_templates/.
func knownEmbeddedTemplates() []string {
return []string{
"fulfillment-shipped",
"order-confirmation",
"payment-receipt",
"shipment-delivered",
"return-confirmed",
"refund-issued",
}
}
// isEmbeddedTemplate reports whether name matches one of the embedded templates.
func isEmbeddedTemplate(name string) bool {
for _, e := range knownEmbeddedTemplates() {
if e == name {
return true
}
}
return false
}
// validEmailTemplateName validates a template name (alphanumeric + hyphens).
func validEmailTemplateName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
+2 -1
View File
@@ -14,6 +14,7 @@ func buildFlowRegistry(
emitPub order.Publisher, emitPub order.Publisher,
emailSender order.EmailSender, emailSender order.EmailSender,
emailTemplates order.EmailTemplateStore, emailTemplates order.EmailTemplateStore,
prefChecker order.EmailPreferenceChecker,
logger *slog.Logger, logger *slog.Logger,
) *flow.Registry { ) *flow.Registry {
freg := flow.NewRegistry() freg := flow.NewRegistry()
@@ -21,7 +22,7 @@ func buildFlowRegistry(
order.RegisterFlowActions(freg, applier, provider) order.RegisterFlowActions(freg, applier, provider)
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations) order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
order.RegisterEmitHook(freg, emitPub) order.RegisterEmitHook(freg, emitPub)
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates) order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
order.RegisterFulfillmentWebhookHook(freg, applier, nil) order.RegisterFulfillmentWebhookHook(freg, applier, nil)
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order") order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
registerProjectFlowHooks(freg, applier, logger) registerProjectFlowHooks(freg, applier, logger)
+1 -1
View File
@@ -120,7 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName) return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
} }
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, s.logger) freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, nil, s.logger)
engine := flow.NewEngine(freg, s.logger) engine := flow.NewEngine(freg, s.logger)
st := flow.NewState(ordID, s.logger) st := flow.NewState(ordID, s.logger)
st.Vars[order.PlaceOrderVar] = po st.Vars[order.PlaceOrderVar] = po
+48 -9
View File
@@ -170,14 +170,29 @@ func main() {
} }
} }
} }
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger) // Email preference checker: when a profile service URL is configured, look up
// customer email preferences before sending transactional emails.
var prefChecker order.EmailPreferenceChecker
if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" {
prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers")
logger.Info("email preference checking enabled", "profile_url", profileURL)
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger)
engine := flow.NewEngine(freg, logger) engine := flow.NewEngine(freg, logger)
def, err := order.EmbeddedFlow("place-and-pay") def, err := order.EmbeddedFlow("place-and-pay")
if err != nil { if err != nil {
log.Fatalf("load flow: %v", err) log.Fatalf("load flow: %v", err)
} }
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def}) fulfillDef, err := order.EmbeddedFlow("fulfill-order")
if err != nil {
log.Fatalf("load fulfill flow: %v", err)
}
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{
def.Name: def,
fulfillDef.Name: fulfillDef,
})
if err != nil { if err != nil {
log.Fatalf("load flows: %v", err) log.Fatalf("load flows: %v", err)
} }
@@ -245,6 +260,14 @@ func main() {
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow) mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow) mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
// Email template admin API (used by the backoffice Email Template Manager).
eth := newEmailTemplateHandler(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
mux.HandleFunc("GET /api/order-email-templates", eth.list)
mux.HandleFunc("GET /api/order-email-templates/{name}", eth.get)
mux.HandleFunc("PUT /api/order-email-templates/{name}", eth.save)
mux.HandleFunc("DELETE /api/order-email-templates/{name}", eth.delete)
mux.HandleFunc("POST /api/order-email-templates/{name}/preview", eth.preview)
// Ingest checkout orders from order-queue (Klarna/Adyen fallback). // Ingest checkout orders from order-queue (Klarna/Adyen fallback).
if amqpURL != "" { if amqpURL != "" {
startOrderIngest(context.Background(), amqpURL, s) startOrderIngest(context.Background(), amqpURL, s)
@@ -543,9 +566,9 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
return order.NewMockProvider() return order.NewMockProvider()
} }
// selectEmailSender picks the flow-email transport. SMTP is the only sender // selectEmailSender picks the flow-email transport. Supports SMTP and
// implementation for now; when no SMTP host is configured the hook still exists // MailerSend. When no sender is configured the hook still exists in
// in capabilities but errors at run time if a flow tries to use it. // capabilities but errors at run time if a flow tries to use it.
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) { func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
kind := config.EnvString("ORDER_EMAIL_SENDER", "") kind := config.EnvString("ORDER_EMAIL_SENDER", "")
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" { if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
@@ -554,9 +577,6 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
if kind == "" { if kind == "" {
kind = "smtp" kind = "smtp"
} }
if kind != "smtp" {
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind)
}
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS")) fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
if fromAddr == "" { if fromAddr == "" {
@@ -566,6 +586,9 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")), Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
Address: fromAddr, Address: fromAddr,
} }
switch kind {
case "smtp":
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{ sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }), Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
@@ -577,8 +600,24 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
logger.Info("email sender enabled", "kind", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address) logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
return sender, nil return sender, nil
case "mailersend":
apiKey := os.Getenv("MAILERSEND_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
}
sender, err := order.NewMailerSendEmailSender(apiKey, from)
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
return sender, nil
default:
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
}
} }
func selectInventoryReservationService() (order.InventoryReservationService, error) { func selectInventoryReservationService() (order.InventoryReservationService, error) {
+41 -1
View File
@@ -8,9 +8,11 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/mail"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.k6n.net/mats/go-cart-actor/internal/customerauth" "git.k6n.net/mats/go-cart-actor/internal/customerauth"
@@ -55,6 +57,32 @@ func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Cred
// authSecret returns the HMAC key for customer session cookies. It reads // authSecret returns the HMAC key for customer session cookies. It reads
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and // CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
// warns that issued sessions will not survive a restart (fine for dev). // warns that issued sessions will not survive a restart (fine for dev).
// selectAuthNotifier picks the transactional auth-message sender. When
// MAILERSEND_API_KEY is set it returns a MailerSend-backed notifier; otherwise
// it returns nil (the server defaults to LogNotifier, which is fine for dev).
func selectAuthNotifier() customerauth.Notifier {
apiKey := os.Getenv("MAILERSEND_API_KEY")
if apiKey == "" {
return nil
}
fromAddr := strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_ADDRESS"))
if fromAddr == "" {
log.Print("warning: MAILERSEND_API_KEY set but PROFILE_EMAIL_FROM_ADDRESS is empty — falling back to LogNotifier")
return nil
}
from := mail.Address{
Name: strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_NAME")),
Address: fromAddr,
}
n, err := customerauth.NewMailerSendNotifier(apiKey, from)
if err != nil {
log.Printf("warning: mailersend notifier: %v — falling back to LogNotifier", err)
return nil
}
log.Print("customer-auth: using MailerSend notifier for verification and reset emails")
return n
}
func authSecret() []byte { func authSecret() []byte {
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" { if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
return []byte(v) return []byte(v)
@@ -172,9 +200,19 @@ func main() {
// CUSTOMER_AUTH_SECRET is all they need to work across replicas. // CUSTOMER_AUTH_SECRET is all they need to work across replicas.
credStore, limiter := buildAuthStorage(ctx, profileDir) credStore, limiter := buildAuthStorage(ctx, profileDir)
// Email index: in-memory email→profileID map so the order service can
// look up email preferences without scanning all profiles. Built from
// the event log on disk at startup, then kept live by the UCP handlers.
emailIndex := profile.NewProfileEmailIndex()
stateStorage := actor.NewState(reg)
profile.BuildEmailIndex(profileDir, stateStorage, reg, emailIndex)
// UCP Customer REST adapter // UCP Customer REST adapter
auditLogPath := filepath.Join(profileDir, "audit.log") auditLogPath := filepath.Join(profileDir, "audit.log")
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore) customerUCP := ucp.CustomerHandler(pool, auditLogPath,
ucp.WithEmailIndex(emailIndex),
ucp.WithCredentialDeleter(credStore),
)
if signer := loadUCPProfileSigner(); signer != nil { if signer := loadUCPProfileSigner(); signer != nil {
customerUCP = ucp.WithSigning(customerUCP, signer) customerUCP = ucp.WithSigning(customerUCP, signer)
log.Print("ucp customer signing enabled") log.Print("ucp customer signing enabled")
@@ -185,7 +223,9 @@ func main() {
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP)) mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
// Customer auth: password signup/login + session cookies + identity linking. // Customer auth: password signup/login + session cookies + identity linking.
authNotifier := selectAuthNotifier()
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{ authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
Notifier: authNotifier,
Limiter: limiter, Limiter: limiter,
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"), BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true", RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
+1
View File
@@ -63,6 +63,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/mailersend/mailersend-go v1.4.0
github.com/mailru/easyjson v0.9.1 // indirect github.com/mailru/easyjson v0.9.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+4
View File
@@ -129,6 +129,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
@@ -138,6 +140,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
github.com/mailersend/mailersend-go v1.4.0 h1:8IXN3HXoyKh6qG0IyTESedEjlaYsZfT0XnV2k7XzjvE=
github.com/mailersend/mailersend-go v1.4.0/go.mod h1:4MeiOnzmjWCsXRNdjg6NGzsijsVrmQ8E/T003/ystQU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI= github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
+86
View File
@@ -0,0 +1,86 @@
package customerauth
import (
"context"
"fmt"
"log"
"net/mail"
"strings"
"time"
"github.com/mailersend/mailersend-go"
)
// MailerSendNotifier delivers transactional auth messages (email verification
// and password reset) through the MailerSend API. It implements the Notifier
// interface and is a drop-in replacement for LogNotifier in production.
type MailerSendNotifier struct {
client *mailersend.Mailersend
from mail.Address
}
// NewMailerSendNotifier creates a new MailerSend-backed notifier. The apiKey
// is the MailerSend API token (MAILERSEND_API_KEY env var). The from address
// must include at least an email; the name portion is optional.
func NewMailerSendNotifier(apiKey string, from mail.Address) (*MailerSendNotifier, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("mailersend notifier: missing API key")
}
if strings.TrimSpace(from.Address) == "" {
return nil, fmt.Errorf("mailersend notifier: missing from address")
}
return &MailerSendNotifier{
client: mailersend.NewMailersend(apiKey),
from: from,
}, nil
}
// SendEmailVerification sends the verification link to the customer's email
// address. Errors are logged but not returned (best-effort delivery matches the
// signature of the Notifier interface).
func (n *MailerSendNotifier) SendEmailVerification(email, link string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := n.send(ctx, email,
"Verify your email address",
fmt.Sprintf("Click the link to verify your email address:\n\n%s", link),
fmt.Sprintf(`<p>Click <a href="%s">here</a> to verify your email address.</p>`, link),
); err != nil {
log.Printf("customerauth: send verification to %s: %v", email, err)
}
}
// SendPasswordReset sends the password-reset link to the customer's email.
// Errors are logged but not returned.
func (n *MailerSendNotifier) SendPasswordReset(email, link string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := n.send(ctx, email,
"Reset your password",
fmt.Sprintf("Click the link to reset your password:\n\n%s\n\nIf you didn't request this, ignore this email.", link),
fmt.Sprintf(`<p>Click <a href="%s">here</a> to reset your password.</p><p>If you didn't request this, ignore this email.</p>`, link),
); err != nil {
log.Printf("customerauth: send password reset to %s: %v", email, err)
}
}
func (n *MailerSendNotifier) send(ctx context.Context, to, subject, textBody, htmlBody string) error {
message := n.client.Email.NewMessage()
message.SetFrom(mailersend.From{
Name: n.from.Name,
Email: n.from.Address,
})
message.SetRecipients([]mailersend.Recipient{
{Email: to},
})
message.SetSubject(subject)
message.SetText(textBody)
if htmlBody != "" {
message.SetHTML(htmlBody)
}
_, err := n.client.Email.Send(ctx, message)
if err != nil {
return fmt.Errorf("mailersend: %w", err)
}
return nil
}
+75
View File
@@ -19,6 +19,7 @@ type CustomerServer struct {
applier ProfileApplier applier ProfileApplier
deleter CredentialDeleter deleter CredentialDeleter
auditLogPath string auditLogPath string
emailIndex *profile.ProfileEmailIndex // optional, maintained by mutations
} }
// NewCustomerServer builds a UCP REST adapter over a profile grain pool. // NewCustomerServer builds a UCP REST adapter over a profile grain pool.
@@ -30,6 +31,33 @@ func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...C
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath} return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
} }
// SetEmailIndex attaches a ProfileEmailIndex that is updated by every
// customer mutation (create, update, delete).
func (s *CustomerServer) SetEmailIndex(ix *profile.ProfileEmailIndex) {
s.emailIndex = ix
}
// indexProfileAfterMutation reads the grain and updates the email index.
func (s *CustomerServer) indexProfileAfterMutation(ctx context.Context, id uint64) {
if s.emailIndex == nil {
return
}
g, err := s.applier.Get(ctx, id)
if err != nil {
return
}
prefs := g.EmailPreferences
// Copy so the index holds its own snapshot (the grain is pooled).
if prefs != nil {
cp := &profile.EmailPreferences{
OrderEmails: prefs.OrderEmails,
MarketingEmails: prefs.MarketingEmails,
}
prefs = cp
}
s.emailIndex.Set(id, g.Email, prefs)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// UCP Customer endpoints // UCP Customer endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -61,6 +89,11 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
AvatarUrl: req.AvatarUrl, AvatarUrl: req.AvatarUrl,
} }
if req.EmailPreferences != nil {
msg.OrderEmails = req.EmailPreferences.OrderEmails
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error()) writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
return return
@@ -72,6 +105,8 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
return return
} }
s.indexProfileAfterMutation(r.Context(), id)
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g)) writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
} }
@@ -132,6 +167,11 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
AvatarUrl: req.AvatarUrl, AvatarUrl: req.AvatarUrl,
} }
if req.EmailPreferences != nil {
msg.OrderEmails = req.EmailPreferences.OrderEmails
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil { if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error()) writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
return return
@@ -143,9 +183,36 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
return return
} }
s.indexProfileAfterMutation(r.Context(), id)
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g)) writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
} }
// handleGetCustomerByEmail handles GET /customers/by-email/{email} — looks up
// the customer profile from the email index and returns the customer response.
// Returns 404 when the email is not found.
func (s *CustomerServer) handleGetCustomerByEmail(w http.ResponseWriter, r *http.Request) {
email := r.PathValue("email")
if email == "" || s.emailIndex == nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
entry, ok := s.emailIndex.Lookup(email)
if !ok {
writeError(w, http.StatusNotFound, "customer not found")
return
}
g, err := s.applier.Get(r.Context(), entry.ID)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(profile.ProfileId(entry.ID).String(), g))
}
// handleAddAddress handles POST /customers/{id}/addresses. // handleAddAddress handles POST /customers/{id}/addresses.
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) { func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r) id, ok := parseCustomerID(r)
@@ -477,5 +544,13 @@ func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerRespons
}) })
} }
if g.EmailPreferences != nil {
prefs := &EmailPreferencesResponse{
OrderEmails: g.EmailPreferences.OrderEmails,
MarketingEmails: g.EmailPreferences.MarketingEmails,
}
resp.EmailPreferences = prefs
}
return resp return resp
} }
+1 -1
View File
@@ -519,7 +519,7 @@ func TestDeleteCustomer(t *testing.T) {
id := testProfileID(t, applier) id := testProfileID(t, applier)
deleter := &testCredentialDeleter{} deleter := &testCredentialDeleter{}
auditPath := filepath.Join(t.TempDir(), "audit.log") auditPath := filepath.Join(t.TempDir(), "audit.log")
handler := CustomerHandler(applier, auditPath, deleter) handler := CustomerHandler(applier, auditPath, WithCredentialDeleter(deleter))
// Set up some profile data // Set up some profile data
pid, _ := profile.ParseProfileId(id) pid, _ := profile.ParseProfileId(id)
+30 -3
View File
@@ -1,6 +1,10 @@
package ucp package ucp
import "net/http" import (
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Order HTTP handler mounting // Order HTTP handler mounting
@@ -86,11 +90,33 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning: // To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
// //
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer)) // mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler { // CustomerHandlerOption configures a CustomerHandler with optional dependencies.
s := NewCustomerServer(applier, auditLogPath, deleter...) type CustomerHandlerOption func(s *CustomerServer)
// WithEmailIndex attaches a ProfileEmailIndex that the customer handler
// updates on every mutation and uses for the GET /by-email/{email} route.
func WithEmailIndex(ix *profile.ProfileEmailIndex) CustomerHandlerOption {
return func(s *CustomerServer) {
s.SetEmailIndex(ix)
}
}
// WithCredentialDeleter attaches a credential deleter for GDPR erasure.
func WithCredentialDeleter(deleter CredentialDeleter) CustomerHandlerOption {
return func(s *CustomerServer) {
s.deleter = deleter
}
}
func CustomerHandler(applier ProfileApplier, auditLogPath string, opts ...CustomerHandlerOption) http.Handler {
s := NewCustomerServer(applier, auditLogPath)
for _, opt := range opts {
opt(s)
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCustomer) mux.HandleFunc("POST /", s.handleCreateCustomer)
mux.HandleFunc("GET /{id}", s.handleGetCustomer) mux.HandleFunc("GET /{id}", s.handleGetCustomer)
mux.HandleFunc("GET /by-email/{email}", s.handleGetCustomerByEmail)
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer) mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer) mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress) mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
@@ -154,6 +180,7 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter) customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer) mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer) mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
mux.HandleFunc("GET /customers/by-email/{email}", customerSrv.handleGetCustomerByEmail)
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer) mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer) mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress) mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
+14
View File
@@ -335,6 +335,12 @@ type OrderLineEntry struct {
// UCP Customer types // UCP Customer types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// EmailPreferencesResponse is the UCP wire format for email preference toggles.
type EmailPreferencesResponse struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// CustomerResponse is the UCP customer profile response body. // CustomerResponse is the UCP customer profile response body.
type CustomerResponse struct { type CustomerResponse struct {
Id string `json:"id"` Id string `json:"id"`
@@ -345,6 +351,7 @@ type CustomerResponse struct {
Currency string `json:"currency,omitempty"` Currency string `json:"currency,omitempty"`
AvatarUrl string `json:"avatarUrl,omitempty"` AvatarUrl string `json:"avatarUrl,omitempty"`
Addresses []AddressResponse `json:"addresses,omitempty"` Addresses []AddressResponse `json:"addresses,omitempty"`
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
} }
// AddressResponse is the UCP wire format for an address. // AddressResponse is the UCP wire format for an address.
@@ -363,6 +370,12 @@ type AddressResponse struct {
IsDefaultBilling bool `json:"isDefaultBilling"` IsDefaultBilling bool `json:"isDefaultBilling"`
} }
// EmailPreferencesInput is the body for updating a customer's email preferences.
type EmailPreferencesInput struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// CustomerUpdateRequest is the body for PUT /customers/{id}. // CustomerUpdateRequest is the body for PUT /customers/{id}.
type CustomerUpdateRequest struct { type CustomerUpdateRequest struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
@@ -371,6 +384,7 @@ type CustomerUpdateRequest struct {
Language *string `json:"language,omitempty"` Language *string `json:"language,omitempty"`
Currency *string `json:"currency,omitempty"` Currency *string `json:"currency,omitempty"`
AvatarUrl *string `json:"avatarUrl,omitempty"` AvatarUrl *string `json:"avatarUrl,omitempty"`
EmailPreferences *EmailPreferencesInput `json:"emailPreferences,omitempty"`
} }
// AddAddressRequest is the body for POST /customers/{id}/addresses. // AddAddressRequest is the body for POST /customers/{id}/addresses.
+87 -3
View File
@@ -21,6 +21,7 @@ import (
"time" "time"
"git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/platform/money"
) )
// EmailMessage is the rendered mail payload a sender delivers. // EmailMessage is the rendered mail payload a sender delivers.
@@ -229,6 +230,12 @@ type EmailTemplate struct {
} }
func (t EmailTemplate) validate(name string) error { 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) == "" { if strings.TrimSpace(t.Subject) == "" {
return fmt.Errorf("email template %q: missing subject", name) return fmt.Errorf("email template %q: missing subject", name)
} }
@@ -330,6 +337,7 @@ type emailHookParams struct {
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"` Subject string `json:"subject,omitempty"`
Category string `json:"category,omitempty"` // "order" or "marketing"; checked against preferences
} }
type flowTemplateData struct { type flowTemplateData struct {
@@ -344,16 +352,30 @@ type flowTemplateData struct {
BillingAddress any 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 // RegisterEmailHook registers the "send_email" flow hook. It reads the current
// order for template data, renders the named template, and delivers it through // 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", // "template": "fulfillment-shipped",
// "to": "{{ .Order.CustomerEmail }}", // "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 { reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
if sender == nil { if sender == nil {
return fmt.Errorf("send_email: no sender configured") 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) 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() from := sender.DefaultFrom()
if strings.TrimSpace(p.From) != "" { if strings.TrimSpace(p.From) != "" {
fromRendered, err := renderTextTemplate("email-from", p.From, data) fromRendered, err := renderTextTemplate("email-from", p.From, data)
@@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any {
return v 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) { func renderTextTemplate(name, src string, data any) (string, error) {
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src) tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
if err != nil { if err != nil {
@@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) {
return renderTextTemplate(name, src, data) 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) { func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
if strings.TrimSpace(src) == "" { if strings.TrimSpace(src) == "" {
return "", nil return "", nil
+106
View File
@@ -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>"
}
+4
View File
@@ -44,6 +44,10 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
// place-and-pay references the emit_order_created hook; register it (nil // place-and-pay references the emit_order_created hook; register it (nil
// publisher = no-op) so flow validation passes in tests too. // publisher = no-op) so flow validation passes in tests too.
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order") 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 return reg
} }
+17
View File
@@ -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 }
]
}
}
]
}
+6 -2
View File
@@ -1,12 +1,15 @@
{ {
"name": "place-and-pay", "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": [ "steps": [
{ {
"name": "place", "name": "place",
"action": "place_order", "action": "place_order",
"hooks": { "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": { "hooks": {
"after": [ "after": [
{ "type": "log", "params": { "message": "payment captured" } }, { "type": "log", "params": { "message": "payment captured" } },
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } },
{ "type": "emit_order_created" } { "type": "emit_order_created" }
] ]
} }
+84
View File
@@ -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
}
+126
View File
@@ -0,0 +1,126 @@
package profile
import (
"bytes"
"io"
"context"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"google.golang.org/protobuf/proto"
)
// emailIndexEntry captures the email preferences snapshot for a profile.
type emailIndexEntry struct {
ID uint64
EmailPreferences *EmailPreferences
}
// ProfileEmailIndex is a thread-safe in-memory map from email address to
// profile ID and email preferences snapshot. It is populated at startup by
// scanning the profile storage directory, and updated when SetProfile
// mutations change the email or preferences.
type ProfileEmailIndex struct {
mu sync.RWMutex
byID map[uint64]string // profileID → email (for cleanup on email change)
idx map[string]emailIndexEntry // email → profile info
}
// NewProfileEmailIndex returns an empty index.
func NewProfileEmailIndex() *ProfileEmailIndex {
return &ProfileEmailIndex{
byID: make(map[uint64]string),
idx: make(map[string]emailIndexEntry),
}
}
// Set records or updates the email→profile mapping. When email is empty the
// entry is removed (profile was anonymized).
func (ix *ProfileEmailIndex) Set(profileID uint64, email string, prefs *EmailPreferences) {
ix.mu.Lock()
defer ix.mu.Unlock()
// Remove any previous email for this profile.
if oldEmail, ok := ix.byID[profileID]; ok {
delete(ix.idx, oldEmail)
}
if strings.TrimSpace(email) == "" {
delete(ix.byID, profileID)
return
}
email = strings.ToLower(strings.TrimSpace(email))
ix.byID[profileID] = email
ix.idx[email] = emailIndexEntry{
ID: profileID,
EmailPreferences: prefs,
}
}
// Lookup returns the email preferences for the given email. The second return
// value is false when the email is not found.
func (ix *ProfileEmailIndex) Lookup(email string) (emailIndexEntry, bool) {
ix.mu.RLock()
defer ix.mu.RUnlock()
entry, ok := ix.idx[strings.ToLower(strings.TrimSpace(email))]
return entry, ok
}
// BuildEmailIndex populates the index by scanning the profile storage
// directory and replaying each event log into a fresh ProfileGrain. It must
// be called once at service startup after the mutation registry and storage
// are initialised.
//
// dir — the same path passed to actor.NewDiskStorage (e.g. "data/profiles").
// replayer — an *actor.StateStorage for reading event log files.
// reg — the profile mutation registry.
// ix — the (empty) index to populate.
func BuildEmailIndex(dir string, replayer interface {
Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error
}, reg actor.MutationRegistry, ix *ProfileEmailIndex) {
entries, err := os.ReadDir(dir)
if err != nil {
log.Printf("email index: cannot read profile dir %s: %v — starting with empty index", dir, err)
return
}
var count int
ctx := context.Background()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".events.log") {
continue
}
idStr := strings.TrimSuffix(entry.Name(), ".events.log")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
continue
}
p := NewProfileGrain(id, time.Now())
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
log.Printf("email index: read %s: %v", entry.Name(), err)
continue
}
if err := replayer.Load(bytes.NewReader(data), func(msg proto.Message, _ time.Time) { //nolint:govet
_, _ = reg.Apply(ctx, p, msg)
}); err != nil {
log.Printf("email index: replay %s: %v", entry.Name(), err)
continue
}
if p.Email != "" {
ix.Set(p.Id, p.Email, p.EmailPreferences)
count++
}
}
log.Printf("email index: built %d entries from %s", count, dir)
}
+11
View File
@@ -29,5 +29,16 @@ func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error {
if m.AvatarUrl != nil { if m.AvatarUrl != nil {
p.AvatarUrl = *m.AvatarUrl p.AvatarUrl = *m.AvatarUrl
} }
if m.OrderEmails != nil || m.MarketingEmails != nil {
if p.EmailPreferences == nil {
p.EmailPreferences = &EmailPreferences{}
}
if m.OrderEmails != nil {
p.EmailPreferences.OrderEmails = m.OrderEmails
}
if m.MarketingEmails != nil {
p.EmailPreferences.MarketingEmails = m.MarketingEmails
}
}
return nil return nil
} }
+10
View File
@@ -29,6 +29,15 @@ type LinkedOrder struct {
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
} }
// EmailPreferences controls which transactional emails a customer receives.
// All fields default to true (opted in) when nil/omitted.
type EmailPreferences struct {
// OrderEmails sends order confirmations, shipping updates, receipts.
OrderEmails *bool `json:"orderEmails,omitempty"`
// MarketingEmails sends promotional offers, newsletters, product recommendations.
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// StoredAddress is a saved address in the user's address book. // StoredAddress is a saved address in the user's address book.
type StoredAddress struct { type StoredAddress struct {
Id uint32 `json:"id"` Id uint32 `json:"id"`
@@ -65,6 +74,7 @@ type ProfileGrain struct {
Checkouts []LinkedCheckout `json:"checkouts,omitempty"` Checkouts []LinkedCheckout `json:"checkouts,omitempty"`
Orders []LinkedOrder `json:"orders,omitempty"` Orders []LinkedOrder `json:"orders,omitempty"`
Addresses []StoredAddress `json:"addresses,omitempty"` Addresses []StoredAddress `json:"addresses,omitempty"`
EmailPreferences *EmailPreferences `json:"emailPreferences,omitempty"`
} }
// NewProfileGrain returns an empty profile grain for id. // NewProfileGrain returns an empty profile grain for id.
+16
View File
@@ -163,6 +163,8 @@ type SetProfile struct {
Language *string `protobuf:"bytes,4,opt,name=language,proto3,oneof" json:"language,omitempty"` Language *string `protobuf:"bytes,4,opt,name=language,proto3,oneof" json:"language,omitempty"`
Currency *string `protobuf:"bytes,5,opt,name=currency,proto3,oneof" json:"currency,omitempty"` Currency *string `protobuf:"bytes,5,opt,name=currency,proto3,oneof" json:"currency,omitempty"`
AvatarUrl *string `protobuf:"bytes,6,opt,name=avatarUrl,proto3,oneof" json:"avatarUrl,omitempty"` AvatarUrl *string `protobuf:"bytes,6,opt,name=avatarUrl,proto3,oneof" json:"avatarUrl,omitempty"`
OrderEmails *bool `protobuf:"varint,7,opt,name=orderEmails,proto3,oneof" json:"orderEmails,omitempty"`
MarketingEmails *bool `protobuf:"varint,8,opt,name=marketingEmails,proto3,oneof" json:"marketingEmails,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -239,6 +241,20 @@ func (x *SetProfile) GetAvatarUrl() string {
return "" return ""
} }
func (x *SetProfile) GetOrderEmails() bool {
if x != nil && x.OrderEmails != nil {
return *x.OrderEmails
}
return false
}
func (x *SetProfile) GetMarketingEmails() bool {
if x != nil && x.MarketingEmails != nil {
return *x.MarketingEmails
}
return false
}
// AddAddress adds a new address to the user's address book. // AddAddress adds a new address to the user's address book.
type AddAddress struct { type AddAddress struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`