Files
eec-campaigns/README.md
T
will 34d61c2e69
CI / test (push) Successful in 9s
Stop failing sync over already-sent campaigns still in the repo
A campaign.md is meant to stay around after it's sent as a record of what
went out and when. Treating any non-draft campaign as a hard rejection
forced deleting it just to keep CI green, and there was no way to fail on
resolving a stale list/attachment reference either -- an archived
campaign has nothing left to resolve, so check status before doing any of
that work. Non-draft campaigns now report as a distinct, non-error
"skipped" bucket instead of "rejected".
2026-08-13 13:24:39 -04:00

145 lines
8.9 KiB
Markdown

# 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 `<slug>/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/
<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` 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" for the HTML part; listmonk never derives a
plaintext alternative on its own, so this same source is also sent
verbatim as `altbody` — recipients on plaintext-only clients see the
raw Markdown (and personalization still resolves, since the `{{ }}`
expressions are still there), rather than getting an HTML-only email.
```
- **`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.
## Personalization
Listmonk renders each recipient's campaign body as a Go template before converting Markdown to HTML, so a subscriber's `attribs` can be referenced directly in `campaign.md`'s body. `eec`'s signup forms set `first_name` (and `last_name`, if given) as attribs on every subscriber who provides one — subscribers who signed up before that (or via the public form when it collected email only) won't have it set.
Verified working syntax, with a fallback for subscribers who don't have `first_name` set:
```
{{ if .Subscriber.Attribs.first_name }}Hi {{ .Subscriber.Attribs.first_name }},{{ else }}Hi there,{{ end }}
```
**Don't trust listmonk's `/api/campaigns/:id/preview` endpoint (or its admin-UI "Preview" button) to test this** — it always renders against listmonk's built-in demo subscriber (`demo@listmonk.app`, attribs `{city: Bengaluru}`), ignoring which subscriber you're looking at, so `first_name` will always look unset there even when it's really set on a real subscriber. Use `campaigns test SLUG EMAIL...` (a real test-send to a real subscriber's own address) to verify personalization actually renders — that's how the syntax above was confirmed.
## 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:<slug>`.
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 (`<slug>-<content-hash>-<original filename>`), 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.
## Sent campaigns stay in the repo
A campaign's `campaign.md` is meant to stick around after it's sent — a record of what went out and when, not something to delete once it's live. Once a campaign is anything other than `draft` in listmonk (`scheduled`/`running`/`paused`/`cancelled`/`finished`), sync leaves it alone entirely — no write, no re-preview, and (as of v0.1.6) no failure either. It's reported as **skipped**, a distinct, non-error bucket from rejected.
## Sync guardrails
A rejected campaign is usually one of these, all deliberate:
- **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. Generate a new repo from [`campaigns-template`](https://gitea.reground.org/will/campaigns-template) (Gitea's "Generate Repo," not a clone/fork — it starts the new repo with its own clean history), private.
2. Follow that generated repo's README checklist: rename the default-template name, restyle `email-templates/campaign.html` if needed, add secrets, confirm the tool version pin, run `Sync default campaign template` once, replace the README with a short domain-specific one.
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.