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": "Warehouse " +// "from": "Warehouse ", +// "category": "order" // } -func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) { +func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore, checker ...EmailPreferenceChecker) { + var prefCheck EmailPreferenceChecker + if len(checker) > 0 { + prefCheck = checker[0] + } reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error { if sender == nil { return fmt.Errorf("send_email: no sender configured") @@ -414,6 +436,14 @@ func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, temp to = append(to, *rcpt) } + // Check email preferences before sending. + if prefCheck != nil && p.Category != "" && len(to) > 0 { + email := to[0].Address + if !prefCheck.Allowed(ctx, email, p.Category) { + return nil // silently skip — customer opted out + } + } + from := sender.DefaultFrom() if strings.TrimSpace(p.From) != "" { fromRendered, err := renderTextTemplate("email-from", p.From, data) @@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any { return v } +func RenderTextTemplate(name, src string, data any) (string, error) { + return renderTextTemplate(name, src, data) +} + +// RenderOptionalHTMLTemplate renders an HTML template with the given data, or +// returns empty string when src is empty. +func RenderOptionalHTMLTemplate(name, src string, data any) (string, error) { + return renderOptionalHTMLTemplate(name, src, data) +} + func renderTextTemplate(name, src string, data any) (string, error) { tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src) if err != nil { @@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) { return renderTextTemplate(name, src, data) } +// SampleTemplateData returns a flowTemplateData populated with sample values +// for previewing email templates in the admin UI. +func SampleTemplateData() any { + now := time.Now().UTC().Format(time.RFC3339) + return flowTemplateData{ + Order: &OrderGrain{ + Id: 12345, + OrderReference: "ORD-SAMPLE-001", + Currency: "SEK", + Status: StatusCaptured, + CustomerName: "Anna Svensson", + CustomerEmail: "anna@example.com", + TotalAmount: money.Cents(29900), + Lines: []Line{ + {Reference: "l1", Sku: "PROD-001", Name: "Sample Product", Quantity: 1, UnitPrice: 29900, TotalAmount: 29900, TaxRate: 2500}, + }, + PlacedAt: now, + }, + OrderID: "abc123", + Fulfillment: &Fulfillment{ + ID: "f_ship001", + Carrier: "PostNord", + TrackingNumber: "SE-123456789", + TrackingURI: "https://tracking.postnord.se/123456789", + }, + ShippingAddress: map[string]any{ + "givenName": "Anna", + "familyName": "Svensson", + "street": "Storgatan 1", + "city": "Stockholm", + "postalCode": "111 22", + "country": "SE", + }, + BillingAddress: map[string]any{ + "givenName": "Anna", + "familyName": "Svensson", + "street": "Storgatan 1", + "city": "Stockholm", + "postalCode": "111 22", + "country": "SE", + }, + } +} + func renderOptionalHTMLTemplate(name, src string, data any) (string, error) { if strings.TrimSpace(src) == "" { return "", nil diff --git a/pkg/order/email_preferences.go b/pkg/order/email_preferences.go new file mode 100644 index 0000000..e355790 --- /dev/null +++ b/pkg/order/email_preferences.go @@ -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 + } +} diff --git a/pkg/order/email_templates/order-confirmation.json b/pkg/order/email_templates/order-confirmation.json new file mode 100644 index 0000000..db9c04c --- /dev/null +++ b/pkg/order/email_templates/order-confirmation.json @@ -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": "

Hi {{ 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 }}{{ end }}

We hope you're happy with your purchase!

Order id: {{ .OrderID }}

" +} diff --git a/pkg/order/flow_test.go b/pkg/order/flow_test.go index c684e53..7badb25 100644 --- a/pkg/order/flow_test.go +++ b/pkg/order/flow_test.go @@ -44,6 +44,10 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry { // place-and-pay references the emit_order_created hook; register it (nil // publisher = no-op) so flow validation passes in tests too. RegisterOrderCreatedEmit(reg, pool, nil, "order", "order") + // place-and-pay also references send_email hooks; register it with nil + // sender and nil templates so validation passes (runtime = error, tests + // that exercise email have their own sender/templates). + RegisterEmailHook(reg, pool, nil, nil) return reg } diff --git a/pkg/order/flows/fulfill-order.json b/pkg/order/flows/fulfill-order.json new file mode 100644 index 0000000..0dfa548 --- /dev/null +++ b/pkg/order/flows/fulfill-order.json @@ -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 } + ] + } + } + ] +} diff --git a/pkg/order/flows/place-and-pay.json b/pkg/order/flows/place-and-pay.json index cab23c7..758e5ca 100644 --- a/pkg/order/flows/place-and-pay.json +++ b/pkg/order/flows/place-and-pay.json @@ -1,12 +1,15 @@ { "name": "place-and-pay", - "description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.", + "description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails. Sends order confirmation and payment receipt emails when a customer email is present.", "steps": [ { "name": "place", "action": "place_order", "hooks": { - "after": [{ "type": "log", "params": { "message": "order placed" } }] + "after": [ + { "type": "log", "params": { "message": "order placed" } }, + { "type": "send_email", "when": "has_customer_email", "params": { "template": "order-confirmation" } } + ] } }, { @@ -24,6 +27,7 @@ "hooks": { "after": [ { "type": "log", "params": { "message": "payment captured" } }, + { "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } }, { "type": "emit_order_created" } ] } diff --git a/pkg/order/mailersend.go b/pkg/order/mailersend.go new file mode 100644 index 0000000..aca318a --- /dev/null +++ b/pkg/order/mailersend.go @@ -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 +} diff --git a/pkg/profile/email_index.go b/pkg/profile/email_index.go new file mode 100644 index 0000000..18c7939 --- /dev/null +++ b/pkg/profile/email_index.go @@ -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) +} diff --git a/pkg/profile/mutation_set_profile.go b/pkg/profile/mutation_set_profile.go index 1bbbcfd..b1c9aff 100644 --- a/pkg/profile/mutation_set_profile.go +++ b/pkg/profile/mutation_set_profile.go @@ -29,5 +29,16 @@ func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error { if m.AvatarUrl != nil { 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 } diff --git a/pkg/profile/profile-grain.go b/pkg/profile/profile-grain.go index 788f4d0..a8d3ae4 100644 --- a/pkg/profile/profile-grain.go +++ b/pkg/profile/profile-grain.go @@ -29,6 +29,15 @@ type LinkedOrder struct { 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. type StoredAddress struct { Id uint32 `json:"id"` @@ -60,11 +69,12 @@ type ProfileGrain struct { Language string `json:"language,omitempty"` Currency string `json:"currency,omitempty"` - AvatarUrl string `json:"avatarUrl,omitempty"` - Carts []LinkedCart `json:"carts,omitempty"` - Checkouts []LinkedCheckout `json:"checkouts,omitempty"` - Orders []LinkedOrder `json:"orders,omitempty"` - Addresses []StoredAddress `json:"addresses,omitempty"` + AvatarUrl string `json:"avatarUrl,omitempty"` + Carts []LinkedCart `json:"carts,omitempty"` + Checkouts []LinkedCheckout `json:"checkouts,omitempty"` + Orders []LinkedOrder `json:"orders,omitempty"` + Addresses []StoredAddress `json:"addresses,omitempty"` + EmailPreferences *EmailPreferences `json:"emailPreferences,omitempty"` } // NewProfileGrain returns an empty profile grain for id. diff --git a/proto/profile/profile.pb.go b/proto/profile/profile.pb.go index a073030..e3e5723 100644 --- a/proto/profile/profile.pb.go +++ b/proto/profile/profile.pb.go @@ -156,15 +156,17 @@ func (x *Address) GetIsDefaultBilling() bool { // SetProfile sets the user's core profile information. type SetProfile struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"` - Email *string `protobuf:"bytes,2,opt,name=email,proto3,oneof" json:"email,omitempty"` - Phone *string `protobuf:"bytes,3,opt,name=phone,proto3,oneof" json:"phone,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"` - AvatarUrl *string `protobuf:"bytes,6,opt,name=avatarUrl,proto3,oneof" json:"avatarUrl,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"` + Email *string `protobuf:"bytes,2,opt,name=email,proto3,oneof" json:"email,omitempty"` + Phone *string `protobuf:"bytes,3,opt,name=phone,proto3,oneof" json:"phone,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"` + 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 + sizeCache protoimpl.SizeCache } func (x *SetProfile) Reset() { @@ -239,6 +241,20 @@ func (x *SetProfile) GetAvatarUrl() string { 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. type AddAddress struct { state protoimpl.MessageState `protogen:"open.v1"`