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:
2026-07-10 08:54:31 -04:00
parent bcf8df5579
commit a630a8f8ab
9 changed files with 476 additions and 6 deletions
+45
View File
@@ -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)