6227d59eae
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>
261 lines
8.0 KiB
Go
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
|
|
}
|