2 Commits

Author SHA1 Message Date
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
4 changed files with 119 additions and 19 deletions
+4 -1
View File
@@ -101,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. 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 ## Sync guardrails
A rejected campaign is usually one of these, all deliberate: 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. - **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. - **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`.**
+30 -12
View File
@@ -18,7 +18,8 @@ import (
type SyncResult struct { type SyncResult struct {
Synced []string // created or updated (content changed) in listmonk Synced []string // created or updated (content changed) in listmonk
Unchanged []string // existing draft, content identical — no API write, no preview 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 PreviewFailed []string // content synced fine, but the automatic preview send itself failed
} }
@@ -45,6 +46,10 @@ func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*
result.Rejected = append(result.Rejected, fmt.Sprintf("%s: %v", slug, err)) result.Rejected = append(result.Rejected, fmt.Sprintf("%s: %v", slug, err))
continue continue
} }
if res.Skipped != "" {
result.Skipped = append(result.Skipped, fmt.Sprintf("%s: %s", slug, res.Skipped))
continue
}
if !res.Changed { if !res.Changed {
result.Unchanged = append(result.Unchanged, slug) result.Unchanged = append(result.Unchanged, slug)
continue continue
@@ -59,6 +64,7 @@ func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*
type syncOneResult struct { type syncOneResult struct {
Changed bool 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 PreviewErr error // set only when Changed and the automatic preview send failed
} }
@@ -68,6 +74,22 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
return syncOneResult{}, err 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) listIDs, err := resolveLists(lm, fm.Lists)
if err != nil { if err != nil {
return syncOneResult{}, err return syncOneResult{}, err
@@ -97,11 +119,6 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
MediaIDs: mediaIDs, MediaIDs: mediaIDs,
} }
existing, err := lm.FindCampaignByName(slug)
if err != nil {
return syncOneResult{}, err
}
var campaignID int var campaignID int
var changed bool var changed bool
switch { switch {
@@ -112,11 +129,6 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
} }
campaignID, changed = created.ID, true 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): case campaignUnchanged(existing, input):
campaignID, changed = existing.ID, false campaignID, changed = existing.ID, false
@@ -222,7 +234,13 @@ func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput
// stored altbody is still null even though its body hasn't changed. // stored altbody is still null even though its body hasn't changed.
existing.AltBody == input.Body && existing.AltBody == input.Body &&
existing.FromEmail == input.FromEmail && 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 && existing.Type == input.Type &&
equalStringSets(existing.Tags, input.Tags) && equalStringSets(existing.Tags, input.Tags) &&
equalIntSets(existing.ListIDs, input.ListIDs) && equalIntSets(existing.ListIDs, input.ListIDs) &&
+81 -5
View File
@@ -152,12 +152,25 @@ func (f *fakeListmonk) writeCampaigns(w http.ResponseWriter) {
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}}) 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) { func (f *fakeListmonk) createCampaign(w http.ResponseWriter, r *http.Request) {
body := decodeBody(r) body := decodeBody(r)
c := fakeCampaign{ c := fakeCampaign{
ID: f.id(), Name: str(body["name"]), Status: "draft", ID: f.id(), Name: str(body["name"]), Status: "draft",
Subject: str(body["subject"]), Body: str(body["body"]), AltBody: str(body["altbody"]), Subject: str(body["subject"]), Body: str(body["body"]), AltBody: str(body["altbody"]),
FromEmail: str(body["from_email"]), TemplateID: toInt(body["template_id"]), FromEmail: str(body["from_email"]), TemplateID: f.resolveTemplateID(body),
Type: str(body["type"]), Tags: toStrings(body["tags"]), Type: str(body["type"]), Tags: toStrings(body["tags"]),
ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media"]), ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media"]),
} }
@@ -174,7 +187,7 @@ func (f *fakeListmonk) updateCampaign(w http.ResponseWriter, r *http.Request) {
f.campaigns[i].Body = str(body["body"]) f.campaigns[i].Body = str(body["body"])
f.campaigns[i].AltBody = str(body["altbody"]) f.campaigns[i].AltBody = str(body["altbody"])
f.campaigns[i].FromEmail = str(body["from_email"]) 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].Type = str(body["type"])
f.campaigns[i].Tags = toStrings(body["tags"]) f.campaigns[i].Tags = toStrings(body["tags"])
f.campaigns[i].ListIDs = toInts(body["lists"]) f.campaigns[i].ListIDs = toInts(body["lists"])
@@ -409,6 +422,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) { func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
f := newFakeListmonk(t) f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}} f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
@@ -433,7 +479,7 @@ func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
} }
} }
func TestSyncDir_RejectsNonDraftCampaign(t *testing.T) { func TestSyncDir_SkipsNonDraftCampaignWithoutFailing(t *testing.T) {
f := newFakeListmonk(t) f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}} f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running", Subject: "Original"}} f.campaigns = []fakeCampaign{{ID: 1, Name: "launch", Status: "running", Subject: "Original"}}
@@ -446,14 +492,44 @@ func TestSyncDir_RejectsNonDraftCampaign(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("SyncDir: %v", err) t.Fatalf("SyncDir: %v", err)
} }
if len(result.Rejected) != 1 || !strings.Contains(result.Rejected[0], "running") { // A sent/running campaign is a historical record, not an error -- it
t.Fatalf("expected a rejection mentioning 'running', got %v", result.Rejected) // 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" { if f.campaigns[0].Subject != "Original" {
t.Errorf("expected the live campaign's content to be untouched, got %+v", f.campaigns[0]) 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) { func TestSyncDir_AmbiguousListNameRejects(t *testing.T) {
f := newFakeListmonk(t) f := newFakeListmonk(t)
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}, {ID: 4, Name: "Newsletter"}} f.lists = []fakeList{{ID: 3, Name: "Newsletter"}, {ID: 4, Name: "Newsletter"}}
+3
View File
@@ -81,6 +81,9 @@ func runSync(lm *listmonk.Client, path string) {
} }
log.Printf("synced: %v", result.Synced) log.Printf("synced: %v", result.Synced)
log.Printf("unchanged: %v", result.Unchanged) log.Printf("unchanged: %v", result.Unchanged)
if len(result.Skipped) > 0 {
log.Printf("skipped (already sent): %v", result.Skipped)
}
if len(result.Rejected) > 0 { if len(result.Rejected) > 0 {
log.Printf("rejected: %v", result.Rejected) log.Printf("rejected: %v", result.Rejected)
} }