6227d59eae
CI / test (push) Successful in 10s
Campaign/parseCampaign never fetched altbody, so campaignUnchanged() compared everything except it -- a campaign synced before v0.1.3 (altbody always null) whose content otherwise hasn't changed would be judged unchanged forever and never get updated with the new altbody, even though every subsequent sync now intends to send one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
642 lines
20 KiB
Go
642 lines
20 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
|
|
AltBody string
|
|
FromEmail string
|
|
TemplateID int
|
|
Type string
|
|
Tags []string
|
|
ListIDs []int
|
|
MediaIDs []int
|
|
}
|
|
|
|
// AsInput converts a fetched Campaign back into the CampaignInput shape,
|
|
// for callers (e.g. Test) that need to re-send a campaign's current fields
|
|
// against an endpoint that validates the full campaign body.
|
|
func (camp *Campaign) AsInput() CampaignInput {
|
|
return CampaignInput{
|
|
Name: camp.Name,
|
|
Subject: camp.Subject,
|
|
FromEmail: camp.FromEmail,
|
|
TemplateID: camp.TemplateID,
|
|
Type: camp.Type,
|
|
Tags: camp.Tags,
|
|
ListIDs: camp.ListIDs,
|
|
Body: camp.Body,
|
|
MediaIDs: camp.MediaIDs,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
// listmonk never derives a plaintext alternative from body/HTML on its
|
|
// own (models/campaigns.go only compiles AltBodyTpl, and
|
|
// internal/manager/message.go only emits a text/plain part, when
|
|
// altbody is explicitly non-null) — confirmed against the deployed
|
|
// v6.2.0 source. Reusing the same raw Markdown+template source as the
|
|
// altbody gets it personalized identically to body (AltBodyTpl compiles
|
|
// whenever the text contains {{ }} expressions), at the cost of
|
|
// Markdown syntax like *emphasis* showing up literally in plaintext
|
|
// clients — an acceptable, well-established tradeoff for Markdown.
|
|
"altbody": in.Body,
|
|
"type": in.Type,
|
|
// listmonk's create/update handlers default an omitted messenger to
|
|
// "email" before validating, but the test-send handler validates the
|
|
// raw request body as-is — an omitted messenger there fails with
|
|
// "Unknown messenger .". eec-campaigns only ever sends email, so set
|
|
// it explicitly everywhere rather than relying on that asymmetry.
|
|
"messenger": "email",
|
|
}
|
|
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"`
|
|
AltBody string `json:"altbody"`
|
|
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,
|
|
AltBody: parsed.AltBody,
|
|
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. listmonk's test-send handler
|
|
// binds the request into the same campReq struct create/update use and
|
|
// validates it in full, so the campaign's other fields (in particular a
|
|
// non-empty "name") must be sent alongside "subscribers" — a request with
|
|
// just {"subscribers": [...]} fails listmonk's own validation with "Invalid
|
|
// length for name" before the "subscribers" field is ever looked at.
|
|
func (c *Client) TestCampaign(id int, in CampaignInput, emails []string) error {
|
|
if len(emails) == 0 {
|
|
return nil
|
|
}
|
|
payload := in.payload()
|
|
payload["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
|
|
}
|