Campaign content and listmonk credentials now live in separate per-domain repos that install this tool at a pinned version, so a client with access to one domain's content repo has no path to another domain's secrets. Renames the module to the actual Gitea host path so `go install` works without vanity-import redirects, drops the four workflows that assumed campaigns/ content lived here, adds real go-test CI, and rewrites the README for the tool's new audience. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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— walksPATHfor<slug>/campaign.mddirectories, creates/updates each as a draft campaign. Re-syncing identical content is a no-op. OptionalCAMPAIGNS_PREVIEW_EMAIL(comma-separated) sets the default auto-preview address for campaigns that don't set their ownpreview_emails.campaigns send SLUG— transitions one campaign fromdraft/pausedtorunning. 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 oflists/segment_queryis required.from_email— required.tags,template_id— optional;template_idoverrides 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 toregular.optinisn't supported and is rejected at sync time.scheduled_atdoesn't exist — deliberately, since listmonk auto-sends ascheduledcampaign the moment it fires, which would let a plaingit pushcause 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:
- Find or create a list named
segment:<slug>. - Ask listmonk to add every matching subscriber to that list, server-side.
- 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
draftin 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, ortype: 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.
- Create a new Gitea repo (e.g.
<client>-campaigns), private. - Add: an empty
campaigns/directory,email-templates/campaign.html(copy the stock template from an existing content repo, or listmonk's ownstatic/email-templates/default.tpl), and.gitea/workflows/{sync,send,test,sync-template}.ymlcopied from an existing content repo — each doesgo install gitea.reground.org/will/eec-campaigns@<pinned-tag>then runs the corresponding subcommand, with that repo's own secrets. - Add repo secrets:
CAMPAIGNS_LISTMONK_BASE_URL,CAMPAIGNS_LISTMONK_API_USER,CAMPAIGNS_LISTMONK_API_TOKEN, and optionallyCAMPAIGNS_PREVIEW_EMAIL— scoped to that domain's own listmonk instance and API user. - Manually run the
Sync default campaign templateworkflow 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.