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)
+38
View File
@@ -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
}
+55
View File
@@ -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)
}
}
+100
View File
@@ -499,3 +499,103 @@ func (c *Client) TestCampaign(id int, emails []string) error {
}
return nil
}
// ---- Templates ----
// Template is the subset of listmonk's template fields eec-campaigns cares
// about. Field names confirmed against knadh/listmonk's actual model
// (models.Template: Name/Type/Body/IsDefault, json tags "name"/"type"/
// "body"/"is_default") and handler routes (GET/POST /api/templates,
// PUT /api/templates/:id).
type Template struct {
ID int
Name string
Type string
Body string
IsDefault bool
}
func (c *Client) templatesAll() ([]Template, error) {
respBody, status, err := c.do(http.MethodGet, "/api/templates", nil)
if err != nil {
return nil, fmt.Errorf("listing templates: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("listing templates failed (%d): %s", status, string(respBody))
}
var parsed struct {
Data []struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Body string `json:"body"`
IsDefault bool `json:"is_default"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("parsing template list response: %w", err)
}
templates := make([]Template, 0, len(parsed.Data))
for _, r := range parsed.Data {
templates = append(templates, Template{ID: r.ID, Name: r.Name, Type: r.Type, Body: r.Body, IsDefault: r.IsDefault})
}
return templates, nil
}
// FindTemplateByName returns (nil, nil) on a plain miss, matching
// FindCampaignByName's convention.
func (c *Client) FindTemplateByName(name string) (*Template, error) {
templates, err := c.templatesAll()
if err != nil {
return nil, err
}
var matches []Template
for _, t := range templates {
if t.Name == name {
matches = append(matches, t)
}
}
switch len(matches) {
case 0:
return nil, nil
case 1:
return &matches[0], nil
default:
return nil, fmt.Errorf("%d templates named %q — ambiguous, refusing to guess", len(matches), name)
}
}
// CreateTemplate creates a "campaign"-type template. isDefault: true makes
// every new campaign use it without per-campaign template_id wiring.
func (c *Client) CreateTemplate(name, body string, isDefault bool) (*Template, error) {
payload := map[string]any{"name": name, "type": "campaign", "body": body, "is_default": isDefault}
respBody, status, err := c.do(http.MethodPost, "/api/templates", payload)
if err != nil {
return nil, fmt.Errorf("creating template %q: %w", name, err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("creating template %q failed (%d): %s", name, status, string(respBody))
}
var parsed struct {
Data struct {
ID int `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("parsing template creation response: %w", err)
}
return &Template{ID: parsed.Data.ID, Name: name, Type: "campaign", Body: body, IsDefault: isDefault}, nil
}
// UpdateTemplate overwrites an existing template's content.
func (c *Client) UpdateTemplate(id int, name, body string, isDefault bool) error {
payload := map[string]any{"name": name, "type": "campaign", "body": body, "is_default": isDefault}
respBody, status, err := c.do(http.MethodPut, fmt.Sprintf("/api/templates/%d", id), payload)
if err != nil {
return fmt.Errorf("updating template %d: %w", id, err)
}
if status != http.StatusOK {
return fmt.Errorf("updating template %d failed (%d): %s", id, status, string(respBody))
}
return nil
}
+77
View File
@@ -333,3 +333,80 @@ func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {
t.Errorf("expected error to surface response body, got %v", err)
}
}
func TestFindTemplateByName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":4,"name":"eec-campaigns default","type":"campaign","is_default":true}]}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
tpl, err := c.FindTemplateByName("eec-campaigns default")
if err != nil {
t.Fatalf("FindTemplateByName: %v", err)
}
if tpl == nil || tpl.ID != 4 || !tpl.IsDefault {
t.Fatalf("unexpected template: %+v", tpl)
}
}
func TestFindTemplateByName_NilOnMiss(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
tpl, err := c.FindTemplateByName("missing")
if err != nil {
t.Fatalf("FindTemplateByName: %v", err)
}
if tpl != nil {
t.Errorf("expected nil for a miss, got %+v", tpl)
}
}
func TestCreateTemplate_SendsCampaignTypeAndIsDefault(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{"data":{"id":9}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
tpl, err := c.CreateTemplate("eec-campaigns default", "<html></html>", true)
if err != nil {
t.Fatalf("CreateTemplate: %v", err)
}
if tpl.ID != 9 {
t.Errorf("expected id 9, got %d", tpl.ID)
}
if gotBody["type"] != "campaign" || gotBody["is_default"] != true {
t.Errorf("expected type=campaign, is_default=true, got %+v", gotBody)
}
}
func TestUpdateTemplate_SendsExpectedPayload(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if err := c.UpdateTemplate(9, "eec-campaigns default", "<html>v2</html>", true); err != nil {
t.Fatalf("UpdateTemplate: %v", err)
}
if gotPath != "/api/templates/9" {
t.Errorf("expected /api/templates/9, got %q", gotPath)
}
if gotBody["body"] != "<html>v2</html>" {
t.Errorf("expected updated body to be sent, got %+v", gotBody)
}
}