diff --git a/cmd/cart/main.go b/cmd/cart/main.go index f0f3be6..c676af7 100644 --- a/cmd/cart/main.go +++ b/cmd/cart/main.go @@ -29,7 +29,6 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" - amqp "github.com/rabbitmq/amqp091-go" "github.com/redis/go-redis/v9" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) @@ -160,35 +159,6 @@ func (c *catalogProjectionCache) Len() int { 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() { // 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) 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) _ = ch.Close() } diff --git a/cmd/order/amqp_test.go b/cmd/order/amqp_test.go index 49a4bec..7d2c506 100644 --- a/cmd/order/amqp_test.go +++ b/cmd/order/amqp_test.go @@ -41,7 +41,7 @@ func testServer(t *testing.T) *server { applier := &orderedApplier{pool: pool, storage: storage} 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))) def, err := order.EmbeddedFlow("place-and-pay") diff --git a/cmd/order/custom_hooks_test.go b/cmd/order/custom_hooks_test.go index 170bfdf..4ad796d 100644 --- a/cmd/order/custom_hooks_test.go +++ b/cmd/order/custom_hooks_test.go @@ -26,7 +26,7 @@ func TestProjectFulfillmentIntegrationLogs(t *testing.T) { logger := slog.New(slog.NewJSONHandler(&logs, nil)) 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) st := flow.NewState(801, logger) diff --git a/cmd/order/email_templates.go b/cmd/order/email_templates.go new file mode 100644 index 0000000..bd4b06b --- /dev/null +++ b/cmd/order/email_templates.go @@ -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 +} diff --git a/cmd/order/flow_registry.go b/cmd/order/flow_registry.go index 8781224..58ceff4 100644 --- a/cmd/order/flow_registry.go +++ b/cmd/order/flow_registry.go @@ -14,6 +14,7 @@ func buildFlowRegistry( emitPub order.Publisher, emailSender order.EmailSender, emailTemplates order.EmailTemplateStore, + prefChecker order.EmailPreferenceChecker, logger *slog.Logger, ) *flow.Registry { freg := flow.NewRegistry() @@ -21,7 +22,7 @@ func buildFlowRegistry( order.RegisterFlowActions(freg, applier, provider) order.RegisterInventoryReservationActions(freg, applier, inventoryReservations) order.RegisterEmitHook(freg, emitPub) - order.RegisterEmailHook(freg, applier, emailSender, emailTemplates) + order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker) order.RegisterFulfillmentWebhookHook(freg, applier, nil) order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order") registerProjectFlowHooks(freg, applier, logger) diff --git a/cmd/order/handlers_checkout.go b/cmd/order/handlers_checkout.go index 503ca42..5b42d12 100644 --- a/cmd/order/handlers_checkout.go +++ b/cmd/order/handlers_checkout.go @@ -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) } - 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) st := flow.NewState(ordID, s.logger) st.Vars[order.PlaceOrderVar] = po diff --git a/cmd/order/main.go b/cmd/order/main.go index 9cd1488..bb5850a 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -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) def, err := order.EmbeddedFlow("place-and-pay") if err != nil { 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 { log.Fatalf("load flows: %v", err) } @@ -245,6 +260,14 @@ func main() { mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow) 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). if amqpURL != "" { startOrderIngest(context.Background(), amqpURL, s) @@ -543,9 +566,9 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider { return order.NewMockProvider() } -// selectEmailSender picks the flow-email transport. SMTP is the only sender -// implementation for now; when no SMTP host is configured the hook still exists -// in capabilities but errors at run time if a flow tries to use it. +// selectEmailSender picks the flow-email transport. Supports SMTP and +// MailerSend. When no sender is configured the hook still exists in +// capabilities but errors at run time if a flow tries to use it. func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) { kind := config.EnvString("ORDER_EMAIL_SENDER", "") if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" { @@ -554,9 +577,6 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) { if kind == "" { kind = "smtp" } - if kind != "smtp" { - return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind) - } fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS")) if fromAddr == "" { @@ -566,19 +586,38 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) { Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")), Address: fromAddr, } - sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{ - Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), - Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }), - Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"), - Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"), - DefaultFrom: from, - Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second), - }) - if err != nil { - return nil, err + + switch kind { + case "smtp": + sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{ + Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), + Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }), + Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"), + Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"), + DefaultFrom: from, + Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second), + }) + if err != nil { + return nil, err + } + logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address) + 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) } - logger.Info("email sender enabled", "kind", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address) - return sender, nil } func selectInventoryReservationService() (order.InventoryReservationService, error) { diff --git a/cmd/profile/main.go b/cmd/profile/main.go index f20d8a5..3b85cec 100644 --- a/cmd/profile/main.go +++ b/cmd/profile/main.go @@ -8,9 +8,11 @@ import ( "log" "net" "net/http" + "net/mail" "os" "os/signal" "path/filepath" + "strings" "time" "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 // CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and // 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 { if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" { return []byte(v) @@ -172,9 +200,19 @@ func main() { // CUSTOMER_AUTH_SECRET is all they need to work across replicas. 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 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 { customerUCP = ucp.WithSigning(customerUCP, signer) log.Print("ucp customer signing enabled") @@ -185,7 +223,9 @@ func main() { mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP)) // Customer auth: password signup/login + session cookies + identity linking. + authNotifier := selectAuthNotifier() authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{ + Notifier: authNotifier, Limiter: limiter, BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"), RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true", diff --git a/go.mod b/go.mod index 067381d..fb041b9 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/josharian/intern v1.0.0 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect diff --git a/go.work.sum b/go.work.sum index d1ce332..b5c840a 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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/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-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/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 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/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/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-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI= diff --git a/internal/customerauth/mail_notifier.go b/internal/customerauth/mail_notifier.go new file mode 100644 index 0000000..e9314e6 --- /dev/null +++ b/internal/customerauth/mail_notifier.go @@ -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(`
Click here to verify your email address.
`, 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(`Click here to reset your password.
If you didn't request this, ignore this email.
`, 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 +} diff --git a/internal/ucp/customer.go b/internal/ucp/customer.go index 86423a8..4450cd3 100644 --- a/internal/ucp/customer.go +++ b/internal/ucp/customer.go @@ -19,6 +19,7 @@ type CustomerServer struct { applier ProfileApplier deleter CredentialDeleter auditLogPath string + emailIndex *profile.ProfileEmailIndex // optional, maintained by mutations } // 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} } +// 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 // --------------------------------------------------------------------------- @@ -61,6 +89,11 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req 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 { writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error()) return @@ -72,6 +105,8 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req return } + s.indexProfileAfterMutation(r.Context(), id) + 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, } + 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 { writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error()) return @@ -143,9 +183,36 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req return } + s.indexProfileAfterMutation(r.Context(), id) + 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. func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) { 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 } diff --git a/internal/ucp/customer_test.go b/internal/ucp/customer_test.go index 1c6b405..deb99aa 100644 --- a/internal/ucp/customer_test.go +++ b/internal/ucp/customer_test.go @@ -519,7 +519,7 @@ func TestDeleteCustomer(t *testing.T) { id := testProfileID(t, applier) deleter := &testCredentialDeleter{} auditPath := filepath.Join(t.TempDir(), "audit.log") - handler := CustomerHandler(applier, auditPath, deleter) + handler := CustomerHandler(applier, auditPath, WithCredentialDeleter(deleter)) // Set up some profile data pid, _ := profile.ParseProfileId(id) diff --git a/internal/ucp/handler.go b/internal/ucp/handler.go index e9f8738..93d2d7f 100644 --- a/internal/ucp/handler.go +++ b/internal/ucp/handler.go @@ -1,6 +1,10 @@ package ucp -import "net/http" +import ( + "net/http" + + "git.k6n.net/mats/go-cart-actor/pkg/profile" +) // --------------------------------------------------------------------------- // 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: // // mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer)) -func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler { - s := NewCustomerServer(applier, auditLogPath, deleter...) +// CustomerHandlerOption configures a CustomerHandler with optional dependencies. +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.HandleFunc("POST /", s.handleCreateCustomer) mux.HandleFunc("GET /{id}", s.handleGetCustomer) + mux.HandleFunc("GET /by-email/{email}", s.handleGetCustomerByEmail) mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer) mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer) 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) mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer) 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("DELETE /customers/{id}", customerSrv.handleDeleteCustomer) mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress) diff --git a/internal/ucp/types.go b/internal/ucp/types.go index 407564a..57c3979 100644 --- a/internal/ucp/types.go +++ b/internal/ucp/types.go @@ -335,16 +335,23 @@ type OrderLineEntry struct { // 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. type CustomerResponse struct { - Id string `json:"id"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Phone string `json:"phone,omitempty"` - Language string `json:"language,omitempty"` - Currency string `json:"currency,omitempty"` - AvatarUrl string `json:"avatarUrl,omitempty"` - Addresses []AddressResponse `json:"addresses,omitempty"` + Id string `json:"id"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Language string `json:"language,omitempty"` + Currency string `json:"currency,omitempty"` + AvatarUrl string `json:"avatarUrl,omitempty"` + Addresses []AddressResponse `json:"addresses,omitempty"` + EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"` } // AddressResponse is the UCP wire format for an address. @@ -363,14 +370,21 @@ type AddressResponse struct { 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}. type CustomerUpdateRequest struct { - Name *string `json:"name,omitempty"` - Email *string `json:"email,omitempty"` - Phone *string `json:"phone,omitempty"` - Language *string `json:"language,omitempty"` - Currency *string `json:"currency,omitempty"` - AvatarUrl *string `json:"avatarUrl,omitempty"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Language *string `json:"language,omitempty"` + Currency *string `json:"currency,omitempty"` + AvatarUrl *string `json:"avatarUrl,omitempty"` + EmailPreferences *EmailPreferencesInput `json:"emailPreferences,omitempty"` } // AddAddressRequest is the body for POST /customers/{id}/addresses. diff --git a/pkg/order/email.go b/pkg/order/email.go index 2c8be98..04ace21 100644 --- a/pkg/order/email.go +++ b/pkg/order/email.go @@ -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": "WarehouseHi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},
Your order {{ .Order.OrderReference }} has been placed successfully.
We'll send you an update when your payment is confirmed and your items are on their way.
Order id: {{ .OrderID }}
Thank you for your purchase!
" +} diff --git a/pkg/order/email_templates/payment-receipt.json b/pkg/order/email_templates/payment-receipt.json new file mode 100644 index 0000000..20a2a13 --- /dev/null +++ b/pkg/order/email_templates/payment-receipt.json @@ -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": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},
Your payment for order {{ .Order.OrderReference }} has been received and confirmed.
We'll let you know as soon as your items ship.
Order id: {{ .OrderID }}
Thank you for shopping with us!
" +} diff --git a/pkg/order/email_templates/refund-issued.json b/pkg/order/email_templates/refund-issued.json new file mode 100644 index 0000000..e89f555 --- /dev/null +++ b/pkg/order/email_templates/refund-issued.json @@ -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": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},
A refund has been issued for order {{ .Order.OrderReference }}.
The amount should appear in your account within a few business days, depending on your payment provider.
Order id: {{ .OrderID }}
Thank you for your patience.
" +} diff --git a/pkg/order/email_templates/return-confirmed.json b/pkg/order/email_templates/return-confirmed.json new file mode 100644 index 0000000..f9b848d --- /dev/null +++ b/pkg/order/email_templates/return-confirmed.json @@ -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": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},
We've received your return request for order {{ .Order.OrderReference }}.
We'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.
Order id: {{ .OrderID }}
If you have any questions, please don't hesitate to contact us.
" +} diff --git a/pkg/order/email_templates/shipment-delivered.json b/pkg/order/email_templates/shipment-delivered.json new file mode 100644 index 0000000..8968c13 --- /dev/null +++ b/pkg/order/email_templates/shipment-delivered.json @@ -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": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},
Your package for order {{ .Order.OrderReference }} has been delivered.
{{ with .Fulfillment }}We hope you're happy with your purchase!
Order id: {{ .OrderID }}