Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c375916f05 | |||
| 34d61c2e69 | |||
| 0c8bc6bd3b | |||
| 6227d59eae | |||
| e238bde036 |
@@ -54,8 +54,11 @@ attachments:
|
|||||||
---
|
---
|
||||||
|
|
||||||
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" for the HTML part; listmonk never derives a
|
||||||
plaintext alternative itself.
|
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.
|
- **`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.
|
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`.**
|
||||||
|
|||||||
+108
-19
@@ -6,8 +6,10 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
"gitea.reground.org/will/eec-campaigns/internal/listmonk"
|
||||||
)
|
)
|
||||||
@@ -18,24 +20,55 @@ 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
|
||||||
|
// 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
|
// SyncDir walks <root>/campaigns/*/campaign.md and upserts each as a
|
||||||
// listmonk draft campaign. One bad campaign doesn't abort the rest — this
|
// listmonk draft campaign. Equivalent to SyncChanged with an empty since —
|
||||||
// mirrors eec's course.SyncDir/SyncResult shape exactly, extended with the
|
// see SyncChanged for the git-diff-scoped version this wraps.
|
||||||
// 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.
|
|
||||||
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
|
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")
|
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
|
||||||
matches, err := filepath.Glob(pattern)
|
matches, err := filepath.Glob(pattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for _, path := range matches {
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
slug := filepath.Base(dir)
|
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))
|
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
|
||||||
@@ -57,9 +94,45 @@ func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*
|
|||||||
return result, nil
|
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 {
|
type syncOneResult struct {
|
||||||
Changed bool
|
Changed bool
|
||||||
PreviewErr error // set only when Changed and the automatic preview send failed
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails []string) (syncOneResult, error) {
|
func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails []string) (syncOneResult, error) {
|
||||||
@@ -68,6 +141,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 +186,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 +196,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
|
||||||
|
|
||||||
@@ -217,8 +296,18 @@ func attachmentFilename(slug, relPath string, content []byte) string {
|
|||||||
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
|
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
|
||||||
return existing.Subject == input.Subject &&
|
return existing.Subject == input.Subject &&
|
||||||
existing.Body == input.Body &&
|
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.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) &&
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,6 +33,7 @@ type fakeCampaign struct {
|
|||||||
Status string
|
Status string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
|
AltBody string
|
||||||
FromEmail string
|
FromEmail string
|
||||||
TemplateID int
|
TemplateID int
|
||||||
Type string
|
Type string
|
||||||
@@ -138,7 +141,7 @@ func (c fakeCampaign) toJSON() map[string]any {
|
|||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": c.ID, "name": c.Name, "status": c.Status, "subject": c.Subject,
|
"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,
|
"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}})
|
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"]),
|
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"]),
|
||||||
}
|
}
|
||||||
@@ -171,8 +187,9 @@ func (f *fakeListmonk) updateCampaign(w http.ResponseWriter, r *http.Request) {
|
|||||||
if f.campaigns[i].ID == id {
|
if f.campaigns[i].ID == id {
|
||||||
f.campaigns[i].Subject = str(body["subject"])
|
f.campaigns[i].Subject = str(body["subject"])
|
||||||
f.campaigns[i].Body = str(body["body"])
|
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].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"])
|
||||||
@@ -347,6 +364,36 @@ func writeCampaignDir(t *testing.T, root, slug, frontmatterExtra, body string) {
|
|||||||
writeFile(t, path, "---\n"+frontmatterExtra+"---\n"+body+"\n")
|
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"
|
const baseFrontmatter = "subject: \"Big Announcement\"\nlists: [\"Newsletter\"]\nfrom_email: hello@example.com\n"
|
||||||
|
|
||||||
// ---- tests ----
|
// ---- 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) {
|
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"}}
|
||||||
@@ -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 := 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"}}
|
||||||
@@ -444,14 +524,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"}}
|
||||||
@@ -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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ type Campaign struct {
|
|||||||
Status string
|
Status string
|
||||||
Subject string
|
Subject string
|
||||||
Body string
|
Body string
|
||||||
|
AltBody string
|
||||||
FromEmail string
|
FromEmail string
|
||||||
TemplateID int
|
TemplateID int
|
||||||
Type string
|
Type string
|
||||||
@@ -335,7 +336,17 @@ func (in CampaignInput) payload() map[string]any {
|
|||||||
"lists": in.ListIDs,
|
"lists": in.ListIDs,
|
||||||
"content_type": "markdown",
|
"content_type": "markdown",
|
||||||
"body": in.Body,
|
"body": in.Body,
|
||||||
"type": in.Type,
|
// 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
|
// listmonk's create/update handlers default an omitted messenger to
|
||||||
// "email" before validating, but the test-send handler validates the
|
// "email" before validating, but the test-send handler validates the
|
||||||
// raw request body as-is — an omitted messenger there fails with
|
// raw request body as-is — an omitted messenger there fails with
|
||||||
@@ -371,6 +382,7 @@ func parseCampaign(data []byte) (*Campaign, error) {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
AltBody string `json:"altbody"`
|
||||||
FromEmail string `json:"from_email"`
|
FromEmail string `json:"from_email"`
|
||||||
TemplateID int `json:"template_id"`
|
TemplateID int `json:"template_id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -399,6 +411,7 @@ func parseCampaign(data []byte) (*Campaign, error) {
|
|||||||
Status: parsed.Status,
|
Status: parsed.Status,
|
||||||
Subject: parsed.Subject,
|
Subject: parsed.Subject,
|
||||||
Body: parsed.Body,
|
Body: parsed.Body,
|
||||||
|
AltBody: parsed.AltBody,
|
||||||
FromEmail: parsed.FromEmail,
|
FromEmail: parsed.FromEmail,
|
||||||
TemplateID: parsed.TemplateID,
|
TemplateID: parsed.TemplateID,
|
||||||
Type: parsed.Type,
|
Type: parsed.Type,
|
||||||
|
|||||||
@@ -29,10 +29,14 @@ func main() {
|
|||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "sync":
|
case "sync":
|
||||||
if len(os.Args) < 3 {
|
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)
|
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":
|
case "send":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: campaigns send SLUG")
|
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
|
// (comma-separated), used for any campaign that doesn't set its own
|
||||||
// preview_emails in frontmatter. Optional — sync still works with none set,
|
// preview_emails in frontmatter. Optional — sync still works with none set,
|
||||||
// it just won't auto-preview campaigns that don't specify their own.
|
// 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
|
var defaultPreview []string
|
||||||
if raw := os.Getenv("CAMPAIGNS_PREVIEW_EMAIL"); raw != "" {
|
if raw := os.Getenv("CAMPAIGNS_PREVIEW_EMAIL"); raw != "" {
|
||||||
for _, addr := range strings.Split(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 {
|
if err != nil {
|
||||||
log.Fatalf("sync: %v", err)
|
log.Fatalf("sync: %v", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("mode: %s", result.Mode)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user