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 }