commit b16d2b6c5f8d8b11fec264acb70fbe7db9b1f78b Author: Will Estes Date: Fri Jul 10 07:30:45 2026 -0400 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/ 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. diff --git a/.gitea/workflows/send.yml b/.gitea/workflows/send.yml new file mode 100644 index 0000000..f4230a3 --- /dev/null +++ b/.gitea/workflows/send.yml @@ -0,0 +1,45 @@ +name: Send campaign +on: + # Pushing a tag like send/big-announcement is the primary trigger — a + # deliberate, git-native, auditable action (git log --tags shows every + # real send that ever happened). These are "command tags," not release + # tags: fine to delete and re-push if a send needs retrying, since the + # actual safety net is listmonk's own draft/paused status check in + # `campaigns send`, not tag uniqueness. + push: + tags: ["send/*"] + # workflow_dispatch is kept alongside it for when clicking a button is + # more convenient than a local `git tag && git push`. Same command either + # way. + workflow_dispatch: + inputs: + slug: + description: "Campaign slug (campaigns//) to send" + required: true + type: string + +jobs: + send: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23.1" + + - name: Determine campaign slug + id: slug + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "value=${{ inputs.slug }}" >> "$GITHUB_OUTPUT" + else + echo "value=${GITHUB_REF#refs/tags/send/}" >> "$GITHUB_OUTPUT" + fi + + - name: Send campaign + run: go run . send "${{ steps.slug.outputs.value }}" + env: + LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }} + LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }} + LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }} diff --git a/.gitea/workflows/sync.yml b/.gitea/workflows/sync.yml new file mode 100644 index 0000000..821d76b --- /dev/null +++ b/.gitea/workflows/sync.yml @@ -0,0 +1,22 @@ +name: Sync campaigns +on: + push: + branches: [master] + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23.1" + + - name: Sync campaigns to listmonk + run: go run . sync . + env: + LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }} + LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }} + LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }} + CAMPAIGNS_PREVIEW_EMAIL: ${{ secrets.CAMPAIGNS_PREVIEW_EMAIL }} diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..fddb10b --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test-send campaign +on: + workflow_dispatch: + inputs: + slug: + description: "Campaign slug (campaigns//) to preview" + required: true + type: string + email: + description: "Address to send the preview to" + required: true + type: string + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23.1" + + - name: Send preview + run: go run . test "${{ inputs.slug }}" "${{ inputs.email }}" + env: + LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }} + LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }} + LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5194f41 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/eec-campaigns diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7fe6a4 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# eec-campaigns + +Broadcast/segment email, authored as Markdown+YAML in git, driving [listmonk](https://listmonk.app)'s real Campaign API — not [`eec`](https://gitea.reground.org/will/eec)'s transactional `/api/tx` path, which has no unsubscribe or bulk-send machinery. Push to `master` and CI syncs `campaigns/` straight to listmonk as **draft** campaigns; a separate, deliberate action (a pushed tag or a manual workflow run) is what actually sends one. A push here can never blast your list by itself. + +## Layout + +``` +campaigns/ + / + campaign.md + assets/ + one-pager.pdf +``` + +Each campaign is a directory under `campaigns/`, named for its slug. **The directory name is the campaign's identity** — it's sent to listmonk as the campaign's `name` and is what `sync`/`send`/`test` all look it up by. There's no separate `name:`/`slug:` field in frontmatter to keep in sync with the directory; renaming the directory creates a new campaign in listmonk rather than renaming the existing one. + +## `campaign.md` + +```yaml +--- +subject: "Big Announcement" +lists: ["Newsletter"] +from_email: hello@reground.org +tags: ["announcement"] +template_id: 4 +segment_query: >- + subscribers.attribs->>'source' = 'workshop-signup' + AND subscribers.attribs->>'eec_enroll' IS NULL +preview_emails: + - cofounder@reground.org +attachments: + - assets/one-pager.pdf +--- + +Campaign body goes here, in Markdown. It's sent to listmonk with +content_type "markdown" — listmonk renders the HTML *and* derives the +plaintext alternative itself, so there's no separate plain-text copy to +maintain. +``` + +- **`subject`** — required, the email subject line. +- **`lists`** — listmonk list **names** (not numeric IDs, unlike `eec`'s `course_steps.list_id`), resolved to IDs at sync time. At least one of `lists`/`segment_query` is required — a campaign needs a target audience. +- **`from_email`** — required. +- **`tags`**, **`template_id`** — optional; `template_id` overrides listmonk's default template (see [Before your first real send](#before-your-first-real-send)). +- **`segment_query`** — optional, see [Segmentation](#segmentation). +- **`preview_emails`** — optional, overrides the default preview address(es) for this campaign's automatic post-sync preview (see [Automatic preview](#automatic-preview)). +- **`attachments`** — optional, paths relative to the campaign's own directory, uploaded to listmonk's media library and attached to the campaign. +- **`type`** — defaults to `regular`. `optin` campaigns aren't supported (no confirmation workflow here) and are rejected at sync time. +- **`scheduled_at` doesn't exist** — deliberately. listmonk auto-sends a `scheduled` campaign the moment it fires, which would let a plain `git push` cause a real send. Scheduled sends need their own explicit, still manually-triggered flow if ever wanted. + +## Segmentation + +`segment_query` is a raw Postgres-style SQL boolean expression — the same segmentation mechanism listmonk's own admin UI search box already accepts (`AND`/`OR`/`NOT`, `IN`, `LIKE`, `EXISTS` subqueries against e.g. `campaign_views` for engagement-based segments). No custom query language here; the string is passed straight through to listmonk's own subscriber-query API. + +Since a listmonk campaign can only target whole list(s), not an arbitrary query directly, `sync` materializes the query into a managed list on your behalf: + +1. Query subscribers matching the expression. +2. Bulk-add matches to an auto-created/reused list named `segment:`. +3. Target the campaign at that list, alongside any named `lists:` also given. + +That list is a point-in-time snapshot taken at sync time, not a live dynamic segment — push again before sending to pick up newly-matching subscribers. An invalid SQL fragment fails that campaign's sync with listmonk's own error message; nothing is created or sent. + +## Attachments + +Each file in `attachments:` is uploaded to listmonk's media library under a synthesized name (`--`), so editing the file produces a fresh upload while an unchanged file is recognized and reused — there's no local manifest of what's already been uploaded, listmonk's own media library is the only state this tool depends on. + +## Automatic preview + +Every `sync` that actually creates or changes a campaign (re-syncing identical content is a no-op — no API write, no preview) automatically sends a preview via listmonk's test-send endpoint, to `preview_emails` if the campaign sets it, otherwise to the repo-wide default (`CAMPAIGNS_PREVIEW_EMAIL`, a CI secret). Check your inbox after pushing — there's no separate manual step for the common "does this look right" check. `campaigns test ` (or the `Test-send campaign` workflow) is there for an on-demand re-preview without touching content. + +## Sending + +Sync only ever produces a **draft**. Two ways to actually send one: + +- **Push a tag** — `git tag send/ && git push origin send/`. This is the primary, git-native trigger: a durable, auditable record (`git log --tags` shows every real send that ever happened). These are "command tags," not release tags — delete and re-push if a send needs retrying; the real safety net is listmonk's own status check (`send` only works from `draft`/`paused`), not tag uniqueness. +- **Manually run the `Send campaign` workflow** in Gitea Actions, entering the slug — for when a button is handier than a local tag push. + +Both run the exact same `campaigns send ` command. + +## Sync guardrails + +A rejected campaign (check the CI run for `rejected:`) is usually one of these, all deliberate: + +- **The campaign is anything other than `draft` in listmonk** (`scheduled`/`running`/`paused`/`cancelled`/`finished`) — sync refuses to touch it. Once something's live or sent, git no longer has write access to it. +- **A list name in `lists:` matches zero or more than one listmonk list** — sync never guesses which one you meant. +- **An invalid `segment_query`** — listmonk's own query error is surfaced verbatim. +- **Missing `subject`/`from_email`/a target audience**, or `type: optin`. + +One bad campaign in the push doesn't block the others — check `rejected:` in the sync job's log for which ones and why. + +## Before your first real send + +listmonk's only currently-provisioned email template (`eec-passthrough`) is a bare passthrough used for `eec`'s transactional course emails — no unsubscribe footer, no branding. Create (or confirm) a real campaign template with a proper unsubscribe link in the listmonk admin UI before running your first `send`; drafts and previews render fine without one, but a real send without it isn't compliant. + +## Open items / verify against the live instance + +A few request shapes in `internal/listmonk` are marked `// TODO(verify)` because they aren't confirmed against public listmonk API docs — check them against the actual running instance/version (e.g. by inspecting the admin UI's own network requests) before depending on them in a real send: + +- The field for attaching uploaded media IDs to a campaign (currently assumed `media_ids`). +- The bulk subscriber→list action's exact endpoint/body shape (currently `PUT /api/subscribers/lists`). +- The test-send endpoint's recipient field (currently assumed `subscribers`). +- Whether `POST /api/campaigns` needs/accepts an explicit `status` field, and that its create-time default really is `draft`. + +## Deploying + +Push to `master` → `.gitea/workflows/sync.yml` runs `campaigns sync .` directly against listmonk (`CAMPAIGNS_LISTMONK_BASE_URL`/`_API_USER`/`_API_TOKEN`, Gitea Actions secrets). No separate deploy step or intermediate service — this tool has no server or database of its own, listmonk is the only state store. Provisioning the dedicated listmonk API user follows `eec`'s existing Ansible role pattern; pasting the resulting token into this repo's Gitea secrets is a manual one-time step (nothing automates writing Gitea repo secrets today). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56fc1b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module reground.org/eec-campaigns + +go 1.23.1 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/campaign/parse.go b/internal/campaign/parse.go new file mode 100644 index 0000000..329edeb --- /dev/null +++ b/internal/campaign/parse.go @@ -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// — also sent to listmonk as the campaign's Name + Dir string // full path to campaigns//, 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 +} diff --git a/internal/campaign/parse_test.go b/internal/campaign/parse_test.go new file mode 100644 index 0000000..f03c227 --- /dev/null +++ b/internal/campaign/parse_test.go @@ -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") + } +} diff --git a/internal/campaign/send.go b/internal/campaign/send.go new file mode 100644 index 0000000..92d6f41 --- /dev/null +++ b/internal/campaign/send.go @@ -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/ 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) +} diff --git a/internal/campaign/send_test.go b/internal/campaign/send_test.go new file mode 100644 index 0000000..6013d34 --- /dev/null +++ b/internal/campaign/send_test.go @@ -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) + } +} diff --git a/internal/campaign/sync.go b/internal/campaign/sync.go new file mode 100644 index 0000000..00a4712 --- /dev/null +++ b/internal/campaign/sync.go @@ -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 /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 +} diff --git a/internal/campaign/sync_test.go b/internal/campaign/sync_test.go new file mode 100644 index 0000000..ac3c9da --- /dev/null +++ b/internal/campaign/sync_test.go @@ -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) + } +} diff --git a/internal/listmonk/listmonk.go b/internal/listmonk/listmonk.go new file mode 100644 index 0000000..af4c91f --- /dev/null +++ b/internal/listmonk/listmonk.go @@ -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:), 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 +} diff --git a/internal/listmonk/listmonk_test.go b/internal/listmonk/listmonk_test.go new file mode 100644 index 0000000..68473e5 --- /dev/null +++ b/internal/listmonk/listmonk_test.go @@ -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) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..62ce8ca --- /dev/null +++ b/main.go @@ -0,0 +1,110 @@ +// Command campaigns drives listmonk's Campaign API from git-authored +// Markdown: sync creates/updates draft campaigns from campaigns// +// directories, send transitions one to running (the one real send trigger +// in this tool), and test previews a campaign's current content without +// touching its status. +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "reground.org/eec-campaigns/internal/campaign" + "reground.org/eec-campaigns/internal/listmonk" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + + lm := listmonk.New( + mustEnv("LISTMONK_BASE_URL"), + mustEnv("LISTMONK_API_USER"), + mustEnv("LISTMONK_API_TOKEN"), + ) + + switch os.Args[1] { + case "sync": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: campaigns sync PATH") + os.Exit(1) + } + runSync(lm, os.Args[2]) + case "send": + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "usage: campaigns send SLUG") + os.Exit(1) + } + runSend(lm, os.Args[2]) + case "test": + if len(os.Args) < 4 { + fmt.Fprintln(os.Stderr, "usage: campaigns test SLUG EMAIL...") + os.Exit(1) + } + runTest(lm, os.Args[2], os.Args[3:]) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: campaigns ") + os.Exit(1) +} + +// runSync's default preview address(es) come from CAMPAIGNS_PREVIEW_EMAIL +// (comma-separated), used for any campaign that doesn't set its own +// preview_emails in frontmatter. Optional — sync still works with none set, +// it just won't auto-preview campaigns that don't specify their own. +func runSync(lm *listmonk.Client, path string) { + var defaultPreview []string + if raw := os.Getenv("CAMPAIGNS_PREVIEW_EMAIL"); raw != "" { + for _, addr := range strings.Split(raw, ",") { + if addr = strings.TrimSpace(addr); addr != "" { + defaultPreview = append(defaultPreview, addr) + } + } + } + + result, err := campaign.SyncDir(lm, path, defaultPreview) + if err != nil { + log.Fatalf("sync: %v", err) + } + log.Printf("synced: %v", result.Synced) + log.Printf("unchanged: %v", result.Unchanged) + if len(result.Rejected) > 0 { + log.Printf("rejected: %v", result.Rejected) + } + if len(result.PreviewFailed) > 0 { + log.Printf("preview send failed: %v", result.PreviewFailed) + } + if len(result.Rejected) > 0 || len(result.PreviewFailed) > 0 { + os.Exit(1) + } +} + +func runSend(lm *listmonk.Client, slug string) { + camp, err := campaign.Send(lm, slug) + if err != nil { + log.Fatalf("send: %v", err) + } + log.Printf("sent: campaign %q (id=%d) is now running", slug, camp.ID) +} + +func runTest(lm *listmonk.Client, slug string, emails []string) { + if err := campaign.Test(lm, slug, emails); err != nil { + log.Fatalf("test: %v", err) + } + log.Printf("test: sent preview of %q to %v", slug, emails) +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s environment variable is required", key) + } + return v +}