# eec-campaigns A CLI that drives [listmonk](https://listmonk.app)'s real Campaign API from git-authored Markdown+YAML — not [`eec`](https://gitea.reground.org/will/eec)'s transactional `/api/tx` path, which has no unsubscribe or bulk-send machinery. This repo is the tool only: no campaign content, no listmonk credentials. Campaign content and secrets live in separate per-domain repos (e.g. `reground-campaigns`) that install this tool at a pinned version — see [Standing up a new domain repo](#standing-up-a-new-domain-repo). **Safety model**: `sync` only ever creates/updates a **draft**. Sending is a separate, deliberate action (`send`) — a `sync` triggered by a plain `git push` can never blast a list by itself. ## Install ``` go install gitea.reground.org/will/eec-campaigns@v0.1.0 ``` Requires three env vars for every command: `LISTMONK_BASE_URL`, `LISTMONK_API_USER`, `LISTMONK_API_TOKEN`. ## Commands - **`campaigns sync PATH`** — walks `PATH` for `/campaign.md` directories, creates/updates each as a draft campaign. Re-syncing identical content is a no-op. Optional `CAMPAIGNS_PREVIEW_EMAIL` (comma-separated) sets the default auto-preview address for campaigns that don't set their own `preview_emails`. - **`campaigns send SLUG`** — transitions one campaign from `draft`/`paused` to `running`. The one real send trigger in this tool. - **`campaigns test SLUG EMAIL...`** — sends a preview of a campaign's current content without touching its status. - **`campaigns template NAME PATH`** — pushes an HTML file into listmonk as a named template, create-or-update, always set as the default campaign template. ## Layout a content repo should follow ``` 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` look it up by. There's no separate `name:`/`slug:` frontmatter field to keep in sync; renaming the directory creates a new campaign rather than renaming the existing one. ## `campaign.md` ```yaml --- subject: "Big Announcement" lists: ["Newsletter"] from_email: hello@example.org tags: ["announcement"] template_id: 4 segment_query: >- subscribers.attribs->>'source' = 'workshop-signup' AND subscribers.attribs->>'eec_enroll' IS NULL preview_emails: - someone@example.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. ``` - **`subject`** — required. - **`lists`** — listmonk list **names** (not IDs), resolved to IDs at sync time. At least one of `lists`/`segment_query` is required. - **`from_email`** — required. - **`tags`**, **`template_id`** — optional; `template_id` overrides the default campaign template. - **`segment_query`** — optional, see [Segmentation](#segmentation). - **`preview_emails`** — optional, overrides the default preview address(es) for this campaign's automatic post-sync preview. - **`attachments`** — optional, paths relative to the campaign's own directory, uploaded to listmonk's media library. - **`type`** — defaults to `regular`. `optin` isn't supported and is rejected at sync time. - **`scheduled_at` doesn't exist** — deliberately, since listmonk auto-sends a `scheduled` campaign the moment it fires, which would let a plain `git push` cause a real send. ## Segmentation `segment_query` is a raw Postgres-style SQL boolean expression — the same mechanism listmonk's own admin UI search box accepts. Since a campaign can only target whole list(s), `sync` materializes the query into a managed list: 1. Find or create a list named `segment:`. 2. Ask listmonk to add every matching subscriber to that list, server-side. 3. Target the campaign at that list, alongside any named `lists:`. That list is a point-in-time snapshot — 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. ## Attachments Each file in `attachments:` is uploaded to listmonk's media library under a synthesized name (`--`), so an edited file re-uploads while an unchanged one is reused — no local manifest, listmonk's media library is the only state this depends on. ## Automatic preview Every `sync` that actually creates or changes a campaign auto-sends a preview via listmonk's test-send endpoint, to `preview_emails` if set, otherwise `CAMPAIGNS_PREVIEW_EMAIL`. `campaigns test` is for an on-demand re-preview without touching content. ## Sync guardrails A rejected campaign is usually one of these, all deliberate: - **Anything other than `draft` in listmonk** (`scheduled`/`running`/`paused`/`cancelled`/`finished`) — sync refuses to touch it once it's live or sent. - **A `lists:` name matches zero or more than one listmonk list** — sync never guesses. - **An invalid `segment_query`** — listmonk's own error is surfaced verbatim. - **Missing `subject`/`from_email`/a target audience, or `type: optin`.** One bad campaign in a sync doesn't block the others. ## Email template `campaigns template NAME PATH` pushes an HTML file into listmonk as a named template — create if missing, overwrite if it exists, always set as the default (`is_default: true`). Unlike a campaign, a template has no draft/live status, so this is always a safe create-or-update. A starting template (listmonk's own stock `default.tpl`, with the required `{{ UnsubscribeURL }}` footer and `{{ template "content" . }}` injection point) lives in each content repo's `email-templates/campaign.html` — restyle freely, those two tags are the only load-bearing parts. ## Standing up a new domain repo Each domain/client gets its own content repo, isolated by ordinary Gitea repo permissions — a client with push access to their repo has no path to any other domain's listmonk credentials, because those credentials simply don't exist in their repo. 1. Create a new Gitea repo (e.g. `-campaigns`), private. 2. Add: an empty `campaigns/` directory, `email-templates/campaign.html` (copy the stock template from an existing content repo, or listmonk's own `static/email-templates/default.tpl`), and `.gitea/workflows/{sync,send,test,sync-template}.yml` copied from an existing content repo — each does `go install gitea.reground.org/will/eec-campaigns@` then runs the corresponding subcommand, with that repo's own secrets. 3. Add repo secrets: `CAMPAIGNS_LISTMONK_BASE_URL`, `CAMPAIGNS_LISTMONK_API_USER`, `CAMPAIGNS_LISTMONK_API_TOKEN`, and optionally `CAMPAIGNS_PREVIEW_EMAIL` — scoped to that domain's own listmonk instance and API user. 4. Manually run the `Sync default campaign template` workflow once before the first real send. Always pin an explicit tag, never `@latest` — see [Releasing](#releasing). ## Releasing Cut an annotated tag after a change lands here: ``` git tag v0.2.0 git push origin v0.2.0 ``` Content repos pin an explicit tag in their workflow files, so a tool change never silently changes behavior for a live domain. Bump one domain's pin, confirm it, then roll the rest forward.