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:
2026-07-10 07:30:45 -04:00
commit b16d2b6c5f
16 changed files with 2461 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
// Package campaign parses campaign content (a campaign.md file with YAML
// frontmatter plus a Markdown body) and syncs it into listmonk as a
// campaign, mirroring the content-as-code pattern eec's internal/course
// package uses for course steps — but flattened, since a campaign has no
// step sequencing.
package campaign
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Frontmatter is the parsed YAML block at the top of a campaign.md file.
type Frontmatter struct {
Subject string `yaml:"subject"`
// Lists names Listmonk lists by name, not numeric ID (unlike eec's
// course_steps.list_id) — resolved to IDs at sync time so a campaign
// file stays legible without cross-referencing the Listmonk admin UI.
Lists []string `yaml:"lists"`
FromEmail string `yaml:"from_email"`
Tags []string `yaml:"tags"`
// TemplateID is optional; 0 means "use listmonk's default template".
TemplateID int `yaml:"template_id"`
// Type defaults to "regular" when omitted; "optin" is rejected in v1
// (see Validate) since this tool has no opt-in-confirmation workflow.
Type string `yaml:"type"`
// SegmentQuery is a raw SQL boolean expression run against subscribers,
// the same segmentation mechanism listmonk's own admin UI search box
// uses — see internal/campaign/sync.go for how this gets materialized
// into list membership.
SegmentQuery string `yaml:"segment_query"`
// PreviewEmails overrides the sync-wide default preview address for
// this campaign's automatic post-sync preview.
PreviewEmails []string `yaml:"preview_emails"`
// Attachments are paths relative to the campaign's own directory
// (typically under assets/), uploaded to listmonk's media library and
// attached to the campaign.
Attachments []string `yaml:"attachments"`
}
// Campaign is one parsed campaign.md, identified by its directory name.
type Campaign struct {
Slug string // campaigns/<slug>/ — also sent to listmonk as the campaign's Name
Dir string // full path to campaigns/<slug>/, for resolving Attachments
Frontmatter
Body string
}
// ParseFile reads a campaign.md file with a leading
// "---\n...\n---\n" frontmatter block and returns the parsed frontmatter and
// body, or a descriptive error — same shape as eec's course.parseFrontmatter.
func ParseFile(path string) (Frontmatter, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Frontmatter{}, "", err
}
text := string(raw)
if !strings.HasPrefix(text, "---\n") {
return Frontmatter{}, "", fmt.Errorf("missing frontmatter (expected file to start with '---')")
}
rest := text[4:]
end := strings.Index(rest, "\n---\n")
if end == -1 {
return Frontmatter{}, "", fmt.Errorf("unterminated frontmatter (missing closing '---')")
}
rawFM := rest[:end]
body := strings.TrimPrefix(rest[end+len("\n---\n"):], "\n")
var fm Frontmatter
if err := yaml.Unmarshal([]byte(rawFM), &fm); err != nil {
return Frontmatter{}, "", fmt.Errorf("parsing frontmatter: %w", err)
}
if fm.Type == "" {
fm.Type = "regular"
}
if err := fm.Validate(); err != nil {
return Frontmatter{}, "", err
}
return fm, body, nil
}
// Validate checks the fields sync.go depends on before ever talking to
// listmonk, so a bad file is rejected with one clear message instead of a
// confusing API error partway through syncing.
func (fm Frontmatter) Validate() error {
if fm.Subject == "" {
return fmt.Errorf("frontmatter missing 'subject'")
}
if len(fm.Lists) == 0 && fm.SegmentQuery == "" {
return fmt.Errorf("frontmatter must set 'lists' and/or 'segment_query' — a campaign needs a target audience")
}
if fm.FromEmail == "" {
return fmt.Errorf("frontmatter missing 'from_email'")
}
if fm.Type != "regular" {
return fmt.Errorf("type %q is not supported yet (only 'regular' campaigns) — optin campaigns need their own confirmation workflow this tool doesn't have", fm.Type)
}
return nil
}