Initial implementation of the eec-campaigns tool
Drives listmonk's real Campaign API from git-authored Markdown+frontmatter, so broadcast/segment emails get listmonk's mature unsubscribe/bulk-send/ attachment handling instead of reimplementing it. sync only ever creates or updates a draft (idempotent, diff-based, refuses to touch a non-draft campaign); a pushed send/<slug> tag or manual workflow run is the only way to actually trigger a send. Includes list-name resolution, segment_query materialization into managed lists, content-hash-deduped attachment uploads, and an automatic post-sync preview email.
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
package campaign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"reground.org/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, 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). 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) {
|
||||
ids, err := lm.QuerySubscriberIDs(query)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("segment_query: %w", err)
|
||||
}
|
||||
listName := "segment:" + slug
|
||||
listID, err := lm.FindOrCreateListByName(listName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("segment list %q: %w", listName, err)
|
||||
}
|
||||
if err := lm.BulkAddToList(ids, listID); err != nil {
|
||||
return 0, fmt.Errorf("segment list %q: %w", listName, 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 &&
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user