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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user