Skip to content

Commit b841318

Browse files
committed
MJML default head attributes
1 parent c852510 commit b841318

File tree

7 files changed

+494
-67
lines changed

7 files changed

+494
-67
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.
88
- **Templates**: Added ability to translate email templates to languages configured in workspace settings
99
- **Contacts**: Fixed invalid "Blacklisted" status option in change status dropdown, replaced with valid "Bounced" and "Complained" statuses (#285)
1010
- **SMTP**: Added configurable EHLO hostname for SMTP connections. Some SMTP servers reject `EHLO localhost`; users can now set a custom hostname (e.g., their domain) via the `SMTP_EHLO_HOSTNAME` env var, setup wizard, or workspace integration settings. Defaults to the SMTP host value when empty.
11+
- **Transactional Notifications**: Fixed delivery stats (sent, delivered, failed, bounced) always showing 0 by linking messages to their originating notification via a new `transactional_notification_id` column
12+
- **Email Builder**: Fixed `<mj-attributes>` global styles not applying in preview and sent emails (#282)
1113

1214
## [27.4] - 2026-03-01
1315

console/src/components/email_builder/blocks/MjAttributesBlock.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ export class MjAttributesBlock extends BaseEmailBlock {
5050

5151
getValidChildTypes(): MJMLComponentType[] {
5252
// mj-attributes can contain attribute elements for any MJML component type
53-
return ['mj-text', 'mj-button', 'mj-image', 'mj-section', 'mj-column', 'mj-wrapper', 'mj-body']
53+
return [
54+
'mj-all', 'mj-class',
55+
'mj-text', 'mj-button', 'mj-image', 'mj-section', 'mj-column',
56+
'mj-wrapper', 'mj-body', 'mj-divider', 'mj-spacer', 'mj-social',
57+
'mj-social-element', 'mj-group'
58+
]
5459
}
5560

5661
/**

console/src/components/email_builder/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type MJMLComponentType =
2424
| 'mj-style'
2525
| 'mj-title'
2626
| 'mj-raw'
27+
| 'mj-all'
28+
| 'mj-class'
2729

2830
// Common attribute interfaces
2931
export interface PaddingAttributes {

pkg/notifuse_mjml/converter_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,3 +738,65 @@ func TestFormatSingleAttribute(t *testing.T) {
738738
})
739739
}
740740
}
741+
742+
func TestEndToEnd_MjAttributesGlobalsPreserved(t *testing.T) {
743+
// Build full tree via JSON: mjml > head > mj-attributes > [mj-all, mj-text] + body > section > column > mj-text
744+
fullJSON := `{
745+
"id": "mjml-1",
746+
"type": "mjml",
747+
"children": [
748+
{
749+
"id": "head-1",
750+
"type": "mj-head",
751+
"children": [
752+
{
753+
"id": "attrs-1",
754+
"type": "mj-attributes",
755+
"children": [
756+
{"id":"all-1","type":"mj-all","attributes":{"fontFamily":"Helvetica"}},
757+
{"id":"text-def","type":"mj-text","attributes":{"color":"#333333"}}
758+
]
759+
}
760+
]
761+
},
762+
{
763+
"id": "body-1",
764+
"type": "mj-body",
765+
"children": [
766+
{
767+
"id": "section-1",
768+
"type": "mj-section",
769+
"children": [
770+
{
771+
"id": "column-1",
772+
"type": "mj-column",
773+
"children": [
774+
{"id":"text-1","type":"mj-text","content":"Hello"}
775+
]
776+
}
777+
]
778+
}
779+
]
780+
}
781+
]
782+
}`
783+
784+
block, err := UnmarshalEmailBlock([]byte(fullJSON))
785+
if err != nil {
786+
t.Fatalf("Failed to unmarshal: %v", err)
787+
}
788+
789+
// Convert to MJML string
790+
mjmlOutput := ConvertJSONToMJML(block)
791+
792+
// Behavior: mj-all appears in the MJML output with its attributes
793+
if !strings.Contains(mjmlOutput, `<mj-all font-family="Helvetica"`) {
794+
t.Errorf("Expected mj-all with font-family in output, got:\n%s", mjmlOutput)
795+
}
796+
797+
// Behavior: body mj-text with no stored attributes produces a tag without inline attributes
798+
// (so mj-attributes globals can take effect at render time)
799+
if !strings.Contains(mjmlOutput, `<mj-text>Hello</mj-text>`) {
800+
t.Errorf("Expected body mj-text without inline attributes, got:\n%s", mjmlOutput)
801+
}
802+
}

pkg/notifuse_mjml/model.go

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const (
3333
MJMLComponentMjStyle MJMLComponentType = "mj-style"
3434
MJMLComponentMjTitle MJMLComponentType = "mj-title"
3535
MJMLComponentMjRaw MJMLComponentType = "mj-raw"
36+
MJMLComponentMjAll MJMLComponentType = "mj-all"
37+
MJMLComponentMjClass MJMLComponentType = "mj-class"
3638
)
3739

3840
// Common attribute interfaces
@@ -564,40 +566,25 @@ func UnmarshalEmailBlock(data []byte) (EmailBlock, error) {
564566
children[i] = child
565567
}
566568

567-
// Merge with defaults
568-
mergedAttrs := mergeAttributesWithDefaults(blockJSON.Type, blockJSON.Attributes)
569+
// Preserve stored attributes as-is (no defaults injected during deserialization)
570+
attrs := blockJSON.Attributes
571+
if attrs == nil {
572+
attrs = make(map[string]interface{})
573+
}
569574

570575
// Create base block
571576
base := &BaseBlock{
572577
ID: blockJSON.ID,
573578
Type: blockJSON.Type,
574579
Children: children,
575-
Attributes: mergedAttrs,
580+
Attributes: attrs,
576581
Content: blockJSON.Content,
577582
}
578583

579584
// Return typed wrapper based on component type
580585
return createTypedBlock(base), nil
581586
}
582587

583-
// mergeAttributesWithDefaults merges default attributes with provided attributes
584-
func mergeAttributesWithDefaults(componentType MJMLComponentType, attrs map[string]interface{}) map[string]interface{} {
585-
defaults := GetDefaultAttributes(componentType)
586-
if attrs == nil {
587-
return defaults
588-
}
589-
merged := make(map[string]interface{})
590-
// Copy defaults first
591-
for k, v := range defaults {
592-
merged[k] = v
593-
}
594-
// Override with provided attributes
595-
for k, v := range attrs {
596-
merged[k] = v
597-
}
598-
return merged
599-
}
600-
601588
// createTypedBlock creates a typed block wrapper for a BaseBlock
602589
func createTypedBlock(base *BaseBlock) EmailBlock {
603590
switch base.Type {
@@ -633,6 +620,10 @@ func createTypedBlock(base *BaseBlock) EmailBlock {
633620
return &MJRawBlock{BaseBlock: base}
634621
case MJMLComponentMjAttributes:
635622
return &MJAttributesBlock{BaseBlock: base}
623+
case MJMLComponentMjAll:
624+
return &MJAttributeElementBlock{BaseBlock: base}
625+
case MJMLComponentMjClass:
626+
return &MJAttributeElementBlock{BaseBlock: base}
636627
case MJMLComponentMjBreakpoint:
637628
return &MJBreakpointBlock{BaseBlock: base}
638629
case MJMLComponentMjFont:
@@ -723,7 +714,15 @@ var ValidChildrenMap = map[MJMLComponentType][]MJMLComponentType{
723714
MJMLComponentMjSpacer: {},
724715
MJMLComponentMjSocialElement: {},
725716
MJMLComponentMjRaw: {},
726-
MJMLComponentMjAttributes: {},
717+
MJMLComponentMjAttributes: {
718+
MJMLComponentMjAll, MJMLComponentMjClass,
719+
MJMLComponentMjText, MJMLComponentMjButton, MJMLComponentMjImage,
720+
MJMLComponentMjSection, MJMLComponentMjColumn, MJMLComponentMjWrapper,
721+
MJMLComponentMjBody, MJMLComponentMjDivider, MJMLComponentMjSpacer,
722+
MJMLComponentMjSocial, MJMLComponentMjSocialElement, MJMLComponentMjGroup,
723+
},
724+
MJMLComponentMjAll: {},
725+
MJMLComponentMjClass: {},
727726
MJMLComponentMjBreakpoint: {},
728727
MJMLComponentMjFont: {},
729728
MJMLComponentMjHtmlAttributes: {},

0 commit comments

Comments
 (0)