Campaign/parseCampaign never fetched altbody, so campaignUnchanged() compared everything except it -- a campaign synced before v0.1.3 (altbody always null) whose content otherwise hasn't changed would be judged unchanged forever and never get updated with the new altbody, even though every subsequent sync now intends to send one. 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" for the HTML part; listmonk never derives a
plaintext alternative on its own, so this same source is also sent
verbatim as `altbody` — recipients on plaintext-only clients see the
raw Markdown (and personalization still resolves, since the `{{ }}`
expressions are still there), rather than getting an HTML-only email.
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.
Personalization
Listmonk renders each recipient's campaign body as a Go template before converting Markdown to HTML, so a subscriber's attribs can be referenced directly in campaign.md's body. eec's signup forms set first_name (and last_name, if given) as attribs on every subscriber who provides one — subscribers who signed up before that (or via the public form when it collected email only) won't have it set.
Verified working syntax, with a fallback for subscribers who don't have first_name set:
{{ if .Subscriber.Attribs.first_name }}Hi {{ .Subscriber.Attribs.first_name }},{{ else }}Hi there,{{ end }}
Don't trust listmonk's /api/campaigns/:id/preview endpoint (or its admin-UI "Preview" button) to test this — it always renders against listmonk's built-in demo subscriber (demo@listmonk.app, attribs {city: Bengaluru}), ignoring which subscriber you're looking at, so first_name will always look unset there even when it's really set on a real subscriber. Use campaigns test SLUG EMAIL... (a real test-send to a real subscriber's own address) to verify personalization actually renders — that's how the syntax above was confirmed.
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.
- 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. - Follow that generated repo's README checklist: rename the default-template name, restyle
email-templates/campaign.htmlif needed, add secrets, confirm the tool version pin, runSync default campaign templateonce, replace the README with a short domain-specific one. - 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.