Skip to content

Commit 0cb8e1a

Browse files
committed
fix gomjml button
1 parent ba63690 commit 0cb8e1a

File tree

6 files changed

+225
-6
lines changed

6 files changed

+225
-6
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [26.14] - 2026-01-30
6+
7+
- **Email Builder**: Fixed buttons with HTML content like `<strong>` rendering as default "Button" text instead of custom content (#242, [gomjml PR#33](https://github.com/preslavrachev/gomjml/pull/33))
8+
59
## [26.13] - 2026-01-25
610

711
- **Segments**: Added real-time validation when creating segments to detect duplicate IDs before submission (#243)

config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
"github.com/spf13/viper"
1515
)
1616

17-
const VERSION = "26.13"
17+
const VERSION = "26.14"
1818

1919
type Config struct {
2020
Server ServerConfig

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,6 @@ require (
114114
gopkg.in/yaml.v2 v2.4.0 // indirect
115115
gopkg.in/yaml.v3 v3.0.1 // indirect
116116
)
117+
118+
// Use Notifuse fork until PR is merged: https://github.com/preslavrachev/gomjml/pull/33
119+
replace github.com/preslavrachev/gomjml => github.com/Notifuse/gomjml v0.0.0-20260130090101-a038317c31c2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5
132132
github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
133133
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
134134
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
135+
github.com/Notifuse/gomjml v0.0.0-20260130090101-a038317c31c2 h1:/xxWOsP0eMyQjjdAL2W0c389n78zo5JdIEST9ZxcxJI=
136+
github.com/Notifuse/gomjml v0.0.0-20260130090101-a038317c31c2/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU=
135137
github.com/Notifuse/liquidgo v0.0.0-20251124135804-bb1578ffeff3 h1:5Cs2RJoA4VyWDvlI/wYr4ZGi4mmDzgJs0C1AlCEQiSE=
136138
github.com/Notifuse/liquidgo v0.0.0-20251124135804-bb1578ffeff3/go.mod h1:iK21HTEIAybTQbbDuLUeHPMhlOfeZTxKDskDbxIMt0o=
137139
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@@ -971,8 +973,6 @@ github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndr
971973
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
972974
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
973975
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
974-
github.com/preslavrachev/gomjml v0.10.0 h1:GdcLph92E3aADmBgR6DnDS7NsnK1DsHeYWmDhZGrJEA=
975-
github.com/preslavrachev/gomjml v0.10.0/go.mod h1:10tpMJhl+46mqf+5wG18fOXaWNB+OOllCpksDRJlJTU=
976976
github.com/prometheus/alertmanager v0.24.0/go.mod h1:r6fy/D7FRuZh5YbnX6J3MBY0eI4Pb5yPYS7/bPSXXqI=
977977
github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
978978
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=

pkg/notifuse_mjml/converter.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func convertBlockToMJMLWithErrorAndParsedData(block EmailBlock, indentLevel int,
5656
}
5757
}
5858

59-
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML)
59+
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML per MJML spec)
6060
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
6161
if blockType == MJMLComponentMjRaw || blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton {
6262
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, content, tagName), nil
@@ -143,7 +143,7 @@ func convertBlockToMJMLWithParsedData(block EmailBlock, indentLevel int, templat
143143
}
144144
}
145145

146-
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML)
146+
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML per MJML spec)
147147
attributeString := formatAttributesWithLiquid(block.GetAttributes(), parsedData, block.GetID())
148148
if blockType == MJMLComponentMjRaw || blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton {
149149
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, content, tagName)
@@ -202,7 +202,7 @@ func convertBlockToMJMLRaw(block EmailBlock, indentLevel int) string {
202202

203203
if content != "" {
204204
// Do NOT process Liquid - keep content raw
205-
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML)
205+
// Block with content - don't escape for mj-raw, mj-text, and mj-button (they can contain HTML per MJML spec)
206206
attributeString := formatAttributes(block.GetAttributes())
207207
if blockType == MJMLComponentMjRaw || blockType == MJMLComponentMjText || blockType == MJMLComponentMjButton {
208208
return fmt.Sprintf("%s<%s%s>%s</%s>", indent, tagName, attributeString, content, tagName)

pkg/notifuse_mjml/template_compilation_test.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,10 +1286,222 @@ func TestCompileTemplateWithPreserveLiquid(t *testing.T) {
12861286
})
12871287
}
12881288

1289+
// TestGomjmlButtonAttributeSupport tests which MJML button attributes are properly
1290+
// supported by the gomjml library (https://documentation.mjml.io/#mj-button)
1291+
func TestGomjmlButtonAttributeSupport(t *testing.T) {
1292+
// Create button with various MJML-spec attributes
1293+
buttonBase := NewBaseBlock("button-1", MJMLComponentMjButton)
1294+
buttonBase.Attributes["href"] = "https://example.com"
1295+
buttonBase.Attributes["font-weight"] = "bold"
1296+
buttonBase.Attributes["font-style"] = "italic"
1297+
buttonBase.Attributes["text-decoration"] = "underline"
1298+
buttonBase.Attributes["text-transform"] = "uppercase"
1299+
buttonBase.Attributes["color"] = "#ff0000"
1300+
buttonBase.Attributes["background-color"] = "#00ff00"
1301+
buttonBase.Content = stringPtr("Click Here")
1302+
buttonBlock := &MJButtonBlock{BaseBlock: buttonBase}
1303+
1304+
// Create complete MJML structure
1305+
column := &MJColumnBlock{BaseBlock: NewBaseBlock("column-1", MJMLComponentMjColumn)}
1306+
column.Children = []EmailBlock{buttonBlock}
1307+
1308+
section := &MJSectionBlock{BaseBlock: NewBaseBlock("section-1", MJMLComponentMjSection)}
1309+
section.Children = []EmailBlock{column}
1310+
1311+
body := &MJBodyBlock{BaseBlock: NewBaseBlock("body-1", MJMLComponentMjBody)}
1312+
body.Children = []EmailBlock{section}
1313+
1314+
mjml := &MJMLBlock{BaseBlock: NewBaseBlock("mjml-1", MJMLComponentMjml)}
1315+
mjml.Children = []EmailBlock{body}
1316+
1317+
req := CompileTemplateRequest{
1318+
WorkspaceID: "test-workspace",
1319+
MessageID: "test-message",
1320+
VisualEditorTree: mjml,
1321+
TrackingSettings: TrackingSettings{
1322+
EnableTracking: false,
1323+
},
1324+
}
1325+
1326+
resp, err := CompileTemplate(req)
1327+
if err != nil {
1328+
t.Fatalf("CompileTemplate failed: %v", err)
1329+
}
1330+
1331+
if !resp.Success {
1332+
t.Fatalf("Expected successful compilation, got error: %v", resp.Error)
1333+
}
1334+
1335+
// Log MJML for debugging
1336+
t.Logf("Generated MJML:\n%s", *resp.MJML)
1337+
1338+
// Check attribute support in generated HTML
1339+
attributeChecks := []struct {
1340+
name string
1341+
pattern string
1342+
required bool // If true, test fails when not found
1343+
}{
1344+
{"font-weight:bold", "font-weight:bold", false},
1345+
{"font-style:italic", "font-style:italic", false},
1346+
{"text-decoration", "text-decoration", false},
1347+
{"text-transform:uppercase", "text-transform:uppercase", false},
1348+
{"color #ff0000", "#ff0000", false},
1349+
{"background #00ff00", "#00ff00", false},
1350+
{"Button text 'Click Here'", "Click Here", true},
1351+
}
1352+
1353+
t.Log("\n=== gomjml Button Attribute Support ===")
1354+
for _, check := range attributeChecks {
1355+
found := strings.Contains(*resp.HTML, check.pattern)
1356+
status := "NOT FOUND"
1357+
if found {
1358+
status = "FOUND"
1359+
}
1360+
t.Logf("%s: %s", check.name, status)
1361+
1362+
if check.required && !found {
1363+
t.Errorf("Required pattern %q not found in HTML", check.pattern)
1364+
}
1365+
}
1366+
1367+
// Check that default "Button" text is NOT present (our fix should work)
1368+
if strings.Contains(*resp.HTML, ">Button<") {
1369+
t.Error("Found default 'Button' text - custom text was not rendered")
1370+
}
1371+
}
1372+
12891373
// TestCompileTemplateWithImageLiquidOnlySrcPartialData tests the bug scenario from issue #226
12901374
// where an mj-image has only Liquid syntax in src (e.g., "{{ postImage }}") and template data
12911375
// exists but doesn't include the referenced variable. This would cause the Liquid engine
12921376
// to render the variable as an empty string, resulting in src="" which breaks MJML compilation.
1377+
// TestCompileTemplateButtonWithHTMLContent tests the bug from GitHub issue #242
1378+
// where button content containing HTML tags like <strong> and <br> renders as "Button"
1379+
// instead of the custom text. The MJML spec requires button content to be plain text.
1380+
func TestCompileTemplateButtonWithHTMLContent(t *testing.T) {
1381+
// This test confirms the bug: when button content contains HTML like
1382+
// <strong>Click here for the recipe!</strong><br/>
1383+
// the button renders as "Button" instead of the custom text
1384+
1385+
tests := []struct {
1386+
name string
1387+
buttonContent string
1388+
expectedText string
1389+
shouldContain []string
1390+
shouldNotContain []string
1391+
}{
1392+
{
1393+
name: "plain text button content",
1394+
buttonContent: "Click here",
1395+
expectedText: "Click here",
1396+
shouldContain: []string{"Click here"},
1397+
},
1398+
{
1399+
name: "button with strong tag - BUG #242",
1400+
buttonContent: "<strong>Click here for the recipe!</strong>",
1401+
expectedText: "Click here for the recipe!",
1402+
shouldContain: []string{"Click here for the recipe!"},
1403+
// Should NOT render as default "Button" text
1404+
shouldNotContain: []string{">Button<"},
1405+
},
1406+
{
1407+
name: "button with strong and br tags - BUG #242",
1408+
buttonContent: "<strong>Click here for the recipe!</strong><br/>",
1409+
expectedText: "Click here for the recipe!",
1410+
shouldContain: []string{"Click here for the recipe!"},
1411+
shouldNotContain: []string{">Button<"},
1412+
},
1413+
{
1414+
name: "button with br tag only",
1415+
buttonContent: "Line 1<br/>Line 2",
1416+
expectedText: "Line 1",
1417+
shouldContain: []string{"Line 1", "Line 2"},
1418+
shouldNotContain: []string{">Button<"},
1419+
},
1420+
{
1421+
name: "button with em tag",
1422+
buttonContent: "<em>Important</em> Action",
1423+
expectedText: "Important Action",
1424+
shouldContain: []string{"Important", "Action"},
1425+
shouldNotContain: []string{">Button<"},
1426+
},
1427+
{
1428+
name: "button with nested formatting",
1429+
buttonContent: "<strong><em>Bold Italic</em></strong>",
1430+
expectedText: "Bold Italic",
1431+
shouldContain: []string{"Bold Italic"},
1432+
shouldNotContain: []string{">Button<"},
1433+
},
1434+
}
1435+
1436+
for _, tt := range tests {
1437+
t.Run(tt.name, func(t *testing.T) {
1438+
// Create button with the test content
1439+
buttonBase := NewBaseBlock("button-1", MJMLComponentMjButton)
1440+
buttonBase.Attributes["href"] = "https://example.com"
1441+
buttonBase.Content = stringPtr(tt.buttonContent)
1442+
buttonBlock := &MJButtonBlock{BaseBlock: buttonBase}
1443+
1444+
// Create complete MJML structure
1445+
column := &MJColumnBlock{BaseBlock: NewBaseBlock("column-1", MJMLComponentMjColumn)}
1446+
column.Children = []EmailBlock{buttonBlock}
1447+
1448+
section := &MJSectionBlock{BaseBlock: NewBaseBlock("section-1", MJMLComponentMjSection)}
1449+
section.Children = []EmailBlock{column}
1450+
1451+
body := &MJBodyBlock{BaseBlock: NewBaseBlock("body-1", MJMLComponentMjBody)}
1452+
body.Children = []EmailBlock{section}
1453+
1454+
mjml := &MJMLBlock{BaseBlock: NewBaseBlock("mjml-1", MJMLComponentMjml)}
1455+
mjml.Children = []EmailBlock{body}
1456+
1457+
req := CompileTemplateRequest{
1458+
WorkspaceID: "test-workspace",
1459+
MessageID: "test-message",
1460+
VisualEditorTree: mjml,
1461+
TrackingSettings: TrackingSettings{
1462+
EnableTracking: false,
1463+
},
1464+
}
1465+
1466+
resp, err := CompileTemplate(req)
1467+
if err != nil {
1468+
t.Fatalf("CompileTemplate failed: %v", err)
1469+
}
1470+
1471+
if !resp.Success {
1472+
t.Fatalf("Expected successful compilation, got error: %v", resp.Error)
1473+
}
1474+
1475+
if resp.HTML == nil {
1476+
t.Fatal("Expected HTML in response")
1477+
}
1478+
1479+
// Log MJML and HTML for debugging
1480+
t.Logf("Input button content: %s", tt.buttonContent)
1481+
t.Logf("Generated MJML:\n%s", *resp.MJML)
1482+
t.Logf("Generated HTML (excerpt):\n%s", *resp.HTML)
1483+
1484+
// Check that expected content appears in HTML
1485+
for _, expected := range tt.shouldContain {
1486+
if !strings.Contains(*resp.HTML, expected) {
1487+
t.Errorf("Expected HTML to contain %q for button text, but it didn't.\n"+
1488+
"This confirms bug #242: button content with HTML tags doesn't render correctly.\n"+
1489+
"HTML output:\n%s", expected, *resp.HTML)
1490+
}
1491+
}
1492+
1493+
// Check that unexpected content does NOT appear
1494+
for _, unexpected := range tt.shouldNotContain {
1495+
if strings.Contains(*resp.HTML, unexpected) {
1496+
t.Errorf("Expected HTML NOT to contain %q (default button text), but it did.\n"+
1497+
"This confirms bug #242: button fell back to default 'Button' text.\n"+
1498+
"HTML output:\n%s", unexpected, *resp.HTML)
1499+
}
1500+
}
1501+
})
1502+
}
1503+
}
1504+
12931505
func TestCompileTemplateWithImageLiquidOnlySrcPartialData(t *testing.T) {
12941506
// Create an mj-image with only Liquid syntax in src attribute
12951507
imageBase := NewBaseBlock("image-1", MJMLComponentMjImage)

0 commit comments

Comments
 (0)