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,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
|
||||
}
|
||||
Reference in New Issue
Block a user