Skip to content

Commit 1b4dd1d

Browse files
committed
fix(mj-section): scope Outlook VML button helpers to left-aligned buttons
- only emit VML button class and button-specific mso margin rules for left-aligned mj-button instances - added section class vml only when a section emits left-aligned button VML rules - kept non-left-aligned button sections free of unnecessary VML hooks - removed mj-outlook-group-fix class emission from column output and align section gutter expectations - add regression coverage for section/button Outlook behavior and template syntax sanitization under minify with mixed multiline/property/value tokens - clean up unused import in mj-hero
1 parent f35a14f commit 1b4dd1d

6 files changed

Lines changed: 154 additions & 33 deletions

File tree

packages/mjml-column/src/index.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -524,10 +524,11 @@ export default class MjColumn extends BodyComponent {
524524
}
525525
526526
const isButton = component.constructor.componentName === 'mj-button'
527+
const isLeftAlignedButton = isButton && component.getAttribute('align') === 'left'
527528
const hasSectionBackground = this.context.hasSectionBackgroundUrl === true
528529
let trClass = ''
529530
530-
if (isButton && hasSectionBackground) {
531+
if (isLeftAlignedButton && hasSectionBackground) {
531532
const buttonClassName = `vml-button-${genRandomHexString(6)}`
532533
const buttonLeftPadding = `${component.getShorthandAttrValue('padding', 'left')}px`
533534
@@ -576,8 +577,6 @@ export default class MjColumn extends BodyComponent {
576577
classesName += ` ${this.getDesktopGutterClassName()}`
577578
}
578579

579-
classesName += ' mj-outlook-group-fix'
580-
581580
if (this.getAttribute('css-class')) {
582581
classesName += ` ${this.getAttribute('css-class')}`
583582
}

packages/mjml-hero/src/index.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { BodyComponent } from 'mjml-core'
22
import { flow, identity, join, filter } from 'lodash/fp'
3-
4-
import widthParser from 'mjml-core/lib/helpers/widthParser'
53
import { msoConditionalTag } from 'mjml-core/lib/helpers/conditionalTag'
4+
65
const makeBackgroundString = flow(filter(identity), join(' '))
76

87
export default class MjHero extends BodyComponent {

packages/mjml-section/src/index.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,19 @@ export default class MjSection extends BodyComponent {
492492

493493
renderSection() {
494494
const hasBackground = this.hasBackground()
495+
const supportOutlookClassic = this.context?.globalData?.supportOutlookClassic !== false
496+
const globalData = this.context?.globalData
497+
const initialVmlRuleCount = Array.isArray(globalData?.vmlButtonStyleRules)
498+
? globalData.vmlButtonStyleRules.length
499+
: 0
500+
const wrappedChildren = this.renderWrappedChildren()
501+
const finalVmlRuleCount = Array.isArray(globalData?.vmlButtonStyleRules)
502+
? globalData.vmlButtonStyleRules.length
503+
: 0
504+
const shouldAddVmlClass =
505+
hasBackground &&
506+
supportOutlookClassic &&
507+
finalVmlRuleCount > initialVmlRuleCount
495508

496509
return `
497510
<div ${this.htmlAttributes({
@@ -507,7 +520,7 @@ export default class MjSection extends BodyComponent {
507520
border: '0',
508521
cellpadding: '0',
509522
cellspacing: '0',
510-
...(hasBackground && { 'class': 'vml' }),
523+
...(shouldAddVmlClass && { 'class': 'vml' }),
511524
role: 'none',
512525
style: 'table',
513526
})}
@@ -521,7 +534,7 @@ export default class MjSection extends BodyComponent {
521534
${msoConditionalTag(`
522535
<table role="none" border="0" cellpadding="0" cellspacing="0">
523536
`)}
524-
${this.renderWrappedChildren()}
537+
${wrappedChildren}
525538
${msoConditionalTag(`
526539
</table>
527540
`)}

packages/mjml/test/section-button-outlook-margin.test.js

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('mj-section + mj-button outlook margin integration', function () {
1717
<mj-body>
1818
<mj-section background-url="https://example.com/bg.png">
1919
<mj-column background-color="black" padding="20px">
20-
<mj-button padding="10px 25px">A</mj-button>
20+
<mj-button align="left" padding="10px 25px">A</mj-button>
2121
</mj-column>
2222
</mj-section>
2323
</mj-body>
@@ -36,6 +36,8 @@ describe('mj-section + mj-button outlook margin integration', function () {
3636
/<td[^>]*style="[^"]*background-color:black;[^"]*padding:20px;[^"]*mso-para-margin-left:20px;[^"]*"/,
3737
)
3838

39+
chai.expect(html).to.include('class="vml"')
40+
3941
chai
4042
.expect(html)
4143
.to.include(`.vml .${buttonClassName} td { mso-para-margin-left:25px; }`)
@@ -62,6 +64,7 @@ describe('mj-section + mj-button outlook margin integration', function () {
6264

6365
chai.expect(html).to.not.match(/<tr class="vml-button-[a-f0-9]{6}">/)
6466
chai.expect(html).to.not.include('mso-para-margin-left')
67+
chai.expect(html).to.not.include('class="vml"')
6568
})
6669

6770
it('should not emit vml-button classes or mso margin styles when support-outlook-classic is false', async function () {
@@ -83,16 +86,40 @@ describe('mj-section + mj-button outlook margin integration', function () {
8386
chai.expect(html).to.not.include('mso-para-margin-left')
8487
})
8588

89+
it('should not emit vml-button classes or button-specific mso margin rules for center-aligned buttons', async function () {
90+
const input = `
91+
<mjml support-outlook-classic="true">
92+
<mj-body>
93+
<mj-section background-url="https://example.com/bg.png">
94+
<mj-column background-color="black" padding="20px">
95+
<mj-button align="center" padding="10px 25px">A</mj-button>
96+
</mj-column>
97+
</mj-section>
98+
</mj-body>
99+
</mjml>
100+
`
101+
102+
const { html } = await mjml(input)
103+
104+
chai.expect(html).to.not.match(/<tr class="vml-button-[a-f0-9]{6}">/)
105+
chai.expect(html).to.not.match(/\.vml \.vml-button-[a-f0-9]{6} td \{ mso-para-margin-left:\d+px; \}/)
106+
chai.expect(html).to.not.match(/\.vml \.vml-button-[a-f0-9]{6} td td \{ mso-para-margin-left:0; \}/)
107+
chai.expect(html).to.not.include('class="vml"')
108+
chai.expect(html).to.match(
109+
/<td[^>]*style="[^"]*background-color:black;[^"]*padding:20px;[^"]*mso-para-margin-left:20px;[^"]*"/,
110+
)
111+
})
112+
86113
it('should consolidate multiple vml-button rules into a single mso style block', async function () {
87114
// Positive-path assertions force Outlook-classic on to avoid cross-test state effects.
88115
const input = `
89116
<mjml support-outlook-classic="true">
90117
<mj-body>
91118
<mj-section background-url="https://example.com/bg.png">
92119
<mj-column background-color="black" padding="20px">
93-
<mj-button padding="10px 25px">A</mj-button>
94-
<mj-button padding="10px 30px">B</mj-button>
95-
<mj-button padding="10px 40px">C</mj-button>
120+
<mj-button align="left" padding="10px 25px">A</mj-button>
121+
<mj-button align="left" padding="10px 30px">B</mj-button>
122+
<mj-button align="left" padding="10px 40px">C</mj-button>
96123
</mj-column>
97124
</mj-section>
98125
</mj-body>
@@ -129,4 +156,29 @@ describe('mj-section + mj-button outlook margin integration', function () {
129156
.to.include(`.vml .${className} td td { mso-para-margin-left:0; }`)
130157
})
131158
})
159+
160+
it('should apply vml class only on sections that emit left-aligned button VML rules', async function () {
161+
const input = `
162+
<mjml support-outlook-classic="true">
163+
<mj-body>
164+
<mj-section background-url="https://example.com/bg.png">
165+
<mj-column background-color="black" padding="20px">
166+
<mj-button align="left" padding="10px 25px">A</mj-button>
167+
</mj-column>
168+
</mj-section>
169+
<mj-section background-url="https://example.com/bg2.png">
170+
<mj-column background-color="black" padding="20px">
171+
<mj-button align="center" padding="10px 25px">B</mj-button>
172+
</mj-column>
173+
</mj-section>
174+
</mj-body>
175+
</mjml>
176+
`
177+
178+
const { html } = await mjml(input)
179+
180+
const vmlClassOccurrences = (html.match(/class="vml"/g) || []).length
181+
182+
chai.expect(vmlClassOccurrences).to.equal(1)
183+
})
132184
})

packages/mjml/test/section-gutter.test.js

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ describe('mj-section gutter', function () {
2424
chai.expect(html).to.include('.mj-column-per-48 { width:48% !important; max-width: 48%; }')
2525
chai.expect(html).to.include('.mj-column-gutter-2-1-per-4 { padding: 0% 2% 0% 0% !important; }')
2626
chai.expect(html).to.include('.mj-column-gutter-2-2-per-4 { padding: 0% 0% 0% 2% !important; }')
27-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4 mj-outlook-group-fix"')
28-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4 mj-outlook-group-fix"')
27+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4"')
28+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4"')
2929
})
3030

3131
it('should keep rtl gutter rules isolated from ltr section gutter rules', async function () {
@@ -57,10 +57,10 @@ describe('mj-section gutter', function () {
5757
const { html, errors } = await mjml(input)
5858

5959
chai.expect(errors, 'rtl gutter should not produce validation errors').to.eql([])
60-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4 mj-outlook-group-fix"')
61-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4 mj-outlook-group-fix"')
62-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4-rtl mj-outlook-group-fix"')
63-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4-rtl mj-outlook-group-fix"')
60+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4"')
61+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4"')
62+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4-rtl"')
63+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4-rtl"')
6464
chai.expect(html).to.include('mj-column-gutter-2-1-per-4-rtl')
6565
chai.expect(html).to.include('mj-column-gutter-2-2-per-4-rtl')
6666
})
@@ -86,10 +86,10 @@ describe('mj-section gutter', function () {
8686
const { html, errors } = await mjml(input)
8787

8888
chai.expect(errors, 'group gutter case should not produce validation errors').to.eql([])
89-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4 mj-outlook-group-fix"')
90-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4 mj-outlook-group-fix"')
91-
chai.expect(html).to.include('style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:48%;padding:0% 2% 0% 0%;"')
92-
chai.expect(html).to.include('style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:48%;padding:0% 0% 0% 2%;"')
89+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4"')
90+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4"')
91+
chai.expect(html).to.include('style="font-size:0px;text-align:left;display:inline-block;vertical-align:top;width:48%;padding:0% 2% 0% 0%;"')
92+
chai.expect(html).to.include('style="font-size:0px;text-align:left;display:inline-block;vertical-align:top;width:48%;padding:0% 0% 0% 2%;"')
9393
chai.expect(html).to.not.include('.mj-column-gutter-2-1-per-4 { padding: 0% 2% 0% 0% !important; }')
9494
chai.expect(html).to.not.include('.mj-column-gutter-2-2-per-4 { padding: 0% 0% 0% 2% !important; }')
9595
})
@@ -114,8 +114,8 @@ describe('mj-section gutter', function () {
114114
const { html, errors } = await mjml(input)
115115

116116
chai.expect(errors, 'section full-width + border + rtl should not produce errors').to.eql([])
117-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4-rtl mj-outlook-group-fix"')
118-
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4-rtl mj-outlook-group-fix"')
117+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-1-per-4-rtl"')
118+
chai.expect(html).to.include('class="mj-column-per-48 mj-column-gutter-2-2-per-4-rtl"')
119119
chai.expect(html).to.include('.mj-column-gutter-2-1-per-4-rtl { padding: 0% 0% 0% 2% !important; }')
120120
chai.expect(html).to.include('.mj-column-gutter-2-2-per-4-rtl { padding: 0% 2% 0% 0% !important; }')
121121
})
@@ -144,9 +144,9 @@ describe('mj-section gutter', function () {
144144
const { html, errors } = await mjml(input)
145145

146146
chai.expect(errors, 'group direction override should not produce errors').to.eql([])
147-
chai.expect(html).to.include('class="mj-column-per-30-434783 mj-column-gutter-3-1-per-4-347826 mj-outlook-group-fix"')
148-
chai.expect(html).to.include('class="mj-column-per-30-434783 mj-column-gutter-3-2-per-4-347826 mj-outlook-group-fix"')
149-
chai.expect(html).to.include('class="mj-column-per-30-434783 mj-column-gutter-3-3-per-4-347826 mj-outlook-group-fix"')
147+
chai.expect(html).to.include('class="mj-column-per-30-434783 mj-column-gutter-3-1-per-4-347826"')
148+
chai.expect(html).to.include('class="mj-column-per-30-434783 mj-column-gutter-3-2-per-4-347826"')
149+
chai.expect(html).to.include('class="mj-column-per-30-434783 mj-column-gutter-3-3-per-4-347826"')
150150
chai.expect(html).to.not.include('mj-column-gutter-3-1-per-4-347826-rtl')
151151
})
152152

@@ -172,9 +172,9 @@ describe('mj-section gutter', function () {
172172
const { html, errors } = await mjml(input)
173173

174174
chai.expect(errors, 'column-level border/padding/inner-border with gutter should not error').to.eql([])
175-
chai.expect(html).to.include('class="mj-column-per-30-666667 mj-column-gutter-3-1-per-4 mj-outlook-group-fix"')
176-
chai.expect(html).to.include('class="mj-column-per-30-666667 mj-column-gutter-3-2-per-4 mj-outlook-group-fix"')
177-
chai.expect(html).to.include('class="mj-column-per-30-666667 mj-column-gutter-3-3-per-4 mj-outlook-group-fix"')
175+
chai.expect(html).to.include('class="mj-column-per-30-666667 mj-column-gutter-3-1-per-4"')
176+
chai.expect(html).to.include('class="mj-column-per-30-666667 mj-column-gutter-3-2-per-4"')
177+
chai.expect(html).to.include('class="mj-column-per-30-666667 mj-column-gutter-3-3-per-4"')
178178
})
179179

180180
it('should support four-column px widths with percentage gutter and balanced px rounding', async function () {
@@ -202,10 +202,10 @@ describe('mj-section gutter', function () {
202202
const { html, errors } = await mjml(input)
203203

204204
chai.expect(errors, 'four-column px rounding scenario should not produce errors').to.eql([])
205-
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-1-px-12 mj-outlook-group-fix"')
206-
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-2-px-12 mj-outlook-group-fix"')
207-
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-3-px-12 mj-outlook-group-fix"')
208-
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-4-px-12 mj-outlook-group-fix"')
205+
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-1-px-12"')
206+
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-2-px-12"')
207+
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-3-px-12"')
208+
chai.expect(html).to.include('class="mj-column-px-111 mj-column-gutter-4-4-px-12"')
209209
chai.expect(html).to.include('.mj-column-gutter-4-1-px-12 { padding: 0px 6px 0px 0px !important; }')
210210
chai.expect(html).to.include('mj-column-gutter-4-2-px-12')
211211
chai.expect(html).to.include('.mj-column-gutter-4-4-px-12 { padding: 0px 0px 0px 6px !important; }')

packages/mjml/test/template-syntax-sanitization.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,64 @@ describe('Template syntax sanitization', function () {
191191
chai.expect(doc).to.include('font-weight:[[\n fontWeight \n]]')
192192
})
193193

194+
it('preserves value/property/multiline tokens across style attrs and blocks when minify + allowMixedSyntax are enabled', async function () {
195+
const input = `
196+
<mjml>
197+
<mj-head>
198+
<mj-style>
199+
.token {
200+
color: {{\nheadlineColor\n}};
201+
[[ dynamicProp ]]: [[ dynamicValue ]];
202+
padding: {%paddingValue%};
203+
}
204+
</mj-style>
205+
</mj-head>
206+
<mj-body>
207+
<mj-section>
208+
<mj-column>
209+
<mj-raw>
210+
<div
211+
style="
212+
color: {{primaryColor}};
213+
[[ fontTag ]]: [[\n fontWeight \n]];
214+
border-color: {%borderColor%};
215+
"
216+
>Token</div>
217+
</mj-raw>
218+
</mj-column>
219+
</mj-section>
220+
</mj-body>
221+
</mjml>
222+
`
223+
224+
const { html } = await mjml(input, {
225+
sanitizeStyles: true,
226+
minify: true,
227+
templateSyntax: syntaxes,
228+
allowMixedSyntax: true,
229+
})
230+
231+
const $ = load(html)
232+
233+
const styleText = $('style')
234+
.map(function () { return $(this).text() })
235+
.get()
236+
.find((t) => t.includes('.token')) || ''
237+
238+
chai.expect(styleText).to.include('color:{{\nheadlineColor\n}}')
239+
chai.expect(styleText).to.include('[[ dynamicProp ]]:[[ dynamicValue ]]')
240+
chai.expect(styleText).to.include('padding:{%paddingValue%}')
241+
242+
const allStyles = $('[style]')
243+
.map(function () { return $(this).attr('style') || '' })
244+
.get()
245+
.join(' ')
246+
247+
chai.expect(allStyles).to.include('color:{{primaryColor}}')
248+
chai.expect(allStyles).to.include('[[ fontTag ]]:[[\n fontWeight \n]]')
249+
chai.expect(allStyles).to.include('border-color:{%borderColor%}')
250+
})
251+
194252
it('throws clear error on broken delimiters inside CSS (pre-check)', async function () {
195253
const input = `
196254
<mjml>

0 commit comments

Comments
 (0)