diff --git a/.gitea/workflows/sync-template.yml b/.gitea/workflows/sync-template.yml new file mode 100644 index 0000000..2b5f6bf --- /dev/null +++ b/.gitea/workflows/sync-template.yml @@ -0,0 +1,20 @@ +name: Sync default campaign template +on: + workflow_dispatch: {} + +jobs: + sync-template: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23.1" + + - name: Sync template to listmonk + run: go run . template "eec-campaigns default" email-templates/campaign.html + env: + LISTMONK_BASE_URL: ${{ secrets.CAMPAIGNS_LISTMONK_BASE_URL }} + LISTMONK_API_USER: ${{ secrets.CAMPAIGNS_LISTMONK_API_USER }} + LISTMONK_API_TOKEN: ${{ secrets.CAMPAIGNS_LISTMONK_API_TOKEN }} diff --git a/README.md b/README.md index e60acde..510af5b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ campaigns/ campaign.md assets/ one-pager.pdf +email-templates/ + campaign.html ``` Each campaign is a directory under `campaigns/`, named for its slug. **The directory name is the campaign's identity** — it's sent to listmonk as the campaign's `name` and is what `sync`/`send`/`test` all look it up by. There's no separate `name:`/`slug:` field in frontmatter to keep in sync with the directory; renaming the directory creates a new campaign in listmonk rather than renaming the existing one. @@ -88,18 +90,22 @@ A rejected campaign (check the CI run for `rejected:`) is usually one of these, One bad campaign in the push doesn't block the others — check `rejected:` in the sync job's log for which ones and why. -## Before your first real send +## Email template -listmonk's only currently-provisioned email template (`eec-passthrough`) is a bare passthrough used for `eec`'s transactional course emails — no unsubscribe footer, no branding. Create (or confirm) a real campaign template with a proper unsubscribe link in the listmonk admin UI before running your first `send`; drafts and previews render fine without one, but a real send without it isn't compliant. +`campaigns template NAME PATH` pushes an HTML file's content into listmonk as a named template — create if missing, overwrite in place if it already exists, always set as the default campaign template (`is_default: true`) so every campaign created here uses it with no per-campaign `template_id` wiring. Unlike a campaign, a template has no draft/live status to protect, so this is always a safe create-or-update. + +`email-templates/campaign.html` in this repo is listmonk's own stock campaign template (`static/email-templates/default.tpl` upstream), committed verbatim as a known-good starting point rather than something invented from scratch — it already has a real `{{ UnsubscribeURL }}` footer link and the required `{{ template "content" . }}` injection point. Restyle it freely; those two tags are the only load-bearing parts. + +Run once via the `Sync default campaign template` workflow (`workflow_dispatch`, manual — changing the default template affects every future send, so it isn't wired to auto-run on push) before your first real `send`. `eec`'s own provisioned template (`eec-passthrough`) is a separate, bare passthrough used only for its transactional course emails — unrelated to this one. ## Open items -Every request shape in `internal/listmonk` is now confirmed against `knadh/listmonk`'s actual Go source (not just its docs, which are incomplete on a few of these): the campaign create/update payload, its `media` field for attachments (listmonk's request/response asymmetry — requests send plain IDs under `media`, responses echo full objects back under the same key), create always defaulting to `draft` regardless of any caller-supplied status, the test-send endpoint's `subscribers` field, and the query-based bulk list action (`PUT /api/subscribers/query/lists`) segmentation uses. What's left is genuinely operational, not code: +Every request shape in `internal/listmonk` is now confirmed against `knadh/listmonk`'s actual Go source (not just its docs, which are incomplete on a few of these): the campaign create/update payload, its `media` field for attachments (listmonk's request/response asymmetry — requests send plain IDs under `media`, responses echo full objects back under the same key), create always defaulting to `draft` regardless of any caller-supplied status, the test-send endpoint's `subscribers` field, the query-based bulk list action (`PUT /api/subscribers/query/lists`) segmentation uses, and the template API (`GET`/`POST /api/templates`, `PUT /api/templates/:id`, fields `name`/`type`/`body`/`is_default`). What's left is genuinely operational, not code: -- **A real, unsubscribe-capable campaign template** needs to exist in the live listmonk instance before a real send (see above) — a content/admin-UI task, not something this tool verifies for you. +- **Running the `Sync default campaign template` workflow at least once** — the template file is committed, but nothing pushes it into listmonk until that workflow (or `campaigns template ...` locally) actually runs. - **Provisioning the dedicated listmonk API user and pasting its token into this repo's Gitea secrets** (see Deploying below) hasn't happened yet. - **Gitea Actions' tag-push and `workflow_dispatch` triggers** are standard, long-supported Actions syntax and the runner already successfully uses `uses: actions/checkout@v4` elsewhere in this org, so this should work as written — but it's still worth confirming the first time `send.yml` actually fires. -- The source was read against `knadh/listmonk`'s `master` branch; if the live instance runs a substantially older or newer version, a quick diff against its own `cmd/campaigns.go`/`cmd/subscribers.go` is cheap insurance before the first real send. +- The source was read against `knadh/listmonk`'s `master` branch; if the live instance runs a substantially older or newer version, a quick diff against its own `cmd/campaigns.go`/`cmd/subscribers.go`/`cmd/templates.go` is cheap insurance before the first real send. ## Deploying diff --git a/email-templates/campaign.html b/email-templates/campaign.html new file mode 100644 index 0000000..c83add3 --- /dev/null +++ b/email-templates/campaign.html @@ -0,0 +1,115 @@ + + + + + {{ .Campaign.Subject }} + + + + + + +
 
+
+ {{ template "content" . }} +
+ + +
 {{ TrackView }}
+ + diff --git a/internal/campaign/sync_test.go b/internal/campaign/sync_test.go index 3659c13..62eca0d 100644 --- a/internal/campaign/sync_test.go +++ b/internal/campaign/sync_test.go @@ -44,6 +44,13 @@ type fakeMedia struct { Filename string } +type fakeTemplate struct { + ID int + Name string + Body string + IsDefault bool +} + type testCall struct { CampaignID int Emails []string @@ -61,6 +68,7 @@ type fakeListmonk struct { lists []fakeList campaigns []fakeCampaign media []fakeMedia + templates []fakeTemplate testCalls []testCall segmentQueryCalls []segmentQueryCall failSegmentQuery bool @@ -107,6 +115,12 @@ func (f *fakeListmonk) handle(w http.ResponseWriter, r *http.Request) { f.uploadMedia(w, r) case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/query/lists": f.queryAddToList(w, r) + case r.Method == http.MethodGet && r.URL.Path == "/api/templates": + f.writeTemplates(w) + case r.Method == http.MethodPost && r.URL.Path == "/api/templates": + f.createTemplate(w, r) + case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/templates/"): + f.updateTemplate(w, r) default: f.t.Errorf("fakeListmonk: unhandled request %s %s", r.Method, r.URL.Path) w.WriteHeader(http.StatusNotFound) @@ -244,6 +258,37 @@ func (f *fakeListmonk) queryAddToList(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{}`)) } +func (f *fakeListmonk) writeTemplates(w http.ResponseWriter) { + results := make([]map[string]any, 0, len(f.templates)) + for _, t := range f.templates { + results = append(results, map[string]any{"id": t.ID, "name": t.Name, "type": "campaign", "is_default": t.IsDefault}) + } + json.NewEncoder(w).Encode(map[string]any{"data": results}) +} + +func (f *fakeListmonk) createTemplate(w http.ResponseWriter, r *http.Request) { + body := decodeBody(r) + isDefault, _ := body["is_default"].(bool) + tpl := fakeTemplate{ID: f.id(), Name: str(body["name"]), Body: str(body["body"]), IsDefault: isDefault} + f.templates = append(f.templates, tpl) + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"id": tpl.ID}}) +} + +func (f *fakeListmonk) updateTemplate(w http.ResponseWriter, r *http.Request) { + id := pathID(r.URL.Path, "/api/templates/") + body := decodeBody(r) + for i := range f.templates { + if f.templates[i].ID == id { + f.templates[i].Name = str(body["name"]) + f.templates[i].Body = str(body["body"]) + f.templates[i].IsDefault, _ = body["is_default"].(bool) + w.Write([]byte(`{}`)) + return + } + } + w.WriteHeader(http.StatusNotFound) +} + func decodeBody(r *http.Request) map[string]any { var body map[string]any b, _ := io.ReadAll(r.Body) diff --git a/internal/campaign/template.go b/internal/campaign/template.go new file mode 100644 index 0000000..403e479 --- /dev/null +++ b/internal/campaign/template.go @@ -0,0 +1,38 @@ +package campaign + +import ( + "fmt" + "os" + + "reground.org/eec-campaigns/internal/listmonk" +) + +// SyncTemplate pushes a template file's content into listmonk under name, +// creating it (as the default campaign template) if missing or overwriting +// its body if it already exists. Unlike campaign sync, a template has no +// send-lifecycle status to protect — there's no "draft vs. live" distinction +// to guard, so this is always a safe create-or-update, no rejection path. +func SyncTemplate(lm *listmonk.Client, name, path string) (*listmonk.Template, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + existing, err := lm.FindTemplateByName(name) + if err != nil { + return nil, err + } + if existing == nil { + created, err := lm.CreateTemplate(name, string(body), true) + if err != nil { + return nil, fmt.Errorf("creating template %q: %w", name, err) + } + return created, nil + } + if err := lm.UpdateTemplate(existing.ID, name, string(body), true); err != nil { + return nil, fmt.Errorf("updating template %q: %w", name, err) + } + existing.Body = string(body) + existing.IsDefault = true + return existing, nil +} diff --git a/internal/campaign/template_test.go b/internal/campaign/template_test.go new file mode 100644 index 0000000..bc42559 --- /dev/null +++ b/internal/campaign/template_test.go @@ -0,0 +1,55 @@ +package campaign + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSyncTemplate_CreatesWhenMissing(t *testing.T) { + f := newFakeListmonk(t) + lm := f.client() + + dir := t.TempDir() + path := filepath.Join(dir, "campaign.html") + if err := os.WriteFile(path, []byte("v1"), 0o644); err != nil { + t.Fatal(err) + } + + tpl, err := SyncTemplate(lm, "eec-campaigns default", path) + if err != nil { + t.Fatalf("SyncTemplate: %v", err) + } + if tpl.ID == 0 { + t.Errorf("expected a nonzero id, got %+v", tpl) + } + if len(f.templates) != 1 || f.templates[0].Body != "v1" { + t.Fatalf("expected 1 template with the file's content, got %+v", f.templates) + } + if !f.templates[0].IsDefault { + t.Error("expected the template to be created as the default") + } +} + +func TestSyncTemplate_UpdatesWhenAlreadyExists(t *testing.T) { + f := newFakeListmonk(t) + f.templates = []fakeTemplate{{ID: 5, Name: "eec-campaigns default", Body: "v1", IsDefault: true}} + lm := f.client() + + dir := t.TempDir() + path := filepath.Join(dir, "campaign.html") + if err := os.WriteFile(path, []byte("v2"), 0o644); err != nil { + t.Fatal(err) + } + + tpl, err := SyncTemplate(lm, "eec-campaigns default", path) + if err != nil { + t.Fatalf("SyncTemplate: %v", err) + } + if tpl.ID != 5 { + t.Errorf("expected the existing template's id 5 to be reused, got %d", tpl.ID) + } + if len(f.templates) != 1 || f.templates[0].Body != "v2" { + t.Fatalf("expected the existing template's body updated in place, got %+v", f.templates) + } +} diff --git a/internal/listmonk/listmonk.go b/internal/listmonk/listmonk.go index 0a73638..562be3a 100644 --- a/internal/listmonk/listmonk.go +++ b/internal/listmonk/listmonk.go @@ -499,3 +499,103 @@ func (c *Client) TestCampaign(id int, emails []string) error { } return nil } + +// ---- Templates ---- + +// Template is the subset of listmonk's template fields eec-campaigns cares +// about. Field names confirmed against knadh/listmonk's actual model +// (models.Template: Name/Type/Body/IsDefault, json tags "name"/"type"/ +// "body"/"is_default") and handler routes (GET/POST /api/templates, +// PUT /api/templates/:id). +type Template struct { + ID int + Name string + Type string + Body string + IsDefault bool +} + +func (c *Client) templatesAll() ([]Template, error) { + respBody, status, err := c.do(http.MethodGet, "/api/templates", nil) + if err != nil { + return nil, fmt.Errorf("listing templates: %w", err) + } + if status != http.StatusOK { + return nil, fmt.Errorf("listing templates failed (%d): %s", status, string(respBody)) + } + var parsed struct { + Data []struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Body string `json:"body"` + IsDefault bool `json:"is_default"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("parsing template list response: %w", err) + } + templates := make([]Template, 0, len(parsed.Data)) + for _, r := range parsed.Data { + templates = append(templates, Template{ID: r.ID, Name: r.Name, Type: r.Type, Body: r.Body, IsDefault: r.IsDefault}) + } + return templates, nil +} + +// FindTemplateByName returns (nil, nil) on a plain miss, matching +// FindCampaignByName's convention. +func (c *Client) FindTemplateByName(name string) (*Template, error) { + templates, err := c.templatesAll() + if err != nil { + return nil, err + } + var matches []Template + for _, t := range templates { + if t.Name == name { + matches = append(matches, t) + } + } + switch len(matches) { + case 0: + return nil, nil + case 1: + return &matches[0], nil + default: + return nil, fmt.Errorf("%d templates named %q — ambiguous, refusing to guess", len(matches), name) + } +} + +// CreateTemplate creates a "campaign"-type template. isDefault: true makes +// every new campaign use it without per-campaign template_id wiring. +func (c *Client) CreateTemplate(name, body string, isDefault bool) (*Template, error) { + payload := map[string]any{"name": name, "type": "campaign", "body": body, "is_default": isDefault} + respBody, status, err := c.do(http.MethodPost, "/api/templates", payload) + if err != nil { + return nil, fmt.Errorf("creating template %q: %w", name, err) + } + if status != http.StatusOK { + return nil, fmt.Errorf("creating template %q failed (%d): %s", name, status, string(respBody)) + } + var parsed struct { + Data struct { + ID int `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("parsing template creation response: %w", err) + } + return &Template{ID: parsed.Data.ID, Name: name, Type: "campaign", Body: body, IsDefault: isDefault}, nil +} + +// UpdateTemplate overwrites an existing template's content. +func (c *Client) UpdateTemplate(id int, name, body string, isDefault bool) error { + payload := map[string]any{"name": name, "type": "campaign", "body": body, "is_default": isDefault} + respBody, status, err := c.do(http.MethodPut, fmt.Sprintf("/api/templates/%d", id), payload) + if err != nil { + return fmt.Errorf("updating template %d: %w", id, err) + } + if status != http.StatusOK { + return fmt.Errorf("updating template %d failed (%d): %s", id, status, string(respBody)) + } + return nil +} diff --git a/internal/listmonk/listmonk_test.go b/internal/listmonk/listmonk_test.go index 5895a26..64183ab 100644 --- a/internal/listmonk/listmonk_test.go +++ b/internal/listmonk/listmonk_test.go @@ -333,3 +333,80 @@ func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) { t.Errorf("expected error to surface response body, got %v", err) } } + +func TestFindTemplateByName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":[{"id":4,"name":"eec-campaigns default","type":"campaign","is_default":true}]}`)) + })) + defer srv.Close() + + c := New(srv.URL, "u", "t") + tpl, err := c.FindTemplateByName("eec-campaigns default") + if err != nil { + t.Fatalf("FindTemplateByName: %v", err) + } + if tpl == nil || tpl.ID != 4 || !tpl.IsDefault { + t.Fatalf("unexpected template: %+v", tpl) + } +} + +func TestFindTemplateByName_NilOnMiss(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + c := New(srv.URL, "u", "t") + tpl, err := c.FindTemplateByName("missing") + if err != nil { + t.Fatalf("FindTemplateByName: %v", err) + } + if tpl != nil { + t.Errorf("expected nil for a miss, got %+v", tpl) + } +} + +func TestCreateTemplate_SendsCampaignTypeAndIsDefault(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &gotBody) + w.Write([]byte(`{"data":{"id":9}}`)) + })) + defer srv.Close() + + c := New(srv.URL, "u", "t") + tpl, err := c.CreateTemplate("eec-campaigns default", "", true) + if err != nil { + t.Fatalf("CreateTemplate: %v", err) + } + if tpl.ID != 9 { + t.Errorf("expected id 9, got %d", tpl.ID) + } + if gotBody["type"] != "campaign" || gotBody["is_default"] != true { + t.Errorf("expected type=campaign, is_default=true, got %+v", gotBody) + } +} + +func TestUpdateTemplate_SendsExpectedPayload(t *testing.T) { + var gotPath string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &gotBody) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(srv.URL, "u", "t") + if err := c.UpdateTemplate(9, "eec-campaigns default", "v2", true); err != nil { + t.Fatalf("UpdateTemplate: %v", err) + } + if gotPath != "/api/templates/9" { + t.Errorf("expected /api/templates/9, got %q", gotPath) + } + if gotBody["body"] != "v2" { + t.Errorf("expected updated body to be sent, got %+v", gotBody) + } +} diff --git a/main.go b/main.go index 62ce8ca..11694ce 100644 --- a/main.go +++ b/main.go @@ -45,13 +45,19 @@ func main() { os.Exit(1) } runTest(lm, os.Args[2], os.Args[3:]) + case "template": + if len(os.Args) < 4 { + fmt.Fprintln(os.Stderr, "usage: campaigns template NAME PATH") + os.Exit(1) + } + runTemplate(lm, os.Args[2], os.Args[3]) default: usage() } } func usage() { - fmt.Fprintln(os.Stderr, "usage: campaigns ") + fmt.Fprintln(os.Stderr, "usage: campaigns ") os.Exit(1) } @@ -101,6 +107,14 @@ func runTest(lm *listmonk.Client, slug string, emails []string) { log.Printf("test: sent preview of %q to %v", slug, emails) } +func runTemplate(lm *listmonk.Client, name, path string) { + tpl, err := campaign.SyncTemplate(lm, name, path) + if err != nil { + log.Fatalf("template: %v", err) + } + log.Printf("template: %q (id=%d) is now the default campaign template", tpl.Name, tpl.ID) +} + func mustEnv(key string) string { v := os.Getenv(key) if v == "" {