Files
eec-campaigns/README.md
T
will b16d2b6c5f Initial implementation of the eec-campaigns tool
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.
2026-07-10 07:30:45 -04:00

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, 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 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:

  1. Query subscribers matching the expression.
  2. Bulk-add matches to an auto-created/reused list named segment:<slug>.
  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.

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/campaigns needs/accepts an explicit status field, and that its create-time default really is draft.

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