a630a8f8ab
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.
602 lines
18 KiB
Go
602 lines
18 KiB
Go
// Package listmonk is a small client for the listmonk REST calls
|
|
// eec-campaigns needs: managing campaigns (create/update/status/test),
|
|
// resolving list names to IDs, uploading media for attachments, and
|
|
// materializing a segment_query into list membership. Deliberately not
|
|
// shared with eec's or drip's own listmonk clients — same reasoning drip's
|
|
// client comment already gives: Go's internal/ visibility rules would block
|
|
// it anyway, and this client's needs (campaigns, media) diverge enough from
|
|
// eec's (transactional sends) and drip's (subscriber sweep) that duplicating
|
|
// the small HTTP-plumbing overlap beats a shared module.
|
|
package listmonk
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
)
|
|
|
|
type Client struct {
|
|
BaseURL string // e.g. https://listmonk.reground.org
|
|
APIUser string
|
|
APIToken string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func New(baseURL, apiUser, apiToken string) *Client {
|
|
return &Client{
|
|
BaseURL: baseURL,
|
|
APIUser: apiUser,
|
|
APIToken: apiToken,
|
|
HTTP: &http.Client{},
|
|
}
|
|
}
|
|
|
|
func (c *Client) do(method, path string, body any) ([]byte, int, error) {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, c.BaseURL+path, reader)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.SetBasicAuth(c.APIUser, c.APIToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return respBody, resp.StatusCode, nil
|
|
}
|
|
|
|
// ---- Lists ----
|
|
|
|
type List struct {
|
|
ID int
|
|
Name string
|
|
}
|
|
|
|
func (c *Client) listAll() ([]List, error) {
|
|
respBody, status, err := c.do(http.MethodGet, "/api/lists?per_page=all", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing lists: %w", err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, fmt.Errorf("listing lists failed (%d): %s", status, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
Data struct {
|
|
Results []struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"results"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return nil, fmt.Errorf("parsing list response: %w", err)
|
|
}
|
|
lists := make([]List, 0, len(parsed.Data.Results))
|
|
for _, r := range parsed.Data.Results {
|
|
lists = append(lists, List{ID: r.ID, Name: r.Name})
|
|
}
|
|
return lists, nil
|
|
}
|
|
|
|
// FindListByName resolves a list name to its numeric ID, matching exactly
|
|
// (never fuzzy) so a campaign's target audience is never guessed. Fetches
|
|
// all lists and filters client-side rather than depending on /api/lists'
|
|
// query-string search semantics, matching drip's existing style of not
|
|
// relying on unconfirmed server-side query behavior.
|
|
func (c *Client) FindListByName(name string) (id int, err error) {
|
|
lists, err := c.listAll()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var matches []List
|
|
for _, l := range lists {
|
|
if l.Name == name {
|
|
matches = append(matches, l)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return 0, fmt.Errorf("no list named %q", name)
|
|
case 1:
|
|
return matches[0].ID, nil
|
|
default:
|
|
return 0, fmt.Errorf("%d lists named %q — ambiguous, refusing to guess", len(matches), name)
|
|
}
|
|
}
|
|
|
|
// FindOrCreateListByName is used for segment_query-backed lists
|
|
// (segment:<slug>), which this tool owns and manages itself rather than
|
|
// requiring the user to pre-create in the Listmonk admin UI.
|
|
func (c *Client) FindOrCreateListByName(name string) (int, error) {
|
|
lists, err := c.listAll()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, l := range lists {
|
|
if l.Name == name {
|
|
return l.ID, nil
|
|
}
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"name": name,
|
|
"type": "private",
|
|
}
|
|
respBody, status, err := c.do(http.MethodPost, "/api/lists", payload)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("creating list %q: %w", name, err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return 0, fmt.Errorf("creating list %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 0, fmt.Errorf("parsing list creation response: %w", err)
|
|
}
|
|
return parsed.Data.ID, nil
|
|
}
|
|
|
|
// ---- Subscribers / segmentation ----
|
|
|
|
// QueryAddToList bulk-adds every subscriber matching the given raw SQL
|
|
// boolean expression to listID, in one call — listmonk's query-based bulk
|
|
// subscriber-list action, confirmed against the actual handler source
|
|
// (cmd/subscribers.go's ManageSubscriberListsByQuery, routed at
|
|
// PUT /api/subscribers/query/lists, request fields query/target_list_ids/
|
|
// action/status). This is the same segmentation mechanism listmonk's own
|
|
// admin UI search box uses, applied server-side without ever fetching
|
|
// individual subscriber IDs client-side.
|
|
func (c *Client) QueryAddToList(query string, listID int) error {
|
|
payload := map[string]any{
|
|
"query": query,
|
|
"target_list_ids": []int{listID},
|
|
"action": "add",
|
|
"status": "unconfirmed",
|
|
}
|
|
respBody, status, err := c.do(http.MethodPut, "/api/subscribers/query/lists", payload)
|
|
if err != nil {
|
|
return fmt.Errorf("segment_query bulk-add to list %d: %w", listID, err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return fmt.Errorf("segment_query bulk-add to list %d failed (%d): %s", listID, status, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Media ----
|
|
|
|
type Media struct {
|
|
ID int
|
|
Filename string
|
|
}
|
|
|
|
func (c *Client) mediaAll() ([]Media, error) {
|
|
respBody, status, err := c.do(http.MethodGet, "/api/media", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing media: %w", err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, fmt.Errorf("listing media failed (%d): %s", status, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
Data []struct {
|
|
ID int `json:"id"`
|
|
Filename string `json:"filename"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return nil, fmt.Errorf("parsing media response: %w", err)
|
|
}
|
|
media := make([]Media, 0, len(parsed.Data))
|
|
for _, r := range parsed.Data {
|
|
media = append(media, Media{ID: r.ID, Filename: r.Filename})
|
|
}
|
|
return media, nil
|
|
}
|
|
|
|
// FindMediaByFilename looks for a previously uploaded file by its exact
|
|
// (content-hash-synthesized) filename — see internal/campaign/sync.go for
|
|
// why the filename itself is the dedup key. Returns (0, false, nil) if not
|
|
// found, never an error for a plain miss.
|
|
func (c *Client) FindMediaByFilename(filename string) (id int, found bool, err error) {
|
|
media, err := c.mediaAll()
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
for _, m := range media {
|
|
if m.Filename == filename {
|
|
return m.ID, true, nil
|
|
}
|
|
}
|
|
return 0, false, nil
|
|
}
|
|
|
|
// UploadMedia uploads content under filename and returns its new media ID.
|
|
func (c *Client) UploadMedia(filename string, content io.Reader) (int, error) {
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
part, err := w.CreateFormFile("file", filename)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := io.Copy(part, content); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/api/media", &buf)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.SetBasicAuth(c.APIUser, c.APIToken)
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("uploading media %q: %w", filename, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return 0, fmt.Errorf("uploading media %q failed (%d): %s", filename, resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var parsed struct {
|
|
Data struct {
|
|
ID int `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return 0, fmt.Errorf("parsing media upload response: %w", err)
|
|
}
|
|
return parsed.Data.ID, nil
|
|
}
|
|
|
|
// ---- Campaigns ----
|
|
|
|
// Campaign is the subset of listmonk's campaign fields eec-campaigns cares
|
|
// about — enough to decide whether a re-sync's desired content differs from
|
|
// what's already there.
|
|
type Campaign struct {
|
|
ID int
|
|
Name string
|
|
Status string
|
|
Subject string
|
|
Body string
|
|
FromEmail string
|
|
TemplateID int
|
|
Type string
|
|
Tags []string
|
|
ListIDs []int
|
|
MediaIDs []int
|
|
}
|
|
|
|
// CampaignInput is what sync.go builds from a campaign's frontmatter+body to
|
|
// create or update a listmonk campaign.
|
|
type CampaignInput struct {
|
|
Name string
|
|
Subject string
|
|
FromEmail string
|
|
TemplateID int // 0 means "omit, use listmonk's default template"
|
|
Type string
|
|
Tags []string
|
|
ListIDs []int
|
|
Body string // raw Markdown; content_type "markdown" below
|
|
MediaIDs []int
|
|
}
|
|
|
|
func (in CampaignInput) payload() map[string]any {
|
|
p := map[string]any{
|
|
"name": in.Name,
|
|
"subject": in.Subject,
|
|
"lists": in.ListIDs,
|
|
"content_type": "markdown",
|
|
"body": in.Body,
|
|
"type": in.Type,
|
|
}
|
|
if in.FromEmail != "" {
|
|
p["from_email"] = in.FromEmail
|
|
}
|
|
if in.TemplateID != 0 {
|
|
p["template_id"] = in.TemplateID
|
|
}
|
|
if len(in.Tags) > 0 {
|
|
p["tags"] = in.Tags
|
|
}
|
|
if len(in.MediaIDs) > 0 {
|
|
// Field name confirmed against knadh/listmonk's actual request-binding
|
|
// struct (cmd/campaigns.go's campReq: `MediaIDs []int json:"media"`) —
|
|
// requests take plain media IDs under "media"; listmonk's own
|
|
// responses echo full media objects back under the same key (see
|
|
// parseCampaign below), a request/response asymmetry on listmonk's
|
|
// side, not a mistake here.
|
|
p["media"] = in.MediaIDs
|
|
}
|
|
return p
|
|
}
|
|
|
|
func parseCampaign(data []byte) (*Campaign, error) {
|
|
var parsed struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Subject string `json:"subject"`
|
|
Body string `json:"body"`
|
|
FromEmail string `json:"from_email"`
|
|
TemplateID int `json:"template_id"`
|
|
Type string `json:"type"`
|
|
Tags []string `json:"tags"`
|
|
Lists []struct {
|
|
ID int `json:"id"`
|
|
} `json:"lists"`
|
|
Media []struct {
|
|
ID int `json:"id"`
|
|
} `json:"media"`
|
|
}
|
|
if err := json.Unmarshal(data, &parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
listIDs := make([]int, 0, len(parsed.Lists))
|
|
for _, l := range parsed.Lists {
|
|
listIDs = append(listIDs, l.ID)
|
|
}
|
|
mediaIDs := make([]int, 0, len(parsed.Media))
|
|
for _, m := range parsed.Media {
|
|
mediaIDs = append(mediaIDs, m.ID)
|
|
}
|
|
return &Campaign{
|
|
ID: parsed.ID,
|
|
Name: parsed.Name,
|
|
Status: parsed.Status,
|
|
Subject: parsed.Subject,
|
|
Body: parsed.Body,
|
|
FromEmail: parsed.FromEmail,
|
|
TemplateID: parsed.TemplateID,
|
|
Type: parsed.Type,
|
|
Tags: parsed.Tags,
|
|
ListIDs: listIDs,
|
|
MediaIDs: mediaIDs,
|
|
}, nil
|
|
}
|
|
|
|
// FindCampaignByName resolves a campaign's identity (see
|
|
// internal/campaign/sync.go — the directory slug is sent as this name) by
|
|
// fetching every campaign and filtering client-side, same reasoning as
|
|
// FindListByName. Returns (nil, nil) on a plain miss, never an error.
|
|
func (c *Client) FindCampaignByName(name string) (*Campaign, error) {
|
|
respBody, status, err := c.do(http.MethodGet, "/api/campaigns?per_page=all", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing campaigns: %w", err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, fmt.Errorf("listing campaigns failed (%d): %s", status, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
Data struct {
|
|
Results []json.RawMessage `json:"results"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return nil, fmt.Errorf("parsing campaign list response: %w", err)
|
|
}
|
|
var matches []*Campaign
|
|
for _, raw := range parsed.Data.Results {
|
|
camp, err := parseCampaign(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing campaign in list response: %w", err)
|
|
}
|
|
if camp.Name == name {
|
|
matches = append(matches, camp)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
return nil, nil
|
|
case 1:
|
|
return matches[0], nil
|
|
default:
|
|
return nil, fmt.Errorf("%d campaigns named %q — ambiguous, refusing to guess", len(matches), name)
|
|
}
|
|
}
|
|
|
|
// CreateCampaign creates a new campaign. Confirmed against knadh/listmonk's
|
|
// actual handler source: the create handler ignores any caller-supplied
|
|
// status and always creates as draft — this tool's core safety invariant —
|
|
// so in.payload() doesn't bother sending one.
|
|
func (c *Client) CreateCampaign(in CampaignInput) (*Campaign, error) {
|
|
respBody, status, err := c.do(http.MethodPost, "/api/campaigns", in.payload())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating campaign %q: %w", in.Name, err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, fmt.Errorf("creating campaign %q failed (%d): %s", in.Name, status, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return nil, fmt.Errorf("parsing campaign creation response: %w", err)
|
|
}
|
|
return parseCampaign(parsed.Data)
|
|
}
|
|
|
|
// UpdateCampaign overwrites a draft campaign's content. Callers (sync.go)
|
|
// must have already confirmed the campaign is still in draft status —
|
|
// this client does not re-check, since the point of a fresh
|
|
// FindCampaignByName just beforehand is exactly that check.
|
|
func (c *Client) UpdateCampaign(id int, in CampaignInput) (*Campaign, error) {
|
|
respBody, status, err := c.do(http.MethodPut, fmt.Sprintf("/api/campaigns/%d", id), in.payload())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("updating campaign %d: %w", id, err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, fmt.Errorf("updating campaign %d failed (%d): %s", id, status, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return nil, fmt.Errorf("parsing campaign update response: %w", err)
|
|
}
|
|
return parseCampaign(parsed.Data)
|
|
}
|
|
|
|
// SetCampaignStatus transitions a campaign's status — "running" is the one
|
|
// real send trigger in this whole tool (see cmd/send).
|
|
func (c *Client) SetCampaignStatus(id int, status string) error {
|
|
payload := map[string]any{"status": status}
|
|
respBody, code, err := c.do(http.MethodPut, fmt.Sprintf("/api/campaigns/%d/status", id), payload)
|
|
if err != nil {
|
|
return fmt.Errorf("setting campaign %d status to %q: %w", id, status, err)
|
|
}
|
|
if code != http.StatusOK {
|
|
return fmt.Errorf("setting campaign %d status to %q failed (%d): %s", id, status, code, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestCampaign sends a preview of the campaign's current content to the
|
|
// given addresses without touching its status. Request field confirmed
|
|
// against knadh/listmonk's actual handler source (cmd/campaigns.go's
|
|
// campReq.SubscriberEmails, json tag "subscribers").
|
|
func (c *Client) TestCampaign(id int, emails []string) error {
|
|
if len(emails) == 0 {
|
|
return nil
|
|
}
|
|
payload := map[string]any{"subscribers": emails}
|
|
respBody, status, err := c.do(http.MethodPost, fmt.Sprintf("/api/campaigns/%d/test", id), payload)
|
|
if err != nil {
|
|
return fmt.Errorf("sending test for campaign %d: %w", id, err)
|
|
}
|
|
if status != http.StatusOK {
|
|
return fmt.Errorf("sending test for campaign %d failed (%d): %s", id, status, string(respBody))
|
|
}
|
|
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
|
|
}
|