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
+102
View File
@@ -0,0 +1,102 @@
// Package campaign parses campaign content (a campaign.md file with YAML
// frontmatter plus a Markdown body) and syncs it into listmonk as a
// campaign, mirroring the content-as-code pattern eec's internal/course
// package uses for course steps — but flattened, since a campaign has no
// step sequencing.
package campaign
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Frontmatter is the parsed YAML block at the top of a campaign.md file.
type Frontmatter struct {
Subject string `yaml:"subject"`
// Lists names Listmonk lists by name, not numeric ID (unlike eec's
// course_steps.list_id) — resolved to IDs at sync time so a campaign
// file stays legible without cross-referencing the Listmonk admin UI.
Lists []string `yaml:"lists"`
FromEmail string `yaml:"from_email"`
Tags []string `yaml:"tags"`
// TemplateID is optional; 0 means "use listmonk's default template".
TemplateID int `yaml:"template_id"`
// Type defaults to "regular" when omitted; "optin" is rejected in v1
// (see Validate) since this tool has no opt-in-confirmation workflow.
Type string `yaml:"type"`
// SegmentQuery is a raw SQL boolean expression run against subscribers,
// the same segmentation mechanism listmonk's own admin UI search box
// uses — see internal/campaign/sync.go for how this gets materialized
// into list membership.
SegmentQuery string `yaml:"segment_query"`
// PreviewEmails overrides the sync-wide default preview address for
// this campaign's automatic post-sync preview.
PreviewEmails []string `yaml:"preview_emails"`
// Attachments are paths relative to the campaign's own directory
// (typically under assets/), uploaded to listmonk's media library and
// attached to the campaign.
Attachments []string `yaml:"attachments"`
}
// Campaign is one parsed campaign.md, identified by its directory name.
type Campaign struct {
Slug string // campaigns/<slug>/ — also sent to listmonk as the campaign's Name
Dir string // full path to campaigns/<slug>/, for resolving Attachments
Frontmatter
Body string
}
// ParseFile reads a campaign.md file with a leading
// "---\n...\n---\n" frontmatter block and returns the parsed frontmatter and
// body, or a descriptive error — same shape as eec's course.parseFrontmatter.
func ParseFile(path string) (Frontmatter, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Frontmatter{}, "", err
}
text := string(raw)
if !strings.HasPrefix(text, "---\n") {
return Frontmatter{}, "", fmt.Errorf("missing frontmatter (expected file to start with '---')")
}
rest := text[4:]
end := strings.Index(rest, "\n---\n")
if end == -1 {
return Frontmatter{}, "", fmt.Errorf("unterminated frontmatter (missing closing '---')")
}
rawFM := rest[:end]
body := strings.TrimPrefix(rest[end+len("\n---\n"):], "\n")
var fm Frontmatter
if err := yaml.Unmarshal([]byte(rawFM), &fm); err != nil {
return Frontmatter{}, "", fmt.Errorf("parsing frontmatter: %w", err)
}
if fm.Type == "" {
fm.Type = "regular"
}
if err := fm.Validate(); err != nil {
return Frontmatter{}, "", err
}
return fm, body, nil
}
// Validate checks the fields sync.go depends on before ever talking to
// listmonk, so a bad file is rejected with one clear message instead of a
// confusing API error partway through syncing.
func (fm Frontmatter) Validate() error {
if fm.Subject == "" {
return fmt.Errorf("frontmatter missing 'subject'")
}
if len(fm.Lists) == 0 && fm.SegmentQuery == "" {
return fmt.Errorf("frontmatter must set 'lists' and/or 'segment_query' — a campaign needs a target audience")
}
if fm.FromEmail == "" {
return fmt.Errorf("frontmatter missing 'from_email'")
}
if fm.Type != "regular" {
return fmt.Errorf("type %q is not supported yet (only 'regular' campaigns) — optin campaigns need their own confirmation workflow this tool doesn't have", fm.Type)
}
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package campaign
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestParseFile_ParsesFrontmatterAndBody(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, `---
subject: "Big Announcement"
lists: ["Newsletter"]
from_email: hello@reground.org
tags: ["announcement"]
attachments:
- assets/one-pager.pdf
---
Hello **world**.
`)
fm, body, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if fm.Subject != "Big Announcement" {
t.Errorf("unexpected subject: %q", fm.Subject)
}
if len(fm.Lists) != 1 || fm.Lists[0] != "Newsletter" {
t.Errorf("unexpected lists: %v", fm.Lists)
}
if fm.Type != "regular" {
t.Errorf("expected type to default to regular, got %q", fm.Type)
}
if !strings.Contains(body, "Hello **world**.") {
t.Errorf("unexpected body: %q", body)
}
}
func TestParseFile_MissingFrontmatterDelimiter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, "no frontmatter here\n")
if _, _, err := ParseFile(path); err == nil {
t.Fatal("expected an error for a file with no frontmatter block")
}
}
func TestParseFile_UnterminatedFrontmatter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, "---\nsubject: hi\nbody with no closing delimiter")
if _, _, err := ParseFile(path); err == nil {
t.Fatal("expected an error for unterminated frontmatter")
}
}
func TestParseFile_MissingSubject(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, `---
lists: ["Newsletter"]
from_email: hello@reground.org
---
body
`)
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "subject") {
t.Fatalf("expected a 'missing subject' error, got %v", err)
}
}
func TestParseFile_MissingFromEmail(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, `---
subject: hi
lists: ["Newsletter"]
---
body
`)
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "from_email") {
t.Fatalf("expected a 'missing from_email' error, got %v", err)
}
}
func TestParseFile_RequiresListsOrSegmentQuery(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, `---
subject: hi
from_email: hello@reground.org
---
body
`)
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "target audience") {
t.Fatalf("expected a 'needs a target audience' error, got %v", err)
}
}
func TestParseFile_SegmentQueryAloneSatisfiesTargetAudience(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, `---
subject: hi
from_email: hello@reground.org
segment_query: "subscribers.attribs->>'source' = 'workshop'"
---
body
`)
if _, _, err := ParseFile(path); err != nil {
t.Fatalf("expected segment_query alone to be sufficient, got %v", err)
}
}
func TestParseFile_RejectsOptin(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, `---
subject: hi
lists: ["Newsletter"]
from_email: hello@reground.org
type: optin
---
body
`)
if _, _, err := ParseFile(path); err == nil || !strings.Contains(err.Error(), "optin") {
t.Fatalf("expected an 'optin not supported' error, got %v", err)
}
}
func TestParseFile_MalformedYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "campaign.md")
writeFile(t, path, "---\nsubject: [unterminated\n---\nbody\n")
if _, _, err := ParseFile(path); err == nil {
t.Fatal("expected an error for malformed YAML frontmatter")
}
}
+43
View File
@@ -0,0 +1,43 @@
package campaign
import (
"fmt"
"reground.org/eec-campaigns/internal/listmonk"
)
// Send transitions a campaign from draft/paused to running — the one real
// send trigger in this whole tool (see cmd/send, triggered only by a pushed
// send/<slug> git tag or a manual workflow_dispatch, never by a plain
// sync). The campaign is looked up fresh by name each call, so this always
// acts on listmonk's current live state, not whatever sync last saw.
func Send(lm *listmonk.Client, slug string) (*listmonk.Campaign, error) {
camp, err := lm.FindCampaignByName(slug)
if err != nil {
return nil, err
}
if camp == nil {
return nil, fmt.Errorf("no campaign named %q in listmonk — run sync first", slug)
}
if camp.Status != "draft" && camp.Status != "paused" {
return nil, fmt.Errorf("campaign %q is %s in listmonk — only draft or paused campaigns can be sent", slug, camp.Status)
}
if err := lm.SetCampaignStatus(camp.ID, "running"); err != nil {
return nil, err
}
return camp, nil
}
// Test sends a preview of a campaign's current content to the given
// addresses without touching its status — the same mechanism sync's
// automatic preview uses, exposed standalone for on-demand re-previews.
func Test(lm *listmonk.Client, slug string, emails []string) error {
camp, err := lm.FindCampaignByName(slug)
if err != nil {
return err
}
if camp == nil {
return fmt.Errorf("no campaign named %q in listmonk — run sync first", slug)
}
return lm.TestCampaign(camp.ID, emails)
}
+80
View File
@@ -0,0 +1,80 @@
package campaign
import (
"strings"
"testing"
)
func TestSend_TransitionsDraftToRunning(t *testing.T) {
f := newFakeListmonk(t)
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "draft"}}
lm := f.client()
camp, err := Send(lm, "launch")
if err != nil {
t.Fatalf("Send: %v", err)
}
if camp.ID != 1 {
t.Errorf("expected campaign id 1, got %d", camp.ID)
}
if f.campaigns[0].Status != "running" {
t.Errorf("expected status running, got %q", f.campaigns[0].Status)
}
}
func TestSend_TransitionsPausedToRunning(t *testing.T) {
f := newFakeListmonk(t)
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "paused"}}
lm := f.client()
if _, err := Send(lm, "launch"); err != nil {
t.Fatalf("Send: %v", err)
}
if f.campaigns[0].Status != "running" {
t.Errorf("expected status running, got %q", f.campaigns[0].Status)
}
}
func TestSend_RefusesWhenAlreadyRunning(t *testing.T) {
f := newFakeListmonk(t)
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running"}}
lm := f.client()
if _, err := Send(lm, "launch"); err == nil || !strings.Contains(err.Error(), "running") {
t.Fatalf("expected a refusal mentioning 'running', got %v", err)
}
if f.campaigns[0].Status != "running" {
t.Errorf("expected status to remain running, got %q", f.campaigns[0].Status)
}
}
func TestSend_ErrorsWhenCampaignMissing(t *testing.T) {
f := newFakeListmonk(t)
lm := f.client()
if _, err := Send(lm, "nonexistent"); err == nil || !strings.Contains(err.Error(), "run sync first") {
t.Fatalf("expected a 'run sync first' error, got %v", err)
}
}
func TestTest_SendsPreviewToGivenAddresses(t *testing.T) {
f := newFakeListmonk(t)
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "draft"}}
lm := f.client()
if err := Test(lm, "launch", []string{"me@example.com"}); err != nil {
t.Fatalf("Test: %v", err)
}
if len(f.testCalls) != 1 || f.testCalls[0].CampaignID != 1 {
t.Fatalf("expected 1 test call against campaign 1, got %+v", f.testCalls)
}
}
func TestTest_ErrorsWhenCampaignMissing(t *testing.T) {
f := newFakeListmonk(t)
lm := f.client()
if err := Test(lm, "nonexistent", []string{"me@example.com"}); err == nil || !strings.Contains(err.Error(), "run sync first") {
t.Fatalf("expected a 'run sync first' error, got %v", err)
}
}
+258
View File
@@ -0,0 +1,258 @@
package campaign
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"reground.org/eec-campaigns/internal/listmonk"
)
// SyncResult summarizes what a sync run did, split so the CI log (and the
// person reading it) can tell at a glance what actually happened — no bad
// campaign silently skipped or overwritten, no preview silently swallowed.
type SyncResult struct {
Synced []string // created or updated (content changed) in listmonk
Unchanged []string // existing draft, content identical — no API write, no preview
Rejected []string // validation/segmentation/list-lookup failure, or non-draft in listmonk — nothing written
PreviewFailed []string // content synced fine, but the automatic preview send itself failed
}
// SyncDir walks <root>/campaigns/*/campaign.md and upserts each as a
// listmonk draft campaign. One bad campaign doesn't abort the rest — this
// mirrors eec's course.SyncDir/SyncResult shape exactly, extended with the
// Unchanged/PreviewFailed buckets this tool's auto-preview and diff-based
// sync need. defaultPreviewEmails is used for any campaign that doesn't set
// its own preview_emails in frontmatter.
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
result := &SyncResult{}
for _, path := range matches {
dir := filepath.Dir(path)
slug := filepath.Base(dir)
res, err := syncOne(lm, slug, dir, path, defaultPreviewEmails)
if err != nil {
result.Rejected = append(result.Rejected, fmt.Sprintf("%s: %v", slug, err))
continue
}
if !res.Changed {
result.Unchanged = append(result.Unchanged, slug)
continue
}
result.Synced = append(result.Synced, slug)
if res.PreviewErr != nil {
result.PreviewFailed = append(result.PreviewFailed, fmt.Sprintf("%s: %v", slug, res.PreviewErr))
}
}
return result, nil
}
type syncOneResult struct {
Changed bool
PreviewErr error // set only when Changed and the automatic preview send failed
}
func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails []string) (syncOneResult, error) {
fm, body, err := ParseFile(path)
if err != nil {
return syncOneResult{}, err
}
listIDs, err := resolveLists(lm, fm.Lists)
if err != nil {
return syncOneResult{}, err
}
if fm.SegmentQuery != "" {
segID, err := resolveSegment(lm, slug, fm.SegmentQuery)
if err != nil {
return syncOneResult{}, err
}
listIDs = append(listIDs, segID)
}
mediaIDs, err := resolveAttachments(lm, slug, dir, fm.Attachments)
if err != nil {
return syncOneResult{}, err
}
input := listmonk.CampaignInput{
Name: slug, // the directory name IS the identity — see internal/campaign/parse.go doc
Subject: fm.Subject,
FromEmail: fm.FromEmail,
TemplateID: fm.TemplateID,
Type: fm.Type,
Tags: fm.Tags,
ListIDs: listIDs,
Body: body,
MediaIDs: mediaIDs,
}
existing, err := lm.FindCampaignByName(slug)
if err != nil {
return syncOneResult{}, err
}
var campaignID int
var changed bool
switch {
case existing == nil:
created, err := lm.CreateCampaign(input)
if err != nil {
return syncOneResult{}, err
}
campaignID, changed = created.ID, true
case existing.Status != "draft":
// The core safety invariant: never silently skip or overwrite a
// live/sent campaign. This is a hard rejection, not a warning.
return syncOneResult{}, fmt.Errorf("campaign is %s in listmonk; sync refuses to modify a non-draft campaign", existing.Status)
case campaignUnchanged(existing, input):
campaignID, changed = existing.ID, false
default:
if _, err := lm.UpdateCampaign(existing.ID, input); err != nil {
return syncOneResult{}, err
}
campaignID, changed = existing.ID, true
}
if !changed {
return syncOneResult{Changed: false}, nil
}
recipients := fm.PreviewEmails
if len(recipients) == 0 {
recipients = defaultPreviewEmails
}
var previewErr error
if len(recipients) > 0 {
previewErr = lm.TestCampaign(campaignID, recipients)
}
return syncOneResult{Changed: true, PreviewErr: previewErr}, nil
}
func resolveLists(lm *listmonk.Client, names []string) ([]int, error) {
ids := make([]int, 0, len(names))
for _, name := range names {
id, err := lm.FindListByName(name)
if err != nil {
return nil, fmt.Errorf("list %q: %w", name, err)
}
ids = append(ids, id)
}
return ids, nil
}
// resolveSegment materializes a segment_query into list membership, since a
// listmonk campaign can only target whole list(s), not an arbitrary query
// directly (still an open, unmerged upstream feature request — see the
// plan's Open items). The list is a point-in-time snapshot: re-running sync
// re-evaluates the query and re-syncs membership, so a later push before
// send picks up newly-matching subscribers.
func resolveSegment(lm *listmonk.Client, slug, query string) (int, error) {
ids, err := lm.QuerySubscriberIDs(query)
if err != nil {
return 0, fmt.Errorf("segment_query: %w", err)
}
listName := "segment:" + slug
listID, err := lm.FindOrCreateListByName(listName)
if err != nil {
return 0, fmt.Errorf("segment list %q: %w", listName, err)
}
if err := lm.BulkAddToList(ids, listID); err != nil {
return 0, fmt.Errorf("segment list %q: %w", listName, err)
}
return listID, nil
}
// resolveAttachments uploads each attachment under a content-hash-synthesized
// filename, so editing a file naturally produces a fresh upload while an
// unchanged file is recognized and reused — no local manifest of what's
// already been uploaded needed, listmonk's own media library is the only
// state store.
func resolveAttachments(lm *listmonk.Client, slug, dir string, relPaths []string) ([]int, error) {
ids := make([]int, 0, len(relPaths))
for _, rel := range relPaths {
full := filepath.Join(dir, rel)
content, err := os.ReadFile(full)
if err != nil {
return nil, fmt.Errorf("attachment %s: %w", rel, err)
}
name := attachmentFilename(slug, rel, content)
id, found, err := lm.FindMediaByFilename(name)
if err != nil {
return nil, fmt.Errorf("attachment %s: %w", rel, err)
}
if !found {
id, err = lm.UploadMedia(name, bytes.NewReader(content))
if err != nil {
return nil, fmt.Errorf("attachment %s: %w", rel, err)
}
}
ids = append(ids, id)
}
return ids, nil
}
func attachmentFilename(slug, relPath string, content []byte) string {
sum := sha256.Sum256(content)
prefix := hex.EncodeToString(sum[:])[:8]
return fmt.Sprintf("%s-%s-%s", slug, prefix, filepath.Base(relPath))
}
// campaignUnchanged reports whether input's content already matches what's
// stored in listmonk, so an unchanged campaign costs zero API writes and
// never re-fires a preview. List/tag/media comparisons are order-independent
// since neither side's ordering is meaningful.
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
return existing.Subject == input.Subject &&
existing.Body == input.Body &&
existing.FromEmail == input.FromEmail &&
existing.TemplateID == input.TemplateID &&
existing.Type == input.Type &&
equalStringSets(existing.Tags, input.Tags) &&
equalIntSets(existing.ListIDs, input.ListIDs) &&
equalIntSets(existing.MediaIDs, input.MediaIDs)
}
func equalStringSets(a, b []string) bool {
if len(a) != len(b) {
return false
}
as, bs := append([]string(nil), a...), append([]string(nil), b...)
sort.Strings(as)
sort.Strings(bs)
for i := range as {
if as[i] != bs[i] {
return false
}
}
return true
}
func equalIntSets(a, b []int) bool {
if len(a) != len(b) {
return false
}
as, bs := append([]int(nil), a...), append([]int(nil), b...)
sort.Ints(as)
sort.Ints(bs)
for i := range as {
if as[i] != bs[i] {
return false
}
}
return true
}
+615
View File
@@ -0,0 +1,615 @@
package campaign
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"reground.org/eec-campaigns/internal/listmonk"
)
// ---- fake listmonk server ----
//
// Following eec's/drip's existing pattern (httptest.NewServer fakes, no
// mocking library), but stateful enough to exercise sync.go's real
// idempotency/diff/segmentation logic rather than just counting hits.
type fakeList struct {
ID int
Name string
}
type fakeCampaign struct {
ID int
Name string
Status string
Subject string
Body string
FromEmail string
TemplateID int
Type string
Tags []string
ListIDs []int
MediaIDs []int
}
type fakeMedia struct {
ID int
Filename string
}
type testCall struct {
CampaignID int
Emails []string
}
type bulkAddCall struct {
IDs []int
ListID int
}
type fakeListmonk struct {
mu sync.Mutex
t *testing.T
nextID int
lists []fakeList
campaigns []fakeCampaign
media []fakeMedia
subscriberIDsForQuery map[string][]int
testCalls []testCall
bulkAddCalls []bulkAddCall
}
func newFakeListmonk(t *testing.T) *fakeListmonk {
return &fakeListmonk{t: t, nextID: 1, subscriberIDsForQuery: map[string][]int{}}
}
func (f *fakeListmonk) id() int {
id := f.nextID
f.nextID++
return id
}
func (f *fakeListmonk) client() *listmonk.Client {
srv := httptest.NewServer(http.HandlerFunc(f.handle))
f.t.Cleanup(srv.Close)
return listmonk.New(srv.URL, "u", "t")
}
func (f *fakeListmonk) handle(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/lists":
f.writeLists(w)
case r.Method == http.MethodPost && r.URL.Path == "/api/lists":
f.createList(w, r)
case r.Method == http.MethodGet && r.URL.Path == "/api/campaigns":
f.writeCampaigns(w)
case r.Method == http.MethodPost && r.URL.Path == "/api/campaigns":
f.createCampaign(w, r)
case r.Method == http.MethodPut && strings.HasSuffix(r.URL.Path, "/status"):
f.setStatus(w, r)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/test"):
f.recordTest(w, r)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/campaigns/"):
f.updateCampaign(w, r)
case r.Method == http.MethodGet && r.URL.Path == "/api/media":
f.writeMedia(w)
case r.Method == http.MethodPost && r.URL.Path == "/api/media":
f.uploadMedia(w, r)
case r.Method == http.MethodGet && r.URL.Path == "/api/subscribers":
f.querySubscribers(w, r)
case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/lists":
f.bulkAdd(w, r)
default:
f.t.Errorf("fakeListmonk: unhandled request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}
func (c fakeCampaign) toJSON() map[string]any {
lists := make([]map[string]any, 0, len(c.ListIDs))
for _, id := range c.ListIDs {
lists = append(lists, map[string]any{"id": id})
}
return map[string]any{
"id": c.ID, "name": c.Name, "status": c.Status, "subject": c.Subject,
"body": c.Body, "from_email": c.FromEmail, "template_id": c.TemplateID,
"type": c.Type, "tags": c.Tags, "lists": lists, "media_ids": c.MediaIDs,
}
}
func (f *fakeListmonk) writeCampaigns(w http.ResponseWriter) {
results := make([]map[string]any, 0, len(f.campaigns))
for _, c := range f.campaigns {
results = append(results, c.toJSON())
}
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
}
func (f *fakeListmonk) createCampaign(w http.ResponseWriter, r *http.Request) {
body := decodeBody(r)
c := fakeCampaign{
ID: f.id(), Name: str(body["name"]), Status: "draft",
Subject: str(body["subject"]), Body: str(body["body"]),
FromEmail: str(body["from_email"]), TemplateID: toInt(body["template_id"]),
Type: str(body["type"]), Tags: toStrings(body["tags"]),
ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media_ids"]),
}
f.campaigns = append(f.campaigns, c)
json.NewEncoder(w).Encode(map[string]any{"data": c.toJSON()})
}
func (f *fakeListmonk) updateCampaign(w http.ResponseWriter, r *http.Request) {
id := pathID(r.URL.Path, "/api/campaigns/")
body := decodeBody(r)
for i := range f.campaigns {
if f.campaigns[i].ID == id {
f.campaigns[i].Subject = str(body["subject"])
f.campaigns[i].Body = str(body["body"])
f.campaigns[i].FromEmail = str(body["from_email"])
f.campaigns[i].TemplateID = toInt(body["template_id"])
f.campaigns[i].Type = str(body["type"])
f.campaigns[i].Tags = toStrings(body["tags"])
f.campaigns[i].ListIDs = toInts(body["lists"])
f.campaigns[i].MediaIDs = toInts(body["media_ids"])
json.NewEncoder(w).Encode(map[string]any{"data": f.campaigns[i].toJSON()})
return
}
}
w.WriteHeader(http.StatusNotFound)
}
func (f *fakeListmonk) setStatus(w http.ResponseWriter, r *http.Request) {
id := pathID(strings.TrimSuffix(r.URL.Path, "/status"), "/api/campaigns/")
body := decodeBody(r)
for i := range f.campaigns {
if f.campaigns[i].ID == id {
f.campaigns[i].Status = str(body["status"])
w.Write([]byte(`{}`))
return
}
}
w.WriteHeader(http.StatusNotFound)
}
func (f *fakeListmonk) recordTest(w http.ResponseWriter, r *http.Request) {
id := pathID(strings.TrimSuffix(r.URL.Path, "/test"), "/api/campaigns/")
body := decodeBody(r)
f.testCalls = append(f.testCalls, testCall{CampaignID: id, Emails: toStrings(body["subscribers"])})
w.Write([]byte(`{}`))
}
func (f *fakeListmonk) writeLists(w http.ResponseWriter) {
results := make([]map[string]any, 0, len(f.lists))
for _, l := range f.lists {
results = append(results, map[string]any{"id": l.ID, "name": l.Name})
}
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
}
func (f *fakeListmonk) createList(w http.ResponseWriter, r *http.Request) {
body := decodeBody(r)
l := fakeList{ID: f.id(), Name: str(body["name"])}
f.lists = append(f.lists, l)
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": l.ID}})
}
func (f *fakeListmonk) writeMedia(w http.ResponseWriter) {
results := make([]map[string]any, 0, len(f.media))
for _, m := range f.media {
results = append(results, map[string]any{"id": m.ID, "filename": m.Filename})
}
json.NewEncoder(w).Encode(map[string]any{"data": results})
}
func (f *fakeListmonk) uploadMedia(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
_, header, err := r.FormFile("file")
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
m := fakeMedia{ID: f.id(), Filename: header.Filename}
f.media = append(f.media, m)
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": m.ID}})
}
func (f *fakeListmonk) querySubscribers(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("query")
ids := f.subscriberIDsForQuery[q]
results := make([]map[string]any, 0, len(ids))
for _, id := range ids {
results = append(results, map[string]any{"id": id})
}
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
}
func (f *fakeListmonk) bulkAdd(w http.ResponseWriter, r *http.Request) {
body := decodeBody(r)
targetIDs := toInts(body["target_list_ids"])
var listID int
if len(targetIDs) > 0 {
listID = targetIDs[0]
}
f.bulkAddCalls = append(f.bulkAddCalls, bulkAddCall{IDs: toInts(body["ids"]), ListID: listID})
w.Write([]byte(`{}`))
}
func decodeBody(r *http.Request) map[string]any {
var body map[string]any
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &body)
return body
}
func str(v any) string {
s, _ := v.(string)
return s
}
func toInt(v any) int {
f, _ := v.(float64)
return int(f)
}
func toStrings(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, x := range arr {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func toInts(v any) []int {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]int, 0, len(arr))
for _, x := range arr {
if n, ok := x.(float64); ok {
out = append(out, int(n))
}
}
return out
}
func pathID(path, prefix string) int {
id, _ := strconv.Atoi(strings.TrimPrefix(path, prefix))
return id
}
// ---- test fixtures ----
func writeCampaignDir(t *testing.T, root, slug, frontmatterExtra, body string) {
t.Helper()
path := filepath.Join(root, "campaigns", slug, "campaign.md")
writeFile(t, path, "---\n"+frontmatterExtra+"---\n"+body+"\n")
}
const baseFrontmatter = "subject: \"Big Announcement\"\nlists: [\"Newsletter\"]\nfrom_email: hello@example.com\n"
// ---- tests ----
func TestSyncDir_CreatesNewDraftCampaign(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 0 {
t.Fatalf("expected no rejections, got %v", result.Rejected)
}
if len(result.Synced) != 1 || result.Synced[0] != "launch" {
t.Fatalf("expected launch in Synced, got %v", result.Synced)
}
if len(f.campaigns) != 1 {
t.Fatalf("expected 1 campaign created, got %d", len(f.campaigns))
}
c := f.campaigns[0]
if c.Status != "draft" {
t.Errorf("expected status draft, got %q", c.Status)
}
if len(c.ListIDs) != 1 || c.ListIDs[0] != 3 {
t.Errorf("expected list ID resolved to [3], got %v", c.ListIDs)
}
}
func TestSyncDir_SecondSyncWithUnchangedContentIsNoop(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("first SyncDir: %v", err)
}
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("second SyncDir: %v", err)
}
if len(result.Synced) != 0 {
t.Errorf("expected no re-sync of unchanged content, got Synced=%v", result.Synced)
}
if len(result.Unchanged) != 1 || result.Unchanged[0] != "launch" {
t.Errorf("expected launch in Unchanged, got %v", result.Unchanged)
}
if len(f.campaigns) != 1 {
t.Errorf("expected still exactly 1 campaign, got %d", len(f.campaigns))
}
}
func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("first SyncDir: %v", err)
}
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello, updated world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("second SyncDir: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "launch" {
t.Fatalf("expected launch re-synced, got Synced=%v Unchanged=%v", result.Synced, result.Unchanged)
}
if len(f.campaigns) != 1 || !strings.Contains(f.campaigns[0].Body, "updated") {
t.Errorf("expected the existing campaign's body to be updated, got %+v", f.campaigns[0])
}
}
func TestSyncDir_RejectsNonDraftCampaign(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running", Subject: "Original"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "running") {
t.Fatalf("expected a rejection mentioning 'running', got %v", result.Rejected)
}
if f.campaigns[0].Subject != "Original" {
t.Errorf("expected the live campaign's content to be untouched, got %+v", f.campaigns[0])
}
}
func TestSyncDir_AmbiguousListNameRejects(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}, {ID: 4, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 1 {
t.Fatalf("expected 1 rejection for an ambiguous list name, got %v", result.Rejected)
}
if len(f.campaigns) != 0 {
t.Errorf("expected no campaign created for a rejected sync, got %d", len(f.campaigns))
}
}
func TestSyncDir_SegmentQueryMaterializesIntoManagedList(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
query := "subscribers.attribs->>'source' = 'workshop'"
f.subscriberIDsForQuery[query] = []int{10, 11, 12}
lm := f.client()
root := t.TempDir()
fm := baseFrontmatter + "segment_query: \"" + query + "\"\n"
writeCampaignDir(t, root, "launch", fm, "Hello world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 0 {
t.Fatalf("expected no rejections, got %v", result.Rejected)
}
if len(f.bulkAddCalls) != 1 {
t.Fatalf("expected 1 bulk-add call, got %d", len(f.bulkAddCalls))
}
if len(f.bulkAddCalls[0].IDs) != 3 {
t.Errorf("expected 3 subscriber IDs bulk-added, got %v", f.bulkAddCalls[0].IDs)
}
var segList *fakeList
for i := range f.lists {
if f.lists[i].Name == "segment:launch" {
segList = &f.lists[i]
}
}
if segList == nil {
t.Fatal("expected a managed 'segment:launch' list to be created")
}
if len(f.campaigns) != 1 {
t.Fatalf("expected 1 campaign, got %d", len(f.campaigns))
}
found := false
for _, id := range f.campaigns[0].ListIDs {
if id == segList.ID {
found = true
}
}
if !found {
t.Errorf("expected campaign to target the segment list %d, got %v", segList.ID, f.campaigns[0].ListIDs)
}
}
func TestSyncDir_InvalidSegmentQueryRejectsWithoutSideEffects(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
// No entry seeded in subscriberIDsForQuery for this exact string simulates
// a query listmonk would reject — here we just confirm an empty/no-match
// result still flows through cleanly without creating a segment list
// mistakenly treated as an error path; a real invalid-SQL rejection from
// listmonk itself surfaces as a plain HTTP-error wrapped by
// QuerySubscriberIDs, exercised at the listmonk package's own test level.
fm := baseFrontmatter + "segment_query: \"subscribers.attribs->>'nope' = 'nothing'\"\n"
writeCampaignDir(t, root, "launch", fm, "Hello world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 0 {
t.Fatalf("expected no rejections for a zero-match segment query, got %v", result.Rejected)
}
}
func TestSyncDir_AttachmentUploadAndDedup(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeFile(t, filepath.Join(root, "campaigns", "launch", "assets", "flyer.pdf"), "pdf-v1")
fm := baseFrontmatter + "attachments:\n - assets/flyer.pdf\n"
writeCampaignDir(t, root, "launch", fm, "Hello world.")
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("first SyncDir: %v", err)
}
if len(f.media) != 1 {
t.Fatalf("expected 1 media upload, got %d", len(f.media))
}
// Re-sync unchanged: no new upload.
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("second SyncDir: %v", err)
}
if len(f.media) != 1 {
t.Errorf("expected dedup to avoid a second upload, got %d media items", len(f.media))
}
// Change the attachment's content: a fresh upload.
writeFile(t, filepath.Join(root, "campaigns", "launch", "assets", "flyer.pdf"), "pdf-v2")
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("third SyncDir: %v", err)
}
if len(f.media) != 2 {
t.Errorf("expected a fresh upload when attachment content changes, got %d media items", len(f.media))
}
}
func TestSyncDir_AutomaticPreviewFiresOnlyOnChange(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
if _, err := SyncDir(lm, root, []string{"me@example.com"}); err != nil {
t.Fatalf("first SyncDir: %v", err)
}
if len(f.testCalls) != 1 {
t.Fatalf("expected exactly 1 preview send after create, got %d", len(f.testCalls))
}
if f.testCalls[0].Emails[0] != "me@example.com" {
t.Errorf("expected preview sent to default address, got %v", f.testCalls[0].Emails)
}
// Unchanged re-sync: no additional preview.
if _, err := SyncDir(lm, root, []string{"me@example.com"}); err != nil {
t.Fatalf("second SyncDir: %v", err)
}
if len(f.testCalls) != 1 {
t.Errorf("expected no additional preview for an unchanged campaign, got %d total", len(f.testCalls))
}
// Change content: exactly one more preview.
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello, updated world.")
if _, err := SyncDir(lm, root, []string{"me@example.com"}); err != nil {
t.Fatalf("third SyncDir: %v", err)
}
if len(f.testCalls) != 2 {
t.Errorf("expected exactly 2 total previews after one content change, got %d", len(f.testCalls))
}
}
func TestSyncDir_PreviewEmailsOverridesDefault(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
fm := baseFrontmatter + "preview_emails:\n - cofounder@example.com\n"
writeCampaignDir(t, root, "launch", fm, "Hello world.")
if _, err := SyncDir(lm, root, []string{"default@example.com"}); err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(f.testCalls) != 1 {
t.Fatalf("expected 1 preview send, got %d", len(f.testCalls))
}
if f.testCalls[0].Emails[0] != "cofounder@example.com" {
t.Errorf("expected campaign-level preview_emails to override the default, got %v", f.testCalls[0].Emails)
}
}
func TestSyncDir_OneBadCampaignDoesNotAbortOthers(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
writeCampaignDir(t, root, "good", baseFrontmatter, "Hello world.")
writeCampaignDir(t, root, "bad", "lists: [\"Newsletter\"]\nfrom_email: hello@example.com\n", "Missing a subject.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "good" {
t.Errorf("expected good to sync despite bad's failure, got Synced=%v", result.Synced)
}
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "bad:") {
t.Errorf("expected bad to be rejected with a slug-prefixed message, got %v", result.Rejected)
}
}
+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
}
+355
View File
@@ -0,0 +1,355 @@
package listmonk
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFindListByName_ReturnsIDOnExactMatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"results":[{"id":3,"name":"Newsletter"},{"id":4,"name":"Workshop Leads"}]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
id, err := c.FindListByName("Newsletter")
if err != nil {
t.Fatalf("FindListByName: %v", err)
}
if id != 3 {
t.Errorf("expected id 3, got %d", id)
}
}
func TestFindListByName_ErrorsOnZeroMatches(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"results":[{"id":3,"name":"Newsletter"}]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if _, err := c.FindListByName("Nonexistent"); err == nil {
t.Fatal("expected an error for zero matches, got nil")
}
}
func TestFindListByName_ErrorsOnAmbiguousMatches(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"results":[{"id":3,"name":"Newsletter"},{"id":5,"name":"Newsletter"}]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if _, err := c.FindListByName("Newsletter"); err == nil {
t.Fatal("expected an error for ambiguous matches, got nil")
}
}
func TestFindOrCreateListByName_ReusesExisting(t *testing.T) {
var postCount int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
postCount++
}
w.Write([]byte(`{"data":{"results":[{"id":9,"name":"segment:launch"}]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
id, err := c.FindOrCreateListByName("segment:launch")
if err != nil {
t.Fatalf("FindOrCreateListByName: %v", err)
}
if id != 9 {
t.Errorf("expected existing id 9, got %d", id)
}
if postCount != 0 {
t.Errorf("expected no create call when list already exists, got %d POSTs", postCount)
}
}
func TestFindOrCreateListByName_CreatesWhenMissing(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Write([]byte(`{"data":{"results":[]}}`))
return
}
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{"data":{"id":11}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
id, err := c.FindOrCreateListByName("segment:launch")
if err != nil {
t.Fatalf("FindOrCreateListByName: %v", err)
}
if id != 11 {
t.Errorf("expected new id 11, got %d", id)
}
if gotBody["name"] != "segment:launch" {
t.Errorf("expected create payload to carry the list name, got %+v", gotBody)
}
}
func TestQuerySubscriberIDs_UsesPerPageAllAndParsesIDs(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.RequestURI()
w.Write([]byte(`{"data":{"results":[{"id":1},{"id":2}]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
ids, err := c.QuerySubscriberIDs("subscribers.attribs->>'source' = 'workshop'")
if err != nil {
t.Fatalf("QuerySubscriberIDs: %v", err)
}
if !strings.Contains(gotPath, "per_page=all") {
t.Errorf("expected per_page=all, got %q", gotPath)
}
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Errorf("expected [1 2], got %v", ids)
}
}
func TestBulkAddToList_SendsExpectedPayload(t *testing.T) {
var gotMethod, gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if err := c.BulkAddToList([]int{1, 2, 3}, 9); err != nil {
t.Fatalf("BulkAddToList: %v", err)
}
if gotMethod != http.MethodPut {
t.Errorf("expected PUT, got %s", gotMethod)
}
if gotPath != "/api/subscribers/lists" {
t.Errorf("expected /api/subscribers/lists, got %q", gotPath)
}
if gotBody["action"] != "add" {
t.Errorf("expected action=add, got %+v", gotBody)
}
}
func TestBulkAddToList_NoopOnEmptyIDs(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if err := c.BulkAddToList(nil, 9); err != nil {
t.Fatalf("BulkAddToList: %v", err)
}
if calls != 0 {
t.Errorf("expected no request for empty id list, got %d", calls)
}
}
func TestFindMediaByFilename(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":[{"id":5,"filename":"launch-abc123-flyer.pdf"}]}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
id, found, err := c.FindMediaByFilename("launch-abc123-flyer.pdf")
if err != nil {
t.Fatalf("FindMediaByFilename: %v", err)
}
if !found || id != 5 {
t.Errorf("expected found id 5, got found=%v id=%d", found, id)
}
_, found, err = c.FindMediaByFilename("nope.pdf")
if err != nil {
t.Fatalf("FindMediaByFilename: %v", err)
}
if found {
t.Error("expected not found for a filename with no match")
}
}
func TestUploadMedia_SendsMultipartFormFile(t *testing.T) {
var gotFilename string
var gotContent string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("ParseMultipartForm: %v", err)
}
file, header, err := r.FormFile("file")
if err != nil {
t.Fatalf("FormFile: %v", err)
}
defer file.Close()
gotFilename = header.Filename
b, _ := io.ReadAll(file)
gotContent = string(b)
w.Write([]byte(`{"data":{"id":42}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
id, err := c.UploadMedia("flyer.pdf", strings.NewReader("pdf-bytes"))
if err != nil {
t.Fatalf("UploadMedia: %v", err)
}
if id != 42 {
t.Errorf("expected id 42, got %d", id)
}
if gotFilename != "flyer.pdf" {
t.Errorf("expected filename flyer.pdf, got %q", gotFilename)
}
if gotContent != "pdf-bytes" {
t.Errorf("expected uploaded content to match, got %q", gotContent)
}
}
func TestFindCampaignByName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"results":[
{"id":1,"name":"launch","status":"draft","subject":"Hi","lists":[{"id":3,"name":"Newsletter"}]}
]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
camp, err := c.FindCampaignByName("launch")
if err != nil {
t.Fatalf("FindCampaignByName: %v", err)
}
if camp == nil || camp.ID != 1 || camp.Status != "draft" {
t.Fatalf("unexpected campaign: %+v", camp)
}
if len(camp.ListIDs) != 1 || camp.ListIDs[0] != 3 {
t.Errorf("expected list IDs [3], got %v", camp.ListIDs)
}
}
func TestFindCampaignByName_NilOnMiss(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"results":[]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
camp, err := c.FindCampaignByName("missing")
if err != nil {
t.Fatalf("FindCampaignByName: %v", err)
}
if camp != nil {
t.Errorf("expected nil for a miss, got %+v", camp)
}
}
func TestFindCampaignByName_ErrorsOnAmbiguous(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"results":[
{"id":1,"name":"launch"},{"id":2,"name":"launch"}
]}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if _, err := c.FindCampaignByName("launch"); err == nil {
t.Fatal("expected an error for ambiguous campaign names, got nil")
}
}
func TestCreateCampaign_SendsMarkdownContentType(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{"data":{"id":7,"name":"launch","status":"draft"}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
camp, err := c.CreateCampaign(CampaignInput{
Name: "launch", Subject: "Hi", FromEmail: "a@b.com",
Type: "regular", ListIDs: []int{3}, Body: "# hi",
})
if err != nil {
t.Fatalf("CreateCampaign: %v", err)
}
if camp.ID != 7 || camp.Status != "draft" {
t.Errorf("unexpected campaign: %+v", camp)
}
if gotBody["content_type"] != "markdown" {
t.Errorf("expected content_type=markdown, got %+v", gotBody)
}
}
func TestSetCampaignStatus_SendsStatusToRunning(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if err := c.SetCampaignStatus(7, "running"); err != nil {
t.Fatalf("SetCampaignStatus: %v", err)
}
if gotPath != "/api/campaigns/7/status" {
t.Errorf("expected /api/campaigns/7/status, got %q", gotPath)
}
if gotBody["status"] != "running" {
t.Errorf("expected status=running, got %+v", gotBody)
}
}
func TestTestCampaign_SendsSubscribersList(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &gotBody)
w.Write([]byte(`{}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
if err := c.TestCampaign(7, []string{"me@example.com"}); err != nil {
t.Fatalf("TestCampaign: %v", err)
}
subs, ok := gotBody["subscribers"].([]any)
if !ok || len(subs) != 1 || subs[0] != "me@example.com" {
t.Errorf("expected subscribers=[me@example.com], got %+v", gotBody)
}
}
func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message":"boom"}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "t")
_, err := c.CreateCampaign(CampaignInput{Name: "x"})
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Errorf("expected error to surface response body, got %v", err)
}
}