Previously verified against master; re-checked cmd/campaigns.go, cmd/subscribers.go, and cmd/handlers.go at the v6.2.0 tag specifically (the version pinned in reground-infrastructure's group_vars) since that's what's actually running. Everything matched exactly — no code changes, just removes the "confirm against your actual version" hedge from the README now that it's been done.
9.8 KiB
eec-campaigns
Broadcast/segment email, authored as Markdown+YAML in git, driving listmonk's real Campaign API — not 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/
<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 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
---
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, unlikeeec'scourse_steps.list_id), resolved to IDs at sync time. At least one oflists/segment_queryis required — a campaign needs a target audience.from_email— required.tags,template_id— optional;template_idoverrides listmonk's default template (see Before your first real send).segment_query— optional, see Segmentation.preview_emails— optional, overrides the default preview address(es) for this campaign's automatic post-sync preview (see 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 toregular.optincampaigns aren't supported (no confirmation workflow here) and are rejected at sync time.scheduled_atdoesn't exist — deliberately. listmonk auto-sends ascheduledcampaign the moment it fires, which would let a plaingit pushcause 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 query-based bulk list action.
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:
- Find or create a list named
segment:<slug>. - Ask listmonk to add every subscriber matching the expression to that list, in one call — no subscriber IDs are ever fetched client-side, listmonk applies the query and the list membership change server-side.
- 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 (<slug>-<content-hash>-<original filename>), 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 <slug> <email> (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/<slug> && git push origin send/<slug>. This is the primary, git-native trigger: a durable, auditable record (git log --tagsshows 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 (sendonly works fromdraft/paused), not tag uniqueness. - Manually run the
Send campaignworkflow in Gitea Actions, entering the slug — for when a button is handier than a local tag push.
Both run the exact same campaigns send <slug> 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
draftin 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, ortype: 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.
Email template
campaigns template NAME PATH pushes an HTML file's content into listmonk as a named template — create if missing, overwrite in place if it already exists, always set as the default campaign template (is_default: true) so every campaign created here uses it with no per-campaign template_id wiring. Unlike a campaign, a template has no draft/live status to protect, so this is always a safe create-or-update.
email-templates/campaign.html in this repo is listmonk's own stock campaign template (static/email-templates/default.tpl upstream), committed verbatim as a known-good starting point rather than something invented from scratch — it already has a real {{ UnsubscribeURL }} footer link and the required {{ template "content" . }} injection point. Restyle it freely; those two tags are the only load-bearing parts.
Run once via the Sync default campaign template workflow (workflow_dispatch, manual — changing the default template affects every future send, so it isn't wired to auto-run on push) before your first real send. eec's own provisioned template (eec-passthrough) is a separate, bare passthrough used only for its transactional course emails — unrelated to this one.
Open items
Every request shape in internal/listmonk — the campaign create/update payload, its media field for attachments (listmonk's request/response asymmetry: requests send plain IDs under media, responses echo full objects back under the same key), create always defaulting to draft regardless of any caller-supplied status, the test-send endpoint's subscribers field, the query-based bulk list action (PUT /api/subscribers/query/lists), and the template API (GET/POST /api/templates, PUT /api/templates/:id, fields name/type/body/is_default) — is confirmed directly against knadh/listmonk's actual Go source at v6.2.0, the exact version this org runs (listmonk_version in reground-infrastructure's group_vars/all/vars.yml), not just its docs (which are incomplete on a few of these) or an assumed-current master. What's left is genuinely operational, not code:
- Running the
Sync default campaign templateworkflow at least once — the template file is committed, but nothing pushes it into listmonk until that workflow (orcampaigns template ...locally) actually runs. - Provisioning the dedicated listmonk API user and pasting its token into this repo's Gitea secrets —
reground-infrastructurenow has acampaignsrole/playbook for the first half (see that repo); pasting the resulting token into Gitea is still a manual step (nothing automates writing Gitea repo secrets today). - Gitea Actions' tag-push and
workflow_dispatchtriggers are standard, long-supported Actions syntax and the runner already successfully usesuses: actions/checkout@v4elsewhere in this org, so this should work as written — but it's still worth confirming the first timesend.ymlactually fires.
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).