Files
eec-campaigns/README.md
T
will a630a8f8ab Add template-as-code sync (campaigns template NAME PATH)
Answers "can the unsubscribe template be added via git+API too": commit
listmonk's own stock campaign template (already unsubscribe-capable) as
email-templates/campaign.html, add a small find-or-create-or-update
Template client (confirmed against knadh/listmonk's actual model/handlers),
a new `template` subcommand, and a manual-dispatch-only workflow to push it
in as the default campaign template. Unlike campaign sync there's no
draft/live status to protect, so this is always a safe overwrite.
2026-07-10 08:54:31 -04:00

9.7 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, unlike eec's course_steps.list_id), resolved to IDs at sync time. At least one of lists/segment_query is required — a campaign needs a target audience.
  • from_email — required.
  • tags, template_id — optional; template_id overrides 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 to regular. optin campaigns aren't supported (no confirmation workflow here) and are rejected at sync time.
  • scheduled_at doesn't exist — deliberately. listmonk auto-sends a scheduled campaign the moment it fires, which would let a plain git push cause 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:

  1. Find or create a list named segment:<slug>.
  2. 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.
  3. 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 taggit tag send/<slug> && git push origin send/<slug>. This is the primary, git-native trigger: a durable, auditable record (git log --tags shows 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 (send only works from draft/paused), not tag uniqueness.
  • Manually run the Send campaign workflow 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 draft in 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, or type: 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 is now confirmed against knadh/listmonk's actual Go source (not just its docs, which are incomplete on a few of these): 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) segmentation uses, and the template API (GET/POST /api/templates, PUT /api/templates/:id, fields name/type/body/is_default). What's left is genuinely operational, not code:

  • Running the Sync default campaign template workflow at least once — the template file is committed, but nothing pushes it into listmonk until that workflow (or campaigns template ... locally) actually runs.
  • Provisioning the dedicated listmonk API user and pasting its token into this repo's Gitea secrets (see Deploying below) hasn't happened yet.
  • Gitea Actions' tag-push and workflow_dispatch triggers are standard, long-supported Actions syntax and the runner already successfully uses uses: actions/checkout@v4 elsewhere in this org, so this should work as written — but it's still worth confirming the first time send.yml actually fires.
  • The source was read against knadh/listmonk's master branch; if the live instance runs a substantially older or newer version, a quick diff against its own cmd/campaigns.go/cmd/subscribers.go/cmd/templates.go is cheap insurance before the first real send.

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).