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.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# 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/
|
||||
<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`
|
||||
|
||||
```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 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 tag** — `git 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).
|
||||
Reference in New Issue
Block a user