Split tool from content: eec-campaigns becomes a standalone CLI
Campaign content and listmonk credentials now live in separate per-domain repos that install this tool at a pinned version, so a client with access to one domain's content repo has no path to another domain's secrets. Renames the module to the actual Gitea host path so `go install` works without vanity-import redirects, drops the four workflows that assumed campaigns/ content lived here, adds real go-test CI, and rewrites the README for the tool's new audience. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.23.1"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: go build ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: Send campaign
|
|
||||||
on:
|
|
||||||
# Pushing a tag like send/big-announcement is the primary trigger — a
|
|
||||||
# deliberate, git-native, auditable action (git log --tags shows every
|
|
||||||
# real send that ever happened). These are "command tags," not release
|
|
||||||
# tags: fine to delete and re-push if a send needs retrying, since the
|
|
||||||
# actual safety net is listmonk's own draft/paused status check in
|
|
||||||
# `campaigns send`, not tag uniqueness.
|
|
||||||
push:
|
|
||||||
tags: ["send/*"]
|
|
||||||
# workflow_dispatch is kept alongside it for when clicking a button is
|
|
||||||
# more convenient than a local `git tag && git push`. Same command either
|
|
||||||
# way.
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
slug:
|
|
||||||
description: "Campaign slug (campaigns/<slug>/) to send"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
send:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.23.1"
|
|
||||||
|
|
||||||
- name: Determine campaign slug
|
|
||||||
id: slug
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
|
||||||
echo "value=${{ inputs.slug }}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "value=${GITHUB_REF#refs/tags/send/}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Send campaign
|
|
||||||
run: go run . send "${{ steps.slug.outputs.value }}"
|
|
||||||
env:
|
|
||||||
LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }}
|
|
||||||
LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }}
|
|
||||||
LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Sync default campaign template
|
|
||||||
on:
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync-template:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.23.1"
|
|
||||||
|
|
||||||
- name: Sync template to listmonk
|
|
||||||
run: go run . template "eec-campaigns default" email-templates/campaign.html
|
|
||||||
env:
|
|
||||||
LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }}
|
|
||||||
LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }}
|
|
||||||
LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: Sync campaigns
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.23.1"
|
|
||||||
|
|
||||||
- name: Sync campaigns to listmonk
|
|
||||||
run: go run . sync .
|
|
||||||
env:
|
|
||||||
LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }}
|
|
||||||
LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }}
|
|
||||||
LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }}
|
|
||||||
CAMPAIGNS_PREVIEW_EMAIL: ${{ secrets.CAMPAIGNS_PREVIEW_EMAIL }}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
name: Test-send campaign
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
slug:
|
|
||||||
description: "Campaign slug (campaigns/<slug>/) to preview"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
email:
|
|
||||||
description: "Address to send the preview to"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.23.1"
|
|
||||||
|
|
||||||
- name: Send preview
|
|
||||||
run: go run . test "${{ inputs.slug }}" "${{ inputs.email }}"
|
|
||||||
env:
|
|
||||||
LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }}
|
|
||||||
LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }}
|
|
||||||
LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }}
|
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
# eec-campaigns
|
# 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.
|
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.
|
||||||
|
|
||||||
## Layout
|
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/
|
campaigns/
|
||||||
@@ -14,7 +33,7 @@ email-templates/
|
|||||||
campaign.html
|
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.
|
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`
|
## `campaign.md`
|
||||||
|
|
||||||
@@ -22,90 +41,86 @@ Each campaign is a directory under `campaigns/`, named for its slug. **The direc
|
|||||||
---
|
---
|
||||||
subject: "Big Announcement"
|
subject: "Big Announcement"
|
||||||
lists: ["Newsletter"]
|
lists: ["Newsletter"]
|
||||||
from_email: hello@reground.org
|
from_email: hello@example.org
|
||||||
tags: ["announcement"]
|
tags: ["announcement"]
|
||||||
template_id: 4
|
template_id: 4
|
||||||
segment_query: >-
|
segment_query: >-
|
||||||
subscribers.attribs->>'source' = 'workshop-signup'
|
subscribers.attribs->>'source' = 'workshop-signup'
|
||||||
AND subscribers.attribs->>'eec_enroll' IS NULL
|
AND subscribers.attribs->>'eec_enroll' IS NULL
|
||||||
preview_emails:
|
preview_emails:
|
||||||
- cofounder@reground.org
|
- someone@example.org
|
||||||
attachments:
|
attachments:
|
||||||
- assets/one-pager.pdf
|
- assets/one-pager.pdf
|
||||||
---
|
---
|
||||||
|
|
||||||
Campaign body goes here, in Markdown. It's sent to listmonk with
|
Campaign body goes here, in Markdown. It's sent to listmonk with
|
||||||
content_type "markdown" — listmonk renders the HTML *and* derives the
|
content_type "markdown" — listmonk renders the HTML *and* derives the
|
||||||
plaintext alternative itself, so there's no separate plain-text copy to
|
plaintext alternative itself.
|
||||||
maintain.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`subject`** — required, the email subject line.
|
- **`subject`** — required.
|
||||||
- **`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.
|
- **`lists`** — listmonk list **names** (not IDs), resolved to IDs at sync time. At least one of `lists`/`segment_query` is required.
|
||||||
- **`from_email`** — required.
|
- **`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)).
|
- **`tags`**, **`template_id`** — optional; `template_id` overrides the default campaign template.
|
||||||
- **`segment_query`** — optional, see [Segmentation](#segmentation).
|
- **`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)).
|
- **`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 and attached to the campaign.
|
- **`attachments`** — optional, paths relative to the campaign's own directory, uploaded to listmonk's media library.
|
||||||
- **`type`** — defaults to `regular`. `optin` campaigns aren't supported (no confirmation workflow here) and are rejected at sync time.
|
- **`type`** — defaults to `regular`. `optin` isn't supported and is 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.
|
- **`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
|
## 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.
|
`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:
|
||||||
|
|
||||||
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:<slug>`.
|
1. Find or create a list named `segment:<slug>`.
|
||||||
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.
|
2. Ask listmonk to add every matching subscriber to that list, server-side.
|
||||||
3. Target the campaign at that list, alongside any named `lists:` also given.
|
3. Target the campaign at that list, alongside any named `lists:`.
|
||||||
|
|
||||||
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.
|
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
|
## 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.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
## 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
|
## Sync guardrails
|
||||||
|
|
||||||
A rejected campaign (check the CI run for `rejected:`) is usually one of these, all deliberate:
|
A rejected campaign 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.
|
- **Anything other than `draft` in listmonk** (`scheduled`/`running`/`paused`/`cancelled`/`finished`) — sync refuses to touch it once it's live or sent.
|
||||||
- **A list name in `lists:` matches zero or more than one listmonk list** — sync never guesses which one you meant.
|
- **A `lists:` name matches zero or more than one listmonk list** — sync never guesses.
|
||||||
- **An invalid `segment_query`** — listmonk's own query error is surfaced verbatim.
|
- **An invalid `segment_query`** — listmonk's own error is surfaced verbatim.
|
||||||
- **Missing `subject`/`from_email`/a target audience**, or `type: optin`.
|
- **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.
|
One bad campaign in a sync doesn't block the others.
|
||||||
|
|
||||||
## Email template
|
## 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.
|
`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.
|
||||||
|
|
||||||
`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.
|
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.
|
||||||
|
|
||||||
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.
|
## Standing up a new domain repo
|
||||||
|
|
||||||
## Open items
|
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.
|
||||||
|
|
||||||
Every request shape in `internal/listmonk` — 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`), and the template API (`GET`/`POST /api/templates`, `PUT /api/templates/:id`, fields `name`/`type`/`body`/`is_default`) — is confirmed directly against `knadh/listmonk`'s actual Go source at **`v6.2.0`**, the exact version this org runs (`listmonk_version` in `reground-infrastructure`'s `group_vars/all/vars.yml`), not just its docs (which are incomplete on a few of these) or an assumed-current `master`. What's left is genuinely operational, not code:
|
1. Create a new Gitea repo (e.g. `<client>-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@<pinned-tag>` 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.
|
||||||
|
|
||||||
- **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.
|
Always pin an explicit tag, never `@latest` — see [Releasing](#releasing).
|
||||||
- **Provisioning the dedicated listmonk API user and pasting its token into this repo's Gitea secrets** — `reground-infrastructure` now has a `campaigns` role/playbook for the first half (see that repo); pasting the resulting token into Gitea is still a manual step (nothing automates writing Gitea repo secrets today).
|
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Deploying
|
## Releasing
|
||||||
|
|
||||||
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).
|
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.
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
<!--
|
|
||||||
Listmonk's own stock campaign template (static/email-templates/default.tpl
|
|
||||||
in knadh/listmonk), committed here verbatim as a known-good, unsubscribe-
|
|
||||||
capable starting point rather than something invented from scratch. Synced
|
|
||||||
into listmonk via `campaigns template <name> email-templates/campaign.html`
|
|
||||||
(see internal/campaign/template.go) as the is_default campaign template, so
|
|
||||||
every campaign this tool creates gets a real unsubscribe link/footer without
|
|
||||||
per-campaign template_id wiring. Restyle freely — the two load-bearing tags
|
|
||||||
are `{{ template "content" . }}` (where the campaign body gets injected) and
|
|
||||||
`{{ UnsubscribeURL }}` (the actual unsubscribe link); everything else here is
|
|
||||||
just Listmonk's default look.
|
|
||||||
-->
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>{{ .Campaign.Subject }}</title>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
|
||||||
<base target="_blank">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: #F0F1F3;
|
|
||||||
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 26px;
|
|
||||||
margin: 0;
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: #f4f4f4f4;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
table td {
|
|
||||||
border-color: #ddd;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrap {
|
|
||||||
background-color: #fff;
|
|
||||||
padding: 30px;
|
|
||||||
max-width: 525px;
|
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: #0055d4;
|
|
||||||
border-radius: 3px;
|
|
||||||
text-decoration: none !important;
|
|
||||||
color: #fff !important;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 10px 30px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.button:hover {
|
|
||||||
background: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
.footer a {
|
|
||||||
color: #888;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gutter {
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #0055d4;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
@media screen and (max-width: 600px) {
|
|
||||||
.wrap {
|
|
||||||
max-width: auto;
|
|
||||||
}
|
|
||||||
.gutter {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="background-color: #F0F1F3;font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;font-size: 15px;line-height: 26px;margin: 0;color: #444;">
|
|
||||||
<div class="gutter" style="padding: 30px;"> </div>
|
|
||||||
<div class="wrap" style="background-color: #fff;padding: 30px;max-width: 525px;margin: 0 auto;border-radius: 5px;">
|
|
||||||
{{ template "content" . }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
|
|
||||||
<p>
|
|
||||||
<a href="{{ UnsubscribeURL }}" style="color: #888;">{{ L.T "email.unsub" }}</a>
|
|
||||||
|
|
||||||
<a href="{{ MessageURL }}" style="color: #888;">{{ L.T "email.viewInBrowser" }}</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="gutter" style="padding: 30px;"> {{ TrackView }}</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module reground.org/eec-campaigns
|
module gitea.reground.org/will/eec-campaigns
|
||||||
|
|
||||||
go 1.23.1
|
go 1.23.1
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package campaign
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"reground.org/eec-campaigns/internal/listmonk"
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Send transitions a campaign from draft/paused to running — the one real
|
// Send transitions a campaign from draft/paused to running — the one real
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"reground.org/eec-campaigns/internal/listmonk"
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncResult summarizes what a sync run did, split so the CI log (and the
|
// SyncResult summarizes what a sync run did, split so the CI log (and the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"reground.org/eec-campaigns/internal/listmonk"
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---- fake listmonk server ----
|
// ---- fake listmonk server ----
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"reground.org/eec-campaigns/internal/listmonk"
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncTemplate pushes a template file's content into listmonk under name,
|
// SyncTemplate pushes a template file's content into listmonk under name,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"reground.org/eec-campaigns/internal/campaign"
|
"gitea.reground.org/will/eec-campaigns/internal/campaign"
|
||||||
"reground.org/eec-campaigns/internal/listmonk"
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user