Initial implementation of the eec-campaigns tool

Drives listmonk's real Campaign API from git-authored Markdown+frontmatter,
so broadcast/segment emails get listmonk's mature unsubscribe/bulk-send/
attachment handling instead of reimplementing it. sync only ever creates or
updates a draft (idempotent, diff-based, refuses to touch a non-draft
campaign); a pushed send/<slug> tag or manual workflow run is the only way
to actually trigger a send. Includes list-name resolution, segment_query
materialization into managed lists, content-hash-deduped attachment
uploads, and an automatic post-sync preview email.
This commit is contained in:
2026-07-10 07:30:45 -04:00
commit b16d2b6c5f
16 changed files with 2461 additions and 0 deletions
+533
View File
@@ -0,0 +1,533 @@
// 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"
"net/url"
)
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 ----
// QuerySubscriberIDs runs a raw SQL boolean expression against the
// subscribers table (the same segmentation mechanism listmonk's own admin
// UI search box uses) and returns the IDs of every match. per_page=all
// deliberately skips pagination, matching drip's existing Query method.
func (c *Client) QuerySubscriberIDs(query string) ([]int, error) {
path := "/api/subscribers?per_page=all&query=" + url.QueryEscape(query)
respBody, status, err := c.do(http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("querying subscribers: %w", err)
}
if status != http.StatusOK {
return nil, fmt.Errorf("segment_query failed (%d): %s", status, string(respBody))
}
var parsed struct {
Data struct {
Results []struct {
ID int `json:"id"`
} `json:"results"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("parsing subscriber query response: %w", err)
}
ids := make([]int, 0, len(parsed.Data.Results))
for _, r := range parsed.Data.Results {
ids = append(ids, r.ID)
}
return ids, nil
}
// BulkAddToList adds the given subscriber IDs to listID.
//
// TODO(verify): the bulk list-membership action's exact endpoint/body shape
// isn't confirmed against a live listmonk instance — this targets the
// documented bulk subscriber-action endpoint (PUT /api/subscribers/lists
// with explicit ids+action+target_list_ids), which is the same mechanism
// listmonk's admin UI uses for "add selected subscribers to list". Confirm
// against the real instance/version before relying on this in production.
func (c *Client) BulkAddToList(ids []int, listID int) error {
if len(ids) == 0 {
return nil
}
payload := map[string]any{
"ids": ids,
"action": "add",
"target_list_ids": []int{listID},
"status": "unconfirmed",
}
respBody, status, err := c.do(http.MethodPut, "/api/subscribers/lists", payload)
if err != nil {
return fmt.Errorf("bulk-adding %d subscriber(s) to list %d: %w", len(ids), listID, err)
}
if status != http.StatusOK {
return fmt.Errorf("bulk-adding subscribers 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 {
// TODO(verify): field name for attaching media library items to a
// campaign isn't documented publicly — confirm against a live
// listmonk admin UI network trace (attach a file to any test
// campaign and inspect the PUT /api/campaigns/{id} request it
// sends) before relying on this in production.
p["media_ids"] = 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"`
MediaIDs []int `json:"media_ids"`
}
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)
}
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: parsed.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. listmonk's create-time default
// status is draft, which is this tool's core safety invariant — see the
// TODO below.
//
// TODO(verify): confirm that POST /api/campaigns' create-time default
// status really is "draft" against a live instance (and whether an explicit
// status field is even accepted on create) before depending on it.
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.
//
// TODO(verify): the request body key for the recipient address list isn't
// confirmed against a live instance — using "subscribers" per listmonk's
// documented shape for this endpoint; confirm before relying on it.
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
}