more cart
This commit is contained in:
+1
-31
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+59
-20
@@ -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) {
|
||||
|
||||
+41
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+28
-14
@@ -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.
|
||||
|
||||
+87
-3
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// EmailMessage is the rendered mail payload a sender delivers.
|
||||
@@ -229,6 +230,12 @@ type EmailTemplate struct {
|
||||
}
|
||||
|
||||
func (t EmailTemplate) validate(name string) error {
|
||||
return t.Validate(name)
|
||||
}
|
||||
|
||||
// Validate checks that the template has the required fields. Exported so callers
|
||||
// (e.g. the email template admin API) can validate before persisting.
|
||||
func (t EmailTemplate) Validate(name string) error {
|
||||
if strings.TrimSpace(t.Subject) == "" {
|
||||
return fmt.Errorf("email template %q: missing subject", name)
|
||||
}
|
||||
@@ -330,6 +337,7 @@ type emailHookParams struct {
|
||||
To string `json:"to,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Category string `json:"category,omitempty"` // "order" or "marketing"; checked against preferences
|
||||
}
|
||||
|
||||
type flowTemplateData struct {
|
||||
@@ -344,16 +352,30 @@ type flowTemplateData struct {
|
||||
BillingAddress any
|
||||
}
|
||||
|
||||
// EmailPreferenceChecker determines whether an email of a given category should
|
||||
// be sent to a customer. Returns true when preferences are unknown (allow).
|
||||
type EmailPreferenceChecker interface {
|
||||
// Allowed returns true if the customer with the given email has opted in to
|
||||
// the given category. category is one of "order" or "marketing".
|
||||
Allowed(ctx context.Context, email string, category string) bool
|
||||
}
|
||||
|
||||
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
|
||||
// order for template data, renders the named template, and delivers it through
|
||||
// the configured sender. Hook params:
|
||||
// the configured sender. When checker is non-nil, emails in a category are
|
||||
// gated by the customer's saved preferences. Hook params:
|
||||
//
|
||||
// {
|
||||
// "template": "fulfillment-shipped",
|
||||
// "to": "{{ .Order.CustomerEmail }}",
|
||||
// "from": "Warehouse <warehouse@example.com>"
|
||||
// "from": "Warehouse <warehouse@example.com>",
|
||||
// "category": "order"
|
||||
// }
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore, checker ...EmailPreferenceChecker) {
|
||||
var prefCheck EmailPreferenceChecker
|
||||
if len(checker) > 0 {
|
||||
prefCheck = checker[0]
|
||||
}
|
||||
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
if sender == nil {
|
||||
return fmt.Errorf("send_email: no sender configured")
|
||||
@@ -414,6 +436,14 @@ func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, temp
|
||||
to = append(to, *rcpt)
|
||||
}
|
||||
|
||||
// Check email preferences before sending.
|
||||
if prefCheck != nil && p.Category != "" && len(to) > 0 {
|
||||
email := to[0].Address
|
||||
if !prefCheck.Allowed(ctx, email, p.Category) {
|
||||
return nil // silently skip — customer opted out
|
||||
}
|
||||
}
|
||||
|
||||
from := sender.DefaultFrom()
|
||||
if strings.TrimSpace(p.From) != "" {
|
||||
fromRendered, err := renderTextTemplate("email-from", p.From, data)
|
||||
@@ -473,6 +503,16 @@ func decodeEmailTemplateJSON(raw json.RawMessage) any {
|
||||
return v
|
||||
}
|
||||
|
||||
func RenderTextTemplate(name, src string, data any) (string, error) {
|
||||
return renderTextTemplate(name, src, data)
|
||||
}
|
||||
|
||||
// RenderOptionalHTMLTemplate renders an HTML template with the given data, or
|
||||
// returns empty string when src is empty.
|
||||
func RenderOptionalHTMLTemplate(name, src string, data any) (string, error) {
|
||||
return renderOptionalHTMLTemplate(name, src, data)
|
||||
}
|
||||
|
||||
func renderTextTemplate(name, src string, data any) (string, error) {
|
||||
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
|
||||
if err != nil {
|
||||
@@ -492,6 +532,50 @@ func renderOptionalTextTemplate(name, src string, data any) (string, error) {
|
||||
return renderTextTemplate(name, src, data)
|
||||
}
|
||||
|
||||
// SampleTemplateData returns a flowTemplateData populated with sample values
|
||||
// for previewing email templates in the admin UI.
|
||||
func SampleTemplateData() any {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return flowTemplateData{
|
||||
Order: &OrderGrain{
|
||||
Id: 12345,
|
||||
OrderReference: "ORD-SAMPLE-001",
|
||||
Currency: "SEK",
|
||||
Status: StatusCaptured,
|
||||
CustomerName: "Anna Svensson",
|
||||
CustomerEmail: "anna@example.com",
|
||||
TotalAmount: money.Cents(29900),
|
||||
Lines: []Line{
|
||||
{Reference: "l1", Sku: "PROD-001", Name: "Sample Product", Quantity: 1, UnitPrice: 29900, TotalAmount: 29900, TaxRate: 2500},
|
||||
},
|
||||
PlacedAt: now,
|
||||
},
|
||||
OrderID: "abc123",
|
||||
Fulfillment: &Fulfillment{
|
||||
ID: "f_ship001",
|
||||
Carrier: "PostNord",
|
||||
TrackingNumber: "SE-123456789",
|
||||
TrackingURI: "https://tracking.postnord.se/123456789",
|
||||
},
|
||||
ShippingAddress: map[string]any{
|
||||
"givenName": "Anna",
|
||||
"familyName": "Svensson",
|
||||
"street": "Storgatan 1",
|
||||
"city": "Stockholm",
|
||||
"postalCode": "111 22",
|
||||
"country": "SE",
|
||||
},
|
||||
BillingAddress: map[string]any{
|
||||
"givenName": "Anna",
|
||||
"familyName": "Svensson",
|
||||
"street": "Storgatan 1",
|
||||
"city": "Stockholm",
|
||||
"postalCode": "111 22",
|
||||
"country": "SE",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "", nil
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProfilePreferencesClient is the subset of the profile service's UCP customer
|
||||
// API that returns email preferences for a given email address.
|
||||
type ProfilePreferencesClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewProfilePreferencesClient creates a client that talks to the profile
|
||||
// service's UCP customer endpoint at baseURL (e.g. "http://profile:8080/ucp/v1/customers").
|
||||
func NewProfilePreferencesClient(baseURL string) *ProfilePreferencesClient {
|
||||
return &ProfilePreferencesClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{Timeout: 5 * time.Second},
|
||||
timeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// emailPreferencesResponse mirrors ucp.EmailPreferencesResponse for decoding.
|
||||
type emailPreferencesResponse struct {
|
||||
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||
}
|
||||
|
||||
// customerLookupResponse mirrors the UCP customer response, but we only need
|
||||
// email preferences.
|
||||
type customerLookupResponse struct {
|
||||
EmailPreferences *emailPreferencesResponse `json:"emailPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// fetchPreferences calls GET /by-email/{email} on the profile service and
|
||||
// returns the parsed email preferences. Returns nil, nil when the profile
|
||||
// is not found (unknown email -> allow all).
|
||||
func (c *ProfilePreferencesClient) fetchPreferences(ctx context.Context, email string) (*emailPreferencesResponse, error) {
|
||||
url := fmt.Sprintf("%s/by-email/%s", c.baseURL, email)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("profile prefs: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil // unknown email -> no preferences
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("profile prefs: unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var data customerLookupResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("profile prefs: decode: %w", err)
|
||||
}
|
||||
return data.EmailPreferences, nil
|
||||
}
|
||||
|
||||
// PreferencesChecker wraps ProfilePreferencesClient to implement
|
||||
// EmailPreferenceChecker.
|
||||
type PreferencesChecker struct {
|
||||
client *ProfilePreferencesClient
|
||||
}
|
||||
|
||||
// NewPreferencesChecker creates an EmailPreferenceChecker that looks up
|
||||
// preferences from the profile service at the given base URL.
|
||||
func NewPreferencesChecker(profileServiceURL string) *PreferencesChecker {
|
||||
return &PreferencesChecker{
|
||||
client: NewProfilePreferencesClient(profileServiceURL),
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed returns true when the customer has not opted out of the given
|
||||
// category. Categories are "order" (order confirmations, shipping updates,
|
||||
// receipts) and "marketing" (promotional offers, newsletters). Returns true
|
||||
// when preferences are unknown (profile not found) — the safe default.
|
||||
func (c *PreferencesChecker) Allowed(ctx context.Context, email string, category string) bool {
|
||||
prefs, err := c.client.fetchPreferences(ctx, email)
|
||||
if err != nil || prefs == nil {
|
||||
return true // safe default: allow when unknown
|
||||
}
|
||||
|
||||
switch category {
|
||||
case "order":
|
||||
return prefs.OrderEmails == nil || *prefs.OrderEmails
|
||||
case "marketing":
|
||||
return prefs.MarketingEmails == nil || *prefs.MarketingEmails
|
||||
default:
|
||||
return true // unknown category -> allow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Order {{ .Order.OrderReference }} confirmed",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has been placed successfully.\n\nWe'll send you an update when your payment is confirmed and your items are on their way.\n\nOrder id: {{ .OrderID }}\n\nThank you for your purchase!",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has been placed successfully.</p><p>We'll send you an update when your payment is confirmed and your items are on their way.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your purchase!</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Payment received for order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour payment for order {{ .Order.OrderReference }} has been received and confirmed.\n\nWe'll let you know as soon as your items ship.\n\nOrder id: {{ .OrderID }}\n\nThank you for shopping with us!",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your payment for order <strong>{{ .Order.OrderReference }}</strong> has been received and confirmed.</p><p>We'll let you know as soon as your items ship.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for shopping with us!</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Refund issued for order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nA refund has been issued for order {{ .Order.OrderReference }}.\n\nThe amount should appear in your account within a few business days, depending on your payment provider.\n\nOrder id: {{ .OrderID }}\n\nThank you for your patience.",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>A refund has been issued for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>The amount should appear in your account within a few business days, depending on your payment provider.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>Thank you for your patience.</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Return request received — order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nWe've received your return request for order {{ .Order.OrderReference }}.\n\nWe'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.\n\nOrder id: {{ .OrderID }}\n\nIf you have any questions, please don't hesitate to contact us.",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>We've received your return request for order <strong>{{ .Order.OrderReference }}</strong>.</p><p>We'll process your return and issue a refund within a few business days. You'll receive an email once the refund is complete.</p><p>Order id: <code>{{ .OrderID }}</code></p><p>If you have any questions, please don't hesitate to contact us.</p>"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Package delivered — order {{ .Order.OrderReference }}",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour package for order {{ .Order.OrderReference }} has been delivered.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}{{ end }}\n\nWe hope you're happy with your purchase! If you have any questions, feel free to reply to this email.\n\nOrder id: {{ .OrderID }}",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your package for order <strong>{{ .Order.OrderReference }}</strong> has been delivered.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li></ul>{{ end }}<p>We hope you're happy with your purchase!</p><p>Order id: <code>{{ .OrderID }}</code></p>"
|
||||
}
|
||||
@@ -44,6 +44,10 @@ func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
|
||||
// place-and-pay references the emit_order_created hook; register it (nil
|
||||
// publisher = no-op) so flow validation passes in tests too.
|
||||
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
|
||||
// place-and-pay also references send_email hooks; register it with nil
|
||||
// sender and nil templates so validation passes (runtime = error, tests
|
||||
// that exercise email have their own sender/templates).
|
||||
RegisterEmailHook(reg, pool, nil, nil)
|
||||
return reg
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "fulfill-order",
|
||||
"description": "Create a fulfillment (shipment) for an order and send the shipping notification to the customer.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "fulfill",
|
||||
"action": "create_fulfillment",
|
||||
"hooks": {
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "fulfillment created" } },
|
||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "fulfillment-shipped" } },
|
||||
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "place-and-pay",
|
||||
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails.",
|
||||
"description": "Place an order and take payment in one transaction. Authorization is compensated (voided + order cancelled) if capture fails. Sends order confirmation and payment receipt emails when a customer email is present.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "place",
|
||||
"action": "place_order",
|
||||
"hooks": {
|
||||
"after": [{ "type": "log", "params": { "message": "order placed" } }]
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "order placed" } },
|
||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "order-confirmation" } }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -24,6 +27,7 @@
|
||||
"hooks": {
|
||||
"after": [
|
||||
{ "type": "log", "params": { "message": "payment captured" } },
|
||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } },
|
||||
{ "type": "emit_order_created" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/mailersend/mailersend-go"
|
||||
)
|
||||
|
||||
// MailerSendEmailSender sends email through the MailerSend API using the
|
||||
// official Go SDK. It implements the EmailSender interface.
|
||||
type MailerSendEmailSender struct {
|
||||
client *mailersend.Mailersend
|
||||
defaultFrom mail.Address
|
||||
}
|
||||
|
||||
// NewMailerSendEmailSender creates a new MailerSend-backed sender.
|
||||
func NewMailerSendEmailSender(apiKey string, defaultFrom mail.Address) (*MailerSendEmailSender, error) {
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, fmt.Errorf("mailersend sender: missing API key")
|
||||
}
|
||||
if strings.TrimSpace(defaultFrom.Address) == "" {
|
||||
return nil, fmt.Errorf("mailersend sender: missing default from address")
|
||||
}
|
||||
return &MailerSendEmailSender{
|
||||
client: mailersend.NewMailersend(apiKey),
|
||||
defaultFrom: defaultFrom,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultFrom returns the default sender address configured for this sender.
|
||||
func (s *MailerSendEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
||||
|
||||
// Send delivers the rendered email message through the MailerSend API.
|
||||
func (s *MailerSendEmailSender) Send(ctx context.Context, msg EmailMessage) error {
|
||||
if strings.TrimSpace(msg.From.Address) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing from address")
|
||||
}
|
||||
if len(msg.To) == 0 {
|
||||
return fmt.Errorf("mailersend sender: missing recipients")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing subject")
|
||||
}
|
||||
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
||||
return fmt.Errorf("mailersend sender: missing body")
|
||||
}
|
||||
|
||||
message := s.client.Email.NewMessage()
|
||||
|
||||
fromName := msg.From.Name
|
||||
if fromName == "" {
|
||||
fromName = s.defaultFrom.Name
|
||||
}
|
||||
message.SetFrom(mailersend.From{
|
||||
Name: fromName,
|
||||
Email: msg.From.Address,
|
||||
})
|
||||
|
||||
recipients := make([]mailersend.Recipient, 0, len(msg.To))
|
||||
for _, rcpt := range msg.To {
|
||||
recipients = append(recipients, mailersend.Recipient{
|
||||
Name: rcpt.Name,
|
||||
Email: rcpt.Address,
|
||||
})
|
||||
}
|
||||
message.SetRecipients(recipients)
|
||||
message.SetSubject(msg.Subject)
|
||||
|
||||
if strings.TrimSpace(msg.HTMLBody) != "" {
|
||||
message.SetHTML(msg.HTMLBody)
|
||||
}
|
||||
if strings.TrimSpace(msg.TextBody) != "" {
|
||||
message.SetText(msg.TextBody)
|
||||
}
|
||||
|
||||
_, err := s.client.Email.Send(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailersend sender: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user