// 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// — also sent to listmonk as the campaign's Name Dir string // full path to campaigns//, 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 }