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",
|
||||
|
||||
Reference in New Issue
Block a user