Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c8bc6bd3b | |||
| 6227d59eae | |||
| e238bde036 | |||
| 43c0604ac6 |
@@ -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.
|
||||||
|
|||||||
@@ -217,8 +217,18 @@ 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 &&
|
// input.TemplateID == 0 means "unspecified, use whatever's assigned"
|
||||||
|
// (see CampaignInput.TemplateID) -- listmonk always assigns some real,
|
||||||
|
// nonzero template_id server-side even when it's omitted from the
|
||||||
|
// create/update payload, so comparing 0 against that would flag every
|
||||||
|
// campaign.md without an explicit template_id as changed on every
|
||||||
|
// sync, forever.
|
||||||
|
(input.TemplateID == 0 || existing.TemplateID == input.TemplateID) &&
|
||||||
existing.Type == input.Type &&
|
existing.Type == input.Type &&
|
||||||
equalStringSets(existing.Tags, input.Tags) &&
|
equalStringSets(existing.Tags, input.Tags) &&
|
||||||
equalIntSets(existing.ListIDs, input.ListIDs) &&
|
equalIntSets(existing.ListIDs, input.ListIDs) &&
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,12 +152,25 @@ func (f *fakeListmonk) writeCampaigns(w http.ResponseWriter) {
|
|||||||
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
|
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"results": results}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fakeDefaultTemplateID is the nonzero template_id real listmonk assigns
|
||||||
|
// server-side to a campaign whose create/update request omitted template_id
|
||||||
|
// entirely -- never 0, which is what makes the zero value ambiguous between
|
||||||
|
// "unspecified" and "really is 0" in campaignUnchanged.
|
||||||
|
const fakeDefaultTemplateID = 1
|
||||||
|
|
||||||
|
func (f *fakeListmonk) resolveTemplateID(body map[string]any) int {
|
||||||
|
if v, ok := body["template_id"]; ok {
|
||||||
|
return toInt(v)
|
||||||
|
}
|
||||||
|
return fakeDefaultTemplateID
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeListmonk) createCampaign(w http.ResponseWriter, r *http.Request) {
|
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: f.resolveTemplateID(body),
|
||||||
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,8 +185,9 @@ 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 = f.resolveTemplateID(body)
|
||||||
f.campaigns[i].Type = str(body["type"])
|
f.campaigns[i].Type = str(body["type"])
|
||||||
f.campaigns[i].Tags = toStrings(body["tags"])
|
f.campaigns[i].Tags = toStrings(body["tags"])
|
||||||
f.campaigns[i].ListIDs = toInts(body["lists"])
|
f.campaigns[i].ListIDs = toInts(body["lists"])
|
||||||
@@ -407,6 +422,39 @@ func TestSyncDir_SecondSyncWithUnchangedContentIsNoop(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncDir_UnspecifiedTemplateIDDoesNotTriggerSpuriousResync(t *testing.T) {
|
||||||
|
f := newFakeListmonk(t)
|
||||||
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
||||||
|
lm := f.client()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
// baseFrontmatter never sets template_id, matching real campaign.md files
|
||||||
|
// like reground-campaigns' welcome/campaign.md.
|
||||||
|
writeCampaignDir(t, root, "launch", baseFrontmatter, "Hello world.")
|
||||||
|
|
||||||
|
if _, err := SyncDir(lm, root, nil); err != nil {
|
||||||
|
t.Fatalf("first SyncDir: %v", err)
|
||||||
|
}
|
||||||
|
if f.campaigns[0].TemplateID != fakeDefaultTemplateID {
|
||||||
|
t.Fatalf("expected the fake to assign its default template_id like real listmonk does, got %d", f.campaigns[0].TemplateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-sync with the exact same, still-unspecified template_id: this must
|
||||||
|
// not be flagged as changed just because listmonk's stored, server-
|
||||||
|
// assigned template_id (nonzero) doesn't literally equal the frontmatter's
|
||||||
|
// unset value (0).
|
||||||
|
result, err := SyncDir(lm, root, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second SyncDir: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Synced) != 0 {
|
||||||
|
t.Errorf("expected no spurious re-sync from an unspecified template_id, got Synced=%v", result.Synced)
|
||||||
|
}
|
||||||
|
if len(result.Unchanged) != 1 || result.Unchanged[0] != "launch" {
|
||||||
|
t.Errorf("expected launch in Unchanged, got %v", result.Unchanged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
|
func TestSyncDir_UpdatesWhenContentChanges(t *testing.T) {
|
||||||
f := newFakeListmonk(t)
|
f := newFakeListmonk(t)
|
||||||
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
f.lists = []fakeList{{ID: 3, Name: "Newsletter"}}
|
||||||
|
|||||||
@@ -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,
|
||||||
"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
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user