more cart

This commit is contained in:
2026-07-01 10:40:28 +02:00
parent 75db64ce75
commit b1e99891e9
30 changed files with 1058 additions and 93 deletions
+1 -31
View File
@@ -29,7 +29,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/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()
}
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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)
+221
View File
@@ -0,0 +1,221 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"git.k6n.net/mats/go-cart-actor/pkg/order"
)
// emailTemplateInfo is the metadata returned by the list endpoint.
type emailTemplateInfo struct {
Name string `json:"name"`
Embedded bool `json:"embedded,omitempty"`
}
// emailTemplateHandler serves CRUD operations for email templates stored in the
// ORDER_EMAIL_TEMPLATES directory. Embedded templates are read-only; on-disk
// copies override them and can be edited via PUT.
type emailTemplateHandler struct {
dir string // the on-disk templates directory, from ORDER_EMAIL_TEMPLATES env
}
func newEmailTemplateHandler(dir string) *emailTemplateHandler {
if dir == "" {
dir = "data/order-email-templates"
}
return &emailTemplateHandler{dir: dir}
}
// list returns all known email templates (embedded + on-disk) with their source.
func (h *emailTemplateHandler) list(w http.ResponseWriter, r *http.Request) {
embedded := knownEmbeddedTemplates()
onDisk := h.listOnDisk()
seen := map[string]bool{}
var infos []emailTemplateInfo
// List on-disk first (they take precedence).
for _, name := range onDisk {
infos = append(infos, emailTemplateInfo{Name: name})
seen[name] = true
}
// Then add embedded templates not overridden by disk.
for _, name := range embedded {
if !seen[name] {
infos = append(infos, emailTemplateInfo{Name: name, Embedded: true})
seen[name] = true
}
}
if infos == nil {
infos = []emailTemplateInfo{}
}
writeJSON(w, http.StatusOK, infos)
}
// get returns the template content for the given name.
func (h *emailTemplateHandler) get(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// save persists a template to the on-disk directory. Returns the saved template.
func (h *emailTemplateHandler) save(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" || !validEmailTemplateName(name) {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template name %q", name))
return
}
var tmpl order.EmailTemplate
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template body: %w", err))
return
}
if err := tmpl.Validate(name); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := os.MkdirAll(h.dir, 0o755); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("create templates dir: %w", err))
return
}
data, err := json.MarshalIndent(tmpl, "", " ")
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("marshal template: %w", err))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("write template: %w", err))
return
}
writeJSON(w, http.StatusOK, tmpl)
}
// delete removes an on-disk template. Embedded templates cannot be deleted.
func (h *emailTemplateHandler) delete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
// Check if it's embedded (read-only).
if isEmbeddedTemplate(name) {
writeErr(w, http.StatusForbidden, fmt.Errorf("template %q is built-in and cannot be deleted; save an override instead", name))
return
}
path := filepath.Join(h.dir, name+".json")
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found on disk", name))
return
}
writeErr(w, http.StatusInternalServerError, fmt.Errorf("delete template: %w", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// preview renders a template with sample data and returns the result.
func (h *emailTemplateHandler) preview(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
return
}
store, err := order.LoadEmailTemplates(h.dir)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
return
}
tmpl, err := store.Get(name)
if err != nil {
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
return
}
// Build sample data for preview.
sampleData := order.SampleTemplateData()
subject, _ := order.RenderTextTemplate("preview-subject", tmpl.Subject, sampleData)
textBody, _ := order.RenderTextTemplate("preview-text", tmpl.Text, sampleData)
htmlBody, _ := order.RenderOptionalHTMLTemplate("preview-html", tmpl.HTML, sampleData)
writeJSON(w, http.StatusOK, map[string]string{
"subject": subject,
"text": textBody,
"html": htmlBody,
})
}
// listOnDisk returns template names from the on-disk directory.
func (h *emailTemplateHandler) listOnDisk() []string {
matches, err := filepath.Glob(filepath.Join(h.dir, "*.json"))
if err != nil {
return nil
}
var names []string
for _, m := range matches {
name := strings.TrimSuffix(filepath.Base(m), ".json")
if validEmailTemplateName(name) {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// knownEmbeddedTemplates returns the names of templates embedded in the binary.
// This must be kept in sync with the files in pkg/order/email_templates/.
func knownEmbeddedTemplates() []string {
return []string{
"fulfillment-shipped",
"order-confirmation",
"payment-receipt",
"shipment-delivered",
"return-confirmed",
"refund-issued",
}
}
// isEmbeddedTemplate reports whether name matches one of the embedded templates.
func isEmbeddedTemplate(name string) bool {
for _, e := range knownEmbeddedTemplates() {
if e == name {
return true
}
}
return false
}
// validEmailTemplateName validates a template name (alphanumeric + hyphens).
func validEmailTemplateName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
if !(r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
return false
}
}
return true
}
+2 -1
View File
@@ -14,6 +14,7 @@ func buildFlowRegistry(
emitPub order.Publisher,
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)
+1 -1
View File
@@ -120,7 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
}
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
+48 -9
View File
@@ -170,14 +170,29 @@ func main() {
}
}
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger)
// Email preference checker: when a profile service URL is configured, look up
// customer email preferences before sending transactional emails.
var prefChecker order.EmailPreferenceChecker
if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" {
prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers")
logger.Info("email preference checking enabled", "profile_url", profileURL)
}
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger)
engine := flow.NewEngine(freg, logger)
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,6 +586,9 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
Address: fromAddr,
}
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 }),
@@ -577,8 +600,24 @@ func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
return sender, nil
case "mailersend":
apiKey := os.Getenv("MAILERSEND_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
}
sender, err := order.NewMailerSendEmailSender(apiKey, from)
if err != nil {
return nil, err
}
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
return sender, nil
default:
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
}
}
func selectInventoryReservationService() (order.InventoryReservationService, error) {
+41 -1
View File
@@ -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",
+1
View File
@@ -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
+4
View File
@@ -129,6 +129,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/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=
+86
View File
@@ -0,0 +1,86 @@
package customerauth
import (
"context"
"fmt"
"log"
"net/mail"
"strings"
"time"
"github.com/mailersend/mailersend-go"
)
// MailerSendNotifier delivers transactional auth messages (email verification
// and password reset) through the MailerSend API. It implements the Notifier
// interface and is a drop-in replacement for LogNotifier in production.
type MailerSendNotifier struct {
client *mailersend.Mailersend
from mail.Address
}
// NewMailerSendNotifier creates a new MailerSend-backed notifier. The apiKey
// is the MailerSend API token (MAILERSEND_API_KEY env var). The from address
// must include at least an email; the name portion is optional.
func NewMailerSendNotifier(apiKey string, from mail.Address) (*MailerSendNotifier, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("mailersend notifier: missing API key")
}
if strings.TrimSpace(from.Address) == "" {
return nil, fmt.Errorf("mailersend notifier: missing from address")
}
return &MailerSendNotifier{
client: mailersend.NewMailersend(apiKey),
from: from,
}, nil
}
// SendEmailVerification sends the verification link to the customer's email
// address. Errors are logged but not returned (best-effort delivery matches the
// signature of the Notifier interface).
func (n *MailerSendNotifier) SendEmailVerification(email, link string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := n.send(ctx, email,
"Verify your email address",
fmt.Sprintf("Click the link to verify your email address:\n\n%s", link),
fmt.Sprintf(`<p>Click <a href="%s">here</a> to verify your email address.</p>`, link),
); err != nil {
log.Printf("customerauth: send verification to %s: %v", email, err)
}
}
// SendPasswordReset sends the password-reset link to the customer's email.
// Errors are logged but not returned.
func (n *MailerSendNotifier) SendPasswordReset(email, link string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := n.send(ctx, email,
"Reset your password",
fmt.Sprintf("Click the link to reset your password:\n\n%s\n\nIf you didn't request this, ignore this email.", link),
fmt.Sprintf(`<p>Click <a href="%s">here</a> to reset your password.</p><p>If you didn't request this, ignore this email.</p>`, link),
); err != nil {
log.Printf("customerauth: send password reset to %s: %v", email, err)
}
}
func (n *MailerSendNotifier) send(ctx context.Context, to, subject, textBody, htmlBody string) error {
message := n.client.Email.NewMessage()
message.SetFrom(mailersend.From{
Name: n.from.Name,
Email: n.from.Address,
})
message.SetRecipients([]mailersend.Recipient{
{Email: to},
})
message.SetSubject(subject)
message.SetText(textBody)
if htmlBody != "" {
message.SetHTML(htmlBody)
}
_, err := n.client.Email.Send(ctx, message)
if err != nil {
return fmt.Errorf("mailersend: %w", err)
}
return nil
}
+75
View File
@@ -19,6 +19,7 @@ type CustomerServer struct {
applier ProfileApplier
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
}
+1 -1
View File
@@ -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)
+30 -3
View File
@@ -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)
+14
View File
@@ -335,6 +335,12 @@ 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"`
@@ -345,6 +351,7 @@ type CustomerResponse struct {
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,6 +370,12 @@ 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"`
@@ -371,6 +384,7 @@ type CustomerUpdateRequest struct {
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.
+87 -3
View File
@@ -21,6 +21,7 @@ import (
"time"
"git.k6n.net/mats/go-cart-actor/pkg/flow"
"git.k6n.net/mats/platform/money"
)
// EmailMessage is the rendered mail payload a sender delivers.
@@ -229,6 +230,12 @@ type EmailTemplate struct {
}
func (t EmailTemplate) validate(name string) error {
return t.Validate(name)
}
// Validate checks that the template has the required fields. Exported so callers
// (e.g. the email template admin API) can validate before persisting.
func (t EmailTemplate) Validate(name string) error {
if strings.TrimSpace(t.Subject) == "" {
return fmt.Errorf("email template %q: missing subject", name)
}
@@ -330,6 +337,7 @@ type emailHookParams struct {
To string `json:"to,omitempty"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Category string `json:"category,omitempty"` // "order" or "marketing"; checked against preferences
}
type flowTemplateData struct {
@@ -344,16 +352,30 @@ type flowTemplateData struct {
BillingAddress any
}
// EmailPreferenceChecker determines whether an email of a given category should
// be sent to a customer. Returns true when preferences are unknown (allow).
type EmailPreferenceChecker interface {
// Allowed returns true if the customer with the given email has opted in to
// the given category. category is one of "order" or "marketing".
Allowed(ctx context.Context, email string, category string) bool
}
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
// order for template data, renders the named template, and delivers it through
// the configured sender. Hook params:
// the configured sender. When checker is non-nil, emails in a category are
// gated by the customer's saved preferences. Hook params:
//
// {
// "template": "fulfillment-shipped",
// "to": "{{ .Order.CustomerEmail }}",
// "from": "Warehouse <warehouse@example.com>"
// "from": "Warehouse <warehouse@example.com>",
// "category": "order"
// }
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore, checker ...EmailPreferenceChecker) {
var prefCheck EmailPreferenceChecker
if len(checker) > 0 {
prefCheck = checker[0]
}
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
if sender == nil {
return fmt.Errorf("send_email: no sender configured")
@@ -414,6 +436,14 @@ func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, temp
to = append(to, *rcpt)
}
// Check email preferences before sending.
if prefCheck != nil && p.Category != "" && len(to) > 0 {
email := to[0].Address
if !prefCheck.Allowed(ctx, email, p.Category) {
return nil // silently skip — customer opted out
}
}
from := sender.DefaultFrom()
if strings.TrimSpace(p.From) != "" {
fromRendered, err := renderTextTemplate("email-from", p.From, data)
@@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any {
return v
}
func RenderTextTemplate(name, src string, data any) (string, error) {
return renderTextTemplate(name, src, data)
}
// RenderOptionalHTMLTemplate renders an HTML template with the given data, or
// returns empty string when src is empty.
func RenderOptionalHTMLTemplate(name, src string, data any) (string, error) {
return renderOptionalHTMLTemplate(name, src, data)
}
func renderTextTemplate(name, src string, data any) (string, error) {
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
if err != nil {
@@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) {
return renderTextTemplate(name, src, data)
}
// SampleTemplateData returns a flowTemplateData populated with sample values
// for previewing email templates in the admin UI.
func SampleTemplateData() any {
now := time.Now().UTC().Format(time.RFC3339)
return flowTemplateData{
Order: &OrderGrain{
Id: 12345,
OrderReference: "ORD-SAMPLE-001",
Currency: "SEK",
Status: StatusCaptured,
CustomerName: "Anna Svensson",
CustomerEmail: "anna@example.com",
TotalAmount: money.Cents(29900),
Lines: []Line{
{Reference: "l1", Sku: "PROD-001", Name: "Sample Product", Quantity: 1, UnitPrice: 29900, TotalAmount: 29900, TaxRate: 2500},
},
PlacedAt: now,
},
OrderID: "abc123",
Fulfillment: &Fulfillment{
ID: "f_ship001",
Carrier: "PostNord",
TrackingNumber: "SE-123456789",
TrackingURI: "https://tracking.postnord.se/123456789",
},
ShippingAddress: map[string]any{
"givenName": "Anna",
"familyName": "Svensson",
"street": "Storgatan 1",
"city": "Stockholm",
"postalCode": "111 22",
"country": "SE",
},
BillingAddress: map[string]any{
"givenName": "Anna",
"familyName": "Svensson",
"street": "Storgatan 1",
"city": "Stockholm",
"postalCode": "111 22",
"country": "SE",
},
}
}
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
if strings.TrimSpace(src) == "" {
return "", nil
+106
View File
@@ -0,0 +1,106 @@
package order
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ProfilePreferencesClient is the subset of the profile service's UCP customer
// API that returns email preferences for a given email address.
type ProfilePreferencesClient struct {
baseURL string
httpClient *http.Client
timeout time.Duration
}
// NewProfilePreferencesClient creates a client that talks to the profile
// service's UCP customer endpoint at baseURL (e.g. "http://profile:8080/ucp/v1/customers").
func NewProfilePreferencesClient(baseURL string) *ProfilePreferencesClient {
return &ProfilePreferencesClient{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{Timeout: 5 * time.Second},
timeout: 5 * time.Second,
}
}
// emailPreferencesResponse mirrors ucp.EmailPreferencesResponse for decoding.
type emailPreferencesResponse struct {
OrderEmails *bool `json:"orderEmails,omitempty"`
MarketingEmails *bool `json:"marketingEmails,omitempty"`
}
// customerLookupResponse mirrors the UCP customer response, but we only need
// email preferences.
type customerLookupResponse struct {
EmailPreferences *emailPreferencesResponse `json:"emailPreferences,omitempty"`
}
// fetchPreferences calls GET /by-email/{email} on the profile service and
// returns the parsed email preferences. Returns nil, nil when the profile
// is not found (unknown email -> allow all).
func (c *ProfilePreferencesClient) fetchPreferences(ctx context.Context, email string) (*emailPreferencesResponse, error) {
url := fmt.Sprintf("%s/by-email/%s", c.baseURL, email)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("profile prefs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil // unknown email -> no preferences
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("profile prefs: unexpected status %d: %s", resp.StatusCode, string(body))
}
var data customerLookupResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("profile prefs: decode: %w", err)
}
return data.EmailPreferences, nil
}
// PreferencesChecker wraps ProfilePreferencesClient to implement
// EmailPreferenceChecker.
type PreferencesChecker struct {
client *ProfilePreferencesClient
}
// NewPreferencesChecker creates an EmailPreferenceChecker that looks up
// preferences from the profile service at the given base URL.
func NewPreferencesChecker(profileServiceURL string) *PreferencesChecker {
return &PreferencesChecker{
client: NewProfilePreferencesClient(profileServiceURL),
}
}
// Allowed returns true when the customer has not opted out of the given
// category. Categories are "order" (order confirmations, shipping updates,
// receipts) and "marketing" (promotional offers, newsletters). Returns true
// when preferences are unknown (profile not found) — the safe default.
func (c *PreferencesChecker) Allowed(ctx context.Context, email string, category string) bool {
prefs, err := c.client.fetchPreferences(ctx, email)
if err != nil || prefs == nil {
return true // safe default: allow when unknown
}
switch category {
case "order":
return prefs.OrderEmails == nil || *prefs.OrderEmails
case "marketing":
return prefs.MarketingEmails == nil || *prefs.MarketingEmails
default:
return true // unknown category -> allow
}
}
@@ -0,0 +1,5 @@
{
"subject": "Order {{ .Order.OrderReference }} confirmed",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has been placed successfully.\n\nWe'll send you an update when your payment is confirmed and your items are on their way.\n\nOrder id: {{ .OrderID }}\n\nThank you for your purchase!",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has been placed successfully.</p><p>We'll send you an update when your payment is confirmed and your items are on their way.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your purchase!</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Payment received for order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour payment for order {{ .Order.OrderReference }} has been received and confirmed.\n\nWe'll let you know as soon as your items ship.\n\nOrder id: {{ .OrderID }}\n\nThank you for shopping with us!",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your payment for order <strong>{{ .Order.OrderReference }}</strong> has been received and confirmed.</p><p>We'll let you know as soon as your items ship.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for shopping with us!</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Refund issued for order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nA refund has been issued for order {{ .Order.OrderReference }}.\n\nThe amount should appear in your account within a few business days, depending on your payment provider.\n\nOrder id: {{ .OrderID }}\n\nThank you for your patience.",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>A refund has been issued for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>The amount should appear in your account within a few business days, depending on your payment provider.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your patience.</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Return request received — order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nWe've received your return request for order {{ .Order.OrderReference }}.\n\nWe'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.\n\nOrder id: {{ .OrderID }}\n\nIf you have any questions, please don't hesitate to contact us.",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>We've received your return request for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>We'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>If you have any questions, please don't hesitate to contact us.</p>"
}
@@ -0,0 +1,5 @@
{
"subject": "Package delivered — order {{ .Order.OrderReference }}",
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour package for order {{ .Order.OrderReference }} has been delivered.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}{{ end }}\n\nWe hope you're happy with your purchase! If you have any questions, feel free to reply to this email.\n\nOrder id: {{ .OrderID }}",
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your package for order <strong>{{ .Order.OrderReference }}</strong> has been delivered.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li></ul>{{ end }}<p>We hope you're happy with your purchase!</p><p>Order id: <code>{{ .OrderID }}</code></p>"
}
+4
View File
@@ -44,6 +44,10 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
// place-and-pay references the emit_order_created hook; register it (nil
// 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
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "fulfill-order",
"description": "Create a fulfillment (shipment) for an order and send the shipping notification to the customer.",
"steps": [
{
"name": "fulfill",
"action": "create_fulfillment",
"hooks": {
"after": [
{ "type": "log", "params": { "message": "fulfillment created" } },
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "fulfillment-shipped" } },
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
]
}
}
]
}
+6 -2
View File
@@ -1,12 +1,15 @@
{
"name": "place-and-pay",
"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" }
]
}
+84
View File
@@ -0,0 +1,84 @@
package order
import (
"context"
"fmt"
"net/mail"
"strings"
"github.com/mailersend/mailersend-go"
)
// MailerSendEmailSender sends email through the MailerSend API using the
// official Go SDK. It implements the EmailSender interface.
type MailerSendEmailSender struct {
client *mailersend.Mailersend
defaultFrom mail.Address
}
// NewMailerSendEmailSender creates a new MailerSend-backed sender.
func NewMailerSendEmailSender(apiKey string, defaultFrom mail.Address) (*MailerSendEmailSender, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, fmt.Errorf("mailersend sender: missing API key")
}
if strings.TrimSpace(defaultFrom.Address) == "" {
return nil, fmt.Errorf("mailersend sender: missing default from address")
}
return &MailerSendEmailSender{
client: mailersend.NewMailersend(apiKey),
defaultFrom: defaultFrom,
}, nil
}
// DefaultFrom returns the default sender address configured for this sender.
func (s *MailerSendEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
// Send delivers the rendered email message through the MailerSend API.
func (s *MailerSendEmailSender) Send(ctx context.Context, msg EmailMessage) error {
if strings.TrimSpace(msg.From.Address) == "" {
return fmt.Errorf("mailersend sender: missing from address")
}
if len(msg.To) == 0 {
return fmt.Errorf("mailersend sender: missing recipients")
}
if strings.TrimSpace(msg.Subject) == "" {
return fmt.Errorf("mailersend sender: missing subject")
}
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
return fmt.Errorf("mailersend sender: missing body")
}
message := s.client.Email.NewMessage()
fromName := msg.From.Name
if fromName == "" {
fromName = s.defaultFrom.Name
}
message.SetFrom(mailersend.From{
Name: fromName,
Email: msg.From.Address,
})
recipients := make([]mailersend.Recipient, 0, len(msg.To))
for _, rcpt := range msg.To {
recipients = append(recipients, mailersend.Recipient{
Name: rcpt.Name,
Email: rcpt.Address,
})
}
message.SetRecipients(recipients)
message.SetSubject(msg.Subject)
if strings.TrimSpace(msg.HTMLBody) != "" {
message.SetHTML(msg.HTMLBody)
}
if strings.TrimSpace(msg.TextBody) != "" {
message.SetText(msg.TextBody)
}
_, err := s.client.Email.Send(ctx, message)
if err != nil {
return fmt.Errorf("mailersend sender: %w", err)
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package profile
import (
"bytes"
"io"
"context"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"google.golang.org/protobuf/proto"
)
// emailIndexEntry captures the email preferences snapshot for a profile.
type emailIndexEntry struct {
ID uint64
EmailPreferences *EmailPreferences
}
// ProfileEmailIndex is a thread-safe in-memory map from email address to
// profile ID and email preferences snapshot. It is populated at startup by
// scanning the profile storage directory, and updated when SetProfile
// mutations change the email or preferences.
type ProfileEmailIndex struct {
mu sync.RWMutex
byID map[uint64]string // profileID → email (for cleanup on email change)
idx map[string]emailIndexEntry // email → profile info
}
// NewProfileEmailIndex returns an empty index.
func NewProfileEmailIndex() *ProfileEmailIndex {
return &ProfileEmailIndex{
byID: make(map[uint64]string),
idx: make(map[string]emailIndexEntry),
}
}
// Set records or updates the email→profile mapping. When email is empty the
// entry is removed (profile was anonymized).
func (ix *ProfileEmailIndex) Set(profileID uint64, email string, prefs *EmailPreferences) {
ix.mu.Lock()
defer ix.mu.Unlock()
// Remove any previous email for this profile.
if oldEmail, ok := ix.byID[profileID]; ok {
delete(ix.idx, oldEmail)
}
if strings.TrimSpace(email) == "" {
delete(ix.byID, profileID)
return
}
email = strings.ToLower(strings.TrimSpace(email))
ix.byID[profileID] = email
ix.idx[email] = emailIndexEntry{
ID: profileID,
EmailPreferences: prefs,
}
}
// Lookup returns the email preferences for the given email. The second return
// value is false when the email is not found.
func (ix *ProfileEmailIndex) Lookup(email string) (emailIndexEntry, bool) {
ix.mu.RLock()
defer ix.mu.RUnlock()
entry, ok := ix.idx[strings.ToLower(strings.TrimSpace(email))]
return entry, ok
}
// BuildEmailIndex populates the index by scanning the profile storage
// directory and replaying each event log into a fresh ProfileGrain. It must
// be called once at service startup after the mutation registry and storage
// are initialised.
//
// dir — the same path passed to actor.NewDiskStorage (e.g. "data/profiles").
// replayer — an *actor.StateStorage for reading event log files.
// reg — the profile mutation registry.
// ix — the (empty) index to populate.
func BuildEmailIndex(dir string, replayer interface {
Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error
}, reg actor.MutationRegistry, ix *ProfileEmailIndex) {
entries, err := os.ReadDir(dir)
if err != nil {
log.Printf("email index: cannot read profile dir %s: %v — starting with empty index", dir, err)
return
}
var count int
ctx := context.Background()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".events.log") {
continue
}
idStr := strings.TrimSuffix(entry.Name(), ".events.log")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
continue
}
p := NewProfileGrain(id, time.Now())
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
log.Printf("email index: read %s: %v", entry.Name(), err)
continue
}
if err := replayer.Load(bytes.NewReader(data), func(msg proto.Message, _ time.Time) { //nolint:govet
_, _ = reg.Apply(ctx, p, msg)
}); err != nil {
log.Printf("email index: replay %s: %v", entry.Name(), err)
continue
}
if p.Email != "" {
ix.Set(p.Id, p.Email, p.EmailPreferences)
count++
}
}
log.Printf("email index: built %d entries from %s", count, dir)
}
+11
View File
@@ -29,5 +29,16 @@ func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error {
if m.AvatarUrl != nil {
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
}
+10
View File
@@ -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"`
@@ -65,6 +74,7 @@ type ProfileGrain struct {
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.
+16
View File
@@ -163,6 +163,8 @@ type SetProfile struct {
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
}
@@ -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"`