5 Commits

Author SHA1 Message Date
will c375916f05 Scope sync to campaigns changed since a given commit
CI / test (push) Successful in 8s
Add SyncChanged (and the ChangedSlugs git-diff helper it's built on) as
an opt-in narrower version of SyncDir: pass the push's "before" SHA and
only the campaign directories that actually changed get looked at at
all, instead of every campaign getting resolved and diffed against
listmonk on every push. An empty or unresolvable since (new branch's
first push, a shallow clone missing that commit, any git failure) always
falls back to a full scan -- this can only ever cause more work than
strictly necessary, never a silently-skipped campaign. SyncDir itself is
now a thin wrapper (SyncChanged with since=""), so every existing caller
and test is unaffected.

campaigns sync PATH now accepts an optional trailing SINCE_SHA.
2026-08-13 13:40:10 -04:00
will 34d61c2e69 Stop failing sync over already-sent campaigns still in the repo
CI / test (push) Successful in 9s
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
will 0c8bc6bd3b Fix spurious re-sync/preview for campaigns without an explicit template_id
CI / test (push) Successful in 21s
campaignUnchanged compared input.TemplateID (0 when unset) literally
against existing.TemplateID, but listmonk always assigns a real, nonzero
template_id server-side even when create/update omits it. Every campaign
without an explicit template_id was therefore flagged "changed" on every
sync, forever, firing an unwanted extra preview email each time — caught
via reground-campaigns' welcome/campaign.md re-previewing on an unrelated
push. Treat TemplateID 0 as "don't compare" instead. Test fake now mirrors
listmonk's real default-assignment behavior so this class of bug is caught.
2026-08-13 11:35:52 -04:00
will 6227d59eae Track altbody in change-detection so existing campaigns actually re-sync
CI / test (push) Successful in 10s
Campaign/parseCampaign never fetched altbody, so campaignUnchanged()
compared everything except it -- a campaign synced before v0.1.3
(altbody always null) whose content otherwise hasn't changed would be
judged unchanged forever and never get updated with the new altbody,
even though every subsequent sync now intends to send one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:13:13 -04:00
will e238bde036 Send altbody so campaigns actually go out multipart/alternative
CI / test (push) Successful in 9s
listmonk never derives a plaintext part from HTML/Markdown on its own
(models/campaigns.go only compiles AltBodyTpl, and
internal/manager/message.go only emits text/plain, when altbody is
explicitly non-null) -- confirmed against the deployed v6.2.0 source.
Every campaign this tool created was HTML-only as a result, contrary
to the README's now-corrected claim.

Reusing the same raw Markdown+template source as altbody personalizes
it identically to body, since AltBodyTpl compiles whenever the text
contains {{ }} expressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:05:49 -04:00
5 changed files with 391 additions and 34 deletions
+9 -3
View File
@@ -54,8 +54,11 @@ attachments:
---
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.
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.
@@ -98,11 +101,14 @@ Each file in `attachments:` is uploaded to listmonk's media library under a synt
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:
- **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`.**
+107 -18
View File
@@ -6,8 +6,10 @@ import (
"encoding/hex"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
)
@@ -18,24 +20,55 @@ import (
type SyncResult struct {
Synced []string // created or updated (content changed) in listmonk
Unchanged []string // existing draft, content identical — no API write, no preview
Rejected []string // validation/segmentation/list-lookup failure, or non-draft in listmonk — nothing written
Skipped []string // already sent/running/paused/etc. in listmonk — a historical record, left untouched, not an error
Rejected []string // validation/segmentation/list-lookup failure — nothing written
PreviewFailed []string // content synced fine, but the automatic preview send itself failed
// Mode is purely for CI log visibility into how this run was scoped:
// "full scan", or "since <sha>: N of M campaign(s) touched" — see
// SyncChanged.
Mode string
}
// SyncDir walks <root>/campaigns/*/campaign.md and upserts each as a
// listmonk draft campaign. One bad campaign doesn't abort the rest — this
// mirrors eec's course.SyncDir/SyncResult shape exactly, extended with the
// Unchanged/PreviewFailed buckets this tool's auto-preview and diff-based
// sync need. defaultPreviewEmails is used for any campaign that doesn't set
// its own preview_emails in frontmatter.
// listmonk draft campaign. Equivalent to SyncChanged with an empty since —
// see SyncChanged for the git-diff-scoped version this wraps.
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
return SyncChanged(lm, root, defaultPreviewEmails, "")
}
// SyncChanged is SyncDir optionally scoped to just the campaigns whose
// files changed since a given commit (via ChangedSlugs' git diff), so a
// push that only touched one campaign doesn't need to fetch and diff every
// other campaign in the repo against listmonk to confirm nothing changed.
// An empty since, or ChangedSlugs failing to resolve it for any reason
// (e.g. a shallow clone missing that commit's history), falls back to a
// full scan exactly like SyncDir — this can never cause a campaign to be
// silently skipped, only ever process more than strictly necessary.
func SyncChanged(lm *listmonk.Client, root string, defaultPreviewEmails []string, since string) (*SyncResult, error) {
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
result := &SyncResult{}
mode := "full scan"
if since != "" {
if slugs, ok := ChangedSlugs(root, since); ok {
total := len(matches)
filtered := matches[:0]
for _, path := range matches {
if slugs[filepath.Base(filepath.Dir(path))] {
filtered = append(filtered, path)
}
}
matches = filtered
mode = fmt.Sprintf("since %s: %d of %d campaign(s) touched", since, len(matches), total)
} else {
mode = fmt.Sprintf("full scan (could not resolve since %s)", since)
}
}
result := &SyncResult{Mode: mode}
for _, path := range matches {
dir := filepath.Dir(path)
slug := filepath.Base(dir)
@@ -45,6 +78,10 @@ func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*
result.Rejected = append(result.Rejected, fmt.Sprintf("%s: %v", slug, err))
continue
}
if res.Skipped != "" {
result.Skipped = append(result.Skipped, fmt.Sprintf("%s: %s", slug, res.Skipped))
continue
}
if !res.Changed {
result.Unchanged = append(result.Unchanged, slug)
continue
@@ -57,8 +94,44 @@ func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*
return result, nil
}
// ChangedSlugs returns the set of campaign slugs (directory names under
// campaigns/) whose files differ between since and HEAD, by shelling out to
// git diff. The second return is false when since is empty, the all-zero
// placeholder SHA (what gitea's push event sends for a brand-new branch's
// first push, since there's no real "before" commit), or the diff itself
// fails for any reason — most commonly a shallow clone that doesn't have
// since's history available locally. All of those mean "can't determine
// what changed," never "nothing changed," so callers must treat false as
// "fall back to processing everything," not as an empty result.
func ChangedSlugs(root, since string) (map[string]bool, bool) {
if since == "" || since == strings.Repeat("0", 40) {
return nil, false
}
cmd := exec.Command("git", "diff", "--name-only", since, "HEAD", "--", "campaigns")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return nil, false
}
slugs := map[string]bool{}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// Lines look like "campaigns/<slug>/campaign.md" or
// "campaigns/<slug>/assets/whatever.pdf" — the pathspec above
// already guarantees the "campaigns/" prefix.
parts := strings.SplitN(line, "/", 3)
if len(parts) >= 2 {
slugs[parts[1]] = true
}
}
return slugs, true
}
type syncOneResult struct {
Changed bool
Skipped string // non-empty (the listmonk status) when left untouched because it's no longer a draft
PreviewErr error // set only when Changed and the automatic preview send failed
}
@@ -68,6 +141,22 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
return syncOneResult{}, err
}
existing, err := lm.FindCampaignByName(slug)
if err != nil {
return syncOneResult{}, err
}
if existing != nil && existing.Status != "draft" {
// Once a campaign has actually gone out (or is running/paused/etc.),
// its campaign.md is a historical record of what was sent and when —
// not something sync should touch or fail the build over. Forcing it
// to be deleted just to keep CI green would throw away exactly the
// record someone would want to look back at later. Check this before
// resolving anything else below, so a since-renamed list or a
// since-changed attachment on an old campaign can never break the
// build either — an archived campaign has nothing left to resolve.
return syncOneResult{Skipped: existing.Status}, nil
}
listIDs, err := resolveLists(lm, fm.Lists)
if err != nil {
return syncOneResult{}, err
@@ -97,11 +186,6 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
MediaIDs: mediaIDs,
}
existing, err := lm.FindCampaignByName(slug)
if err != nil {
return syncOneResult{}, err
}
var campaignID int
var changed bool
switch {
@@ -112,11 +196,6 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
}
campaignID, changed = created.ID, true
case existing.Status != "draft":
// The core safety invariant: never silently skip or overwrite a
// live/sent campaign. This is a hard rejection, not a warning.
return syncOneResult{}, fmt.Errorf("campaign is %s in listmonk; sync refuses to modify a non-draft campaign", existing.Status)
case campaignUnchanged(existing, input):
campaignID, changed = existing.ID, false
@@ -217,8 +296,18 @@ func attachmentFilename(slug, relPath string, content []byte) string {
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
return existing.Subject == input.Subject &&
existing.Body == input.Body &&
// altbody always mirrors body (see CampaignInput.payload) -- comparing
// it here is what catches a campaign synced before that started, whose
// stored altbody is still null even though its body hasn't changed.
existing.AltBody == input.Body &&
existing.FromEmail == input.FromEmail &&
existing.TemplateID == input.TemplateID &&
// input.TemplateID == 0 means "unspecified, use whatever's assigned"
// (see CampaignInput.TemplateID) -- listmonk always assigns some real,
// nonzero template_id server-side even when it's omitted from the
// create/update payload, so comparing 0 against that would flag every
// campaign.md without an explicit template_id as changed on every
// sync, forever.
(input.TemplateID == 0 || existing.TemplateID == input.TemplateID) &&
existing.Type == input.Type &&
equalStringSets(existing.Tags, input.Tags) &&
equalIntSets(existing.ListIDs, input.ListIDs) &&
+244 -7
View File
@@ -5,6 +5,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
@@ -31,6 +33,7 @@ type fakeCampaign struct {
Status string
Subject string
Body string
AltBody string
FromEmail string
TemplateID int
Type string
@@ -138,7 +141,7 @@ func (c fakeCampaign) toJSON() map[string]any {
}
return map[string]any{
"id": c.ID, "name": c.Name, "status": c.Status, "subject": c.Subject,
"body": c.Body, "from_email": c.FromEmail, "template_id": c.TemplateID,
"body": c.Body, "altbody": c.AltBody, "from_email": c.FromEmail, "template_id": c.TemplateID,
"type": c.Type, "tags": c.Tags, "lists": lists, "media": media,
}
}
@@ -151,12 +154,25 @@ func (f *fakeListmonk) writeCampaigns(w http.ResponseWriter) {
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
}
// fakeDefaultTemplateID is the nonzero template_id real listmonk assigns
// server-side to a campaign whose create/update request omitted template_id
// entirely -- never 0, which is what makes the zero value ambiguous between
// "unspecified" and "really is 0" in campaignUnchanged.
const fakeDefaultTemplateID = 1
func (f *fakeListmonk) resolveTemplateID(body map[string]any) int {
if v, ok := body["template_id"]; ok {
return toInt(v)
}
return fakeDefaultTemplateID
}
func (f *fakeListmonk) createCampaign(w http.ResponseWriter, r *http.Request) {
body := decodeBody(r)
c := fakeCampaign{
ID: f.id(), Name: str(body["name"]), Status: "draft",
Subject: str(body["subject"]), Body: str(body["body"]),
FromEmail: str(body["from_email"]), TemplateID: toInt(body["template_id"]),
Subject: str(body["subject"]), Body: str(body["body"]), AltBody: str(body["altbody"]),
FromEmail: str(body["from_email"]), TemplateID: f.resolveTemplateID(body),
Type: str(body["type"]), Tags: toStrings(body["tags"]),
ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media"]),
}
@@ -171,8 +187,9 @@ func (f *fakeListmonk) updateCampaign(w http.ResponseWriter, r *http.Request) {
if f.campaigns[i].ID == id {
f.campaigns[i].Subject = str(body["subject"])
f.campaigns[i].Body = str(body["body"])
f.campaigns[i].AltBody = str(body["altbody"])
f.campaigns[i].FromEmail = str(body["from_email"])
f.campaigns[i].TemplateID = toInt(body["template_id"])
f.campaigns[i].TemplateID = f.resolveTemplateID(body)
f.campaigns[i].Type = str(body["type"])
f.campaigns[i].Tags = toStrings(body["tags"])
f.campaigns[i].ListIDs = toInts(body["lists"])
@@ -347,6 +364,36 @@ func writeCampaignDir(t *testing.T, root, slug, frontmatterExtra, body string) {
writeFile(t, path, "---\n"+frontmatterExtra+"---\n"+body+"\n")
}
// ---- git test fixtures, for ChangedSlugs/SyncChanged ----
func runGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func initGitRepo(t *testing.T, dir string) {
t.Helper()
runGit(t, dir, "init", "-q", "-b", "master")
}
// gitCommit stages everything and commits, returning the new commit's SHA.
func gitCommit(t *testing.T, dir, message string) string {
t.Helper()
runGit(t, dir, "add", "-A")
runGit(t, dir, "commit", "-q", "-m", message)
return runGit(t, dir, "rev-parse", "HEAD")
}
const baseFrontmatter = "subject: \"Big Announcement\"\nlists: [\"Newsletter\"]\nfrom_email: hello@example.com\n"
// ---- tests ----
@@ -407,6 +454,39 @@ func TestSyncDir_SecondSyncWithUnchangedContentIsNoop(t *testing.T) {
}
}
func TestSyncDir_UnspecifiedTemplateIDDoesNotTriggerSpuriousResync(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
// baseFrontmatter never sets template_id, matching real campaign.md files
// like reground-campaigns' welcome/campaign.md.
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
if _, err := SyncDir(lm, root, nil); err != nil {
t.Fatalf("first SyncDir: %v", err)
}
if f.campaigns[0].TemplateID != fakeDefaultTemplateID {
t.Fatalf("expected the fake to assign its default template_id like real listmonk does, got %d", f.campaigns[0].TemplateID)
}
// Re-sync with the exact same, still-unspecified template_id: this must
// not be flagged as changed just because listmonk's stored, server-
// assigned template_id (nonzero) doesn't literally equal the frontmatter's
// unset value (0).
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("second SyncDir: %v", err)
}
if len(result.Synced) != 0 {
t.Errorf("expected no spurious re-sync from an unspecified template_id, got Synced=%v", result.Synced)
}
if len(result.Unchanged) != 1 || result.Unchanged[0] != "launch" {
t.Errorf("expected launch in Unchanged, got %v", result.Unchanged)
}
}
func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
@@ -431,7 +511,7 @@ func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
}
}
func TestSyncDir_RejectsNonDraftCampaign(t *testing.T) {
func TestSyncDir_SkipsNonDraftCampaignWithoutFailing(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running", Subject: "Original"}}
@@ -444,14 +524,44 @@ func TestSyncDir_RejectsNonDraftCampaign(t *testing.T) {
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "running") {
t.Fatalf("expected a rejection mentioning 'running', got %v", result.Rejected)
// A sent/running campaign is a historical record, not an error -- it
// must never land in Rejected (which fails the whole CI job) just
// because its campaign.md is still around.
if len(result.Rejected) != 0 {
t.Fatalf("expected no rejection for an already-sent campaign, got %v", result.Rejected)
}
if len(result.Skipped) != 1 || !strings.Contains(result.Skipped[0], "running") {
t.Fatalf("expected launch in Skipped mentioning 'running', got %v", result.Skipped)
}
if f.campaigns[0].Subject != "Original" {
t.Errorf("expected the live campaign's content to be untouched, got %+v", f.campaigns[0])
}
}
func TestSyncDir_NonDraftCampaignSkipsEvenWithAStaleListReference(t *testing.T) {
// A finished campaign's campaign.md might reference a list that's since
// been renamed or removed -- that must never break the build, since an
// archived campaign has nothing left to resolve.
f := newFakeListmonk(t)
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "finished", Subject: "Original"}}
lm := f.client()
root := t.TempDir()
fm := "subject: \"Big Announcement\"\nlists: [\"No Longer Exists\"]\nfrom_email: hello@example.com\n"
writeCampaignDir(t, root, "launch", fm, "Hello world.")
result, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("SyncDir: %v", err)
}
if len(result.Rejected) != 0 {
t.Fatalf("expected no rejection despite the stale list reference, got %v", result.Rejected)
}
if len(result.Skipped) != 1 {
t.Fatalf("expected launch in Skipped, got %v", result.Skipped)
}
}
func TestSyncDir_AmbiguousListNameRejects(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}, {ID: 4, Name: "Newsletter"}}
@@ -655,3 +765,130 @@ func TestSyncDir_OneBadCampaignDoesNotAbortOthers(t *testing.T) {
t.Errorf("expected bad to be rejected with a slug-prefixed message, got %v", result.Rejected)
}
}
// ---- ChangedSlugs / SyncChanged ----
func TestChangedSlugs_ReturnsOnlySlugsTouchedSinceGivenCommit(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Original a.")
writeCampaignDir(t, root, "b", baseFrontmatter, "Original b.")
base := gitCommit(t, root, "add a and b")
writeCampaignDir(t, root, "a", baseFrontmatter, "Updated a.")
gitCommit(t, root, "update a")
slugs, ok := ChangedSlugs(root, base)
if !ok {
t.Fatal("expected ChangedSlugs to resolve a valid commit")
}
if !slugs["a"] || slugs["b"] {
t.Errorf("expected only 'a' in changed slugs, got %v", slugs)
}
}
func TestChangedSlugs_AllZeroShaMeansUnresolvable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello.")
gitCommit(t, root, "add a")
// The all-zero SHA is what a push event reports as "before" for a
// brand-new branch's first push -- there's no real commit to diff
// against, so this must signal "can't determine," not "empty diff."
_, ok := ChangedSlugs(root, strings.Repeat("0", 40))
if ok {
t.Error("expected the all-zero SHA to be treated as unresolvable")
}
}
func TestChangedSlugs_UnknownShaMeansUnresolvable(t *testing.T) {
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello.")
gitCommit(t, root, "add a")
// A commit git has never heard of (e.g. one this shallow clone never
// fetched) must fall back to "can't determine," not error out or
// silently produce an empty diff.
_, ok := ChangedSlugs(root, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if ok {
t.Error("expected an unknown commit to be treated as unresolvable")
}
}
func TestChangedSlugs_EmptySinceMeansUnresolvable(t *testing.T) {
root := t.TempDir()
_, ok := ChangedSlugs(root, "")
if ok {
t.Error("expected an empty since to be treated as unresolvable")
}
}
func TestSyncChanged_OnlyProcessesCampaignsTouchedSinceGivenCommit(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Original a.")
// b's frontmatter references a list that doesn't exist in listmonk --
// if SyncChanged ever looks at it, it comes back Rejected.
bFrontmatter := "subject: \"B\"\nlists: [\"No Such List\"]\nfrom_email: hello@example.com\n"
writeCampaignDir(t, root, "b", bFrontmatter, "Original b.")
base := gitCommit(t, root, "add a and b")
// A full scan at this point must reject b -- sanity check that b's
// broken list reference is real, not a mistake in the fixture.
sanity, err := SyncDir(lm, root, nil)
if err != nil {
t.Fatalf("sanity SyncDir: %v", err)
}
if len(sanity.Rejected) != 1 || !strings.Contains(sanity.Rejected[0], "b:") {
t.Fatalf("expected a full scan to reject b, got Rejected=%v", sanity.Rejected)
}
// Reset: the sanity run may have created campaign "a" in listmonk.
f.campaigns = nil
writeCampaignDir(t, root, "a", baseFrontmatter, "Updated a.")
gitCommit(t, root, "update a only")
result, err := SyncChanged(lm, root, nil, base)
if err != nil {
t.Fatalf("SyncChanged: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "a" {
t.Errorf("expected only a synced, got Synced=%v", result.Synced)
}
// The real point of this test: b was never even looked at, despite its
// broken list reference, because it wasn't touched by this push.
if len(result.Rejected) != 0 {
t.Errorf("expected b to be left untouched (not rejected) since it wasn't in the diff, got Rejected=%v", result.Rejected)
}
if !strings.Contains(result.Mode, "1 of 2") {
t.Errorf("expected Mode to report 1 of 2 campaigns touched, got %q", result.Mode)
}
}
func TestSyncChanged_FallsBackToFullScanWhenSinceUnresolvable(t *testing.T) {
f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
lm := f.client()
root := t.TempDir()
initGitRepo(t, root)
writeCampaignDir(t, root, "a", baseFrontmatter, "Hello a.")
gitCommit(t, root, "add a")
result, err := SyncChanged(lm, root, nil, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if err != nil {
t.Fatalf("SyncChanged: %v", err)
}
if len(result.Synced) != 1 || result.Synced[0] != "a" {
t.Errorf("expected a fallback full scan to still sync a, got Synced=%v", result.Synced)
}
if !strings.Contains(result.Mode, "full scan") {
t.Errorf("expected Mode to note the fallback to a full scan, got %q", result.Mode)
}
}
+13
View File
@@ -289,6 +289,7 @@ type Campaign struct {
Status string
Subject string
Body string
AltBody string
FromEmail string
TemplateID int
Type string
@@ -335,6 +336,16 @@ func (in CampaignInput) payload() map[string]any {
"lists": in.ListIDs,
"content_type": "markdown",
"body": in.Body,
// listmonk never derives a plaintext alternative from body/HTML on its
// own (models/campaigns.go only compiles AltBodyTpl, and
// internal/manager/message.go only emits a text/plain part, when
// altbody is explicitly non-null) — confirmed against the deployed
// v6.2.0 source. Reusing the same raw Markdown+template source as the
// altbody gets it personalized identically to body (AltBodyTpl compiles
// whenever the text contains {{ }} expressions), at the cost of
// Markdown syntax like *emphasis* showing up literally in plaintext
// clients — an acceptable, well-established tradeoff for Markdown.
"altbody": in.Body,
"type": in.Type,
// listmonk's create/update handlers default an omitted messenger to
// "email" before validating, but the test-send handler validates the
@@ -371,6 +382,7 @@ func parseCampaign(data []byte) (*Campaign, error) {
Status string `json:"status"`
Subject string `json:"subject"`
Body string `json:"body"`
AltBody string `json:"altbody"`
FromEmail string `json:"from_email"`
TemplateID int `json:"template_id"`
Type string `json:"type"`
@@ -399,6 +411,7 @@ func parseCampaign(data []byte) (*Campaign, error) {
Status: parsed.Status,
Subject: parsed.Subject,
Body: parsed.Body,
AltBody: parsed.AltBody,
FromEmail: parsed.FromEmail,
TemplateID: parsed.TemplateID,
Type: parsed.Type,
+16 -4
View File
@@ -29,10 +29,14 @@ func main() {
switch os.Args[1] {
case "sync":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: campaigns sync PATH")
fmt.Fprintln(os.Stderr, "usage: campaigns sync PATH [SINCE_SHA]")
os.Exit(1)
}
runSync(lm, os.Args[2])
since := ""
if len(os.Args) >= 4 {
since = os.Args[3]
}
runSync(lm, os.Args[2], since)
case "send":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: campaigns send SLUG")
@@ -65,7 +69,11 @@ func usage() {
// (comma-separated), used for any campaign that doesn't set its own
// preview_emails in frontmatter. Optional — sync still works with none set,
// it just won't auto-preview campaigns that don't specify their own.
func runSync(lm *listmonk.Client, path string) {
//
// since is optional — the commit to diff against (typically the push
// event's "before" SHA) to scope this run to just the campaigns that
// changed, per campaign.SyncChanged. Pass "" to always do a full scan.
func runSync(lm *listmonk.Client, path, since string) {
var defaultPreview []string
if raw := os.Getenv("CAMPAIGNS_PREVIEW_EMAIL"); raw != "" {
for _, addr := range strings.Split(raw, ",") {
@@ -75,12 +83,16 @@ func runSync(lm *listmonk.Client, path string) {
}
}
result, err := campaign.SyncDir(lm, path, defaultPreview)
result, err := campaign.SyncChanged(lm, path, defaultPreview, since)
if err != nil {
log.Fatalf("sync: %v", err)
}
log.Printf("mode: %s", result.Mode)
log.Printf("synced: %v", result.Synced)
log.Printf("unchanged: %v", result.Unchanged)
if len(result.Skipped) > 0 {
log.Printf("skipped (already sent): %v", result.Skipped)
}
if len(result.Rejected) > 0 {
log.Printf("rejected: %v", result.Rejected)
}