Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e238bde036 | |||
| 43c0604ac6 | |||
| 39d02303c7 | |||
| 7eea2b1a8b | |||
| a4ec1eda78 |
@@ -54,8 +54,11 @@ attachments:
|
|||||||
---
|
---
|
||||||
|
|
||||||
Campaign body goes here, in Markdown. It's sent to listmonk with
|
Campaign body goes here, in Markdown. It's sent to listmonk with
|
||||||
content_type "markdown" — listmonk renders the HTML *and* derives the
|
content_type "markdown" for the HTML part; listmonk never derives a
|
||||||
plaintext alternative itself.
|
plaintext alternative on its own, so this same source is also sent
|
||||||
|
verbatim as `altbody` — recipients on plaintext-only clients see the
|
||||||
|
raw Markdown (and personalization still resolves, since the `{{ }}`
|
||||||
|
expressions are still there), rather than getting an HTML-only email.
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`subject`** — required.
|
- **`subject`** — required.
|
||||||
@@ -68,6 +71,18 @@ plaintext alternative itself.
|
|||||||
- **`type`** — defaults to `regular`. `optin` isn't supported and is rejected at sync time.
|
- **`type`** — defaults to `regular`. `optin` isn't supported and is rejected at sync time.
|
||||||
- **`scheduled_at` doesn't exist** — deliberately, since listmonk auto-sends a `scheduled` campaign the moment it fires, which would let a plain `git push` cause a real send.
|
- **`scheduled_at` doesn't exist** — deliberately, since listmonk auto-sends a `scheduled` campaign the moment it fires, which would let a plain `git push` cause a real send.
|
||||||
|
|
||||||
|
## Personalization
|
||||||
|
|
||||||
|
Listmonk renders each recipient's campaign body as a Go template before converting Markdown to HTML, so a subscriber's `attribs` can be referenced directly in `campaign.md`'s body. `eec`'s signup forms set `first_name` (and `last_name`, if given) as attribs on every subscriber who provides one — subscribers who signed up before that (or via the public form when it collected email only) won't have it set.
|
||||||
|
|
||||||
|
Verified working syntax, with a fallback for subscribers who don't have `first_name` set:
|
||||||
|
|
||||||
|
```
|
||||||
|
{{ if .Subscriber.Attribs.first_name }}Hi {{ .Subscriber.Attribs.first_name }},{{ else }}Hi there,{{ end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't trust listmonk's `/api/campaigns/:id/preview` endpoint (or its admin-UI "Preview" button) to test this** — it always renders against listmonk's built-in demo subscriber (`demo@listmonk.app`, attribs `{city: Bengaluru}`), ignoring which subscriber you're looking at, so `first_name` will always look unset there even when it's really set on a real subscriber. Use `campaigns test SLUG EMAIL...` (a real test-send to a real subscriber's own address) to verify personalization actually renders — that's how the syntax above was confirmed.
|
||||||
|
|
||||||
## Segmentation
|
## Segmentation
|
||||||
|
|
||||||
`segment_query` is a raw Postgres-style SQL boolean expression — the same mechanism listmonk's own admin UI search box accepts. Since a campaign can only target whole list(s), `sync` materializes the query into a managed list:
|
`segment_query` is a raw Postgres-style SQL boolean expression — the same mechanism listmonk's own admin UI search box accepts. Since a campaign can only target whole list(s), `sync` materializes the query into a managed list:
|
||||||
@@ -107,8 +122,8 @@ A starting template (listmonk's own stock `default.tpl`, with the required `{{ U
|
|||||||
|
|
||||||
Each domain/client gets its own content repo, isolated by ordinary Gitea repo permissions — a client with push access to their repo has no path to any other domain's listmonk credentials, because those credentials simply don't exist in their repo.
|
Each domain/client gets its own content repo, isolated by ordinary Gitea repo permissions — a client with push access to their repo has no path to any other domain's listmonk credentials, because those credentials simply don't exist in their repo.
|
||||||
|
|
||||||
1. Create a new Gitea repo (e.g. `<client>-campaigns`), private.
|
1. Generate a new repo from [`campaigns-template`](https://gitea.reground.org/will/campaigns-template) (Gitea's "Generate Repo," not a clone/fork — it starts the new repo with its own clean history), private.
|
||||||
2. Add: an empty `campaigns/` directory, `email-templates/campaign.html` (copy the stock template from an existing content repo, or listmonk's own `static/email-templates/default.tpl`), and `.gitea/workflows/{sync,send,test,sync-template}.yml` copied from an existing content repo — each does `go install gitea.reground.org/will/eec-campaigns@<pinned-tag>` then runs the corresponding subcommand, with that repo's own secrets.
|
2. Follow that generated repo's README checklist: rename the default-template name, restyle `email-templates/campaign.html` if needed, add secrets, confirm the tool version pin, run `Sync default campaign template` once, replace the README with a short domain-specific one.
|
||||||
3. Add repo secrets: `CAMPAIGNS_LISTMONK_BASE_URL`, `CAMPAIGNS_LISTMONK_API_USER`, `CAMPAIGNS_LISTMONK_API_TOKEN`, and optionally `CAMPAIGNS_PREVIEW_EMAIL` — scoped to that domain's own listmonk instance and API user.
|
3. Add repo secrets: `CAMPAIGNS_LISTMONK_BASE_URL`, `CAMPAIGNS_LISTMONK_API_USER`, `CAMPAIGNS_LISTMONK_API_TOKEN`, and optionally `CAMPAIGNS_PREVIEW_EMAIL` — scoped to that domain's own listmonk instance and API user.
|
||||||
4. Manually run the `Sync default campaign template` workflow once before the first real send.
|
4. Manually run the `Sync default campaign template` workflow once before the first real send.
|
||||||
|
|
||||||
|
|||||||
@@ -39,5 +39,5 @@ func Test(lm *listmonk.Client, slug string, emails []string) error {
|
|||||||
if camp == nil {
|
if camp == nil {
|
||||||
return fmt.Errorf("no campaign named %q in listmonk — run sync first", slug)
|
return fmt.Errorf("no campaign named %q in listmonk — run sync first", slug)
|
||||||
}
|
}
|
||||||
return lm.TestCampaign(camp.ID, emails)
|
return lm.TestCampaign(camp.ID, camp.AsInput(), emails)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func syncOne(lm *listmonk.Client, slug, dir, path string, defaultPreviewEmails [
|
|||||||
}
|
}
|
||||||
var previewErr error
|
var previewErr error
|
||||||
if len(recipients) > 0 {
|
if len(recipients) > 0 {
|
||||||
previewErr = lm.TestCampaign(campaignID, recipients)
|
previewErr = lm.TestCampaign(campaignID, input, recipients)
|
||||||
}
|
}
|
||||||
return syncOneResult{Changed: true, PreviewErr: previewErr}, nil
|
return syncOneResult{Changed: true, PreviewErr: previewErr}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,23 @@ type Campaign struct {
|
|||||||
MediaIDs []int
|
MediaIDs []int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AsInput converts a fetched Campaign back into the CampaignInput shape,
|
||||||
|
// for callers (e.g. Test) that need to re-send a campaign's current fields
|
||||||
|
// against an endpoint that validates the full campaign body.
|
||||||
|
func (camp *Campaign) AsInput() CampaignInput {
|
||||||
|
return CampaignInput{
|
||||||
|
Name: camp.Name,
|
||||||
|
Subject: camp.Subject,
|
||||||
|
FromEmail: camp.FromEmail,
|
||||||
|
TemplateID: camp.TemplateID,
|
||||||
|
Type: camp.Type,
|
||||||
|
Tags: camp.Tags,
|
||||||
|
ListIDs: camp.ListIDs,
|
||||||
|
Body: camp.Body,
|
||||||
|
MediaIDs: camp.MediaIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CampaignInput is what sync.go builds from a campaign's frontmatter+body to
|
// CampaignInput is what sync.go builds from a campaign's frontmatter+body to
|
||||||
// create or update a listmonk campaign.
|
// create or update a listmonk campaign.
|
||||||
type CampaignInput struct {
|
type CampaignInput struct {
|
||||||
@@ -318,7 +335,23 @@ func (in CampaignInput) payload() map[string]any {
|
|||||||
"lists": in.ListIDs,
|
"lists": in.ListIDs,
|
||||||
"content_type": "markdown",
|
"content_type": "markdown",
|
||||||
"body": in.Body,
|
"body": in.Body,
|
||||||
"type": in.Type,
|
// listmonk never derives a plaintext alternative from body/HTML on its
|
||||||
|
// own (models/campaigns.go only compiles AltBodyTpl, and
|
||||||
|
// internal/manager/message.go only emits a text/plain part, when
|
||||||
|
// altbody is explicitly non-null) — confirmed against the deployed
|
||||||
|
// v6.2.0 source. Reusing the same raw Markdown+template source as the
|
||||||
|
// altbody gets it personalized identically to body (AltBodyTpl compiles
|
||||||
|
// whenever the text contains {{ }} expressions), at the cost of
|
||||||
|
// Markdown syntax like *emphasis* showing up literally in plaintext
|
||||||
|
// clients — an acceptable, well-established tradeoff for Markdown.
|
||||||
|
"altbody": in.Body,
|
||||||
|
"type": in.Type,
|
||||||
|
// listmonk's create/update handlers default an omitted messenger to
|
||||||
|
// "email" before validating, but the test-send handler validates the
|
||||||
|
// raw request body as-is — an omitted messenger there fails with
|
||||||
|
// "Unknown messenger .". eec-campaigns only ever sends email, so set
|
||||||
|
// it explicitly everywhere rather than relying on that asymmetry.
|
||||||
|
"messenger": "email",
|
||||||
}
|
}
|
||||||
if in.FromEmail != "" {
|
if in.FromEmail != "" {
|
||||||
p["from_email"] = in.FromEmail
|
p["from_email"] = in.FromEmail
|
||||||
@@ -482,14 +515,18 @@ func (c *Client) SetCampaignStatus(id int, status string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestCampaign sends a preview of the campaign's current content to the
|
// TestCampaign sends a preview of the campaign's current content to the
|
||||||
// given addresses without touching its status. Request field confirmed
|
// given addresses without touching its status. listmonk's test-send handler
|
||||||
// against knadh/listmonk's actual handler source (cmd/campaigns.go's
|
// binds the request into the same campReq struct create/update use and
|
||||||
// campReq.SubscriberEmails, json tag "subscribers").
|
// validates it in full, so the campaign's other fields (in particular a
|
||||||
func (c *Client) TestCampaign(id int, emails []string) error {
|
// non-empty "name") must be sent alongside "subscribers" — a request with
|
||||||
|
// just {"subscribers": [...]} fails listmonk's own validation with "Invalid
|
||||||
|
// length for name" before the "subscribers" field is ever looked at.
|
||||||
|
func (c *Client) TestCampaign(id int, in CampaignInput, emails []string) error {
|
||||||
if len(emails) == 0 {
|
if len(emails) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
payload := map[string]any{"subscribers": emails}
|
payload := in.payload()
|
||||||
|
payload["subscribers"] = emails
|
||||||
respBody, status, err := c.do(http.MethodPost, fmt.Sprintf("/api/campaigns/%d/test", id), payload)
|
respBody, status, err := c.do(http.MethodPost, fmt.Sprintf("/api/campaigns/%d/test", id), payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("sending test for campaign %d: %w", id, err)
|
return fmt.Errorf("sending test for campaign %d: %w", id, err)
|
||||||
|
|||||||
@@ -311,13 +311,17 @@ func TestTestCampaign_SendsSubscribersList(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
c := New(srv.URL, "u", "t")
|
c := New(srv.URL, "u", "t")
|
||||||
if err := c.TestCampaign(7, []string{"me@example.com"}); err != nil {
|
in := CampaignInput{Name: "launch", Subject: "Hi", Body: "body", Type: "regular"}
|
||||||
|
if err := c.TestCampaign(7, in, []string{"me@example.com"}); err != nil {
|
||||||
t.Fatalf("TestCampaign: %v", err)
|
t.Fatalf("TestCampaign: %v", err)
|
||||||
}
|
}
|
||||||
subs, ok := gotBody["subscribers"].([]any)
|
subs, ok := gotBody["subscribers"].([]any)
|
||||||
if !ok || len(subs) != 1 || subs[0] != "me@example.com" {
|
if !ok || len(subs) != 1 || subs[0] != "me@example.com" {
|
||||||
t.Errorf("expected subscribers=[me@example.com], got %+v", gotBody)
|
t.Errorf("expected subscribers=[me@example.com], got %+v", gotBody)
|
||||||
}
|
}
|
||||||
|
if gotBody["name"] != "launch" {
|
||||||
|
t.Errorf("expected the full campaign body (name=launch) alongside subscribers, got %+v", gotBody)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {
|
func TestErrorResponsesAreWrappedWithStatusAndBody(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user