b16d2b6c5f
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.
103 lines
3.8 KiB
Go
103 lines
3.8 KiB
Go
// 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
|
|
}
|