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
+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
}