Add template-as-code sync (campaigns template NAME PATH)
Answers "can the unsubscribe template be added via git+API too": commit listmonk's own stock campaign template (already unsubscribe-capable) as email-templates/campaign.html, add a small find-or-create-or-update Template client (confirmed against knadh/listmonk's actual model/handlers), a new `template` subcommand, and a manual-dispatch-only workflow to push it in as the default campaign template. Unlike campaign sync there's no draft/live status to protect, so this is always a safe overwrite.
This commit is contained in:
@@ -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 }}
|
||||||
@@ -10,6 +10,8 @@ campaigns/
|
|||||||
campaign.md
|
campaign.md
|
||||||
assets/
|
assets/
|
||||||
one-pager.pdf
|
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.
|
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.
|
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
|
## 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.
|
- **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.
|
- **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
|
## Deploying
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!--
|
||||||
|
Listmonk's own stock campaign template (static/email-templates/default.tpl
|
||||||
|
in knadh/listmonk), committed here verbatim as a known-good, unsubscribe-
|
||||||
|
capable starting point rather than something invented from scratch. Synced
|
||||||
|
into listmonk via `campaigns template <name> email-templates/campaign.html`
|
||||||
|
(see internal/campaign/template.go) as the is_default campaign template, so
|
||||||
|
every campaign this tool creates gets a real unsubscribe link/footer without
|
||||||
|
per-campaign template_id wiring. Restyle freely — the two load-bearing tags
|
||||||
|
are `{{ template "content" . }}` (where the campaign body gets injected) and
|
||||||
|
`{{ UnsubscribeURL }}` (the actual unsubscribe link); everything else here is
|
||||||
|
just Listmonk's default look.
|
||||||
|
-->
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{ .Campaign.Subject }}</title>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
||||||
|
<base target="_blank">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #F0F1F3;
|
||||||
|
font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 26px;
|
||||||
|
margin: 0;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f4f4f4f4;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
table td {
|
||||||
|
border-color: #ddd;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 30px;
|
||||||
|
max-width: 525px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
background: #0055d4;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-decoration: none !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 10px 30px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.footer a {
|
||||||
|
color: #888;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gutter {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0055d4;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.wrap {
|
||||||
|
max-width: auto;
|
||||||
|
}
|
||||||
|
.gutter {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background-color: #F0F1F3;font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, sans-serif;font-size: 15px;line-height: 26px;margin: 0;color: #444;">
|
||||||
|
<div class="gutter" style="padding: 30px;"> </div>
|
||||||
|
<div class="wrap" style="background-color: #fff;padding: 30px;max-width: 525px;margin: 0 auto;border-radius: 5px;">
|
||||||
|
{{ template "content" . }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer" style="text-align: center;font-size: 12px;color: #888;">
|
||||||
|
<p>
|
||||||
|
<a href="{{ UnsubscribeURL }}" style="color: #888;">{{ L.T "email.unsub" }}</a>
|
||||||
|
|
||||||
|
<a href="{{ MessageURL }}" style="color: #888;">{{ L.T "email.viewInBrowser" }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="gutter" style="padding: 30px;"> {{ TrackView }}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -44,6 +44,13 @@ type fakeMedia struct {
|
|||||||
Filename string
|
Filename string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeTemplate struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Body string
|
||||||
|
IsDefault bool
|
||||||
|
}
|
||||||
|
|
||||||
type testCall struct {
|
type testCall struct {
|
||||||
CampaignID int
|
CampaignID int
|
||||||
Emails []string
|
Emails []string
|
||||||
@@ -61,6 +68,7 @@ type fakeListmonk struct {
|
|||||||
lists []fakeList
|
lists []fakeList
|
||||||
campaigns []fakeCampaign
|
campaigns []fakeCampaign
|
||||||
media []fakeMedia
|
media []fakeMedia
|
||||||
|
templates []fakeTemplate
|
||||||
testCalls []testCall
|
testCalls []testCall
|
||||||
segmentQueryCalls []segmentQueryCall
|
segmentQueryCalls []segmentQueryCall
|
||||||
failSegmentQuery bool
|
failSegmentQuery bool
|
||||||
@@ -107,6 +115,12 @@ func (f *fakeListmonk) handle(w http.ResponseWriter, r *http.Request) {
|
|||||||
f.uploadMedia(w, r)
|
f.uploadMedia(w, r)
|
||||||
case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/query/lists":
|
case r.Method == http.MethodPut && r.URL.Path == "/api/subscribers/query/lists":
|
||||||
f.queryAddToList(w, r)
|
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:
|
default:
|
||||||
f.t.Errorf("fakeListmonk: unhandled request %s %s", r.Method, r.URL.Path)
|
f.t.Errorf("fakeListmonk: unhandled request %s %s", r.Method, r.URL.Path)
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
@@ -244,6 +258,37 @@ func (f *fakeListmonk) queryAddToList(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte(`{}`))
|
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 {
|
func decodeBody(r *http.Request) map[string]any {
|
||||||
var body map[string]any
|
var body map[string]any
|
||||||
b, _ := io.ReadAll(r.Body)
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("<html>v1</html>"), 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 != "<html>v1</html>" {
|
||||||
|
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: "<html>v1</html>", IsDefault: true}}
|
||||||
|
lm := f.client()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "campaign.html")
|
||||||
|
if err := os.WriteFile(path, []byte("<html>v2</html>"), 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 != "<html>v2</html>" {
|
||||||
|
t.Fatalf("expected the existing template's body updated in place, got %+v", f.templates)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -499,3 +499,103 @@ func (c *Client) TestCampaign(id int, emails []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -333,3 +333,80 @@ func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {
|
|||||||
t.Errorf("expected error to surface response body, got %v", err)
|
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", "<html></html>", 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", "<html>v2</html>", 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"] != "<html>v2</html>" {
|
||||||
|
t.Errorf("expected updated body to be sent, got %+v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,13 +45,19 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
runTest(lm, os.Args[2], os.Args[3:])
|
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:
|
default:
|
||||||
usage()
|
usage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprintln(os.Stderr, "usage: campaigns <sync PATH|send SLUG|test SLUG EMAIL...>")
|
fmt.Fprintln(os.Stderr, "usage: campaigns <sync PATH|send SLUG|test SLUG EMAIL...|template NAME PATH>")
|
||||||
os.Exit(1)
|
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)
|
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 {
|
func mustEnv(key string) string {
|
||||||
v := os.Getenv(key)
|
v := os.Getenv(key)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user