3 Commits

Author SHA1 Message Date
will 6227d59eae Track altbody in change-detection so existing campaigns actually re-sync
CI / test (push) Successful in 10s
Campaign/parseCampaign never fetched altbody, so campaignUnchanged()
compared everything except it -- a campaign synced before v0.1.3
(altbody always null) whose content otherwise hasn't changed would be
judged unchanged forever and never get updated with the new altbody,
even though every subsequent sync now intends to send one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:13:13 -04:00
will e238bde036 Send altbody so campaigns actually go out multipart/alternative
CI / test (push) Successful in 9s
listmonk never derives a plaintext part from HTML/Markdown on its own
(models/campaigns.go only compiles AltBodyTpl, and
internal/manager/message.go only emits text/plain, when altbody is
explicitly non-null) -- confirmed against the deployed v6.2.0 source.
Every campaign this tool created was HTML-only as a result, contrary
to the README's now-corrected claim.

Reusing the same raw Markdown+template source as altbody personalizes
it identically to body, since AltBodyTpl compiles whenever the text
contains {{ }} expressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:05:49 -04:00
will 43c0604ac6 Always send messenger=email in campaign payloads
CI / test (push) Successful in 8s
Discovered while verifying the TestCampaign fix against real listmonk:
create/update apparently default an omitted messenger to "email"
server-side, but the test-send endpoint validates the raw request body
without that same defaulting, failing with "Unknown messenger .".
eec-campaigns only ever targets email, so send it explicitly instead
of depending on an inconsistency between endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:54:30 -04:00
4 changed files with 33 additions and 5 deletions
+5 -2
View File
@@ -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.
+4
View File
@@ -217,6 +217,10 @@ func attachmentFilename(slug, relPath string, content []byte) string {
func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool { func campaignUnchanged(existing *listmonk.Campaign, input listmonk.CampaignInput) bool {
return existing.Subject == input.Subject && return existing.Subject == input.Subject &&
existing.Body == input.Body && existing.Body == input.Body &&
// altbody always mirrors body (see CampaignInput.payload) -- comparing
// it here is what catches a campaign synced before that started, whose
// stored altbody is still null even though its body hasn't changed.
existing.AltBody == input.Body &&
existing.FromEmail == input.FromEmail && existing.FromEmail == input.FromEmail &&
existing.TemplateID == input.TemplateID && existing.TemplateID == input.TemplateID &&
existing.Type == input.Type && existing.Type == input.Type &&
+4 -2
View File
@@ -31,6 +31,7 @@ type fakeCampaign struct {
Status string Status string
Subject string Subject string
Body string Body string
AltBody string
FromEmail string FromEmail string
TemplateID int TemplateID int
Type string Type string
@@ -138,7 +139,7 @@ func (c fakeCampaign) toJSON() map[string]any {
} }
return map[string]any{ return map[string]any{
"id": c.ID, "name": c.Name, "status": c.Status, "subject": c.Subject, "id": c.ID, "name": c.Name, "status": c.Status, "subject": c.Subject,
"body": c.Body, "from_email": c.FromEmail, "template_id": c.TemplateID, "body": c.Body, "altbody": c.AltBody, "from_email": c.FromEmail, "template_id": c.TemplateID,
"type": c.Type, "tags": c.Tags, "lists": lists, "media": media, "type": c.Type, "tags": c.Tags, "lists": lists, "media": media,
} }
} }
@@ -155,7 +156,7 @@ func (f *fakeListmonk) createCampaign(w http.ResponseWriter, r *http.Request) {
body := decodeBody(r) body := decodeBody(r)
c := fakeCampaign{ c := fakeCampaign{
ID: f.id(), Name: str(body["name"]), Status: "draft", ID: f.id(), Name: str(body["name"]), Status: "draft",
Subject: str(body["subject"]), Body: str(body["body"]), Subject: str(body["subject"]), Body: str(body["body"]), AltBody: str(body["altbody"]),
FromEmail: str(body["from_email"]), TemplateID: toInt(body["template_id"]), FromEmail: str(body["from_email"]), TemplateID: toInt(body["template_id"]),
Type: str(body["type"]), Tags: toStrings(body["tags"]), Type: str(body["type"]), Tags: toStrings(body["tags"]),
ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media"]), ListIDs: toInts(body["lists"]), MediaIDs: toInts(body["media"]),
@@ -171,6 +172,7 @@ func (f *fakeListmonk) updateCampaign(w http.ResponseWriter, r *http.Request) {
if f.campaigns[i].ID == id { if f.campaigns[i].ID == id {
f.campaigns[i].Subject = str(body["subject"]) f.campaigns[i].Subject = str(body["subject"])
f.campaigns[i].Body = str(body["body"]) f.campaigns[i].Body = str(body["body"])
f.campaigns[i].AltBody = str(body["altbody"])
f.campaigns[i].FromEmail = str(body["from_email"]) f.campaigns[i].FromEmail = str(body["from_email"])
f.campaigns[i].TemplateID = toInt(body["template_id"]) f.campaigns[i].TemplateID = toInt(body["template_id"])
f.campaigns[i].Type = str(body["type"]) f.campaigns[i].Type = str(body["type"])
+19
View File
@@ -289,6 +289,7 @@ type Campaign struct {
Status string Status string
Subject string Subject string
Body string Body string
AltBody string
FromEmail string FromEmail string
TemplateID int TemplateID int
Type string Type string
@@ -335,7 +336,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,
// 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, "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
@@ -365,6 +382,7 @@ func parseCampaign(data []byte) (*Campaign, error) {
Status string `json:"status"` Status string `json:"status"`
Subject string `json:"subject"` Subject string `json:"subject"`
Body string `json:"body"` Body string `json:"body"`
AltBody string `json:"altbody"`
FromEmail string `json:"from_email"` FromEmail string `json:"from_email"`
TemplateID int `json:"template_id"` TemplateID int `json:"template_id"`
Type string `json:"type"` Type string `json:"type"`
@@ -393,6 +411,7 @@ func parseCampaign(data []byte) (*Campaign, error) {
Status: parsed.Status, Status: parsed.Status,
Subject: parsed.Subject, Subject: parsed.Subject,
Body: parsed.Body, Body: parsed.Body,
AltBody: parsed.AltBody,
FromEmail: parsed.FromEmail, FromEmail: parsed.FromEmail,
TemplateID: parsed.TemplateID, TemplateID: parsed.TemplateID,
Type: parsed.Type, Type: parsed.Type,