package campaign import ( "bytes" "crypto/sha256" "encoding/hex" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "gitea.reground.org/will/eec-campaigns/internal/listmonk" ) // SyncResult summarizes what a sync run did, split so the CI log (and the // person reading it) can tell at a glance what actually happened — no bad // campaign silently skipped or overwritten, no preview silently swallowed. type SyncResult struct { Synced []string // created or updated (content changed) in listmonk Unchanged []string // existing draft, content identical — no API write, no preview 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 : N of M campaign(s) touched" — see // SyncChanged. Mode string } // SyncDir walks /campaigns/*/campaign.md and upserts each as a // 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 } 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) res, err := syncOne(lm, slug, dir, path, defaultPreviewEmails) if err != nil { 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 } result.Synced = append(result.Synced, slug) if res.PreviewErr != nil { result.PreviewFailed = append(result.PreviewFailed, fmt.Sprintf("%s: %v", slug, res.PreviewErr)) } } 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//campaign.md" or // "campaigns//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 } func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails []string) (syncOneResult, error) { fm, body, err := ParseFile(path) if err != nil { 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 } if fm.SegmentQuery != "" { segID, err := resolveSegment(lm, slug, fm.SegmentQuery) if err != nil { return syncOneResult{}, err } listIDs = append(listIDs, segID) } mediaIDs, err := resolveAttachments(lm, slug, dir, fm.Attachments) if err != nil { return syncOneResult{}, err } input := listmonk.CampaignInput{ Name: slug, // the directory name IS the identity — see internal/campaign/parse.go doc Subject: fm.Subject, FromEmail: fm.FromEmail, TemplateID: fm.TemplateID, Type: fm.Type, Tags: fm.Tags, ListIDs: listIDs, Body: body, MediaIDs: mediaIDs, } var campaignID int var changed bool switch { case existing == nil: created, err := lm.CreateCampaign(input) if err != nil { return syncOneResult{}, err } campaignID, changed = created.ID, true case campaignUnchanged(existing, input): campaignID, changed = existing.ID, false default: if _, err := lm.UpdateCampaign(existing.ID, input); err != nil { return syncOneResult{}, err } campaignID, changed = existing.ID, true } if !changed { return syncOneResult{Changed: false}, nil } recipients := fm.PreviewEmails if len(recipients) == 0 { recipients = defaultPreviewEmails } var previewErr error if len(recipients) > 0 { previewErr = lm.TestCampaign(campaignID, input, recipients) } return syncOneResult{Changed: true, PreviewErr: previewErr}, nil } func resolveLists(lm *listmonk.Client, names []string) ([]int, error) { ids := make([]int, 0, len(names)) for _, name := range names { id, err := lm.FindListByName(name) if err != nil { return nil, fmt.Errorf("list %q: %w", name, err) } ids = append(ids, id) } return ids, nil } // resolveSegment materializes a segment_query into list membership, since a // listmonk campaign can only target whole list(s), not an arbitrary query // directly (still an open, unmerged upstream feature request — see the // plan's Open items). listmonk applies the query server-side in one call // (QueryAddToList) — no subscriber IDs are ever fetched client-side. The // list is a point-in-time snapshot: re-running sync re-evaluates the query // and re-syncs membership, so a later push before send picks up // newly-matching subscribers. func resolveSegment(lm *listmonk.Client, slug, query string) (int, error) { listName := "segment:" + slug listID, err := lm.FindOrCreateListByName(listName) if err != nil { return 0, fmt.Errorf("segment list %q: %w", listName, err) } if err := lm.QueryAddToList(query, listID); err != nil { return 0, fmt.Errorf("segment_query: %w", err) } return listID, nil } // resolveAttachments uploads each attachment under a content-hash-synthesized // filename, so editing a file naturally produces a fresh upload while an // unchanged file is recognized and reused — no local manifest of what's // already been uploaded needed, listmonk's own media library is the only // state store. func resolveAttachments(lm *listmonk.Client, slug, dir string, relPaths []string) ([]int, error) { ids := make([]int, 0, len(relPaths)) for _, rel := range relPaths { full := filepath.Join(dir, rel) content, err := os.ReadFile(full) if err != nil { return nil, fmt.Errorf("attachment %s: %w", rel, err) } name := attachmentFilename(slug, rel, content) id, found, err := lm.FindMediaByFilename(name) if err != nil { return nil, fmt.Errorf("attachment %s: %w", rel, err) } if !found { id, err = lm.UploadMedia(name, bytes.NewReader(content)) if err != nil { return nil, fmt.Errorf("attachment %s: %w", rel, err) } } ids = append(ids, id) } return ids, nil } func attachmentFilename(slug, relPath string, content []byte) string { sum := sha256.Sum256(content) prefix := hex.EncodeToString(sum[:])[:8] return fmt.Sprintf("%s-%s-%s", slug, prefix, filepath.Base(relPath)) } // campaignUnchanged reports whether input's content already matches what's // stored in listmonk, so an unchanged campaign costs zero API writes and // never re-fires a preview. List/tag/media comparisons are order-independent // since neither side's ordering is meaningful. 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 && // 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) && equalIntSets(existing.MediaIDs, input.MediaIDs) } func equalStringSets(a, b []string) bool { if len(a) != len(b) { return false } as, bs := append([]string(nil), a...), append([]string(nil), b...) sort.Strings(as) sort.Strings(bs) for i := range as { if as[i] != bs[i] { return false } } return true } func equalIntSets(a, b []int) bool { if len(a) != len(b) { return false } as, bs := append([]int(nil), a...), append([]int(nil), b...) sort.Ints(as) sort.Ints(bs) for i := range as { if as[i] != bs[i] { return false } } return true }