Add template-as-code sync (campaigns template NAME PATH)
Answers "can the unsubscribe template be added via git+API too": commit listmonk's own stock campaign template (already unsubscribe-capable) as email-templates/campaign.html, add a small find-or-create-or-update Template client (confirmed against knadh/listmonk's actual model/handlers), a new `template` subcommand, and a manual-dispatch-only workflow to push it in as the default campaign template. Unlike campaign sync there's no draft/live status to protect, so this is always a safe overwrite.
This commit is contained in:
@@ -44,6 +44,13 @@ type fakeMedia struct {
|
||||
Filename string
|
||||
}
|
||||
|
||||
type fakeTemplate struct {
|
||||
ID int
|
||||
Name string
|
||||
Body string
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
type testCall struct {
|
||||
CampaignID int
|
||||
Emails []string
|
||||
@@ -61,6 +68,7 @@ type fakeListmonk struct {
|
||||
lists []fakeList
|
||||
campaigns []fakeCampaign
|
||||
media []fakeMedia
|
||||
templates []fakeTemplate
|
||||
testCalls []testCall
|
||||
segmentQueryCalls []segmentQueryCall
|
||||
failSegmentQuery bool
|
||||
@@ -107,6 +115,12 @@ func (f *fakeListmonk) handle(w http.ResponseWriter, r *http.Request) {
|
||||
f.uploadMedia(w, r)
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/query/lists":
|
||||
f.queryAddToList(w, r)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/templates":
|
||||
f.writeTemplates(w)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/templates":
|
||||
f.createTemplate(w, r)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/templates/"):
|
||||
f.updateTemplate(w, r)
|
||||
default:
|
||||
f.t.Errorf("fakeListmonk: unhandled request %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -244,6 +258,37 @@ func (f *fakeListmonk) queryAddToList(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{}`))
|
||||
}
|
||||
|
||||
func (f *fakeListmonk) writeTemplates(w http.ResponseWriter) {
|
||||
results := make([]map[string]any, 0, len(f.templates))
|
||||
for _, t := range f.templates {
|
||||
results = append(results, map[string]any{"id": t.ID, "name": t.Name, "type": "campaign", "is_default": t.IsDefault})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": results})
|
||||
}
|
||||
|
||||
func (f *fakeListmonk) createTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
body := decodeBody(r)
|
||||
isDefault, _ := body["is_default"].(bool)
|
||||
tpl := fakeTemplate{ID: f.id(), Name: str(body["name"]), Body: str(body["body"]), IsDefault: isDefault}
|
||||
f.templates = append(f.templates, tpl)
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": tpl.ID}})
|
||||
}
|
||||
|
||||
func (f *fakeListmonk) updateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
id := pathID(r.URL.Path, "/api/templates/")
|
||||
body := decodeBody(r)
|
||||
for i := range f.templates {
|
||||
if f.templates[i].ID == id {
|
||||
f.templates[i].Name = str(body["name"])
|
||||
f.templates[i].Body = str(body["body"])
|
||||
f.templates[i].IsDefault, _ = body["is_default"].(bool)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func decodeBody(r *http.Request) map[string]any {
|
||||
var body map[string]any
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"reground.org/eec-campaigns/internal/listmonk"
|
||||
)
|
||||
|
||||
// SyncTemplate pushes a template file's content into listmonk under name,
|
||||
// creating it (as the default campaign template) if missing or overwriting
|
||||
// its body if it already exists. Unlike campaign sync, a template has no
|
||||
// send-lifecycle status to protect — there's no "draft vs. live" distinction
|
||||
// to guard, so this is always a safe create-or-update, no rejection path.
|
||||
func SyncTemplate(lm *listmonk.Client, name, path string) (*listmonk.Template, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := lm.FindTemplateByName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
created, err := lm.CreateTemplate(name, string(body), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating template %q: %w", name, err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
if err := lm.UpdateTemplate(existing.ID, name, string(body), true); err != nil {
|
||||
return nil, fmt.Errorf("updating template %q: %w", name, err)
|
||||
}
|
||||
existing.Body = string(body)
|
||||
existing.IsDefault = true
|
||||
return existing, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSyncTemplate_CreatesWhenMissing(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
lm := f.client()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.html")
|
||||
if err := os.WriteFile(path, []byte("<html>v1</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tpl, err := SyncTemplate(lm, "eec-campaigns default", path)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncTemplate: %v", err)
|
||||
}
|
||||
if tpl.ID == 0 {
|
||||
t.Errorf("expected a nonzero id, got %+v", tpl)
|
||||
}
|
||||
if len(f.templates) != 1 || f.templates[0].Body != "<html>v1</html>" {
|
||||
t.Fatalf("expected 1 template with the file's content, got %+v", f.templates)
|
||||
}
|
||||
if !f.templates[0].IsDefault {
|
||||
t.Error("expected the template to be created as the default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTemplate_UpdatesWhenAlreadyExists(t *testing.T) {
|
||||
f := newFakeListmonk(t)
|
||||
f.templates = []fakeTemplate{{ID: 5, Name: "eec-campaigns default", Body: "<html>v1</html>", IsDefault: true}}
|
||||
lm := f.client()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "campaign.html")
|
||||
if err := os.WriteFile(path, []byte("<html>v2</html>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tpl, err := SyncTemplate(lm, "eec-campaigns default", path)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncTemplate: %v", err)
|
||||
}
|
||||
if tpl.ID != 5 {
|
||||
t.Errorf("expected the existing template's id 5 to be reused, got %d", tpl.ID)
|
||||
}
|
||||
if len(f.templates) != 1 || f.templates[0].Body != "<html>v2</html>" {
|
||||
t.Fatalf("expected the existing template's body updated in place, got %+v", f.templates)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user