Files
T
will 6227d59eae
CI / test (push) Successful in 10s
Track altbody in change-detection so existing campaigns actually re-sync
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

261 lines
8.0 KiB
Go

package campaign
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
"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
Rejected []string // validation/segmentation/list-lookup failure, or non-draft in listmonk — nothing written
PreviewFailed []string // content synced fine, but the automatic preview send itself failed
}
// 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.
func SyncDir(lm *listmonk.Client, root string, defaultPreviewEmails []string) (*SyncResult, error) {
pattern := filepath.Join(root, "campaigns", "*", "campaign.md")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}
result := &SyncResult{}
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.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
}
type syncOneResult struct {
Changed bool
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
}
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,
}
existing, err := lm.FindCampaignByName(slug)
if err != nil {
return syncOneResult{}, err
}
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 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
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 &&
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
}