Files
eec-campaigns/README.md
T
will a4ec1eda78
CI / test (push) Successful in 9s
Point the new-domain runbook at the campaigns-template repo
Generate-from-template replaces copy-from-an-existing-repo now that
campaigns-template exists, so onboarding always starts from a known-
clean structure instead of whatever the last content repo happened
to drift to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 05:13:59 -04:00

7.2 KiB

eec-campaigns

A CLI that drives listmonk's real Campaign API from git-authored Markdown+YAML — not eec's transactional /api/tx path, which has no unsubscribe or bulk-send machinery.

This repo is the tool only: no campaign content, no listmonk credentials. Campaign content and secrets live in separate per-domain repos (e.g. reground-campaigns) that install this tool at a pinned version — see Standing up a new domain repo.

Safety model: sync only ever creates/updates a draft. Sending is a separate, deliberate action (send) — a sync triggered by a plain git push can never blast a list by itself.

Install

go install gitea.reground.org/will/eec-campaigns@v0.1.0

Requires three env vars for every command: LISTMONK_BASE_URL, LISTMONK_API_USER, LISTMONK_API_TOKEN.

Commands

  • campaigns sync PATH — walks PATH for <slug>/campaign.md directories, creates/updates each as a draft campaign. Re-syncing identical content is a no-op. Optional CAMPAIGNS_PREVIEW_EMAIL (comma-separated) sets the default auto-preview address for campaigns that don't set their own preview_emails.
  • campaigns send SLUG — transitions one campaign from draft/paused to running. The one real send trigger in this tool.
  • campaigns test SLUG EMAIL... — sends a preview of a campaign's current content without touching its status.
  • campaigns template NAME PATH — pushes an HTML file into listmonk as a named template, create-or-update, always set as the default campaign template.

Layout a content repo should follow

campaigns/
  <slug>/
    campaign.md
    assets/
      one-pager.pdf
email-templates/
  campaign.html

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 look it up by. There's no separate name:/slug: frontmatter field to keep in sync; renaming the directory creates a new campaign rather than renaming the existing one.

campaign.md

---
subject: "Big Announcement"
lists: ["Newsletter"]
from_email: hello@example.org
tags: ["announcement"]
template_id: 4
segment_query: >-
  subscribers.attribs->>'source' = 'workshop-signup'
  AND subscribers.attribs->>'eec_enroll' IS NULL
preview_emails:
  - someone@example.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.
  • subject — required.
  • lists — listmonk list names (not IDs), resolved to IDs at sync time. At least one of lists/segment_query is required.
  • from_email — required.
  • tags, template_id — optional; template_id overrides the default campaign template.
  • segment_query — optional, see Segmentation.
  • preview_emails — optional, overrides the default preview address(es) for this campaign's automatic post-sync preview.
  • attachments — optional, paths relative to the campaign's own directory, uploaded to listmonk's media library.
  • type — defaults to regular. optin isn't supported and is rejected at sync time.
  • scheduled_at doesn't exist — deliberately, since listmonk auto-sends a scheduled campaign the moment it fires, which would let a plain git push cause a real send.

Segmentation

segment_query is a raw Postgres-style SQL boolean expression — the same mechanism listmonk's own admin UI search box accepts. Since a campaign can only target whole list(s), sync materializes the query into a managed list:

  1. Find or create a list named segment:<slug>.
  2. Ask listmonk to add every matching subscriber to that list, server-side.
  3. Target the campaign at that list, alongside any named lists:.

That list is a point-in-time snapshot — 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.

Attachments

Each file in attachments: is uploaded to listmonk's media library under a synthesized name (<slug>-<content-hash>-<original filename>), so an edited file re-uploads while an unchanged one is reused — no local manifest, listmonk's media library is the only state this depends on.

Automatic preview

Every sync that actually creates or changes a campaign auto-sends a preview via listmonk's test-send endpoint, to preview_emails if set, otherwise CAMPAIGNS_PREVIEW_EMAIL. campaigns test is for an on-demand re-preview without touching content.

Sync guardrails

A rejected campaign is usually one of these, all deliberate:

  • Anything other than draft in listmonk (scheduled/running/paused/cancelled/finished) — sync refuses to touch it once it's live or sent.
  • A lists: name matches zero or more than one listmonk list — sync never guesses.
  • An invalid segment_query — listmonk's own error is surfaced verbatim.
  • Missing subject/from_email/a target audience, or type: optin.

One bad campaign in a sync doesn't block the others.

Email template

campaigns template NAME PATH pushes an HTML file into listmonk as a named template — create if missing, overwrite if it exists, always set as the default (is_default: true). Unlike a campaign, a template has no draft/live status, so this is always a safe create-or-update.

A starting template (listmonk's own stock default.tpl, with the required {{ UnsubscribeURL }} footer and {{ template "content" . }} injection point) lives in each content repo's email-templates/campaign.html — restyle freely, those two tags are the only load-bearing parts.

Standing up a new domain repo

Each domain/client gets its own content repo, isolated by ordinary Gitea repo permissions — a client with push access to their repo has no path to any other domain's listmonk credentials, because those credentials simply don't exist in their repo.

  1. Generate a new repo from campaigns-template (Gitea's "Generate Repo," not a clone/fork — it starts the new repo with its own clean history), private.
  2. Follow that generated repo's README checklist: rename the default-template name, restyle email-templates/campaign.html if needed, add secrets, confirm the tool version pin, run Sync default campaign template once, replace the README with a short domain-specific one.
  3. Add repo secrets: CAMPAIGNS_LISTMONK_BASE_URL, CAMPAIGNS_LISTMONK_API_USER, CAMPAIGNS_LISTMONK_API_TOKEN, and optionally CAMPAIGNS_PREVIEW_EMAIL — scoped to that domain's own listmonk instance and API user.
  4. Manually run the Sync default campaign template workflow once before the first real send.

Always pin an explicit tag, never @latest — see Releasing.

Releasing

Cut an annotated tag after a change lands here:

git tag v0.2.0
git push origin v0.2.0

Content repos pin an explicit tag in their workflow files, so a tool change never silently changes behavior for a live domain. Bump one domain's pin, confirm it, then roll the rest forward.