Initial scaffold: campaigns tooling for thistleandmoth.org
Sync campaigns / sync (push) Failing after 20s

Mirrors michaelferrara-campaigns/twosquirrelspress-campaigns.
No campaigns written yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 13:20:18 -04:00
commit b688bf354a
7 changed files with 270 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
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
# `eec-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: Install eec-campaigns
run: go install gitea.reground.org/will/eec-campaigns@v0.1.0
- 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: eec-campaigns 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 }}
+23
View File
@@ -0,0 +1,23 @@
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: Install eec-campaigns
run: go install gitea.reground.org/will/eec-campaigns@v0.1.0
- name: Sync template to listmonk
run: eec-campaigns template "Thistle & Moth 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 }}
+25
View File
@@ -0,0 +1,25 @@
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: Install eec-campaigns
run: go install gitea.reground.org/will/eec-campaigns@v0.1.0
- name: Sync campaigns to listmonk
run: eec-campaigns 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 }}
+32
View File
@@ -0,0 +1,32 @@
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: Install eec-campaigns
run: go install gitea.reground.org/will/eec-campaigns@v0.1.0
- name: Send preview
run: eec-campaigns 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 }}
+27
View File
@@ -0,0 +1,27 @@
# thistleandmoth-campaigns
thistleandmoth.org's campaign content, synced to listmonk by [`eec-campaigns`](https://gitea.reground.org/will/eec-campaigns). This repo holds only campaign content and this domain's listmonk secrets — the tool itself is installed at a pinned version in CI.
## To send a campaign
1. Write `campaigns/<slug>/campaign.md` (frontmatter + Markdown body — see the [eec-campaigns README](https://gitea.reground.org/will/eec-campaigns#campaignmd) for the full field reference). Set `from_email: "Thistle & Moth <hello@thistleandmoth.org>"` explicitly — there's no default, and sync rejects a campaign with no `from_email` at all.
2. `git push` to `master` — CI syncs it into listmonk as a **draft** and emails a preview to `CAMPAIGNS_PREVIEW_EMAIL` (or the campaign's own `preview_emails`).
3. Check the preview inbox. Keep pushing edits until it looks right — each push re-syncs and re-previews.
4. When ready to actually send: `git tag send/<slug> && git push origin send/<slug>`. This is the one action that transitions the campaign from draft to running — a plain `git push` to master can never do it.
Alternatively, run the `Send campaign` workflow manually from Gitea Actions with the slug as input — same effect as the tag push.
## Other workflows
- **`Test-send campaign`** (manual) — re-preview a campaign's current content to any address, without touching its status.
- **`Sync default campaign template`** (manual) — pushes `email-templates/campaign.html` into listmonk as the default campaign template. Run once after setup, and again whenever that file changes.
## Setup
Repo secrets (Gitea Actions → Secrets) are already set: `CAMPAIGNS_LISTMONK_BASE_URL`, `CAMPAIGNS_LISTMONK_API_USER`, `CAMPAIGNS_LISTMONK_API_TOKEN` — scoped to a dedicated `campaigns-thistleandmoth` listmonk API user (see `reground-infrastructure`'s `campaigns-thistleandmoth` Ansible role). `CAMPAIGNS_PREVIEW_EMAIL` isn't set yet — add it (or rely on each campaign's own `preview_emails`) before the first real send.
The workflows in `.gitea/workflows/` pin a specific `eec-campaigns` version (currently `v0.1.0`). Bump that pin deliberately when a new tool version is ready, not automatically.
## Sending as hello@thistleandmoth.org
Listmonk sends everything — every domain's campaigns — authenticated as one shared identity, `hello@reground.org`. For a campaign here to actually go out `From: hello@thistleandmoth.org` instead of bouncing as a spoofing attempt, Mailcow's Sender ACL on `hello@reground.org` has to explicitly allow it. **This has not been done yet for thistleandmoth.org** — add `hello@thistleandmoth.org` to that Sender ACL in the Mailcow admin UI before the first real send, or Postfix will reject it.
View File
+115
View File
@@ -0,0 +1,115 @@
<!--
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;">&nbsp;</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>
&nbsp;&nbsp;
<a href="{{ MessageURL }}" style="color: #888;">{{ L.T "email.viewInBrowser" }}</a>
</p>
</div>
<div class="gutter" style="padding: 30px;">&nbsp;{{ TrackView }}</div>
</body>
</html>