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/<slug> 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.
7.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
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 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:
- Query subscribers matching the expression.
- Bulk-add matches to an auto-created/reused list named
segment:<slug>. - 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.
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/campaignsneeds/accepts an explicitstatusfield, and that its create-time default really isdraft.
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).