# eec-campaigns Broadcast/segment email, authored as Markdown+YAML in git, driving [listmonk](https://listmonk.app)'s real Campaign API — not [`eec`](https://gitea.reground.org/will/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/ / 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` ```yaml --- 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](#before-your-first-real-send)). - **`segment_query`** — optional, see [Segmentation](#segmentation). - **`preview_emails`** — optional, overrides the default preview address(es) for this campaign's automatic post-sync preview (see [Automatic preview](#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:`. 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 (`--`), 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 ` (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/ && git push origin send/`. 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 ` 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).