// 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:), 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 }